cinoc-deploy commited on
Commit
a2907ce
·
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 +360 -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 +130 -0
  17. cinoc/adapters/layout/__init__.py +3 -0
  18. cinoc/adapters/layout/_base.py +114 -0
  19. cinoc/adapters/layout/assembler.py +89 -0
  20. cinoc/adapters/layout/crop.py +72 -0
  21. cinoc/adapters/layout/pp_doclayout.py +147 -0
  22. cinoc/adapters/layout/precomputed.py +178 -0
  23. cinoc/adapters/layout/remote.py +194 -0
  24. cinoc/adapters/layout/to_text.py +106 -0
  25. cinoc/adapters/llm/__init__.py +15 -0
  26. cinoc/adapters/llm/_base.py +285 -0
  27. cinoc/adapters/llm/anthropic.py +193 -0
  28. cinoc/adapters/llm/mistral.py +221 -0
  29. cinoc/adapters/llm/ollama.py +195 -0
  30. cinoc/adapters/llm/openai.py +188 -0
  31. cinoc/adapters/ner/__init__.py +1 -0
  32. cinoc/adapters/ner/spacy_extractor.py +204 -0
  33. cinoc/adapters/ocr/__init__.py +8 -0
  34. cinoc/adapters/ocr/_base.py +64 -0
  35. cinoc/adapters/ocr/azure_di.py +223 -0
  36. cinoc/adapters/ocr/calamari.py +106 -0
  37. cinoc/adapters/ocr/google_vision.py +157 -0
  38. cinoc/adapters/ocr/kraken.py +166 -0
  39. cinoc/adapters/ocr/mistral_ocr.py +113 -0
  40. cinoc/adapters/ocr/pero.py +114 -0
  41. cinoc/adapters/ocr/precomputed.py +91 -0
  42. cinoc/adapters/ocr/tesseract.py +332 -0
  43. cinoc/adapters/storage/__init__.py +24 -0
  44. cinoc/adapters/storage/history_store.py +191 -0
  45. cinoc/adapters/storage/job_store.py +159 -0
  46. cinoc/adapters/storage/publisher.py +147 -0
  47. cinoc/app/ANALYSE_COUCHE_6.md +263 -0
  48. cinoc/app/__init__.py +34 -0
  49. cinoc/app/alto_store.py +72 -0
  50. cinoc/app/corpus_import.py +449 -0
.gitattributes ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ *.png filter=lfs diff=lfs merge=lfs -text
2
+ *.jpg filter=lfs diff=lfs merge=lfs -text
3
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
4
+ *.pdf filter=lfs diff=lfs merge=lfs -text
5
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
6
+ *.woff filter=lfs diff=lfs merge=lfs -text
7
+ *.otf filter=lfs diff=lfs merge=lfs -text
8
+ *.ttf 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+g7ca8e2eab'
22
+ __version_tuple__ = version_tuple = (0, 1, 1, 'dev1', 'g7ca8e2eab')
23
+
24
+ __commit_id__ = commit_id = 'g7ca8e2eab'
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,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ - **redirections re-validées** à chaque saut (une 30x ne peut pas pointer vers
12
+ l'interne) ; nombre de sauts borné ;
13
+ - **taille plafonnée** au fil de l'eau (on ne fait pas confiance à
14
+ ``Content-Length``).
15
+
16
+ **DNS-rebinding fermé — épinglage d'IP.** ``assert_public_url`` résout l'hôte
17
+ **une seule fois**, valide **toutes** les IP, et **retourne** ces IP. La
18
+ connexion vise alors directement l'IP **épinglée** (transport ``httpx`` à
19
+ ``network_backend`` custom, cf. :class:`_PinnedBackend`) : il n'y a **pas de
20
+ seconde résolution** que pourrait détourner un DNS hostile. Le nom d'hôte de
21
+ l'URL reste inchangé, donc le ``Host`` envoyé, le **SNI** et la vérification du
22
+ certificat TLS portent toujours sur le vrai hôte (aucun override fragile) —
23
+ seule la cible TCP est figée. L'épinglage est ré-appliqué **à chaque saut** de
24
+ redirection. Le **gate « mode public » (403)** côté interface reste la première
25
+ barrière (les imports distants y sont refusés) ; l'épinglage est la défense en
26
+ profondeur, désormais étanche.
27
+
28
+ Auth & redirections : les en-têtes (jeton) ne suivent **pas** un changement
29
+ d'hôte (cf. ``_stream_validated``). Une seule pile HTTP (``httpx`` + son moteur
30
+ ``httpcore``) ; **aucun état global** (pas d'``install_opener``).
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ import contextlib
36
+ import ipaddress
37
+ import json
38
+ import os
39
+ import socket
40
+ import typing
41
+ from collections.abc import Iterator
42
+ from pathlib import Path
43
+ from urllib.parse import urlsplit
44
+
45
+ import httpcore
46
+ import httpx
47
+
48
+ from cinoc.domain.errors import CinocError
49
+
50
+ #: Plafonds (généreux mais bornés — un Space gratuit n'est pas un entrepôt).
51
+ MANIFEST_MAX_BYTES = 16 * 1024 * 1024
52
+ IMAGE_MAX_BYTES = 50 * 1024 * 1024
53
+ DEFAULT_TIMEOUT = 30.0
54
+ _MAX_REDIRECTS = 5
55
+ _ALLOWED_SCHEMES = frozenset({"http", "https"})
56
+
57
+
58
+ class CorpusHttpError(CinocError):
59
+ """Échec de transport d'un importeur de corpus."""
60
+
61
+
62
+ class SsrfError(CorpusHttpError):
63
+ """L'URL cible une ressource interdite (schéma, userinfo ou IP non publique)."""
64
+
65
+
66
+ class HttpFetchError(CorpusHttpError):
67
+ """La requête a échoué (statut, redirection cassée, dépassement de taille)."""
68
+
69
+
70
+ def _is_public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
71
+ return not (
72
+ ip.is_private
73
+ or ip.is_loopback
74
+ or ip.is_link_local
75
+ or ip.is_reserved
76
+ or ip.is_multicast
77
+ or ip.is_unspecified
78
+ )
79
+
80
+
81
+ def assert_public_url(url: str) -> tuple[str, ...]:
82
+ """Valide ``url`` et **retourne les IP publiques** vers lesquelles épingler.
83
+
84
+ Résout l'hôte **une seule fois** et exige que **toutes** les IP retournées
85
+ soient publiques : un nom qui résout (même partiellement) vers l'interne est
86
+ rejeté (``SsrfError``). Les IP validées sont retournées pour que l'appelant
87
+ **épingle** la connexion dessus (anti-DNS-rebinding) — pas de seconde
88
+ résolution. Pour une IP littérale, retourne cette IP (aucun DNS réel).
89
+ """
90
+ parts = urlsplit(url)
91
+ if parts.scheme not in _ALLOWED_SCHEMES:
92
+ raise SsrfError(f"schéma non autorisé : {parts.scheme!r} (http/https seuls).")
93
+ if parts.username or parts.password:
94
+ raise SsrfError("userinfo interdit dans l'URL.")
95
+ host = parts.hostname
96
+ if not host:
97
+ raise SsrfError("URL sans hôte.")
98
+ port = parts.port or (443 if parts.scheme == "https" else 80)
99
+ try:
100
+ infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
101
+ except OSError as exc:
102
+ raise SsrfError(f"résolution DNS impossible pour {host!r} : {exc}.") from exc
103
+ pins: list[str] = []
104
+ for info in infos:
105
+ addr = str(info[4][0]) # sockaddr[0] = adresse textuelle (IPv4/IPv6)
106
+ if not _is_public(ipaddress.ip_address(addr)):
107
+ raise SsrfError(
108
+ f"hôte {host!r} résout vers une IP non publique ({addr})."
109
+ )
110
+ if addr not in pins:
111
+ pins.append(addr)
112
+ return tuple(pins)
113
+
114
+
115
+ class _PinnedBackend(httpcore.SyncBackend):
116
+ """Épingle la cible TCP sur des IP **pré-validées** (anti-DNS-rebinding).
117
+
118
+ ``httpcore`` appelle ``connect_tcp(host, port)`` avec le **nom d'hôte** de
119
+ l'origine ; on substitue les IP déjà résolues+validées, sans repasser par le
120
+ DNS. Le nom d'hôte de l'URL restant inchangé, le SNI et la vérification du
121
+ certificat TLS (pilotés par ``httpcore`` depuis l'origin) portent toujours
122
+ sur le **vrai hôte** — donc aucun override fragile. On essaie les IP dans
123
+ l'ordre (comme ``socket.create_connection`` le ferait), préservant le
124
+ repli multi-adresses.
125
+ """
126
+
127
+ def __init__(self, ips: tuple[str, ...]) -> None:
128
+ super().__init__()
129
+ self._ips = ips
130
+
131
+ def connect_tcp(
132
+ self,
133
+ host: str,
134
+ port: int,
135
+ timeout: float | None = None,
136
+ local_address: str | None = None,
137
+ socket_options: typing.Any = None,
138
+ ) -> httpcore.NetworkStream:
139
+ last_exc: httpcore.ConnectError | None = None
140
+ for ip in self._ips:
141
+ try:
142
+ return super().connect_tcp(
143
+ ip,
144
+ port,
145
+ timeout=timeout,
146
+ local_address=local_address,
147
+ socket_options=socket_options,
148
+ )
149
+ except httpcore.ConnectError as exc:
150
+ last_exc = exc
151
+ assert last_exc is not None # _ips est non vide (cf. _make_client)
152
+ raise last_exc
153
+
154
+
155
+ def _pinned_transport(ips: tuple[str, ...]) -> httpx.HTTPTransport:
156
+ """Transport ``httpx`` dont le backend réseau épingle ``ips``.
157
+
158
+ ``httpx`` n'expose pas ``network_backend`` ; on le pose sur le pool interne
159
+ (seul point d'injection). Le ``isinstance`` fait échouer **bruyamment** si
160
+ une future version de ``httpx`` change la forme du pool, plutôt que de
161
+ contourner silencieusement l'épinglage.
162
+ """
163
+ transport = httpx.HTTPTransport()
164
+ pool = transport._pool
165
+ if not isinstance(pool, httpcore.ConnectionPool): # pragma: no cover
166
+ transport.close()
167
+ raise CorpusHttpError("épinglage IP indisponible : transport httpx inattendu.")
168
+ pool._network_backend = _PinnedBackend(ips)
169
+ return transport
170
+
171
+
172
+ def _make_client(pins: tuple[str, ...] | None, timeout: float) -> httpx.Client:
173
+ """Client mono-saut. ``pins`` vide/None → résolution httpx normale.
174
+
175
+ Le cas ``None`` n'arrive qu'en test (``assert_public_url`` neutralisé en
176
+ no-op pour viser le loopback) ; en production il est toujours non vide.
177
+ """
178
+ if not pins:
179
+ return httpx.Client(timeout=timeout, follow_redirects=False)
180
+ return httpx.Client(
181
+ transport=_pinned_transport(pins), timeout=timeout, follow_redirects=False
182
+ )
183
+
184
+
185
+ @contextlib.contextmanager
186
+ def _stream_validated(
187
+ url: str, *, timeout: float, headers: dict[str, str] | None = None
188
+ ) -> Iterator[httpx.Response]:
189
+ """GET en streaming, **épinglé à l'IP validée** et re-validé à chaque saut.
190
+
191
+ Chaque saut (origine + redirections, bornées) : résout+valide une fois via
192
+ ``assert_public_url``, épingle la connexion sur l'IP retournée, puis envoie.
193
+ Les en-têtes fournis (ex. ``Authorization: Token …``) ne sont **jamais**
194
+ propagés vers un **hôte différent** de l'origine (fuite de credentials sur
195
+ redirection). Un client ``httpx`` distinct est créé par saut (épinglage
196
+ propre à l'IP du saut) ; tous sont fermés à la sortie du contexte.
197
+ """
198
+ origin_host = urlsplit(url).hostname
199
+ current = url
200
+ with contextlib.ExitStack() as stack:
201
+ for _ in range(_MAX_REDIRECTS + 1):
202
+ pins = assert_public_url(current)
203
+ # Auth conservée seulement tant qu'on reste sur l'hôte d'origine.
204
+ hop_headers = headers if urlsplit(current).hostname == origin_host else None
205
+ client = stack.enter_context(_make_client(pins, timeout))
206
+ request = client.build_request("GET", current, headers=hop_headers)
207
+ response = client.send(request, stream=True)
208
+ if response.is_redirect:
209
+ location = response.headers.get("location", "")
210
+ response.close()
211
+ if not location:
212
+ raise HttpFetchError(
213
+ f"redirection sans 'Location' depuis {current!r}."
214
+ )
215
+ current = str(request.url.join(location))
216
+ continue
217
+ try:
218
+ yield response
219
+ finally:
220
+ response.close()
221
+ return
222
+ raise HttpFetchError(f"trop de redirections (> {_MAX_REDIRECTS}) pour {url!r}.")
223
+
224
+
225
+ def _read_capped(response: httpx.Response, max_bytes: int) -> bytes:
226
+ chunks: list[bytes] = []
227
+ total = 0
228
+ for chunk in response.iter_bytes():
229
+ total += len(chunk)
230
+ if total > max_bytes:
231
+ raise HttpFetchError(f"réponse dépasse le plafond de {max_bytes} octets.")
232
+ chunks.append(chunk)
233
+ return b"".join(chunks)
234
+
235
+
236
+ def fetch_json(
237
+ url: str,
238
+ *,
239
+ timeout: float = DEFAULT_TIMEOUT,
240
+ headers: dict[str, str] | None = None,
241
+ ) -> object:
242
+ """Récupère et parse un JSON ; anti-SSRF + plafond ``MANIFEST_MAX_BYTES``.
243
+
244
+ ``headers`` permet l'authentification d'API (ex. ``Authorization: Token …``) ;
245
+ il n'est jamais journalisé (les messages d'erreur ne portent que l'URL).
246
+ """
247
+ try:
248
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
249
+ response.raise_for_status()
250
+ raw = _read_capped(response, MANIFEST_MAX_BYTES)
251
+ except httpx.HTTPStatusError as exc:
252
+ raise HttpFetchError(
253
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
254
+ ) from exc
255
+ except httpx.HTTPError as exc:
256
+ raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
257
+ try:
258
+ return json.loads(raw)
259
+ except json.JSONDecodeError as exc:
260
+ raise HttpFetchError(f"réponse non-JSON depuis {url!r} : {exc}.") from exc
261
+
262
+
263
+ def download(
264
+ url: str,
265
+ dest: Path,
266
+ *,
267
+ timeout: float = DEFAULT_TIMEOUT,
268
+ headers: dict[str, str] | None = None,
269
+ ) -> None:
270
+ """Télécharge ``url`` vers ``dest`` en **flux disque** ; anti-SSRF + plafond.
271
+
272
+ Écrit au fil de l'eau dans un fichier ``.part`` voisin (jamais tout le corps
273
+ en RAM), plafonne à ``IMAGE_MAX_BYTES``, puis **renomme atomiquement**
274
+ (``os.replace``) vers ``dest``. À la moindre erreur (statut, dépassement de
275
+ plafond, I/O, annulation), le ``.part`` est supprimé : **aucun fichier
276
+ partiel** n'est laissé derrière, et ``dest`` n'apparaît qu'une fois complet.
277
+
278
+ ``headers`` (ex. ``Authorization``) n'est transmis qu'à l'hôte d'origine —
279
+ ``_stream_validated`` le retire sur une redirection cross-hôte (cf. D-050).
280
+ """
281
+ part = dest.with_name(dest.name + ".part")
282
+ try:
283
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
284
+ try:
285
+ response.raise_for_status()
286
+ except httpx.HTTPStatusError as exc:
287
+ raise HttpFetchError(
288
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
289
+ ) from exc
290
+ total = 0
291
+ with part.open("wb") as handle:
292
+ for chunk in response.iter_bytes():
293
+ total += len(chunk)
294
+ if total > IMAGE_MAX_BYTES:
295
+ raise HttpFetchError(
296
+ f"réponse dépasse le plafond de {IMAGE_MAX_BYTES} octets."
297
+ )
298
+ handle.write(chunk)
299
+ os.replace(part, dest)
300
+ except BaseException:
301
+ part.unlink(missing_ok=True)
302
+ raise
303
+
304
+
305
+ def fetch_bytes(
306
+ url: str,
307
+ *,
308
+ timeout: float = DEFAULT_TIMEOUT,
309
+ headers: dict[str, str] | None = None,
310
+ ) -> bytes:
311
+ """Récupère un corps **binaire** en mémoire ; anti-SSRF + plafond manifeste.
312
+
313
+ Pour les ressources qui doivent être parsées depuis des **octets** (ex. ALTO
314
+ Gallica : lxml refuse un ``str`` portant une déclaration d'encodage XML).
315
+ """
316
+ try:
317
+ with _stream_validated(url, timeout=timeout, headers=headers) as response:
318
+ response.raise_for_status()
319
+ return _read_capped(response, MANIFEST_MAX_BYTES)
320
+ except httpx.HTTPStatusError as exc:
321
+ raise HttpFetchError(
322
+ f"statut HTTP {exc.response.status_code} sur {url!r}."
323
+ ) from exc
324
+ except httpx.HTTPError as exc:
325
+ # Échec de transport (connexion/timeout/protocole) : on l'enveloppe en
326
+ # HttpFetchError pour que les appelants n'aient qu'un type réseau à gérer
327
+ # (jamais une exception httpx brute qui échapperait au repli).
328
+ raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
329
+
330
+
331
+ def fetch_text(
332
+ url: str,
333
+ *,
334
+ timeout: float = DEFAULT_TIMEOUT,
335
+ headers: dict[str, str] | None = None,
336
+ ) -> str:
337
+ """Récupère un corps **texte** (UTF-8, erreurs remplacées) ; anti-SSRF + plafond.
338
+
339
+ Pour les endpoints qui ne servent pas du JSON (ex. catalogue HTR-United).
340
+ """
341
+ # Accept #15 : un corps distant peut contenir des octets non-UTF-8 isolés ;
342
+ # `errors="replace"` insère U+FFFD plutôt que d'échouer sur une donnée mal
343
+ # encodée (le contenu reste exploitable, pas une GT manuelle).
344
+ return fetch_bytes(url, timeout=timeout, headers=headers).decode(
345
+ "utf-8", errors="replace"
346
+ )
347
+
348
+
349
+ __all__ = [
350
+ "CorpusHttpError",
351
+ "HttpFetchError",
352
+ "IMAGE_MAX_BYTES",
353
+ "MANIFEST_MAX_BYTES",
354
+ "SsrfError",
355
+ "assert_public_url",
356
+ "download",
357
+ "fetch_bytes",
358
+ "fetch_json",
359
+ "fetch_text",
360
+ ]
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,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 _render_jpeg(source: str | Path, max_px: int) -> bytes | None:
27
+ """Octets JPEG d'une image locale redimensionnée ; ``None`` si indisponible.
28
+
29
+ Cœur partagé des deux saveurs (data-URI / fichier) : redimensionne pour que le
30
+ plus grand côté ≤ ``max_px`` (jamais agrandi), convertit en RGB, encode JPEG.
31
+ ``None`` (avec ``logger.warning``) si Pillow absent, fichier introuvable, ou
32
+ image illisible — le rendu reste fonctionnel (aperçu synthétique)."""
33
+ path = Path(source)
34
+ if not path.is_file():
35
+ return None
36
+ try:
37
+ from PIL import Image, UnidentifiedImageError
38
+ except ImportError as exc:
39
+ logger.warning(
40
+ "[images] Pillow indisponible (%s) — vignettes omises, aperçu "
41
+ "synthétique. Installer l'extra ``[images]``.",
42
+ exc,
43
+ )
44
+ return None
45
+ try:
46
+ with Image.open(path) as opened:
47
+ rgb = opened.convert("RGB")
48
+ rgb.thumbnail((max_px, max_px))
49
+ buffer = io.BytesIO()
50
+ rgb.save(buffer, format="JPEG", quality=82, optimize=True)
51
+ except (UnidentifiedImageError, OSError) as exc:
52
+ logger.warning("[images] image illisible %s : %s — vignette omise.", path, exc)
53
+ return None
54
+ return buffer.getvalue()
55
+
56
+
57
+ def thumbnail_data_uri(source: str | Path, *, max_px: int = 280) -> str | None:
58
+ """Vignette JPEG **data-URI** d'une image locale ; ``None`` si indisponible.
59
+
60
+ Saveur fichier unique : les octets sont inlinés dans le HTML. ``None`` si le
61
+ dérivé n'a pu être produit (Pillow absent, fichier manquant/illisible)."""
62
+ raw = _render_jpeg(source, max_px)
63
+ if raw is None:
64
+ return None
65
+ payload = base64.b64encode(raw).decode("ascii")
66
+ return f"data:image/jpeg;base64,{payload}"
67
+
68
+
69
+ def thumbnail_to_file(
70
+ source: str | Path, dest_dir: str | Path, stem: str, *, max_px: int = 280
71
+ ) -> str | None:
72
+ """Écrit la vignette JPEG dans ``dest_dir/<stem>.jpg`` ; renvoie un href relatif.
73
+
74
+ Saveur dossier : le dérivé est **un fichier sur disque** (pas inliné), le HTML
75
+ n'en porte qu'une **référence relative**. L'href renvoyé est relatif au
76
+ **parent** de ``dest_dir`` (``f"{dest_dir.name}/{stem}.jpg"``, séparateur ``/``
77
+ portable) : l'appelant garantit que le HTML est un voisin de ``dest_dir`` (cf.
78
+ ``app.report_images.write_report_bundle``). ``None`` si le dérivé n'a pu être
79
+ produit (dégradé gracieux, aucun fichier écrit, aucun dossier créé)."""
80
+ raw = _render_jpeg(source, max_px)
81
+ if raw is None:
82
+ return None
83
+ folder = Path(dest_dir)
84
+ folder.mkdir(parents=True, exist_ok=True)
85
+ (folder / f"{stem}.jpg").write_bytes(raw)
86
+ return f"{folder.name}/{stem}.jpg"
87
+
88
+
89
+ def iiif_derivative(
90
+ source: str | Path, dest_path: str | Path, *, width: int
91
+ ) -> tuple[int, int] | None:
92
+ """Écrit un dérivé JPEG de **largeur exacte** ``width`` (jamais agrandi) à
93
+ ``dest_path`` ; renvoie ``(largeur, hauteur)`` réels, ou ``None`` (dégradé).
94
+
95
+ Variante **largeur-fixe** de ``_render_jpeg`` (qui borne le plus grand côté) :
96
+ une taille IIIF Image API ``full/{w},/`` exige une largeur exacte, pas un
97
+ cadrage. Convertit en RGB, ré-échantillonne (Lanczos), crée les dossiers
98
+ parents. ``None`` (avec ``logger.warning``) si Pillow absent, fichier
99
+ introuvable ou image illisible — l'outil appelant décide d'échouer ou non."""
100
+ path = Path(source)
101
+ if not path.is_file():
102
+ logger.warning("[images] image introuvable %s — dérivé IIIF omis.", path)
103
+ return None
104
+ try:
105
+ from PIL import Image, UnidentifiedImageError
106
+ except ImportError as exc:
107
+ logger.warning(
108
+ "[images] Pillow indisponible (%s) — dérivé IIIF omis. Extra ``[images]``.",
109
+ exc,
110
+ )
111
+ return None
112
+ try:
113
+ with Image.open(path) as opened:
114
+ rgb = opened.convert("RGB")
115
+ orig_w, orig_h = rgb.size
116
+ target_w = min(width, orig_w) # jamais agrandi
117
+ target_h = max(1, round(orig_h * target_w / orig_w))
118
+ resized = rgb.resize((target_w, target_h), Image.Resampling.LANCZOS)
119
+ dest = Path(dest_path)
120
+ dest.parent.mkdir(parents=True, exist_ok=True)
121
+ resized.save(dest, format="JPEG", quality=82, optimize=True)
122
+ except (UnidentifiedImageError, OSError) as exc:
123
+ logger.warning(
124
+ "[images] image illisible %s : %s — dérivé IIIF omis.", path, exc
125
+ )
126
+ return None
127
+ return (target_w, target_h)
128
+
129
+
130
+ __all__ = ["thumbnail_data_uri", "thumbnail_to_file", "iiif_derivative"]
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/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/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,193 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ def __init__(
127
+ self,
128
+ *,
129
+ label: str,
130
+ model: str = _DEFAULT_MODEL,
131
+ role: str = "text_only",
132
+ prompt: str | None = None,
133
+ ) -> None:
134
+ self._label = validate_llm_label(label, "AnthropicAdapter")
135
+ self._model = model
136
+ self._role: PipelineMode = validate_role(role, "AnthropicAdapter", _SUPPORTED)
137
+ self._prompt = (
138
+ prompt if prompt is not None else default_prompt_for_role(self._role)
139
+ )
140
+
141
+ @property
142
+ def name(self) -> str:
143
+ return f"anthropic:{self._label}"
144
+
145
+ @property
146
+ def version(self) -> str:
147
+ return _VERSION
148
+
149
+ @property
150
+ def input_types(self) -> frozenset[ArtifactType]:
151
+ return llm_input_types(self._role)
152
+
153
+ @property
154
+ def output_types(self) -> frozenset[ArtifactType]:
155
+ return frozenset({llm_output_type(self._role)})
156
+
157
+ def execute(
158
+ self,
159
+ inputs: dict[ArtifactType, Artifact],
160
+ params: dict[str, ParamValue],
161
+ context: RunContext,
162
+ control: RunControl,
163
+ ) -> StepOutput:
164
+ def text_invoke(prompt: str) -> LLMCompletion:
165
+ return _invoke_anthropic(
166
+ model=self._model, prompt=prompt, deadline=context.deadline
167
+ )
168
+
169
+ def vision_invoke(
170
+ prompt: str, media_type: str, image_b64: str
171
+ ) -> LLMCompletion:
172
+ return _invoke_anthropic_vision(
173
+ model=self._model,
174
+ prompt=prompt,
175
+ media_type=media_type,
176
+ image_b64=image_b64,
177
+ deadline=context.deadline,
178
+ )
179
+
180
+ return run_llm_step(
181
+ role=self._role,
182
+ label=self._label,
183
+ name=self.name,
184
+ prompt=self._prompt,
185
+ inputs=inputs,
186
+ context=context,
187
+ control=control,
188
+ text_invoke=text_invoke,
189
+ vision_invoke=vision_invoke,
190
+ )
191
+
192
+
193
+ __all__ = ["AnthropicAdapter"]
cinoc/adapters/llm/mistral.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ def __init__(
122
+ self,
123
+ *,
124
+ label: str,
125
+ model: str = _DEFAULT_MODEL,
126
+ role: str = "text_only",
127
+ prompt: str | None = None,
128
+ ) -> None:
129
+ self._label = validate_llm_label(label, "MistralAdapter")
130
+ self._model = model
131
+ self._role: PipelineMode = validate_role(role, "MistralAdapter", _SUPPORTED)
132
+ self._prompt = (
133
+ prompt if prompt is not None else default_prompt_for_role(self._role)
134
+ )
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ return f"mistral:{self._label}"
139
+
140
+ @property
141
+ def version(self) -> str:
142
+ return _VERSION
143
+
144
+ @property
145
+ def input_types(self) -> frozenset[ArtifactType]:
146
+ return llm_input_types(self._role)
147
+
148
+ @property
149
+ def output_types(self) -> frozenset[ArtifactType]:
150
+ return frozenset({llm_output_type(self._role)})
151
+
152
+ def execute(
153
+ self,
154
+ inputs: dict[ArtifactType, Artifact],
155
+ params: dict[str, ParamValue],
156
+ context: RunContext,
157
+ control: RunControl,
158
+ ) -> StepOutput:
159
+ def text_invoke(prompt: str) -> LLMCompletion:
160
+ return _invoke_mistral(
161
+ model=self._model, prompt=prompt, deadline=context.deadline
162
+ )
163
+
164
+ def vision_invoke(
165
+ prompt: str, media_type: str, image_b64: str
166
+ ) -> LLMCompletion:
167
+ return _invoke_mistral_vision(
168
+ model=self._model,
169
+ prompt=prompt,
170
+ media_type=media_type,
171
+ image_b64=image_b64,
172
+ deadline=context.deadline,
173
+ )
174
+
175
+ return run_llm_step(
176
+ role=self._role,
177
+ label=self._label,
178
+ name=self.name,
179
+ prompt=self._prompt,
180
+ inputs=inputs,
181
+ context=context,
182
+ control=control,
183
+ text_invoke=text_invoke,
184
+ vision_invoke=vision_invoke,
185
+ )
186
+
187
+
188
+ def list_mistral_models(*, timeout: float = 5.0) -> tuple[str, ...]:
189
+ """IDs des modèles disponibles pour la clé Mistral courante (``models.list``).
190
+
191
+ **Best-effort, commodité d'UI** : ``MISTRAL_API_KEY`` absente, SDK absent ou
192
+ erreur réseau → ``()`` (l'interface retombe sur la saisie libre). Rien de
193
+ hardcodé : la liste vient **directement de l'API Mistral**.
194
+ """
195
+ import os
196
+
197
+ api_key = os.environ.get("MISTRAL_API_KEY")
198
+ if not api_key:
199
+ return ()
200
+ try:
201
+ from mistralai import Mistral # type: ignore[import-not-found]
202
+ except ImportError:
203
+ return ()
204
+ try:
205
+ client = Mistral(api_key=api_key, timeout_ms=int(timeout * 1000))
206
+ response = client.models.list()
207
+ except Exception as exc: # noqa: BLE001 — voir rationale ci-dessous
208
+ # Catch-all **assumé** : commodité d'UI qui ne doit jamais casser la
209
+ # page du composeur. Le SDK mistralai **enveloppe** ses erreurs HTTP
210
+ # (auth/429/5xx) dans ses propres types + propage des erreurs httpx pour
211
+ # le transport ; narrower au seul ``httpx.HTTPError`` (comme ollama, qui
212
+ # appelle httpx en direct) laisserait fuir une erreur SDK et casserait la
213
+ # page. On trace (§7) et on retombe sur la saisie libre.
214
+ logger.warning("[mistral] liste des modèles indisponible : %s", exc)
215
+ return ()
216
+ data = getattr(response, "data", None) or ()
217
+ ids = [m.id for m in data if isinstance(getattr(m, "id", None), str)]
218
+ return tuple(sorted(set(ids)))
219
+
220
+
221
+ __all__ = ["MistralAdapter", "list_mistral_models"]
cinoc/adapters/llm/ollama.py ADDED
@@ -0,0 +1,195 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 NoReturn
19
+
20
+ from cinoc.adapters.llm._base import (
21
+ DEFAULT_CORRECTION_PROMPT,
22
+ LLMCompletion,
23
+ normalize_llm_content,
24
+ run_llm_step,
25
+ usage_tokens,
26
+ validate_llm_label,
27
+ )
28
+ from cinoc.domain.artifacts import Artifact, ArtifactType
29
+ from cinoc.domain.deadline import Deadline
30
+ from cinoc.domain.errors import AdapterStepError
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 = "llama3"
37
+ _DEFAULT_HOST = "http://localhost:11434"
38
+
39
+
40
+ def _fail_or_cancel(control: RunControl, model: str, exc: Exception) -> NoReturn:
41
+ """Traduit un échec d'appel ollama.
42
+
43
+ Si l'annulation a été déclenchée, le ``client.close`` enregistré a fait
44
+ échouer la requête en vol : on lève alors ``RunCancelledError`` (via
45
+ ``raise_if_cancelled``). Sinon c'est une vraie panne → ``AdapterStepError``.
46
+ Sonder ``is_cancelled`` est **fiable**, là où l'implémentation source
47
+ devinait par le message d'exception (heuristique fragile, D-A).
48
+ """
49
+ control.raise_if_cancelled()
50
+ raise AdapterStepError(f"ollama a échoué ({model}) : {exc}") from exc
51
+
52
+
53
+ def _invoke_ollama( # pragma: no cover -- réseau (serveur ollama ; cf. marqueur 'live')
54
+ *,
55
+ model: str,
56
+ prompt: str,
57
+ host: str,
58
+ deadline: Deadline,
59
+ control: RunControl,
60
+ ) -> LLMCompletion:
61
+ try:
62
+ import httpx # type: ignore[import-not-found]
63
+ except ImportError as exc:
64
+ raise AdapterStepError(
65
+ "ollama : httpx non installé (pip install 'cinoc[ollama]')."
66
+ ) from exc
67
+ payload = {
68
+ "model": model,
69
+ "messages": [{"role": "user", "content": prompt}],
70
+ "stream": False,
71
+ }
72
+ client = httpx.Client(timeout=deadline.as_sdk_timeout())
73
+ # Annulation câblée : fermer le client interrompt la requête en vol.
74
+ control.register_cancel_handle(client.close)
75
+ try:
76
+ # **/api/chat** (et non /api/generate) : applique le gabarit de chat du
77
+ # modèle. Indispensable pour les modèles *instruct* (ex. churro), qui
78
+ # échouent sur /api/generate (complétion brute). C'est l'endpoint
79
+ # qu'utilise ``ollama run``.
80
+ response = client.post(f"{host}/api/chat", json=payload)
81
+ response.raise_for_status()
82
+ data = response.json()
83
+ except httpx.HTTPError as exc:
84
+ _fail_or_cancel(control, model, exc)
85
+ finally:
86
+ client.close()
87
+ # Ollama peut renvoyer une **erreur en HTTP 200** (modèle introuvable sous ce
88
+ # nom, mémoire insuffisante…) : ``raise_for_status`` ne la voit pas. On la
89
+ # remonte explicitement, sinon la « correction » serait un texte vide silencieux.
90
+ if isinstance(data, dict) and data.get("error"):
91
+ raise AdapterStepError(f"ollama a échoué ({model}) : {data['error']}")
92
+ message = data.get("message") if isinstance(data, dict) else None
93
+ content = message.get("content") if isinstance(message, dict) else None
94
+ # /api/chat expose la consommation : prompt_eval_count / eval_count.
95
+ counts = data if isinstance(data, dict) else {}
96
+ return LLMCompletion(
97
+ normalize_llm_content(content),
98
+ usage_tokens(counts.get("prompt_eval_count")),
99
+ usage_tokens(counts.get("eval_count")),
100
+ )
101
+
102
+
103
+ def list_installed_models(
104
+ host: str = _DEFAULT_HOST, *, timeout: float = 3.0
105
+ ) -> tuple[str, ...]:
106
+ """Noms des modèles installés sur le serveur ollama (``GET /api/tags``).
107
+
108
+ **Best-effort, commodité d'UI** : serveur injoignable, ``httpx`` absent ou
109
+ réponse inattendue → ``()`` (l'interface retombe alors sur la saisie libre).
110
+ Aucune exception propagée. Le ``name`` renvoyé est le **tag exact** servable à
111
+ ``/api/generate`` (ex. ``gemma3:1b`` ou ``hf.co/…/churro-3B-GGUF:Q4_K_M``).
112
+ """
113
+ try:
114
+ import httpx # type: ignore[import-not-found]
115
+ except ImportError:
116
+ return ()
117
+ try:
118
+ with httpx.Client(timeout=timeout) as client:
119
+ response = client.get(f"{host}/api/tags")
120
+ response.raise_for_status()
121
+ data = response.json()
122
+ except (httpx.HTTPError, ValueError):
123
+ return ()
124
+ models = data.get("models") if isinstance(data, dict) else None
125
+ if not isinstance(models, list):
126
+ return ()
127
+ return tuple(
128
+ m["name"]
129
+ for m in models
130
+ if isinstance(m, dict) and isinstance(m.get("name"), str)
131
+ )
132
+
133
+
134
+ class OllamaAdapter:
135
+ """Post-correction LLM, serveur ollama local : ``RAW_TEXT`` → ``CORRECTED_TEXT``."""
136
+
137
+ def __init__(
138
+ self,
139
+ *,
140
+ label: str,
141
+ model: str = _DEFAULT_MODEL,
142
+ host: str = _DEFAULT_HOST,
143
+ prompt: str = DEFAULT_CORRECTION_PROMPT,
144
+ ) -> None:
145
+ self._label = validate_llm_label(label, "OllamaAdapter")
146
+ self._model = model
147
+ self._host = host
148
+ self._prompt = prompt
149
+
150
+ @property
151
+ def name(self) -> str:
152
+ return f"ollama:{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 frozenset({ArtifactType.RAW_TEXT})
161
+
162
+ @property
163
+ def output_types(self) -> frozenset[ArtifactType]:
164
+ return frozenset({ArtifactType.CORRECTED_TEXT})
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_ollama(
175
+ model=self._model,
176
+ prompt=prompt,
177
+ host=self._host,
178
+ deadline=context.deadline,
179
+ control=control,
180
+ )
181
+
182
+ return run_llm_step(
183
+ role="text_only",
184
+ label=self._label,
185
+ name=self.name,
186
+ prompt=self._prompt,
187
+ inputs=inputs,
188
+ context=context,
189
+ control=control,
190
+ text_invoke=text_invoke,
191
+ vision_invoke=None,
192
+ )
193
+
194
+
195
+ __all__ = ["OllamaAdapter"]
cinoc/adapters/llm/openai.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+ def __init__(
122
+ self,
123
+ *,
124
+ label: str,
125
+ model: str = _DEFAULT_MODEL,
126
+ role: str = "text_only",
127
+ prompt: str | None = None,
128
+ ) -> None:
129
+ self._label = validate_llm_label(label, "OpenAIAdapter")
130
+ self._model = model
131
+ self._role: PipelineMode = validate_role(role, "OpenAIAdapter", _SUPPORTED)
132
+ self._prompt = (
133
+ prompt if prompt is not None else default_prompt_for_role(self._role)
134
+ )
135
+
136
+ @property
137
+ def name(self) -> str:
138
+ return f"openai:{self._label}"
139
+
140
+ @property
141
+ def version(self) -> str:
142
+ return _VERSION
143
+
144
+ @property
145
+ def input_types(self) -> frozenset[ArtifactType]:
146
+ return llm_input_types(self._role)
147
+
148
+ @property
149
+ def output_types(self) -> frozenset[ArtifactType]:
150
+ return frozenset({llm_output_type(self._role)})
151
+
152
+ def execute(
153
+ self,
154
+ inputs: dict[ArtifactType, Artifact],
155
+ params: dict[str, ParamValue],
156
+ context: RunContext,
157
+ control: RunControl,
158
+ ) -> StepOutput:
159
+ def text_invoke(prompt: str) -> LLMCompletion:
160
+ return _invoke_openai(
161
+ model=self._model, prompt=prompt, deadline=context.deadline
162
+ )
163
+
164
+ def vision_invoke(
165
+ prompt: str, media_type: str, image_b64: str
166
+ ) -> LLMCompletion:
167
+ return _invoke_openai_vision(
168
+ model=self._model,
169
+ prompt=prompt,
170
+ media_type=media_type,
171
+ image_b64=image_b64,
172
+ deadline=context.deadline,
173
+ )
174
+
175
+ return run_llm_step(
176
+ role=self._role,
177
+ label=self._label,
178
+ name=self.name,
179
+ prompt=self._prompt,
180
+ inputs=inputs,
181
+ context=context,
182
+ control=control,
183
+ text_invoke=text_invoke,
184
+ vision_invoke=vision_invoke,
185
+ )
186
+
187
+
188
+ __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/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
+ ]
cinoc/adapters/storage/history_store.py ADDED
@@ -0,0 +1,191 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``HistoryStore`` — historique **longitudinal** des runs, en SQLite (couche 5).
2
+
3
+ Persiste, run après run, la **valeur agrégée de chaque métrique** par pipeline et
4
+ par vue, pour suivre l'évolution dans le temps (tendance) et **détecter les
5
+ régressions** (un moteur dont le CER se dégrade d'un run au suivant).
6
+
7
+ Le store ne connaît **que des enregistrements primitifs** (``HistoryRecord``) : il
8
+ n'importe pas ``RunResult`` (couche 3) — c'est la couche ``app`` qui aplatit un
9
+ ``RunResult`` en enregistrements (``app/history.py``). Persistance pure, réutilisable
10
+ et testable avec de la donnée simple.
11
+
12
+ Concurrence : **une connexion par opération** (``sqlite3.connect`` est bon marché),
13
+ donc aucun partage de connexion entre threads. Ré-enregistrer un même run est
14
+ **idempotent** (clé primaire ``(run_id, pipeline, view, metric)``).
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import sqlite3
20
+ from collections.abc import Iterable
21
+ from contextlib import closing
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ _SCHEMA = """
26
+ CREATE TABLE IF NOT EXISTS run_metrics (
27
+ run_id TEXT NOT NULL,
28
+ completed_at TEXT NOT NULL,
29
+ corpus_name TEXT NOT NULL,
30
+ code_version TEXT NOT NULL,
31
+ pipeline TEXT NOT NULL,
32
+ view TEXT NOT NULL,
33
+ metric TEXT NOT NULL,
34
+ value REAL NOT NULL,
35
+ PRIMARY KEY (run_id, pipeline, view, metric)
36
+ );
37
+ CREATE INDEX IF NOT EXISTS idx_run_metrics_lookup
38
+ ON run_metrics (view, metric, pipeline, completed_at);
39
+ """
40
+
41
+ _COLUMNS = (
42
+ "run_id, completed_at, corpus_name, code_version, pipeline, view, metric, value"
43
+ )
44
+
45
+
46
+ @dataclass(frozen=True)
47
+ class HistoryRecord:
48
+ """Une valeur de métrique agrégée pour un run (ligne d'historique)."""
49
+
50
+ run_id: str
51
+ completed_at: str # ISO 8601 (UTC) — triable lexicographiquement
52
+ corpus_name: str
53
+ code_version: str
54
+ pipeline: str
55
+ view: str
56
+ metric: str
57
+ value: float
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Regression:
62
+ """Dégradation d'un pipeline entre ses deux runs les plus récents."""
63
+
64
+ pipeline: str
65
+ view: str
66
+ metric: str
67
+ previous_run_id: str
68
+ latest_run_id: str
69
+ previous: float
70
+ latest: float
71
+ delta: float # latest - previous (signé)
72
+
73
+
74
+ class HistoryStore:
75
+ """Historique longitudinal sur un fichier SQLite."""
76
+
77
+ def __init__(self, db_path: str | Path) -> None:
78
+ # **Initialisation paresseuse** : on ne touche pas au filesystem ici.
79
+ # ``create_app`` peut instancier le store avec un chemin par défaut
80
+ # (``/data`` sur un Space) non writable au moment de la construction ;
81
+ # le dossier + le schéma sont créés à la **première** opération réelle.
82
+ self._path = Path(db_path)
83
+
84
+ def _connect(self) -> sqlite3.Connection:
85
+ """Ouvre une connexion, créant dossier + schéma au besoin (idempotent)."""
86
+ self._path.parent.mkdir(parents=True, exist_ok=True)
87
+ conn = sqlite3.connect(self._path)
88
+ conn.executescript(_SCHEMA)
89
+ return conn
90
+
91
+ def add(self, records: Iterable[HistoryRecord]) -> int:
92
+ """Enregistre des lignes (``INSERT OR REPLACE``) ; renvoie le compte."""
93
+ rows = [
94
+ (
95
+ r.run_id,
96
+ r.completed_at,
97
+ r.corpus_name,
98
+ r.code_version,
99
+ r.pipeline,
100
+ r.view,
101
+ r.metric,
102
+ r.value,
103
+ )
104
+ for r in records
105
+ ]
106
+ if not rows:
107
+ return 0
108
+ with closing(self._connect()) as conn:
109
+ conn.executemany(
110
+ f"INSERT OR REPLACE INTO run_metrics ({_COLUMNS}) "
111
+ "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
112
+ rows,
113
+ )
114
+ conn.commit()
115
+ return len(rows)
116
+
117
+ def history(
118
+ self, pipeline: str, view: str, metric: str
119
+ ) -> tuple[HistoryRecord, ...]:
120
+ """Suite chronologique (croissante) d'une métrique pour un pipeline/vue."""
121
+ with closing(self._connect()) as conn:
122
+ cur = conn.execute(
123
+ f"SELECT {_COLUMNS} FROM run_metrics "
124
+ "WHERE pipeline = ? AND view = ? AND metric = ? "
125
+ "ORDER BY completed_at, run_id", # run_id = bris d'égalité déterministe
126
+ (pipeline, view, metric),
127
+ )
128
+ return tuple(HistoryRecord(*row) for row in cur.fetchall())
129
+
130
+ def all_records(self, *, limit: int = 1000) -> tuple[HistoryRecord, ...]:
131
+ """Lignes les plus récentes d'abord, **bornées** (pour la vue Historique)."""
132
+ with closing(self._connect()) as conn:
133
+ cur = conn.execute(
134
+ f"SELECT {_COLUMNS} FROM run_metrics "
135
+ "ORDER BY completed_at DESC, run_id, pipeline, view, metric "
136
+ "LIMIT ?",
137
+ (limit,),
138
+ )
139
+ return tuple(HistoryRecord(*row) for row in cur.fetchall())
140
+
141
+ def regressions(
142
+ self,
143
+ view: str,
144
+ metric: str,
145
+ *,
146
+ threshold: float = 0.0,
147
+ higher_is_better: bool = False,
148
+ ) -> tuple[Regression, ...]:
149
+ """Pipelines dont la métrique s'est dégradée entre les 2 derniers runs.
150
+
151
+ ``higher_is_better=False`` (défaut, CER/WER) : régression = la valeur a
152
+ **augmenté** de plus de ``threshold``. ``True`` inverse le sens.
153
+ """
154
+ with closing(self._connect()) as conn:
155
+ cur = conn.execute(
156
+ "SELECT pipeline, completed_at, value, run_id FROM run_metrics "
157
+ "WHERE view = ? AND metric = ? "
158
+ "ORDER BY pipeline, completed_at, run_id", # bris d'égalité stable
159
+ (view, metric),
160
+ )
161
+ rows = cur.fetchall()
162
+
163
+ by_pipeline: dict[str, list[tuple[str, float, str]]] = {}
164
+ for pipeline, completed_at, value, run_id in rows:
165
+ by_pipeline.setdefault(pipeline, []).append((completed_at, value, run_id))
166
+
167
+ out: list[Regression] = []
168
+ for pipeline, series in by_pipeline.items():
169
+ if len(series) < 2:
170
+ continue
171
+ _, prev_value, prev_run = series[-2]
172
+ _, last_value, last_run = series[-1]
173
+ delta = last_value - prev_value
174
+ worse = -delta if higher_is_better else delta
175
+ if worse > threshold:
176
+ out.append(
177
+ Regression(
178
+ pipeline=pipeline,
179
+ view=view,
180
+ metric=metric,
181
+ previous_run_id=prev_run,
182
+ latest_run_id=last_run,
183
+ previous=prev_value,
184
+ latest=last_value,
185
+ delta=delta,
186
+ )
187
+ )
188
+ return tuple(out)
189
+
190
+
191
+ __all__ = ["HistoryRecord", "HistoryStore", "Regression"]
cinoc/adapters/storage/job_store.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """``JobStore`` — état des jobs de run, **en mémoire et thread-safe** (couche 5).
2
+
3
+ Un job = une exécution asynchrone d'un ``RunSpec`` (lancée par le ``JobRunner``,
4
+ couche 6). Le store ne *fait* rien : il garde l'état observable (la machine à
5
+ états ci-dessous) pour que le web le restitue et qu'un ``cancel`` soit traçable.
6
+
7
+ Concurrence : un seul verrou protège le dict ; ``Job`` est **immuable** (pydantic
8
+ ``frozen``), donc un lecteur (requête HTTP) reçoit un instantané cohérent sans
9
+ risque de course avec le worker qui le remplace sous verrou.
10
+
11
+ Chaque transition est aussi **journalisée** (``_history``) : un instantané ``Job``
12
+ par changement d'état, indexé par un id monotone (1-based). C'est la matière
13
+ première du **flux SSE** + de la reprise ``Last-Event-ID`` (couche 8) — rejouable
14
+ même après la fin du job.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import threading
20
+ import uuid
21
+ from enum import StrEnum
22
+
23
+ from pydantic import BaseModel, ConfigDict
24
+
25
+ from cinoc.domain.errors import CinocError
26
+ from cinoc.domain.run import utcnow
27
+
28
+
29
+ class JobState(StrEnum):
30
+ """États d'un job. Terminaux : ``DONE``, ``FAILED``, ``CANCELLED``."""
31
+
32
+ PENDING = "pending"
33
+ RUNNING = "running"
34
+ DONE = "done"
35
+ FAILED = "failed"
36
+ CANCELLED = "cancelled"
37
+
38
+ @property
39
+ def is_terminal(self) -> bool:
40
+ return self in _TERMINAL
41
+
42
+
43
+ _TERMINAL = frozenset({JobState.DONE, JobState.FAILED, JobState.CANCELLED})
44
+
45
+
46
+ class JobError(CinocError):
47
+ """Job introuvable."""
48
+
49
+
50
+ class Job(BaseModel):
51
+ """Instantané immuable de l'état d'un job."""
52
+
53
+ model_config = ConfigDict(frozen=True, extra="forbid")
54
+
55
+ id: str
56
+ state: JobState
57
+ created_at: str
58
+ updated_at: str
59
+ #: Stem du ``RunResult`` JSON écrit quand ``state == DONE`` (sinon ``None``).
60
+ report_name: str | None = None
61
+ #: Message d'erreur quand ``state == FAILED`` (sinon ``None``).
62
+ error: str | None = None
63
+ #: URL distante du rapport publié si la persistance est active, sinon ``None``.
64
+ published_url: str | None = None
65
+ #: Progression : unités (document × concurrent) traitées / total. ``0/0`` =
66
+ #: inconnu (job pas encore démarré). Alimente la barre de progression (SSE).
67
+ done: int = 0
68
+ total: int = 0
69
+
70
+
71
+ class JobStore:
72
+ """Dictionnaire thread-safe ``job_id → Job`` (instantanés immuables)."""
73
+
74
+ def __init__(self) -> None:
75
+ self._jobs: dict[str, Job] = {}
76
+ self._history: dict[str, list[Job]] = {}
77
+ self._lock = threading.Lock()
78
+
79
+ def create(self) -> Job:
80
+ """Crée un job ``PENDING`` (id généré) et le renvoie."""
81
+ now = utcnow().isoformat()
82
+ job = Job(id=uuid.uuid4().hex, state=JobState.PENDING, created_at=now,
83
+ updated_at=now)
84
+ with self._lock:
85
+ self._jobs[job.id] = job
86
+ self._history[job.id] = [job]
87
+ return job
88
+
89
+ def get(self, job_id: str) -> Job | None:
90
+ with self._lock:
91
+ return self._jobs.get(job_id)
92
+
93
+ def all_jobs(self) -> tuple[Job, ...]:
94
+ """Tous les jobs, du plus récent au plus ancien."""
95
+ with self._lock:
96
+ jobs = tuple(self._jobs.values())
97
+ return tuple(sorted(jobs, key=lambda j: j.created_at, reverse=True))
98
+
99
+ def update(
100
+ self,
101
+ job_id: str,
102
+ *,
103
+ state: JobState,
104
+ report_name: str | None = None,
105
+ error: str | None = None,
106
+ published_url: str | None = None,
107
+ ) -> Job:
108
+ """Remplace l'instantané du job (sous verrou). Lève si inconnu."""
109
+ with self._lock:
110
+ current = self._jobs.get(job_id)
111
+ if current is None:
112
+ raise JobError(f"job {job_id!r} introuvable.")
113
+ updated = current.model_copy(
114
+ update={
115
+ "state": state,
116
+ "report_name": report_name,
117
+ "error": error,
118
+ "published_url": published_url,
119
+ "updated_at": utcnow().isoformat(),
120
+ }
121
+ )
122
+ self._jobs[job_id] = updated
123
+ self._history[job_id].append(updated)
124
+ return updated
125
+
126
+ def progress(self, job_id: str, done: int, total: int) -> Job:
127
+ """Met à jour la progression (``done``/``total``) **sans changer l'état**.
128
+
129
+ Ajoute un instantané au journal → un **événement SSE** (de type ``running``)
130
+ par unité traitée, que l'UI consomme pour la barre de progression. Les
131
+ autres champs (état, rapport…) sont préservés (``model_copy`` ciblé).
132
+ """
133
+ with self._lock:
134
+ current = self._jobs.get(job_id)
135
+ if current is None:
136
+ raise JobError(f"job {job_id!r} introuvable.")
137
+ updated = current.model_copy(
138
+ update={
139
+ "done": done,
140
+ "total": total,
141
+ "updated_at": utcnow().isoformat(),
142
+ }
143
+ )
144
+ self._jobs[job_id] = updated
145
+ self._history[job_id].append(updated)
146
+ return updated
147
+
148
+ def history_since(self, job_id: str, after_id: int) -> list[tuple[int, Job]]:
149
+ """Instantanés (``event_id``, ``Job``) du journal **après** ``after_id``.
150
+
151
+ ``event_id`` est la position 1-based dans le journal du job → c'est l'id
152
+ d'événement SSE (``Last-Event-ID``). Job inconnu → liste vide.
153
+ """
154
+ with self._lock:
155
+ history = list(self._history.get(job_id, ()))
156
+ return [(i, job) for i, job in enumerate(history, start=1) if i > after_id]
157
+
158
+
159
+ __all__ = ["Job", "JobError", "JobState", "JobStore"]
cinoc/adapters/storage/publisher.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Publication d'un ``RunResult`` vers un dépôt distant (persistance).
2
+
3
+ Le disque d'un HF Space est **éphémère** : un run calculé est perdu au
4
+ redémarrage. Ce module **pousse** le ``RunResult`` JSON vers un dépôt GitHub
5
+ (API *contents*) après chaque run réussi, ce qui le rend **durable** et alimente
6
+ la vitrine publique.
7
+
8
+ Sécurité & opt-in :
9
+
10
+ - **Désactivé par défaut.** Sans dépôt **et** jeton (secrets), on renvoie un
11
+ ``NoopPublisher`` : aucune sortie réseau. La vitrine read-only (sans secret)
12
+ reste donc inerte.
13
+ - Le **jeton vient d'un secret** (``CINOC_PUBLISH_TOKEN``) et n'est **jamais
14
+ journalisé** (il ne vit que dans l'en-tête ``Authorization``).
15
+ - Aucune dépendance ajoutée : l'appel HTTP réel utilise ``urllib`` (stdlib),
16
+ importé et exécuté seulement à la publication (chemin *live*, hors CI).
17
+
18
+ La construction de la requête (:func:`build_put_request`) est **pure** et
19
+ testée ; seul l'envoi réseau est non couvert.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import base64
25
+ import json
26
+ import logging
27
+ import os
28
+ from collections.abc import Callable
29
+ from pathlib import Path
30
+ from typing import NamedTuple, Protocol
31
+ from urllib.parse import quote
32
+
33
+ from pydantic import BaseModel, ConfigDict
34
+
35
+ logger = logging.getLogger(__name__)
36
+
37
+ _API = "https://api.github.com"
38
+
39
+
40
+ class PublishTarget(BaseModel):
41
+ """Cible de publication (dépôt GitHub) — résolue depuis les secrets."""
42
+
43
+ model_config = ConfigDict(frozen=True, extra="forbid")
44
+
45
+ repo: str # "owner/name"
46
+ token: str
47
+ branch: str = "main"
48
+ subdir: str = "reports"
49
+
50
+
51
+ class PutRequest(NamedTuple):
52
+ """Requête HTTP prête à envoyer (pure, testable sans réseau)."""
53
+
54
+ url: str
55
+ headers: dict[str, str]
56
+ body: bytes
57
+
58
+
59
+ class ResultPublisher(Protocol):
60
+ """Publie un ``RunResult`` ; renvoie l'URL distante, ou ``None`` si inactif."""
61
+
62
+ def publish(self, name: str, run_result_path: Path) -> str | None: ...
63
+
64
+
65
+ class NoopPublisher:
66
+ """Publisher inactif (aucun secret configuré) : ne fait rien."""
67
+
68
+ def publish(self, name: str, run_result_path: Path) -> str | None:
69
+ return None
70
+
71
+
72
+ def build_put_request(target: PublishTarget, name: str, content: bytes) -> PutRequest:
73
+ """Construit la requête *PUT contents* GitHub pour ``<subdir>/<name>.json``."""
74
+ path = quote(f"{target.subdir}/{name}.json", safe="/")
75
+ body = json.dumps(
76
+ {
77
+ "message": f"cinoc: publie le rapport {name}",
78
+ "content": base64.b64encode(content).decode("ascii"),
79
+ "branch": target.branch,
80
+ }
81
+ ).encode("utf-8")
82
+ headers = {
83
+ "Authorization": f"Bearer {target.token}",
84
+ "Accept": "application/vnd.github+json",
85
+ "X-GitHub-Api-Version": "2022-11-28",
86
+ "Content-Type": "application/json",
87
+ }
88
+ return PutRequest(f"{_API}/repos/{target.repo}/contents/{path}", headers, body)
89
+
90
+
91
+ class GitHubPublisher:
92
+ """Pousse le ``RunResult`` JSON vers un dépôt GitHub (API *contents*)."""
93
+
94
+ def __init__(self, target: PublishTarget) -> None:
95
+ self._target = target
96
+
97
+ def publish(self, name: str, run_result_path: Path) -> str | None:
98
+ content = run_result_path.read_bytes()
99
+ request = build_put_request(self._target, name, content)
100
+ return self._send(request)
101
+
102
+ def _send(self, request: PutRequest) -> str | None: # pragma: no cover (réseau)
103
+ import urllib.request
104
+
105
+ req = urllib.request.Request(
106
+ request.url, data=request.body, headers=request.headers, method="PUT"
107
+ )
108
+ with urllib.request.urlopen(req, timeout=30) as resp:
109
+ payload = json.loads(resp.read().decode("utf-8"))
110
+ content = payload.get("content") or {}
111
+ return content.get("html_url")
112
+
113
+
114
+ def resolve_publish_target(
115
+ get_env: Callable[[str], str | None] = os.environ.get,
116
+ ) -> PublishTarget | None:
117
+ """Cible depuis les secrets, ou ``None`` si dépôt **ou** jeton manquant."""
118
+ repo = (get_env("CINOC_PUBLISH_REPO") or "").strip()
119
+ token = (get_env("CINOC_PUBLISH_TOKEN") or "").strip()
120
+ if not repo or "/" not in repo or not token:
121
+ return None
122
+ return PublishTarget(
123
+ repo=repo,
124
+ token=token,
125
+ branch=(get_env("CINOC_PUBLISH_BRANCH") or "main").strip(),
126
+ subdir=(get_env("CINOC_PUBLISH_DIR") or "reports").strip(),
127
+ )
128
+
129
+
130
+ def resolve_publisher(
131
+ get_env: Callable[[str], str | None] = os.environ.get,
132
+ ) -> ResultPublisher:
133
+ """``GitHubPublisher`` si configuré (secrets), sinon ``NoopPublisher``."""
134
+ target = resolve_publish_target(get_env)
135
+ return GitHubPublisher(target) if target is not None else NoopPublisher()
136
+
137
+
138
+ __all__ = [
139
+ "GitHubPublisher",
140
+ "NoopPublisher",
141
+ "PublishTarget",
142
+ "PutRequest",
143
+ "ResultPublisher",
144
+ "build_put_request",
145
+ "resolve_publish_target",
146
+ "resolve_publisher",
147
+ ]
cinoc/app/ANALYSE_COUCHE_6.md ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ANALYSE_COUCHE_6.md — `app` (Picarones → Cinoc)
2
+
3
+ > **Type** : session d'ANALYSE (guide de portage durable). **Aucun code Cinoc écrit.**
4
+ > **Couche 6 = `app`** (orchestration applicative) dans
5
+ > `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`.
6
+ > **Source figée** : `Picarones/picarones/app/` — **27 fichiers, 6 648 LOC**.
7
+ > **Méthode** : 4 sous-agents d'exploration en parallèle (un par cluster), synthèse + recoupement
8
+ > personnel des points décisifs dans le code (couplages inter-couches, sécurité, code mort, shim).
9
+ > **Deux natures de savoir** (CLAUDE.md §9) : **Partie 1** = analyse source (durable) ;
10
+ > **Partie 2** = réorg cible (périssable, « à confirmer à la tranche »). Verdicts **PROVISOIRES**.
11
+
12
+ ---
13
+
14
+ ## PARTIE 0 — Vérification anti-contradiction (obligatoire)
15
+
16
+ Aucune conclusion ne contredit `CLAUDE.md` ni une couche mergée (domain ✅, formats ✅ ; evaluation = plan). Au contraire, l'analyse **confirme** trois décisions déjà actées ailleurs :
17
+
18
+ | Décision actée (où) | Confirmé dans le code Picarones |
19
+ |---|---|
20
+ | `RunSpec`/`StepSpec` → `domain`, **séparés du loader YAML** (backlog domain ; couche 1 §9) | `run_spec.py` mêle effectivement type déclaratif + loader YAML + résolution dotted-path → split nécessaire |
21
+ | `_benchmark_*` : double format `BenchmarkResult↔RunResult` supprimé + calcul rapatrié en `evaluation/runner` (CLAUDE.md §8.1 ; couche 3 §6,§11,§12-4) | `_benchmark_converter` calcule (compute_metrics, hooks, aggregate) ; `_benchmark_ner` calcule du NER ; les deux vivent à tort en `app/` |
22
+ | Persistance / store → `adapters/storage` (couche 3 §8) | `benchmark_service.persist()` écrit du JSONL ; `job_runner` parle à `adapters.storage.JobStore` |
23
+ | Registre + factory + **découverte entry-points en `app`** (CLAUDE.md §3) | `registry_service.bootstrap_default_registries()` + `resolve_adapter_class()` + `_resolve_entity_extractor()` = les **germes** (mais enregistrement codé en dur, **pas** d'entry-points) |
24
+
25
+ **Nuance importante à ne pas violer** (CLAUDE.md §3) : le **seul** point d'extension tiers = les **briques de pipeline**. Métriques, projecteurs, importeurs restent **internes**. Or `registry_service` mélange aujourd'hui registre de **métriques** (interne) et résolution d'**adapters** (extensible) — à scinder en cible.
26
+
27
+ ---
28
+
29
+ # PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable)
30
+
31
+ ## 1.1 Inventaire par cluster (27 fichiers / 6 648 LOC)
32
+
33
+ | Cluster | Fichiers | LOC | Rôle global vérifié |
34
+ |---|---|---|---|
35
+ | **A. Colonne vertébrale + API** | `__init__.py`(27), `results.py`(24), `services/__init__.py`(96), `schemas/__init__.py`(49), `schemas/run_spec.py`(559) | **755** | Headers de paquet, façades de re-export, DTO YAML + loader |
36
+ | **B. Shim double-format `_benchmark_*`** | `_benchmark_adapter_resolver.py`(313), `_benchmark_conversions.py`(255), `_benchmark_converter.py`(236), `_benchmark_helpers.py`(266), `_benchmark_ner.py`(189), `_benchmark_persistence.py`(55), `benchmark_service.py`(407) | **1 721** | Conversion `RunResult`↔`BenchmarkResult` (legacy) + calcul de métriques mal placé + runner v2 |
37
+ | **C. Orchestration / jobs / reprise** | `run_orchestrator.py`(500), `run_orchestrator_execution.py`(310), `run_orchestrator_helpers/{__init__(55),builders(241),factories(119),legacy(308),loaders(43)}`, `_orchestrator_partial.py`(256), `job_runner.py`(267), `partial_store.py`(360) | **2 459** | `RunOrchestrator`, exécution asynchrone, reprise partielle (fingerprint SHA-256) |
38
+ | **D. Services transverses** | `path_security.py`(454), `corpus_service.py`(541), `registry_service.py`(327), `python_helpers.py`(277), `dependencies.py`(114) | **1 713** | Sécurité chemins (invariant §12), import ZIP corpus, bootstrap registres, bridge Python, capture deps |
39
+
40
+ **5 fichiers > 400 LOC** (budget Cinoc) : `run_spec`(559), `corpus_service`(541), `run_orchestrator`(500), `path_security`(454), `benchmark_service`(407).
41
+
42
+ ## 1.2 Couplages inter-couches & consommateurs réels
43
+
44
+ **`app/` importe** (orientation **respectée** : que des couches internes ; **jamais** `reports/` ni `interfaces/`) :
45
+
46
+ | Vers | Symboles principaux | Cluster qui couple |
47
+ |---|---|---|
48
+ | `domain` | errors, artifacts (ArtifactType/Artifact), corpus (CorpusSpec), documents (DocumentRef/GroundTruthRef), pipeline_spec, evaluation_spec, run_manifest | tous |
49
+ | `evaluation` | **benchmark_result, metric_hooks, metric_result, metrics.{text,ner,over_normalization,search,alto_structural}, views, projectors, registry, corpus** | **B** (calcul/shim) + **D** (registry_service) |
50
+ | `pipeline` | runner.CorpusRunner, types.{PipelineResult,RunContext}, run_result.RunResult, llm_pipeline_builder | B, C |
51
+ | `formats` | alto.parser.parse_alto, text.normalization.get_builtin_profile | C (loaders), legacy |
52
+ | `adapters` | storage.JobStore, ocr.base.BaseOCRAdapter, corpus._fallback_log | B, C |
53
+
54
+ **Qui consomme `app/`** (grep hors `tests/`) — seulement **2 frontières** (sain) :
55
+
56
+ | Consommateur | Importe | Usage |
57
+ |---|---|---|
58
+ | `picarones/__init__.py` (API top-level) | `RunSpec`, `RunSpecLoadError`, `load_run_spec_from_yaml`, `RunOrchestrator`, `OrchestrationResult`, `prepare_preset_args`, `run_result_to_benchmark_result` | API publique + ⚠️ auto-déclenche `register_default_metrics()` à l'import (effet de bord) |
59
+ | `interfaces/cli/_workflows.py` | `RunOrchestrator`, `prepare_preset_args`, `run_result_to_benchmark_result` | `prepare → execute_preset → convertir en BenchmarkResult` |
60
+ | `interfaces/web/{benchmark_utils,_path_helpers,security_paths}.py` | `path_security.*`, `RunOrchestrator`, `prepare_preset_args`, `run_result_to_benchmark_result` | endpoints `/api/benchmark/start`, sandbox chemins |
61
+
62
+ > **Pattern consommateur clé** : CLI et web font tous deux
63
+ > `RunResult → run_result_to_benchmark_result() → BenchmarkResult` **parce que les renderers
64
+ > (couche 7) consomment `BenchmarkResult`, pas `RunResult`**. C'est la racine du double format.
65
+ > En Cinoc (format unique `RunResult`, reports le lit direct) **cette conversion disparaît entièrement**.
66
+
67
+ ## 1.3 La dette centrale : double format de sortie + god-module éclaté
68
+
69
+ - Picarones produit **deux** formats : `RunResult` (couche 4, `pipeline/run_result.py`) **et** `BenchmarkResult` (couche 3, `evaluation/benchmark_result.py`). Le cluster B est presque entièrement le **pont** entre les deux.
70
+ - Historique vérifié : un god-module **`benchmark_runner.py` (~1 700 LOC)** a été éclaté en `_benchmark_*` lors d'une « Phase 6 audit code-quality ». **`benchmark_runner.py` n'existe plus** — toutes les docstrings qui le citent (≈ 15 fichiers) sont **périmées**. Idem `report_service.py` (cité, inexistant).
71
+ - Réconciliation du « ~1 570 LOC de shim » (CLAUDE.md §8.1) : shim strict ≈ `_benchmark_converter`(236) + `_benchmark_helpers`(266) + `_benchmark_persistence`(55) + ~½ `_benchmark_conversions`(255) + ~½ `_benchmark_adapter_resolver`(313) ≈ **950–1 000 LOC**, + le pont `run_orchestrator_helpers/legacy.py`(308) + le mort `partial_store.py`(360, legacy) → **~1 600 LOC de code legacy/shim/double-format** au total. ✅ cohérent.
72
+
73
+ ## 1.4 Code mort & doc morte confirmés (recoupés dans le code)
74
+
75
+ | Élément | Preuve | Note |
76
+ |---|---|---|
77
+ | **`partial_store.py` : `_load_partial`, `_save_partial_line`, `_delete_partial`, `partial_path_for_engine`** | **0 consommateur hors-tests** ; servaient `run_benchmark_via_service` | ⚠️ ~½ du fichier est **mort** depuis la suppression de cette fonction |
78
+ | **`run_benchmark_via_service`** | **aucun `def`** dans `picarones/` ; ne subsistent que docstrings/commentaires (dont l'avis de dépréciation de `__init__.py`) | fonction **supprimée** mais « fantôme » partout en prose |
79
+ | Réfs à **`benchmark_runner.py`** (~15 fichiers) | `find` → inexistant | doc morte (god-module éclaté) |
80
+ | Réfs à **`_benchmark_execution.py`** (factories/builders) | inexistant | pattern copié-commenté, pas importé |
81
+ | Réf à **`report_service.py`** (`services/__init__` docstring) | inexistant | doc morte |
82
+ | `run_orchestrator._build_pipelines`/`_load_corpus` (staticmethods l.472-494) | wrappers qui ne délèguent qu'aux fonctions globales | conservés **pour un test** uniquement |
83
+
84
+ ## 1.5 Bugs / fragilités notables (vérifiés)
85
+
86
+ | Fichier | Problème | Gravité |
87
+ |---|---|---|
88
+ | `run_orchestrator.py` | `progress_callback`/`cancel_event` **acceptés mais ignorés** sur le chemin `bench.run()` (consommés seulement si `partial_dir`) — le caller croit son callback branché | moyen (silencieux) |
89
+ | `run_orchestrator_execution.py` | appelle `BenchmarkService._evaluate_document_in_views()` (**privée**) pour recalculer les vues des docs repris | fragile (couplage privé) |
90
+ | `factories.py` | `counter_state` (lock+dict) partagé par closure ; OK seulement si chaque run a une factory fraîche | risque multithread |
91
+ | `partial_store.py` / `_orchestrator_partial.py` | fingerprint = path+size+mtime (pas de hash de contenu) → ne détecte pas une modif d'image à id constant | acceptable (documenté) |
92
+ | `run_spec.py` | 9 champs « Phase B » (`char_exclude`, `normalization_profile`, `partial_dir`, `entity_extractor`, `profile`, `output_json`, `timeout_seconds_per_doc`, `max_in_flight`, `poll_interval_seconds`) **validés mais à demi-consommés** (migration B1→B2.x inachevée) | dette de migration |
93
+ | `python_helpers.py` | `_dummy_pipeline_yaml()` = pipeline **bidon** juste pour satisfaire `RunSpec.pipelines (min_len=1)` en mode preset ; `adapter_kwargs` vide → `RunManifest` non reproductible sans le code Python | hack |
94
+ | `picarones/__init__.py` | `register_default_metrics()` **auto-déclenché à l'import** | anti-pattern (interdit Cinoc §7) |
95
+
96
+ ## 1.6 Verdicts par fichier — **PROVISOIRES — à confirmer au build**
97
+
98
+ > Légende : **GARDER** (concept à reporter ~tel quel) · **MODIFIER** (concept gardé, forme à revoir/dégonfler/splitter) · **DÉPLACER** (change de couche) · **SUPPRIMER**.
99
+ > Le verdict décrit l'intention **pour le port Cinoc** ; les faits (rôle/conso) sont, eux, durables.
100
+
101
+ ### Cluster A — colonne vertébrale (755 LOC)
102
+ | Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
103
+ |---|---|---|---|
104
+ | `__init__.py` | 27 | header paquet, `__all__` vide, sans effet de bord | **GARDER** — réécrire docstring, purger « 6 P0 du S1 » |
105
+ | `results.py` | 24 | **shim** re-export `RunResult/RunDocumentResult/ReportRenderer` ← `pipeline/run_result` | **SUPPRIMER** — en Cinoc `RunResult` vit en `evaluation/result.py`, reports le lit direct (zéro shim §5.1) |
106
+ | `services/__init__.py` | 96 | façade re-export (dont le converter `run_result_to_benchmark_result`) | **MODIFIER** — `__init__` mince §7 ; ne pas ré-exporter de shim |
107
+ | `schemas/__init__.py` | 49 | façade re-export `run_spec` | **MODIFIER/fusionner** avec le split ci-dessous |
108
+ | `schemas/run_spec.py` | 559 | `RunSpec`/`StepSpec`/`PipelineSpecYaml` (DTO YAML, 7 validators) + `load_run_spec_from_yaml` + `resolve_adapter_class` (importlib dotted-path) + `CANONICAL_VIEW_NAMES` | **DÉPLACER (split)** — type déclaratif `RunSpec`/`StepSpec` → **`domain`** (backlog confirmé) ; **loader YAML + `resolve_adapter_class`** → restent **`app`** ; **purger les 9 champs Phase B** (implémenter pour de vrai ou retirer) ; budget <400 |
109
+
110
+ ### Cluster B — shim double-format (1 721 LOC)
111
+ | Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
112
+ |---|---|---|---|
113
+ | `benchmark_service.py` | 407 | **runner v2 propre** : orchestre `CorpusRunner`(4) + `view_executor`(3), assemble `RunResult`, persiste JSONL ; 0 import `_benchmark_*` | **MODIFIER (split)** — *pas « garder tel quel »* : assemblage `RunResult` + éval vues → **`evaluation/runner`** (3) ; persistance JSONL → **`adapters/storage`** (5) ; il ne reste en `app` qu'une coquille d'orchestration mince |
114
+ | `_benchmark_converter.py` | 236 | cœur du shim `RunResult→BenchmarkResult` ; **calcule** CER/WER + hooks + agrégation | **SUPPRIMER** — double format éliminé ; le calcul est déjà en `evaluation/`, juste appelé d'ici |
115
+ | `_benchmark_helpers.py` | 266 | extracteurs de texte + fingerprint pour le converter | **SUPPRIMER** — récupérer au plus 1-2 extracteurs `Artifact→texte` utiles → `pipeline`/`evaluation` |
116
+ | `_benchmark_persistence.py` | 55 | `asdict(BenchmarkResult)→JSON` | **SUPPRIMER** — pas de `BenchmarkResult` ; persistance `RunResult` → `adapters/storage` |
117
+ | `_benchmark_ner.py` | 189 | **calcule** NER (`compute_ner_metrics`) puis l'attache au `BenchmarkResult` | **DÉPLACER → `evaluation`** — le NER est une métrique (couche 3) |
118
+ | `_benchmark_conversions.py` | 255 | `Corpus`(3, chargé) → `CorpusSpec`(1, déclaratif) + `Document→DocumentRef` ; `_safe_doc_id` (normalisation) | **MODIFIER** — pont qui dépend du modèle corpus Cinoc (à confirmer) ; **garder la normalisation `doc_id`** (utile, réutilisée par NER + corpus_service) |
119
+ | `_benchmark_adapter_resolver.py` | 313 | `engine(BaseOCRAdapter / OCRLLMPipelineConfig) → PipelineSpec` + `build_adapter_resolver` (`name→StepExecutor`) | **MODIFIER** — le **resolver `name→executor` = germe du registre/factory** (garder, → registre `app`) ; la conversion `engine→spec` legacy disparaît (en Cinoc on déclare `PipelineSpec` direct) |
120
+
121
+ ### Cluster C — orchestration / jobs / reprise (2 459 LOC)
122
+ | Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
123
+ |---|---|---|---|
124
+ | `run_orchestrator.py` | 500 | `RunOrchestrator.execute()` **et** `execute_preset()` → `OrchestrationResult` ; orchestre vraiment (corpus→registres→pipelines→service) | **MODIFIER** — concept d'orchestrateur `app` à **garder** ; dégonfler : fusionner `execute`/`execute_preset` (le 2ᵉ existe pour les engines en mémoire legacy), retirer la persistance `output_json` legacy + les callbacks « acceptés-ignorés » ; budget <400 |
125
+ | `run_orchestrator_execution.py` | 310 | `execute_with_partial()` (reprise par pipeline) ; recalcule les vues via méthode **privée** du service | **MODIFIER/différer** — feature réelle mais couplage fragile ; à reconcevoir proprement **à la tranche reprise** (≠ MVP) |
126
+ | `run_orchestrator_helpers/__init__.py` | 55 | re-export pour préserver l'API historique | **SUPPRIMER** — pas d'API historique à préserver (zéro shim) |
127
+ | `…/builders.py` | 241 | `_load_corpus`/`_build_pipelines`/`_build_views`/`_build_benchmark_service` | **MODIFIER** — helpers d'assemblage à **garder** mais replier dans l'orchestrateur/registre |
128
+ | `…/factories.py` | 119 | `_default_gt_factory`/`_default_inputs_factory`/`_make_context_factory` (closure progress, thread-safe) | **GARDER (concept)** — frontière « doc → artefacts » + `RunContext` par doc |
129
+ | `…/legacy.py` | 308 | pont `BenchmarkResult` : `_PipelineEngineProxy`, `_resolve_entity_extractor` (dotted-path→callable NER), `_persist_legacy_benchmark_json` | **SUPPRIMER** — `BenchmarkResult` disparu ; **garder le concept** `_resolve_entity_extractor` = résolution de module tiers (→ entry-points `app`) |
130
+ | `…/loaders.py` | 43 | `_filesystem_payload_loader` (disque→payload ALTO/texte) + `_kwargs_signature` | **GARDER (concept)** — chargement payload = I/O (`app` ou `adapters/storage`) |
131
+ | `_orchestrator_partial.py` | 256 | reprise **par pipeline** (fingerprint, load/append/delete/filter) ; système **vivant** | **MODIFIER** — devenir le **seul** mécanisme de reprise (différer à sa tranche) |
132
+ | `job_runner.py` | 267 | `JobRunner` thread daemon + `JobStore` ; annulation **best-effort non branchée** | **GARDER (concept)** pour `serve` — mais brancher une vraie annulation via `Deadline`/`RunControl` (couche 4, invariant §12) ; couple à `adapters/storage` |
133
+ | `partial_store.py` | 360 | reprise **par engine** (legacy) ; **~½ mort** ; seuls `compute_run_fingerprint`+`_partial_path` vivants | **SUPPRIMER** — garder seulement le **concept fingerprint**, fusionné dans le mécanisme partial unique |
134
+
135
+ ### Cluster D — services transverses (1 713 LOC)
136
+ | Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
137
+ |---|---|---|---|
138
+ | `path_security.py` | 454 | **invariant §12** : `validated_path` (resolve+`is_relative_to`), `WorkspaceManager` (sandbox/session, anti-symlink post-mkdir), `safe_report_name`, `validated_prompt_filename` | **GARDER** — porter **fidèlement** (→ package `security/` §8.5) ; dédupliquer `prompt_filename`/`report_name`, moderniser (`is_relative_to` natif 3.11), splitter <400 |
139
+ | `corpus_service.py` | 541 | import ZIP sandboxé (anti zip-bomb/traversal/symlink) + appariement image/GT (`.gt.alto.xml`…) → `CorpusSpec` | **GARDER/DÉPLACER ?** — défenses à garder ; place à confirmer (`app` vs `adapters/corpus` qui porte déjà IIIF/Gallica/…) ; splitter <400 (extraction · catalogage · appariement) |
140
+ | `registry_service.py` | 327 | `bootstrap_default_registries()` (boucle **codée en dur** sur métriques+projecteurs), `RegistriesBundle` ; **pas** d'entry-points | **MODIFIER (scinder)** — registre **métriques/projecteurs** = interne (couche 3) ; registre **modules de pipeline** + **découverte entry-points** (`cinoc.modules`) + `register()` = couche `app` (CLAUDE.md §3) |
141
+ | `python_helpers.py` | 277 | `prepare_preset_args`/`PresetArgs` : bridge engines-en-mémoire→`RunSpec` ; `_dummy_pipeline_yaml` (hack) | **SUPPRIMER/reconcevoir** — existe pour les objets engine legacy ; en Cinoc on déclare `PipelineSpec` direct ; si une API Python est voulue, la rebâtir proprement |
142
+ | `dependencies.py` | 114 | `capture_dependencies_lock` + `capture_system_binaries_lock` → `RunManifest` (reproductibilité §12) ; pur, stateless | **GARDER** — invariant repro ; étendre la table binaires au-delà de Tesseract (starter pack) ; place `app` vs `adapters` à confirmer |
143
+
144
+ ---
145
+
146
+ # PARTIE 2 — RÉORGANISATION CIBLE Cinoc (périssable — à confirmer à la tranche)
147
+
148
+ > ⚠️ Périssable. Le contact du code amont (evaluation/pipeline/adapters non encore mergés) corrigera ces formes.
149
+ > **On ne construit PAS la couche `app` de haut en bas** : elle **apparaît par tranches verticales**, au fur et à mesure que `interfaces` en a besoin.
150
+
151
+ ## 2.1 Rôle d'`app` en Cinoc = **coquille mince** (les deux axes, CLAUDE.md §2)
152
+
153
+ `app` orchestre, **ne calcule jamais** (« l'app appelle, ne calcule plus »). Elle :
154
+ 1. charge/valide une spec (`RunSpec`) et résout les modules par nom ;
155
+ 2. appelle `pipeline`(4) puis `evaluation`(3) ; l'assemblage de `RunResult` est fait **par `evaluation`** ;
156
+ 3. délègue la persistance à `adapters/storage`(5) et le rendu à `reports`(7, via callable injecté) ;
157
+ 4. applique les invariants transverses : **sécurité chemins** (§12), **reproductibilité** (deps/binaires → `RunManifest`), **annulation/timeout** (`Deadline`/`RunControl`).
158
+
159
+ | Axe | Contenu `app` | Quand |
160
+ |---|---|---|
161
+ | **Enveloppe (plein-scope, maintenant)** | contrat d'orchestration ; **registre de modules + factory + découverte entry-points** (`cinoc.modules`) + `register()` ; `RunSpec` (en `domain`) + loader YAML (en `app`) ; sécurité chemins | conçu d'office |
162
+ | **Surface (incrémentale, élaguée)** | quels workflows CLI (`run/report/compare/demo/serve`), quels importeurs branchés, le job-runner web, la reprise partielle | une tranche à la fois |
163
+
164
+ ## 2.2 Où va chaque concept Picarones (table de transfert)
165
+
166
+ | Concept Picarones (app) | Destination Cinoc | Note |
167
+ |---|---|---|
168
+ | `RunSpec`/`StepSpec` (déclaratif) | **`domain`** (couche 1) | frère de `PipelineSpec`/`EvaluationSpec` ; backlog confirmé |
169
+ | `load_run_spec_from_yaml` + `resolve_adapter_class` | **`app`** (loader/résolution = I/O + import dynamique) | « séparer du loader YAML » |
170
+ | `RunOrchestrator` (mince) + builders/factories | **`app`** | un seul `execute`, pas de `execute_preset` |
171
+ | assemblage `RunResult` + éval vues (`benchmark_service`) | **`evaluation/runner`** (couche 3) | RunResult = sortie d'évaluation |
172
+ | persistance JSONL / store longitudinal | **`adapters/storage`** (couche 5) | tidy store (couche 3 §8) |
173
+ | `_benchmark_ner` (calcul NER) | **`evaluation/metrics`** (couche 3) | NER = métrique |
174
+ | `path_security` | **`app/security/`** (package, §8.5) | invariant §12, port fidèle |
175
+ | `corpus_service` (import ZIP + appariement) | **`app`** ou **`adapters/corpus`** | à confirmer (adapters/corpus porte déjà les importeurs) |
176
+ | `dependencies` (capture deps/binaires) | **`app`** (ou `adapters`) | reproductibilité |
177
+ | `registry_service` (modules de pipeline) | **`app`** (+ entry-points) | seul point d'extension tiers |
178
+ | `job_runner` | **`app`** | pour `serve` ; annulation réelle via `RunControl` |
179
+ | `_orchestrator_partial` (reprise) | **`app`** (1 seul système) | à la tranche reprise |
180
+ | `results.py`, `legacy.py`, `_benchmark_{converter,helpers,persistence}`, `partial_store` (mort), `python_helpers` (preset), `…helpers/__init__` re-export | **SUPPRIMÉ** | shim/double-format/legacy/mort |
181
+
182
+ ## 2.3 Esquisse d'arborescence cible (à confirmer)
183
+
184
+ ```
185
+ cinoc/app/
186
+ ├── __init__.py mince, zéro effet de bord (pas de register_* auto)
187
+ ├── orchestrator.py RunOrchestrator unique → appelle pipeline + evaluation, assemble via eval
188
+ ├── spec_loader.py load_run_spec_from_yaml + resolve dotted-path/entry-point (RunSpec vit en domain)
189
+ ├── modules/ registre + factory + découverte entry-points (cinoc.modules) + register()
190
+ ├── security/ validated_path, WorkspaceManager, safe_* (invariant §12)
191
+ ├── corpus_import.py (?) import ZIP + appariement image/GT → CorpusSpec [ou adapters/corpus]
192
+ ├── reproducibility.py (?) capture deps + binaires → RunManifest [ou adapters]
193
+ └── jobs.py JobRunner (serve) + annulation coopérative
194
+ ```
195
+ *(Tous < 400 LOC ; `corpus_import`/`reproducibility` peut-être en `adapters` — décidé à la tranche.)*
196
+
197
+ ## 2.4 Apparition par tranches (≠ couche complète)
198
+
199
+ | Tranche (cf. couche 3 §15) | Ce que `app` matérialise alors |
200
+ |---|---|
201
+ | **1. Squelette `demo`** (corpus pré-calculé → CER → RunResult → HTML → CLI) | orchestrateur **minimal** + registre de modules (1 module `precomputed`) + `RunSpec` minimal. Prouve que la coquille tient. |
202
+ | **2. Épaississement texte** (`run`, profils, `compare`) | loader YAML complet, sécurité chemins, capture repro, plus de modules au registre |
203
+ | **3. `serve`** | `JobRunner` + sandbox session + annulation |
204
+ | **4. reprise partielle** | mécanisme partial unique (fingerprint), si justifié par un consommateur réel |
205
+
206
+ ---
207
+
208
+ # PARTIE 3 — RISQUES DE TRANSFERT & DETTES (comment détecter / désamorcer)
209
+
210
+ | # | Risque / dette | Détection | Désamorçage |
211
+ |---|---|---|---|
212
+ | **R1** | **Réintroduire le double format** en gardant un `BenchmarkResult` ou un converter « le temps de migrer » | test `no-legacy-imports` (interdire `benchmark_result`, `run_result_to_benchmark_result`) ; 1 seul type de sortie | tout consommateur lit `RunResult` direct ; **reports + web + compare migrés en bloc** (couche 3 §12-4 : ~30 conso) |
213
+ | **R2** | **Calcul qui reste en `app`** (NER, hooks, agrégation) | `test_layer_dependencies` : `app` n'importe **pas** `evaluation/metrics*` pour calculer ; revue « app appelle, ne calcule pas » | NER/hooks/agrégation → `evaluation/runner` |
214
+ | **R3** | **Mélanger registre interne (métriques) et registre extensible (modules)** → ouvrir une prise de trop | revue : entry-points `cinoc.modules` **uniquement** pour briques de pipeline (CLAUDE.md §3) | deux registres distincts ; métriques/projecteurs non pluggables |
215
+ | **R4** | **Sécurité chemins affaiblie au portage** (path-traversal/symlink/zip-bomb) | tests obligatoires : `..`, chemin absolu, symlink, zip-bomb, octet nul **doivent échouer** ; `validated_path` partout | porter `path_security` + `corpus_service._extract_safely` **fidèlement**, tester d'abord |
216
+ | **R5** | **Champs de spec à demi-câblés** (les 9 « Phase B ») recopiés tels quels | revue : tout champ `RunSpec` a un consommateur réel en CI (« pas de conso = supprimé ») | n'ajouter un champ que quand sa tranche le consomme |
217
+ | **R6** | **Reproductibilité perdue** (mode preset Picarones perdait `adapter_kwargs`) | golden `RunManifest` byte-stable ; test : run reproductible **depuis le manifest seul** | pas de chemin « engines en mémoire » ; toute brique déclare `version` (§3) |
218
+ | **R7** | **Annulation/timeout fantômes** (Picarones : callbacks acceptés-ignorés, cancel best-effort) | test : un `Deadline` dépassé **interrompt** ; annulation observable | brancher `Deadline`/`RunControl` (couche 4) dès `job_runner` |
219
+ | **R8** | **Doc/commentaires périmés recopiés** (`benchmark_runner.py`, `report_service.py`, `run_benchmark_via_service`, sprints S/A/Phase) | grep anti-narration ; `test_no_legacy_imports` | « garder le pourquoi, jeter la datation » (couche 2 MIG-3) |
220
+ | **R9** | **Code mort transféré** (½ `partial_store`, staticmethods test-only, `_dummy_pipeline_yaml`) | couverture CI ; « pas de conso = supprimé » | ne pas porter ; reprise = 1 seul système |
221
+ | **R10** | **Budgets explosés** (5 fichiers >400) | `test_file_budgets` strict | splitter dès l'écriture (orchestrateur / sécurité / corpus) |
222
+ | **R11** | **Effet de bord à l'import** (`register_default_metrics()` auto) | `test_no_side_effect_imports` | enregistrement **explicite, idempotent, testable** (§7) |
223
+
224
+ ---
225
+
226
+ ## Résumé pour la future session de CONSTRUCTION (3–5 points)
227
+
228
+ 1. **`app` = coquille d'orchestration mince qui NE CALCULE PAS.** Elle appelle `pipeline`(4) puis `evaluation`(3) ; l'assemblage de `RunResult` se fait **dans `evaluation/runner`**, la persistance dans **`adapters/storage`**. Le seul « gros » runner sain de Picarones (`benchmark_service.py`) se **scinde** entre ces couches — ce n'est pas un « garder tel quel ».
229
+ 2. **Tout le cluster `_benchmark_*` + `results.py` + `legacy.py` + ½ `partial_store` + `python_helpers`(preset) = SUPPRIMÉ** : c'est le double format `BenchmarkResult↔RunResult` (+ un god-module éclaté, + une fonction `run_benchmark_via_service` déjà morte qui laisse du code orphelin). En Cinoc, `RunResult` est unique et **les reports le lisent direct** → la conversion n'existe plus. Le **NER** part en `evaluation` (c'est une métrique).
230
+ 3. **Deux germes d'enveloppe à concevoir plein-scope, sans les remplir** : (a) `RunSpec`/`StepSpec` **en `domain`** + loader YAML/résolution **en `app`** (split « séparer du loader ») ; (b) **registre de modules de pipeline + factory + découverte entry-points** (`cinoc.modules`) — **uniquement** pour les briques de pipeline, jamais les métriques/importeurs.
231
+ 4. **Invariants à porter fidèlement et à tester EN PREMIER** : sécurité chemins (`validated_path`/`WorkspaceManager`/anti zip-bomb-traversal-symlink, §12), reproductibilité (`dependencies` → `RunManifest`), annulation coopérative (`Deadline`/`RunControl` — Picarones ne la branche pas vraiment, à corriger).
232
+ 5. **Ne pas bâtir `app` en bloc** : elle naît par tranches. Tranche 1 (`demo`) = orchestrateur minimal + registre (1 module `precomputed`) + `RunSpec` minimal — juste assez pour prouver que la coquille tient. Budgets <400, zéro shim, zéro effet de bord à l'import, zéro champ de spec sans consommateur.
233
+
234
+ ## DoD vivante (couche 6) — **autorité de détail** ; le `MIGRATION_PLAN.md` indexe
235
+
236
+ > **T8 (D-066)** : l'orchestrateur collecte un `DocumentUsage` par (pipeline × document) réussi et le passe à `evaluate_run` → `RunResult.usage` trié (déterministe). Preuve : `tests/app/test_orchestrator.py::test_run_result_carries_sorted_usage`.
237
+
238
+ > **T14 (D-072)** : builders `kraken`/`mistral_ocr` + sondes `engine_statuses` (SDK/clé) + `_OCR_ENGINES` étendu — matrice CLI/web alignée. Preuves : `tests/app/test_module_registry.py` · `test_engines.py`.
239
+
240
+ > **T15 (D-073)** : `+ resume.py` (`ResumeStore` + `unit_key`) — cache d'exécution par empreinte, branché à l'orchestrateur (`resume_store=`) et à la CLI (`run --resume-dir`) ; évaluation toujours recalculée. Preuves : `tests/app/test_resume.py`.
241
+
242
+ > Tri-état : `[x]` fait **+ preuve** · `[ ]` à faire · `[~]` différé/réserve + raison.
243
+ > Maj dans le **même commit** que le code. **Statut : ✅ T2** — orchestrateur (+ **workspace** par run) + registre/factory + `RunSpec` + **loader YAML** + **sécurité chemins** verts. **+ T6 découverte entry-points** : `discover_plugins` (groupe `cinoc.modules`, builder tiers enregistré comme le socle ; **fail-closed mode public** ; câblé CLI + web). **Prouvé de bout en bout** : loader injecté + **vrai `EntryPoint`** (résolution dotted-path) + **paquet réellement pip-installé auto-découvert** (`importlib.metadata`, test `slow` isolé). — *preuves : `tests/app/test_plugin_discovery.py`, `tests/app/test_plugin_install.py` + plugins de réf. `tests/fixtures/sample_segmenter_plugin.py` & `…/sample_plugin_pkg/`*. **+ T7 `history.py`** : trait d'union `RunResult`→`HistoryStore` — `records_from_run` aplatit les agrégats par pipeline/vue en `HistoryRecord` (métriques `None` écartées), `record_run` persiste. La connaissance de la forme `RunResult` vit **ici** (couche 6), pas dans le store (couche 5 = persistance pure). — *preuves : `tests/app/test_history.py`*. **Câblage auto livré (S6, D-046)** : `JobRunner` prend un `history_store` optionnel et appelle `record_run` après chaque run terminé (best-effort, comme `publish`) → **différé D-042 levé** ; `create_app` instancie un `HistoryStore` sous `catalog_dir`. — *preuves : `tests/app/test_history_recording.py`*. Différé : reprise partielle, deps-lock (hors CI).
244
+
245
+ **Enveloppe :**
246
+ - [x] `app` = **coquille mince qui NE CALCULE PAS** (appelle pipeline puis `evaluate_run` ; l'assemblage métrique vit en `evaluation`). — *preuve : `test_app_imports_are_allowed` ; l'orchestrateur délègue, n'importe aucune `evaluation.metrics*`*
247
+ - [x] **Registre + factory** (`name→Module` via builder enregistré, convention `<kind>:<label>`) — socle en dur, **seul** point d'extension. — *preuve : `test_module_registry` (build `precomputed` ; kind inconnu ; kwargs incohérents)*
248
+ - [ ] Découverte **entry-points `cinoc.modules`** + `register()` tiers. — *T6*
249
+ - [x] `RunSpec` → `domain` (compose `PipelineSpec` **directement**, sans `StepSpec` — D-010) ; orchestrateur assemble `RunManifest` (`adapter_kwargs` capturés). — *preuve : `test_run_spec` + `test_orchestrator`*
250
+ - [x] **loader YAML** (`RunSpec.model_validate`, `extra="forbid"`) + **sécurité chemins** (`validated_path` : rejet `..`/absolu-hors-base/octet-nul/symlink, §12 ; **GT `must_exist=True`** → erreur typée au chargement, pas d'`OSError` opaque en run) — **pas** de `resolve_adapter_class` (le registre résout `name→Module`, D-010). — *preuve : `test_loader` (+ GT manquante rejetée) + `test_security`*
251
+ - [x] **Isolation des workspaces par pipeline** (sous-dossier dédié) : deux pipelines partageant un `adapter_name` **écrivain** (ex. `openai:gpt`) n'écrasent plus mutuellement leur sortie → zéro contamination inter-pipelines. — *preuve : `test_orchestrator::test_pipelines_sharing_a_writer_do_not_contaminate`* — **corrigé à l'audit T3.**
252
+ - [x] **`RunManifest.module_versions`** : version déclarée de chaque module exécuté capturée (R-2). — *preuve : `test_orchestrator::test_manifest_captures_module_versions`.* `[~]` deps/binaires lock (`dependencies_lock`/`system_binaries_lock`, dont version **binaire** tesseract) : capture live, **différée** (hors CI).
253
+
254
+ **Garde-fous :**
255
+ - [x] `no_side_effect_imports` (**pas de `register_default_*()` auto** ni singleton module-level ; `register_default_modules`/`metrics` explicites) · `file_budgets` · `layer_dependencies`. — *preuve : suite archi verte*
256
+
257
+ **Validation par tranche :** `MIGRATION_PLAN.md` §3 — orchestrateur minimal + registre 1 module + `RunSpec` minimal (T1) · sécurité chemins testée d'abord + repro (T2) · entry-points + plugin (T6).
258
+
259
+ - [~] **Supprimé** : tout `_benchmark_*` (double format) · `results.py`/`legacy.py` (shim) · ½ `partial_store` mort · `python_helpers` (preset). **Différé** : reprise partielle (1 seul mécanisme, à sa tranche).
260
+
261
+ ---
262
+
263
+ *(Tous les verdicts ci-dessus sont **PROVISOIRES — à confirmer au build** ; le contact du code amont non encore mergé prévaut.)*
cinoc/app/__init__.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Couche 6 — ``app`` : orchestration + registre de modules (coquille mince).
2
+
3
+ ``app`` câble les couches internes et **ne calcule pas** : il appelle pipeline
4
+ puis evaluation. ``__init__`` mince, sans effet de bord.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from cinoc.app.loader import RunSpecError, load_run_spec
10
+ from cinoc.app.modules.registry import (
11
+ ModuleRegistry,
12
+ ModuleResolutionError,
13
+ register_default_modules,
14
+ )
15
+ from cinoc.app.orchestrator import OrchestrationError, run
16
+ from cinoc.app.results import RunResultError, dump_run_result, load_run_result
17
+ from cinoc.app.security import PathSecurityError, validated_path
18
+ from cinoc.app.versioning import resolve_code_version
19
+
20
+ __all__ = [
21
+ "ModuleRegistry",
22
+ "ModuleResolutionError",
23
+ "OrchestrationError",
24
+ "PathSecurityError",
25
+ "RunResultError",
26
+ "RunSpecError",
27
+ "dump_run_result",
28
+ "load_run_result",
29
+ "load_run_spec",
30
+ "register_default_modules",
31
+ "resolve_code_version",
32
+ "run",
33
+ "validated_path",
34
+ ]
cinoc/app/alto_store.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Persistance des exports **ALTO XML** d'un run (couche 6).
2
+
3
+ Un run avec ``Competitor.alto`` produit un artefact ``ALTO_XML`` par document que
4
+ le ``RunResult`` (métriques scalaires) **ne porte pas** et que le workspace
5
+ temporaire du run **détruit** à la fin. Ce store le **persiste**, indexé par
6
+ ``run_id``, pour qu'un endpoint web le restitue en archive ZIP — la forme
7
+ ré-importable dans un outil de relecture (eScriptorium, Transkribus).
8
+
9
+ Un seul chemin : le sink du ``JobRunner`` y dépose les ALTO produits ; le routeur
10
+ des rapports les rezippe à la demande. Pas de second format, pas de cache pré-écrit.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import io
16
+ import threading
17
+ import zipfile
18
+ from pathlib import Path
19
+
20
+ from cinoc.app.security import PathSecurityError, safe_stem, validated_path
21
+
22
+
23
+ class AltoStore:
24
+ """Registre disque des exports ALTO (``base_dir/<run>/<entrée>.alto.xml``)."""
25
+
26
+ def __init__(self, base_dir: Path) -> None:
27
+ self._base = base_dir
28
+ self._lock = threading.Lock()
29
+
30
+ def save(
31
+ self, run_id: str, document_id: str, pipeline_name: str, xml: bytes
32
+ ) -> None:
33
+ """Persiste un ALTO XML pour ``(run_id, pipeline_name, document_id)``."""
34
+ with self._lock:
35
+ folder = self._base / safe_stem(run_id)
36
+ folder.mkdir(parents=True, exist_ok=True)
37
+ stem = safe_stem(f"{pipeline_name}::{document_id}")
38
+ (folder / f"{stem}.alto.xml").write_bytes(xml)
39
+
40
+ def has(self, run_id: str) -> bool:
41
+ """``True`` si au moins un ALTO est persisté pour ce run."""
42
+ folder = self._folder(run_id)
43
+ if folder is None or not folder.is_dir():
44
+ return False
45
+ return any(folder.glob("*.alto.xml"))
46
+
47
+ def archive(self, run_id: str) -> bytes | None:
48
+ """Archive ZIP **déterministe** (entrées triées) des ALTO du run, ou ``None``.
49
+
50
+ ``None`` si aucun ALTO n'est persisté (run sans export) → le routeur répond
51
+ ``404``. Les octets XML ne sont jamais altérés (ré-import fidèle)."""
52
+ folder = self._folder(run_id)
53
+ if folder is None or not folder.is_dir():
54
+ return None
55
+ files = sorted(folder.glob("*.alto.xml"))
56
+ if not files:
57
+ return None
58
+ buffer = io.BytesIO()
59
+ with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
60
+ for path in files:
61
+ archive.writestr(path.name, path.read_bytes())
62
+ return buffer.getvalue()
63
+
64
+ def _folder(self, run_id: str) -> Path | None:
65
+ """Dossier du run, **chemin validé** (anti path-traversal sur le run_id URL)."""
66
+ try:
67
+ return validated_path(safe_stem(run_id), self._base)
68
+ except PathSecurityError:
69
+ return None
70
+
71
+
72
+ __all__ = ["AltoStore"]
cinoc/app/corpus_import.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Matérialisation d'un corpus IIIF en ``CorpusSpec`` (couche 6).
2
+
3
+ Sépare **localiser** (l'adapter ``IIIFImporter`` parle IIIF) de **matérialiser**
4
+ (ici : télécharger les images sous ``dest`` validé, fabriquer le ``CorpusSpec``).
5
+ Sortie **unique** : un ``CorpusSpec`` (jamais un dict-manifeste), prêt pour un run.
6
+
7
+ Images seules → ``DocumentRef`` sans vérité-terrain : le corpus est OCR-able mais
8
+ non scoré tant qu'une GT n'est pas appariée (cf. ``adapters/corpus/iiif.py``).
9
+
10
+ ``importer`` et ``download`` sont **injectables** → le chemin de matérialisation
11
+ se teste sans réseau ; le test ``live`` couvre le vrai fetch IIIF.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import json
17
+ import logging
18
+ from collections.abc import Callable, Iterator
19
+ from pathlib import Path
20
+ from typing import Protocol
21
+ from urllib.parse import urlsplit
22
+
23
+ from cinoc.adapters.corpus import _http
24
+ from cinoc.adapters.corpus.escriptorium import DEFAULT_LAYER, EScriptoriumImporter
25
+ from cinoc.adapters.corpus.gallica import GALLICA_BASE, GallicaImporter, vue_number
26
+ from cinoc.adapters.corpus.huggingface import (
27
+ HFPage,
28
+ snapshot_curated_layout,
29
+ stream_pages,
30
+ )
31
+ from cinoc.adapters.corpus.iiif import IIIFImage, IIIFImporter
32
+ from cinoc.app.security import PathSecurityError, validated_path
33
+ from cinoc.domain.artifacts import ArtifactType
34
+ from cinoc.domain.corpus import CorpusSpec
35
+ from cinoc.domain.documents import DocumentRef, GroundTruthRef
36
+ from cinoc.domain.errors import CinocError
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ #: Extensions d'image reconnues pour nommer le fichier téléchargé.
41
+ _IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2"})
42
+ _DEFAULT_EXT = ".jpg"
43
+
44
+ ImageFetcher = Callable[[str], "tuple[IIIFImage, ...]"]
45
+ OcrFetcher = Callable[[int], str]
46
+ #: Streame les pages d'un dataset HF conforme. Injectable (test sans ``datasets``).
47
+ HFStreamer = Callable[..., "Iterator[HFPage]"]
48
+
49
+
50
+ class Downloader(Protocol):
51
+ """Télécharge ``url`` vers ``dest`` ; ``headers`` optionnels (auth même-hôte)."""
52
+
53
+ def __call__(
54
+ self, url: str, dest: Path, *, headers: dict[str, str] | None = None
55
+ ) -> None: ...
56
+
57
+
58
+ class CorpusImportError(CinocError):
59
+ """L'import d'un corpus distant n'a produit aucune page exploitable."""
60
+
61
+
62
+ def _image_ext(image_url: str) -> str:
63
+ suffix = Path(urlsplit(image_url).path).suffix.lower()
64
+ return suffix if suffix in _IMAGE_EXT else _DEFAULT_EXT
65
+
66
+
67
+ def _download_image(
68
+ fetch_bytes: Downloader,
69
+ image_url: str,
70
+ doc_id: str,
71
+ dest_dir: Path,
72
+ *,
73
+ headers: dict[str, str] | None = None,
74
+ ) -> str:
75
+ """Télécharge une image sous ``dest_dir`` (chemin validé) ; renvoie l'URI local.
76
+
77
+ ``headers`` (ex. ``Authorization``) n'est fourni que pour un média **même-hôte**
78
+ (cf. ``import_escriptorium_corpus``) ; les imports publics (IIIF/Gallica)
79
+ passent ``None``.
80
+ """
81
+ target = validated_path(f"{doc_id}{_image_ext(image_url)}", dest_dir)
82
+ fetch_bytes(image_url, target, headers=headers)
83
+ return str(target)
84
+
85
+
86
+ def import_iiif_corpus(
87
+ manifest_url: str,
88
+ dest: str | Path,
89
+ *,
90
+ name: str,
91
+ limit: int | None = None,
92
+ importer: IIIFImporter | None = None,
93
+ download: Downloader | None = None,
94
+ ) -> CorpusSpec:
95
+ """Importe un manifeste IIIF → images téléchargées sous ``dest`` → ``CorpusSpec``.
96
+
97
+ ``limit`` borne le nombre de pages (les premières). ``dest`` est créé au besoin ;
98
+ chaque fichier passe par ``validated_path`` (anti-traversal).
99
+ """
100
+ fetch_images: ImageFetcher = (importer or IIIFImporter()).fetch_images
101
+ fetch_bytes: Downloader = download or _http.download
102
+ dest_dir = Path(dest)
103
+ dest_dir.mkdir(parents=True, exist_ok=True)
104
+
105
+ images = fetch_images(manifest_url)
106
+ if limit is not None:
107
+ images = images[:limit]
108
+ if not images:
109
+ raise CorpusImportError(
110
+ f"aucune image exploitable dans le manifeste {manifest_url!r}."
111
+ )
112
+
113
+ documents: list[DocumentRef] = []
114
+ for index, image in enumerate(images, start=1):
115
+ doc_id = f"page_{index:04d}"
116
+ image_uri = _download_image(fetch_bytes, image.image_url, doc_id, dest_dir)
117
+ documents.append(
118
+ DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=())
119
+ )
120
+
121
+ return CorpusSpec(
122
+ name=name,
123
+ documents=tuple(documents),
124
+ metadata={"source": "iiif", "manifest_url": manifest_url},
125
+ )
126
+
127
+
128
+ def import_escriptorium_corpus(
129
+ base_url: str,
130
+ token: str,
131
+ doc_pk: int,
132
+ dest: str | Path,
133
+ *,
134
+ name: str | None = None,
135
+ layer: str = DEFAULT_LAYER,
136
+ limit: int | None = None,
137
+ importer: EScriptoriumImporter | None = None,
138
+ download: Downloader | None = None,
139
+ ) -> CorpusSpec:
140
+ """Importe un document eScriptorium → corpus **scorable** sous ``dest``.
141
+
142
+ Pour chaque page : télécharge l'image et écrit le texte de la couche ``layer``
143
+ comme vérité-terrain (``.gt.txt`` → ``GroundTruthRef`` ``RAW_TEXT``). Une page
144
+ sans texte reste image-seule ; une page sans image est ignorée (avertie).
145
+
146
+ Média gardé : le jeton ``Authorization`` n'est joint au download que si le
147
+ média est **sur le même hôte** que ``base_url`` (règle host de D-050). Un média
148
+ servi par un autre hôte (CDN tiers) est téléchargé **sans jeton** — on ne livre
149
+ pas le credential à un tiers (un média gardé y répondra 401, sans fuite).
150
+ """
151
+ imp = importer or EScriptoriumImporter(base_url, token, layer=layer)
152
+ fetch_bytes: Downloader = download or _http.download
153
+ base_host = urlsplit(base_url).hostname
154
+ media_auth = {"Authorization": f"Token {token}"}
155
+ dest_dir = Path(dest)
156
+ dest_dir.mkdir(parents=True, exist_ok=True)
157
+
158
+ pages = imp.fetch_pages(doc_pk)
159
+ if limit is not None:
160
+ pages = pages[:limit]
161
+
162
+ documents: list[DocumentRef] = []
163
+ for page in pages:
164
+ if not page.image_url:
165
+ logger.warning("[escriptorium] part %s sans image — ignorée.", page.pk)
166
+ continue
167
+ doc_id = f"part_{page.pk:05d}"
168
+ same_host = urlsplit(page.image_url).hostname == base_host
169
+ headers = media_auth if same_host else None
170
+ if not same_host:
171
+ logger.warning(
172
+ "[escriptorium] média part %s hors hôte (%s) — téléchargé sans "
173
+ "jeton (pas de fuite vers un tiers).",
174
+ page.pk,
175
+ urlsplit(page.image_url).hostname,
176
+ )
177
+ image_uri = _download_image(
178
+ fetch_bytes, page.image_url, doc_id, dest_dir, headers=headers
179
+ )
180
+ ground_truths: tuple[GroundTruthRef, ...] = ()
181
+ if page.gt_text.strip():
182
+ gt_target = validated_path(f"{doc_id}.gt.txt", dest_dir)
183
+ gt_target.write_text(page.gt_text, encoding="utf-8")
184
+ ground_truths = (
185
+ GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_target)),
186
+ )
187
+ documents.append(
188
+ DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=ground_truths)
189
+ )
190
+
191
+ if not documents:
192
+ raise CorpusImportError(
193
+ f"document eScriptorium {doc_pk} : aucune page avec image exploitable."
194
+ )
195
+ return CorpusSpec(
196
+ name=name or f"escriptorium-{doc_pk}",
197
+ documents=tuple(documents),
198
+ metadata={
199
+ "source": "escriptorium",
200
+ "base_url": base_url.rstrip("/"),
201
+ "doc_pk": str(doc_pk),
202
+ "layer": layer,
203
+ },
204
+ )
205
+
206
+
207
+ def import_gallica_corpus(
208
+ ark: str,
209
+ dest: str | Path,
210
+ *,
211
+ base_url: str = GALLICA_BASE,
212
+ name: str | None = None,
213
+ limit: int | None = None,
214
+ include_ocr: bool = True,
215
+ image_importer: IIIFImporter | None = None,
216
+ download: Downloader | None = None,
217
+ fetch_ocr: OcrFetcher | None = None,
218
+ ) -> CorpusSpec:
219
+ """Importe un document Gallica (ARK) → images (via IIIF) + OCR Gallica optionnel.
220
+
221
+ Les images viennent du **manifeste IIIF** Gallica ; si ``include_ocr``, l'OCR
222
+ brut de chaque vue (``texteBrut``) est écrit comme **référence étiquetée** :
223
+ type domaine ``REFERENCE_TEXT`` (≠ vérité-terrain manuelle) + métadonnée
224
+ ``gt_source=gallica_ocr``. Une vue d'évaluation par défaut **ne le score pas**
225
+ comme une GT ; seule une vue *référence* dédiée (opt-in) le compare.
226
+
227
+ Le numéro de vue est **lu dans l'URL de l'image** (``/f{n}/``), pas déduit de la
228
+ position : un canvas sans image sauté par le parseur IIIF décalerait sinon tout
229
+ l'OCR (le bug ``selected_indices+1`` réintroduit). Position 1-based = repli si
230
+ l'URL ne porte pas de ``/f{n}/``.
231
+ """
232
+ gallica = GallicaImporter(ark, base_url=base_url)
233
+ fetch_bytes: Downloader = download or _http.download
234
+ ocr_of: OcrFetcher = fetch_ocr or gallica.fetch_ocr_text
235
+ dest_dir = Path(dest)
236
+ dest_dir.mkdir(parents=True, exist_ok=True)
237
+
238
+ images = (image_importer or IIIFImporter()).fetch_images(gallica.manifest_url)
239
+ if limit is not None:
240
+ images = images[:limit]
241
+ if not images:
242
+ raise CorpusImportError(f"aucune image pour l'ARK Gallica {gallica.ark!r}.")
243
+
244
+ documents: list[DocumentRef] = []
245
+ has_ocr_gt = False
246
+ for position, image in enumerate(images, start=1):
247
+ vue = vue_number(image.image_url) or position
248
+ doc_id = f"f{vue:04d}"
249
+ image_uri = _download_image(fetch_bytes, image.image_url, doc_id, dest_dir)
250
+ ground_truths: tuple[GroundTruthRef, ...] = ()
251
+ if include_ocr:
252
+ text = ocr_of(vue)
253
+ if text.strip():
254
+ gt_target = validated_path(f"{doc_id}.gallica_ocr.txt", dest_dir)
255
+ gt_target.write_text(text, encoding="utf-8")
256
+ ground_truths = (
257
+ GroundTruthRef(
258
+ type=ArtifactType.REFERENCE_TEXT, uri=str(gt_target)
259
+ ),
260
+ )
261
+ has_ocr_gt = True
262
+ documents.append(
263
+ DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=ground_truths)
264
+ )
265
+
266
+ metadata = {
267
+ "source": "gallica",
268
+ "ark": gallica.ark,
269
+ "manifest_url": gallica.manifest_url,
270
+ }
271
+ if has_ocr_gt:
272
+ metadata["gt_source"] = "gallica_ocr"
273
+ return CorpusSpec(
274
+ name=name or f"gallica-{gallica.ark.split('/')[-1]}",
275
+ documents=tuple(documents),
276
+ metadata=metadata,
277
+ )
278
+
279
+
280
+ def import_hf_corpus(
281
+ dataset_id: str,
282
+ dest: str | Path,
283
+ *,
284
+ name: str | None = None,
285
+ split: str = "train",
286
+ limit: int | None = None,
287
+ stream: HFStreamer | None = None,
288
+ ) -> CorpusSpec:
289
+ """Importe un dataset **HuggingFace** (convention Cinoc) → ``CorpusSpec`` scorable.
290
+
291
+ Streamé **page-par-page** (jamais de snapshot complet) : chaque page écrit son
292
+ image et son texte de vérité-terrain. La GT d'un dataset Cinoc curé est une
293
+ **vraie GT** (``GroundTruthRef`` ``RAW_TEXT``), pas une référence OCR. ``limit``
294
+ borne le nombre de pages. ``stream`` est injectable (test sans ``datasets``).
295
+ """
296
+ pages = (stream or stream_pages)(dataset_id, split=split, limit=limit)
297
+ dest_dir = Path(dest)
298
+ dest_dir.mkdir(parents=True, exist_ok=True)
299
+
300
+ documents: list[DocumentRef] = []
301
+ for index, page in enumerate(pages, start=1):
302
+ doc_id = f"page_{index:04d}"
303
+ target = validated_path(f"{doc_id}{page.image_ext}", dest_dir)
304
+ target.write_bytes(page.image_bytes)
305
+ ground_truths: tuple[GroundTruthRef, ...] = ()
306
+ if page.gt_text.strip():
307
+ gt_target = validated_path(f"{doc_id}.gt.txt", dest_dir)
308
+ gt_target.write_text(page.gt_text, encoding="utf-8")
309
+ ground_truths = (
310
+ GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_target)),
311
+ )
312
+ documents.append(
313
+ DocumentRef(id=doc_id, image_uri=str(target), ground_truths=ground_truths)
314
+ )
315
+
316
+ if not documents:
317
+ raise CorpusImportError(
318
+ f"dataset HuggingFace {dataset_id!r} (split {split!r}) : aucune page."
319
+ )
320
+ return CorpusSpec(
321
+ name=name or f"hf-{dataset_id.replace('/', '-')}",
322
+ documents=tuple(documents),
323
+ metadata={"source": "huggingface", "dataset_id": dataset_id, "split": split},
324
+ )
325
+
326
+
327
+ def _curated_entry(entry: object, root: Path, base_url: str | None) -> DocumentRef:
328
+ """Une entrée ``corpus.json`` → ``DocumentRef`` (image = référence, GT = vraie).
329
+
330
+ Image : ``base_url`` + chemin relatif si fourni — le chemin est un dérivé
331
+ **IIIF statique** du layout (``iiif/<id>/full/…``), et ``base_url`` la racine
332
+ ``resolve`` HF épinglée (ou un serveur IIIF d'institution) ; sinon chemin
333
+ local du dérivé medium. La référence est matérialisée **au run** par
334
+ l'orchestrateur. GT : ``RAW_TEXT`` sur le ``.gt.txt`` local. Chemins relatifs
335
+ durcis (``validated_path`` rejette ``..``) — un dataset tiers hostile ne peut
336
+ pas pointer hors du layout."""
337
+ if not isinstance(entry, dict):
338
+ raise CorpusImportError(f"entrée corpus.json invalide : {entry!r}")
339
+ try:
340
+ doc_id, image_rel, gt_rel = entry["id"], entry["image"], entry["ground_truth"]
341
+ except (KeyError, TypeError) as exc:
342
+ raise CorpusImportError(f"entrée corpus.json incomplète : {entry!r}") from exc
343
+ try:
344
+ gt_path = validated_path(str(gt_rel), root) # rejette le path-traversal
345
+ image_local = validated_path(str(image_rel), root) # idem + valide la forme
346
+ except PathSecurityError as exc:
347
+ raise CorpusImportError(f"chemin non sûr dans corpus.json : {exc}") from exc
348
+ image_uri = (
349
+ f"{base_url.rstrip('/')}/{str(image_rel).lstrip('/')}"
350
+ if base_url
351
+ else str(image_local)
352
+ )
353
+ stratum = entry.get("stratum")
354
+ return DocumentRef(
355
+ id=str(doc_id),
356
+ image_uri=image_uri,
357
+ ground_truths=(GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_path)),),
358
+ metadata={"stratum": str(stratum)} if stratum else {},
359
+ )
360
+
361
+
362
+ def import_curated_corpus(
363
+ layout_dir: str | Path,
364
+ *,
365
+ base_url: str | None = None,
366
+ revision: str | None = None,
367
+ name: str | None = None,
368
+ ) -> CorpusSpec:
369
+ """Importe un **layout dataset canonique Cinoc** → ``CorpusSpec`` scorable.
370
+
371
+ Variante **par référence** de ``import_hf_corpus`` (même sortie, pas un format
372
+ parallèle) : produit par ``app.dataset_standardize`` ou snapshot d'un dataset
373
+ HF publié. L'image est une **référence** (``base_url`` + dérivé IIIF statique
374
+ du layout — en pratique une URL ``resolve`` HF épinglée ; sinon chemin local
375
+ du dérivé medium), matérialisée au run par l'orchestrateur ; la GT est une
376
+ **vraie GT** ``RAW_TEXT``. ``base_url`` + ``revision`` (SHA HF) →
377
+ reproductibilité pinnée (``RunManifest``).
378
+ """
379
+ root = Path(layout_dir)
380
+ manifest_path = root / "corpus.json"
381
+ if not manifest_path.is_file():
382
+ raise CorpusImportError(f"layout cur�� sans corpus.json : {root}")
383
+ try:
384
+ manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
385
+ except (OSError, json.JSONDecodeError) as exc:
386
+ raise CorpusImportError(f"corpus.json illisible ({root}) : {exc}") from exc
387
+ entries = manifest.get("documents") if isinstance(manifest, dict) else None
388
+ if not isinstance(entries, list) or not entries:
389
+ raise CorpusImportError(f"corpus.json sans documents : {root}")
390
+
391
+ documents = tuple(_curated_entry(e, root, base_url) for e in entries)
392
+ corpus_meta: dict[str, str] = {"source": "curated"}
393
+ if revision:
394
+ corpus_meta["revision"] = revision
395
+ licenses = manifest.get("license")
396
+ if isinstance(licenses, dict):
397
+ for key, value in licenses.items():
398
+ corpus_meta[f"license_{key}"] = str(value)
399
+ return CorpusSpec(
400
+ name=name or str(manifest.get("name") or root.name),
401
+ documents=documents,
402
+ metadata=corpus_meta,
403
+ )
404
+
405
+
406
+ #: Télécharge un layout curé HF → dossier local (au moins ``corpus.json`` + GT).
407
+ #: Injectable → l'import distant se teste sans réseau ni ``huggingface_hub``.
408
+ CuratedFetcher = Callable[[str, Path, "str | None"], Path]
409
+
410
+
411
+ def _hf_resolve_base(repo_id: str, revision: str | None) -> str:
412
+ """Racine ``resolve`` d'un dataset HF, épinglée à ``revision`` (sinon ``main``)."""
413
+ return f"https://huggingface.co/datasets/{repo_id}/resolve/{revision or 'main'}"
414
+
415
+
416
+ def import_curated_hf_corpus(
417
+ repo_id: str,
418
+ dest: str | Path,
419
+ *,
420
+ revision: str | None = None,
421
+ name: str | None = None,
422
+ fetch: CuratedFetcher | None = None,
423
+ ) -> CorpusSpec:
424
+ """Importe un **dataset curé Cinoc publié sur HF** → ``CorpusSpec`` scorable.
425
+
426
+ Variante **distante** de ``import_curated_corpus`` : télécharge **uniquement**
427
+ le manifeste + la GT (les images restent des références IIIF HF, épinglées à
428
+ ``revision``), puis délègue à l'importeur local. Un corpus de référence publié
429
+ devient ainsi sélectionnable au Banc d'essai en quelques secondes, sans
430
+ rapatrier les images. ``fetch`` est injectable (test sans réseau).
431
+ """
432
+ layout_dir = (fetch or snapshot_curated_layout)(repo_id, Path(dest), revision)
433
+ return import_curated_corpus(
434
+ layout_dir,
435
+ base_url=_hf_resolve_base(repo_id, revision),
436
+ revision=revision,
437
+ name=name,
438
+ )
439
+
440
+
441
+ __all__ = [
442
+ "CorpusImportError",
443
+ "import_curated_corpus",
444
+ "import_curated_hf_corpus",
445
+ "import_escriptorium_corpus",
446
+ "import_gallica_corpus",
447
+ "import_hf_corpus",
448
+ "import_iiif_corpus",
449
+ ]