Spaces:
Running
Running
cinoc-deploy commited on
Commit ·
600b32c
0
Parent(s):
Déploiement de la vitrine Cinoc
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .gitattributes +8 -0
- Dockerfile +90 -0
- README.md +52 -0
- cinoc/_version.py +24 -0
- cinoc/adapters/ANALYSE_COUCHE_5.md +271 -0
- cinoc/adapters/__init__.py +9 -0
- cinoc/adapters/_resilience.py +170 -0
- cinoc/adapters/_workspace.py +31 -0
- cinoc/adapters/corpus/__init__.py +39 -0
- cinoc/adapters/corpus/_http.py +408 -0
- cinoc/adapters/corpus/escriptorium.py +185 -0
- cinoc/adapters/corpus/gallica.py +160 -0
- cinoc/adapters/corpus/htr_united.py +176 -0
- cinoc/adapters/corpus/huggingface.py +487 -0
- cinoc/adapters/corpus/iiif.py +137 -0
- cinoc/adapters/images.py +137 -0
- cinoc/adapters/layout/__init__.py +3 -0
- cinoc/adapters/layout/_base.py +114 -0
- cinoc/adapters/layout/_saknussemm_bridge.py +187 -0
- cinoc/adapters/layout/alto_source.py +230 -0
- cinoc/adapters/layout/assembler.py +89 -0
- cinoc/adapters/layout/crop.py +72 -0
- cinoc/adapters/layout/pp_doclayout.py +147 -0
- cinoc/adapters/layout/precomputed.py +178 -0
- cinoc/adapters/layout/remote.py +194 -0
- cinoc/adapters/layout/saknussemm_correct.py +324 -0
- cinoc/adapters/layout/to_text.py +106 -0
- cinoc/adapters/llm/__init__.py +15 -0
- cinoc/adapters/llm/_base.py +285 -0
- cinoc/adapters/llm/anthropic.py +202 -0
- cinoc/adapters/llm/mistral.py +230 -0
- cinoc/adapters/llm/ollama.py +240 -0
- cinoc/adapters/llm/ollama_structured.py +120 -0
- cinoc/adapters/llm/openai.py +197 -0
- cinoc/adapters/ner/__init__.py +1 -0
- cinoc/adapters/ner/spacy_extractor.py +204 -0
- cinoc/adapters/ocr/__init__.py +8 -0
- cinoc/adapters/ocr/_base.py +64 -0
- cinoc/adapters/ocr/azure_di.py +223 -0
- cinoc/adapters/ocr/calamari.py +106 -0
- cinoc/adapters/ocr/google_vision.py +157 -0
- cinoc/adapters/ocr/kraken.py +166 -0
- cinoc/adapters/ocr/mistral_ocr.py +113 -0
- cinoc/adapters/ocr/pero.py +114 -0
- cinoc/adapters/ocr/precomputed.py +91 -0
- cinoc/adapters/ocr/tesseract.py +332 -0
- cinoc/adapters/preprocess/__init__.py +5 -0
- cinoc/adapters/preprocess/_ops.py +115 -0
- cinoc/adapters/preprocess/image_prep.py +214 -0
- cinoc/adapters/storage/__init__.py +24 -0
.gitattributes
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Image **moteur** Cinoc pour un Hugging Face Space (SDK: docker).
|
| 2 |
+
#
|
| 3 |
+
# ≠ la vitrine lecture seule (deploy/Dockerfile{,.space}) : celle-ci **bake un vrai
|
| 4 |
+
# moteur OCR gratuit** (Tesseract 5 + langues fra/lat/eng) pour qu'un visiteur du
|
| 5 |
+
# Space public lance un run Tesseract **sans clé ni installation**. Les moteurs
|
| 6 |
+
# cloud et les plugins tiers restent *gated* (mode public, fail-closed côté appli).
|
| 7 |
+
#
|
| 8 |
+
# Contexte de build « racine plate » (Dockerfile / requirements.txt / pyproject.toml
|
| 9 |
+
# / cinoc / reports TOUS à la racine) — assemblé par .github/workflows/deploy-space.yml,
|
| 10 |
+
# comme Dockerfile.space.
|
| 11 |
+
FROM python:3.11-slim
|
| 12 |
+
|
| 13 |
+
# ── Dépendances système : Tesseract 5 + langues + libs image (Pillow) ──────────
|
| 14 |
+
# Les langues sont baquées (pas téléchargées au runtime) : un Space free-tier n'a
|
| 15 |
+
# pas d'accès réseau garanti au démarrage, et le déterminisme exige une version de
|
| 16 |
+
# binaire figée. ``fra`` est le besoin patrimonial central ; ``lat`` (latin) et
|
| 17 |
+
# ``eng`` complètent. Pas de pin de version exacte (Debian point-release rebump
|
| 18 |
+
# fréquemment ``5.3.0-2`` → ``+deb12u1`` → casse le build) : la repro repose sur la
|
| 19 |
+
# capture de ``tesseract --version`` dans ``RunManifest.system_binaries_lock``.
|
| 20 |
+
RUN apt-get update -o Acquire::Retries=3 \
|
| 21 |
+
&& apt-get install -y --no-install-recommends -o Acquire::Retries=3 \
|
| 22 |
+
tesseract-ocr \
|
| 23 |
+
tesseract-ocr-fra \
|
| 24 |
+
tesseract-ocr-lat \
|
| 25 |
+
tesseract-ocr-eng \
|
| 26 |
+
libtiff6 \
|
| 27 |
+
libjpeg62-turbo \
|
| 28 |
+
libpng16-16 \
|
| 29 |
+
libwebp7 \
|
| 30 |
+
&& apt-get clean \
|
| 31 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 32 |
+
|
| 33 |
+
# ── Variables d'exécution ──────────────────────────────────────────────────────
|
| 34 |
+
# TESSDATA_PREFIX : emplacement Debian bookworm des modèles Tesseract 5.
|
| 35 |
+
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
| 36 |
+
# OMP_THREAD_LIMIT=1 : OBLIGATOIRE sur free-tier 2 vCPU. Tesseract LSTM (oem 3)
|
| 37 |
+
# parallélise via OpenMP ; non borné sur un CPU partagé il **deadlock** (incident
|
| 38 |
+
# documenté côté Picarones, 2026-05-16) — l'OCR gèle et chaque document timeoute.
|
| 39 |
+
# Mono-thread par appel = plus rapide ET déterministe ici.
|
| 40 |
+
ENV OMP_THREAD_LIMIT=1
|
| 41 |
+
# Mode public (verrou *fail-closed*) **non forcé** : par défaut l'instance exécute
|
| 42 |
+
# ses moteurs (cloud compris) avec la clé de l'opérateur — pas de 403. Qui expose
|
| 43 |
+
# un Space **public** et veut protéger sa clé d'un visiteur pose lui-même
|
| 44 |
+
# CINOC_PUBLIC_MODE=true (alors seul le socle gratuit tesseract s'exécute).
|
| 45 |
+
ENV PYTHONUNBUFFERED=1
|
| 46 |
+
|
| 47 |
+
# ── Smoke-test du binaire au BUILD (fail-fast) ─────────────────────────────────
|
| 48 |
+
# Un Tesseract cassé OU figé doit casser le BUILD (le Space garde l'image
|
| 49 |
+
# précédente fonctionnelle), pas déployer une image qui gèle en silence :
|
| 50 |
+
# (a) le binaire charge ses libs et expose la langue ``fra`` (sinon build refusé) ;
|
| 51 |
+
# (b) une reconnaissance réelle sur une image générée se TERMINE sous 30 s — un
|
| 52 |
+
# timeout (rc 124) = deadlock OpenMP / libs incohérentes → build refusé.
|
| 53 |
+
RUN set -eu; \
|
| 54 |
+
timeout 30 tesseract --version; \
|
| 55 |
+
timeout 30 tesseract --list-langs 2>&1 | grep -qx fra; \
|
| 56 |
+
printf 'P1\n16 16\n' > /tmp/smoke.pbm; \
|
| 57 |
+
i=0; while [ "$i" -lt 16 ]; do \
|
| 58 |
+
printf '1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0\n' >> /tmp/smoke.pbm; \
|
| 59 |
+
i=$((i + 1)); \
|
| 60 |
+
done; \
|
| 61 |
+
rc=0; \
|
| 62 |
+
timeout 30 tesseract /tmp/smoke.pbm - -l fra --psm 6 >/dev/null 2>&1 || rc=$?; \
|
| 63 |
+
rm -f /tmp/smoke.pbm; \
|
| 64 |
+
if [ "$rc" = 124 ]; then \
|
| 65 |
+
echo "FATAL: tesseract a gelé (timeout) — build refusé, libs incohérentes." >&2; \
|
| 66 |
+
exit 1; \
|
| 67 |
+
fi
|
| 68 |
+
|
| 69 |
+
# ── Utilisateur non-root (sécurité d'un service exposé) ────────────────────────
|
| 70 |
+
RUN useradd --create-home --uid 1000 cinoc
|
| 71 |
+
WORKDIR /home/cinoc/app
|
| 72 |
+
|
| 73 |
+
# ── Dépendances Python d'abord (cache de couche) ───────────────────────────────
|
| 74 |
+
# requirements.txt fige le cœur + l'extra [serve] + pytesseract (wrapper du binaire).
|
| 75 |
+
COPY requirements.txt ./requirements.txt
|
| 76 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 77 |
+
|
| 78 |
+
# ── Code + paquet (sans deps : tout est déjà figé ci-dessus) ───────────────────
|
| 79 |
+
COPY pyproject.toml README.md ./
|
| 80 |
+
COPY cinoc ./cinoc
|
| 81 |
+
RUN pip install --no-cache-dir --no-deps .
|
| 82 |
+
|
| 83 |
+
# ── Rapports bakés (lecture seule) — dossier toujours présent ──────────────────
|
| 84 |
+
COPY reports ./reports
|
| 85 |
+
ENV CINOC_REPORTS_DIR=/home/cinoc/app/reports
|
| 86 |
+
|
| 87 |
+
USER cinoc
|
| 88 |
+
EXPOSE 7860
|
| 89 |
+
# 0.0.0.0 attendu en conteneur (le réseau est géré par la plateforme).
|
| 90 |
+
CMD ["cinoc", "serve", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Cinoc — banc d'essai OCR/HTR/VLM
|
| 3 |
+
emoji: 📜
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Cinoc — banc d'essai OCR/HTR/VLM
|
| 13 |
+
|
| 14 |
+
Banc d'essai **déterministe** de pipelines de transcription (OCR / HTR / VLM).
|
| 15 |
+
Ce Space **exécute un vrai OCR Tesseract gratuitement** — **sans clé ni
|
| 16 |
+
installation** : déposez un corpus, lancez Tesseract, obtenez un rapport CER/WER/MER
|
| 17 |
+
réel. Il sert aussi des rapports `RunResult` pré-calculés, rendus en HTML autonome.
|
| 18 |
+
|
| 19 |
+
- `/` — liste des rapports disponibles
|
| 20 |
+
- `/library` — préparer un corpus (upload ZIP)
|
| 21 |
+
- `/benchmark` — lancer un run **Tesseract** (gratuit, local) sur un corpus
|
| 22 |
+
- `/reports/<nom>` — un rapport rendu en HTML
|
| 23 |
+
- `/health` — sonde de vivacité
|
| 24 |
+
|
| 25 |
+
**Par défaut, l'instance exécute ses moteurs avec la clé de son opérateur** —
|
| 26 |
+
aucun blocage. Sans clé posée, seul le **socle gratuit** tourne (Tesseract, aucun
|
| 27 |
+
secret, aucun appel facturé) ; dès qu'une clé est présente (`MISTRAL_API_KEY`,
|
| 28 |
+
`OPENAI_API_KEY`…), les moteurs **cloud** (OCR→LLM, VLM) s'exécutent normalement.
|
| 29 |
+
|
| 30 |
+
## Utiliser un moteur cloud (Mistral, OpenAI…)
|
| 31 |
+
|
| 32 |
+
1. **Secret** : `MISTRAL_API_KEY` = votre clé (Settings → Variables and secrets).
|
| 33 |
+
Le SDK `mistralai` est déjà dans `requirements.txt`. Redéployez.
|
| 34 |
+
2. C'est tout : le menu déroulant « Modèle » du Banc d'essai se remplit directement
|
| 35 |
+
depuis l'API Mistral (`models.list`) et les prompts sont éditables par concurrent.
|
| 36 |
+
|
| 37 |
+
> 🔴 **Sécurité** : sur un Space **public**, la clé que vous posez est dépensable par
|
| 38 |
+
> n'importe quel visiteur. Rendez le Space **Private** (Settings → Visibility →
|
| 39 |
+
> Private) si vous y posez une clé facturée, **ou** activez le verrou ci-dessous.
|
| 40 |
+
|
| 41 |
+
## Verrou « mode public » — opt-in, pour protéger une clé sur un Space public
|
| 42 |
+
|
| 43 |
+
Si vous tenez à exposer un Space **public** *tout en* gardant une clé posée sans
|
| 44 |
+
qu'un visiteur puisse la dépenser, activez le verrou **fail-closed** : variable
|
| 45 |
+
`CINOC_PUBLIC_MODE` = `true` (Settings → Variables and secrets). Alors seul le
|
| 46 |
+
**socle gratuit** (Tesseract) s'exécute ; les moteurs **cloud**, les **imports
|
| 47 |
+
distants** (IIIF/Gallica/…) et les **plugins tiers** sont **refusés** (`403`). Le
|
| 48 |
+
verrou est **désactivé par défaut** : ne le posez que si ce scénario est le vôtre.
|
| 49 |
+
|
| 50 |
+
> Ce fichier est l'en-tête de configuration du Space Hugging Face (`sdk: docker`).
|
| 51 |
+
> Au déploiement, il devient le `README.md` racine du dépôt du Space ; le
|
| 52 |
+
> `Dockerfile` et `requirements.txt` vivent dans `deploy/`.
|
cinoc/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file generated by vcs-versioning
|
| 2 |
+
# don't change, don't track in version control
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"__version__",
|
| 7 |
+
"__version_tuple__",
|
| 8 |
+
"version",
|
| 9 |
+
"version_tuple",
|
| 10 |
+
"__commit_id__",
|
| 11 |
+
"commit_id",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
version: str
|
| 15 |
+
__version__: str
|
| 16 |
+
__version_tuple__: tuple[int | str, ...]
|
| 17 |
+
version_tuple: tuple[int | str, ...]
|
| 18 |
+
commit_id: str | None
|
| 19 |
+
__commit_id__: str | None
|
| 20 |
+
|
| 21 |
+
__version__ = version = '0.1.1.dev1+gea3d42207'
|
| 22 |
+
__version_tuple__ = version_tuple = (0, 1, 1, 'dev1', 'gea3d42207')
|
| 23 |
+
|
| 24 |
+
__commit_id__ = commit_id = 'gea3d42207'
|
cinoc/adapters/ANALYSE_COUCHE_5.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ANALYSE_COUCHE_5.md — `adapters/` (Picarones → Cinoc)
|
| 2 |
+
|
| 3 |
+
> **Type** : session d'ANALYSE (ne code rien). Guide de portage **durable**.
|
| 4 |
+
> **Couche** : 5 = `adapters` (cf. `CLAUDE.md` §3 : `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`).
|
| 5 |
+
> **Source gelée** : `../Picarones/picarones/adapters/` — **41 fichiers, 9 682 LOC** (mesuré).
|
| 6 |
+
> **Cible** : `cinoc/adapters/`.
|
| 7 |
+
> **Couches amont mergées dont on dépend** : `domain` (mergée), `formats` (mergée), `evaluation` (plan acté `MIGRATION_COUCHE_3.md`), `pipeline` (à venir — porte le **contrat de module**).
|
| 8 |
+
> **Partie 1 = durable** (source figée). **Partie 2 = périssable** (« à confirmer à la tranche »).
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 0. Drapeaux à lever AVANT toute construction (lire en premier)
|
| 13 |
+
|
| 14 |
+
| # | Drapeau | Statut | Action |
|
| 15 |
+
|---|---|---|---|
|
| 16 |
+
| **F-0** | La consigne d'analyse disait « Couche4 », le titre/ livrable disent « couche 5 ». | Coquille. `CLAUDE.md` §3 + `PROMPT_ANALYSE_COUCHE.md` confirment **couche 5 = `adapters`**. | Aucun conflit. Analysé = `adapters`. |
|
| 17 |
+
| **F-1** | `CLAUDE.md` D1 décrit le legacy comme « double contrat **`BaseOCRAdapter`→`BaseModule`** ». **Le code gelé ne contient PAS cet héritage.** | `base.py:58` = `class BaseOCRAdapter(ABC)` ; `llm/base.py:202` = `class BaseLLMAdapter(ABC)` ; **aucun n'hérite de `BaseModule`**. Le vrai contrat runtime est **`StepExecutor`** (`pipeline/protocols.py:42`, `execute(...)`). `BaseModule` (`domain/module_protocol.py`, `process(...)`) est **vestigial** : sa docstring `:24-27` *prétend* (à tort) que les adapters en sont des cas particuliers ; ses seuls usages sont un ré-export dans `__init__` + `evaluation/metrics/module_policy.py` (lui-même classé « mort » par `MIGRATION_COUCHE_3.md` §11). | **Ne PAS s'arrêter** : la *décision* D1 (un seul `Protocol` en couche 4, `BaseModule` supprimé) reste **correcte et même renforcée** — Picarones a déjà ce `Protocol` unique (`StepExecutor`). Seule la *caractérisation factuelle* de la source est à corriger : il s'agit d'un contrat **vestigial parallèle** (`BaseModule.process`), pas d'un emballage. Documenté §1.2. |
|
| 18 |
+
| **F-2** | `CLAUDE.md` §8.8 dit « 8+8+8 adapters LLM/VLM ». | Réalité : **8 OCR + 4 LLM + 4 VLM**. | Direction (« → minimal/starter ») valide ; chiffres rectifiés §1.1. |
|
| 19 |
+
| **F-3** | `_fallback_log.py` n'a qu'un consommateur final : le **détecteur narratif** `IMPORTER_FALLBACK_TRIGGERED`. | Or le **moteur narratif est SUPPRIMÉ** (`CLAUDE.md` §6, D2). | ⇒ « pas de consommateur = supprimé ». Verdict **SUPPRIMER** (remplacé par `logger.warning`). §1.3-corpus. |
|
| 20 |
+
|
| 21 |
+
Aucun de ces points ne contredit une couche **mergée** ni une décision actée → analyse poursuivie.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
# PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable)
|
| 26 |
+
|
| 27 |
+
## 1.1 Inventaire exact (41 fichiers / 9 682 LOC)
|
| 28 |
+
|
| 29 |
+
| Sous-paquet | Fichiers | LOC | Rôle vérifié |
|
| 30 |
+
|---|---|---|---|
|
| 31 |
+
| **`/` (utils racine)** | 6 | **866** | I/O atomique, retry, http(x), downscale image, résolution de chemins. Mutualisés OCR+LLM. |
|
| 32 |
+
| **`ocr/`** | 12 | **3 206** | Contrat `BaseOCRAdapter` + factory + 8 moteurs + sidecar confidences. |
|
| 33 |
+
| **`corpus/`** | 8 | **3 045** | Importeurs de corpus patrimoniaux (IIIF, Gallica, eScriptorium, HTR-United, HuggingFace) + http urllib anti-SSRF + journal fallback. |
|
| 34 |
+
| **`llm/`** | 6 | **1 208** | Post-correction texte (`RAW_TEXT → CORRECTED_TEXT`). Base + 4 providers. |
|
| 35 |
+
| **`storage/`** | 3 | **964** | `ArtifactStore` (cache fichier) + `JobStore` (SQLite jobs web). |
|
| 36 |
+
| **`vlm/`** | 6 | **393** | Transcription `IMAGE → RAW_TEXT` zéro-OCR. Base + 4 manifestes de composition. |
|
| 37 |
+
|
| 38 |
+
Libs externes (toutes confinées à `adapters/`, conforme `CLAUDE.md` §3) : `pytesseract`+`PIL` (tesseract), `kraken`, `calamari-ocr`+TF, `pero-ocr`+torch, `google-cloud-vision`, `azure-ai-documentintelligence`, `mistralai`, `openai`, `anthropic`, `httpx` (ollama — **dép. cœur**, pas un extra), `datasets` (hf), `yaml`/`tqdm` (corpus). Le **noyau de contrat** (`ocr/base.py`, `factory.py`, `confidences.py`, `llm/base.py`, `vlm/base.py`, utils racine) n'importe **aucune** lib tierce sauf `httpx`/`PIL`.
|
| 39 |
+
|
| 40 |
+
## 1.2 Le contrat réel (la couture décisive `domain↔pipeline↔adapters`)
|
| 41 |
+
|
| 42 |
+
**Trois contrats coexistent ; un seul est vivant côté runtime.**
|
| 43 |
+
|
| 44 |
+
| Contrat | Lieu | Forme | Statut réel |
|
| 45 |
+
|---|---|---|---|
|
| 46 |
+
| `BaseModule` | `domain/module_protocol.py:39` | `process(inputs)→outputs`, types `tuple`, **aucun** concern d'exécution | **Vestigial.** Aucun adapter ne l'hérite ni ne l'implémente. Consommateurs : ré-export `__init__` + `module_policy.py` (mort). |
|
| 47 |
+
| **`StepExecutor`** | `pipeline/protocols.py:42` | `name`,`input_types`/`output_types` (`frozenset`), **`execute(inputs, params, context: RunContext, control: RunControl) → dict[ArtifactType, Artifact]`** | **Contrat vivant.** Porte deadline (`context.deadline`) + annulation (`control`). Satisfait par **duck-typing** (jamais hérité). |
|
| 48 |
+
| `BaseOCRAdapter` / `BaseLLMAdapter` / `BaseVLMAdapter` | `adapters/{ocr,llm,vlm}/base.py` | ABC d'**implémentation** (défauts `input_types`/`output_types`, helpers, `name`/`execute` abstraits) | Ce sont des **mixins d'implémentation**, **pas** un second contrat. `BaseVLMAdapter(BaseLLMAdapter)` ; les deux autres `(ABC)`. |
|
| 49 |
+
|
| 50 |
+
**Résolution `adapter_name → executor`** : pas de registre. `build_adapter_resolver(engines)` (`app/services/_benchmark_adapter_resolver.py:215`) construit un `dict[str, StepExecutor]` à partir d'**instances** d'engines fabriquées en amont, et lève `PicaronesError` sur collision de `name` à configs distinctes. La factory `ocr_adapter_from_name` (`ocr/factory.py:64`) est une **cascade `if/elif` codée en dur** (anti-OCP). **Aucune découverte de plugins entry-points** (`registry_service.py:39` le dit explicitement). Seule extensibilité « tierce » : `adapter_class: dotted.path` dans le YAML `run_spec` (`app/schemas/run_spec.py:32-62`), classe importable dans le venv.
|
| 51 |
+
|
| 52 |
+
> ⚠️ **Conséquence pour Cinoc** : le `Protocol` de module que `CLAUDE.md` D1 veut construire en **couche 4** ≈ **`StepExecutor` déjà existant**. Les adapters l'implémentent **directement**. `BaseModule` est **supprimé sans perte**. *(`version` manque dans `StepExecutor`/`BaseOCRAdapter` → à **ajouter** pour la repro `RunManifest`, cf. §2.1.)*
|
| 53 |
+
|
| 54 |
+
## 1.3 Verdicts fichier par fichier — **PROVISOIRE — à confirmer au build**
|
| 55 |
+
|
| 56 |
+
Légende verdict : **G**=garder · **M**=modifier · **C**=changer de couche · **S**=supprimer · **I**=incrémental (porter à la tranche qui le consomme, pas avant).
|
| 57 |
+
|
| 58 |
+
### Utils racine (`adapters/*.py`)
|
| 59 |
+
|
| 60 |
+
| Fichier | LOC | Rôle vérifié | Consommateurs réels (hors tests) | Bugs / dette | Verdict |
|
| 61 |
+
|---|---|---|---|---|---|
|
| 62 |
+
| `__init__.py` | 28 | Docstring de paquet, `__all__=[]`. | 0 (imports par chemin complet). | — | **G** (mince) |
|
| 63 |
+
| `_atomic_io.py` | 151 | `atomic_write_text` (tmp+`fsync`+`os.replace`). | `atomic_write_text` : **11 adapters**. | `atomic_write_bytes` = **0 conso** (mort). Corps dupliqué text/bytes. | **M** : garder `atomic_write_text`, **S** `atomic_write_bytes`. **`storage/` doit l'utiliser** (cf. infra). |
|
| 64 |
+
| `_httpx_helpers.py` | 213 | Client httpx deadline-aware + cancel cross-thread (`make_httpx_client`, `translate_or_reraise`). | **1** (`llm/ollama_adapter`). | Détection « close » par **heuristique de message** (`:179-190`) fragile. Couple `adapters→pipeline.run_control`. | **G+M** (revoir l'heuristique ; unifier la trad. d'erreur). |
|
| 65 |
+
| `_image.py` | 87 | Downscale image base64 (`PIL` lazy, dégradable). | **3** (ollama/openai/mistral LLM). | Bloc d'appel `if max_edge>0: downscale` **copié 3×** chez les callers. | **G** (factoriser l'appel dans la base LLM). |
|
| 66 |
+
| `_retry.py` | 274 | Retry exponentiel + `Retry-After` + jitter, deadline-aware (`call_with_retry`, `is_retryable`, `compute_retry_wait`). | `call_with_retry` : **3 OCR cloud** ; primitives : `llm/base.py`. | **Bug** : jitter `×[1.0,1.5)` appliqué **après** le plafond → `DEFAULT_MAX_WAIT` (120 s) dépassé jusqu'à **180 s** (`:138-139`). `retry_after_seconds`/`DEFAULT_MAX_WAIT` exportés sans conso externe. **Boucle ré-implémentée** dans `llm/base.py:384-429` ET `corpus/_http.download_url`. | **M** : corriger le jitter ; **unifier les 3 implémentations** ; resserrer l'API publique. |
|
| 67 |
+
| `output_paths.py` | 113 | `resolve_output_path` (segment par pipeline anti-collision). | **9 adapters**. | `context: Any` (duck-typing `RunContext`) → dégrade en silence si attribut absent. | **G**. |
|
| 68 |
+
|
| 69 |
+
### `ocr/`
|
| 70 |
+
|
| 71 |
+
| Fichier | LOC | Rôle / sortie | Consommateurs réels | Bugs / dette | Verdict |
|
| 72 |
+
|---|---|---|---|---|---|
|
| 73 |
+
| `__init__.py` | 48 | Façade ; ré-exporte les 8 adapters + base + factory. | traversé par `llm_pipeline_config`. | **Import eager** des 8 classes (sûr car les modules n'importent pas la lib lourde au top-level) mais court-circuite la robustesse lazy de la factory. | **M** (mince ; aligner sur le registre). |
|
| 74 |
+
| `base.py` | 189 | `BaseOCRAdapter(ABC)` + `OCRAdapterError`. **`effective_output_types`** = sous-ensemble **garanti** (vs maximal). | 8 adapters + resolver + factory + cli + robustness. | **Pas d'attribut `version`** (repro). `control` jamais honoré (cf. dette §1.4). | **M** : = mixin d'impl ; **ajouter `version`** ; garder `effective_output_types`. Contrat = `Protocol` couche 4. |
|
| 75 |
+
| `factory.py` | 194 | `ocr_adapter_from_name(name) → BaseOCRAdapter`. | cli + web + `__init__`. | **Cascade `if/elif`** (anti-OCP) ; alias incohérents (`calamari_ocr`→`calamari`, pas de court `kraken`) ; `_SUPPORTED`/`_ALIASES` divergent à la main ; message Azure cite `azure-ai-formrecognizer` ≠ SDK réel. | **C→app** : remplacer par **registre + entry-points** (`CLAUDE.md` §3 : registre = app/adapters, découverte = app). |
|
| 76 |
+
| `confidences.py` | 164 | Sidecar JSON de confidences. `ConfidenceToken`=TypedDict `{text, confidence∈[0,1]}`, **niveau mot**, normalisation Tesseract 0-100→0-1. | **1** (tesseract). Artefact `CONFIDENCES` lu par `evaluation/calibration`. | `model_version:null` toujours sérialisé ; entrée `list[dict]` non typée. | **G+M** : logique en adapters ; **promouvoir `ConfidenceToken`→`domain`** quand la calibration est consommée (backlog domain `CLAUDE.md`). |
|
| 77 |
+
| `precomputed.py` | 221 | **Starter.** Lit `<stem>.<label>.txt`. `IMAGE→RAW_TEXT`. 0 dép externe. | factory + builders. | Nommage `name` = `precomputed_<label>` (`_`) ≠ doc factory (`:`). `control` inutilisé ; double I/O policy `empty`. | **G** (starter) ; corriger nommage. |
|
| 78 |
+
| `tesseract.py` | 548 | **Starter.** `pytesseract` (binaire). `RAW_TEXT` (+`CONFIDENCES`/`ALTO_XML` **opt-in** via `effective_output_types`). | factory + cli + web. | >400 LOC. `control` inutilisé (timeout subprocess compense). | **G** (starter) ; **trim/split <400**. |
|
| 79 |
+
| `kraken.py` | 237 | HTR ML local (torch). `RAW_TEXT`. Extra `[kraken]`. | factory + web. | Deadline **ignorée** (inférence non interruptible) ; API Kraken 4.x fragile vs 5.x. | **G+I** (incrémental). |
|
| 80 |
+
| `calamari.py` | 253 | OCR ligne (TF). `RAW_TEXT`. Extra `[calamari]`. | factory + web. | Parsing résultat fragile (`getattr(...,"sentence","")`) ; deadline ignorée. | **G+I**. |
|
| 81 |
+
| `pero_ocr.py` | 226 | HTR ML local (torch, PAGE aplati). `RAW_TEXT`. | factory + web. | Docstring tronquée (`:1-3`) ; deadline ignorée ; ordre `page_size=(h,w)` à vérifier. | **G+I**. |
|
| 82 |
+
| `google_vision.py` | 330 | Cloud (SDK **ou** REST/urllib). `RAW_TEXT`. | factory + web. | SDK path ignore la deadline ; motif HTTP dupliqué (cf. azure/mistral). | **G+I**. |
|
| 83 |
+
| `azure_doc_intel.py` | 425 | Cloud async + polling (SDK ou REST). `RAW_TEXT`. | factory + web. | >400. **Meilleure gestion deadline** (REST) ; SDK path l'ignore. Backoff maison. | **G+I** ; trim<400. |
|
| 84 |
+
| `mistral_ocr.py` | 371 | `/v1/ocr` (urllib) **ou** chat/vision (SDK). `RAW_TEXT`. | factory + web. | SDK chat ignore deadline. **Importe `adapters.llm.base.normalize_llm_content`** (couplage cross-famille). | **G+I** ; sortir `normalize_*` en helper partagé. |
|
| 85 |
+
|
| 86 |
+
### `llm/` + `vlm/`
|
| 87 |
+
|
| 88 |
+
| Fichier | LOC | Rôle / contrat | Consommateurs réels | Bugs / dette | Verdict |
|
| 89 |
+
|---|---|---|---|---|---|
|
| 90 |
+
| `llm/__init__.py` | 16 | Docstring cadrage. | 0. | — | **G**. |
|
| 91 |
+
| `llm/base.py` | 582 | **Cœur.** `BaseLLMAdapter(ABC)`. **Double API** : `complete(prompt, image_b64, *, deadline, control)→LLMResult` (appel + retry) **et** `execute(...)` (orchestration fichier→complete→fichier). `RAW_TEXT→CORRECTED_TEXT`. | pipeline (llm_config/builder) + VLM + `mistral_ocr` (helper). | >400. `LLMResult.tokens_used` **jamais peuplé** (`:194` vs `:396`) → coût non remonté. `except Exception`(`:401`) intentionnel+tracé. | **M** : **split <400** (helpers / `LLMResult` / base / orchestration) ; **peupler ou supprimer `tokens_used`**. |
|
| 92 |
+
| `llm/openai_adapter.py` | 123 | **Starter.** SDK `openai`, extra `[llm]`, `temperature=0`. | web factory. | `del control` (SDK non câblé, documenté). | **G** (starter). |
|
| 93 |
+
| `llm/ollama_adapter.py` | 160 | **Starter.** httpx brut (**dép. cœur**), **seul à câbler `control`** (cancel cross-thread). | web factory. | `except (TimeoutException, HTTPError): pass` (`:131-132`) — re-raise délégué, à mieux commenter. | **G** (starter). |
|
| 94 |
+
| `llm/anthropic_adapter.py` | 129 | Idem motif, extra `[llm]`. | web factory. | `del control`. | **G+I**. |
|
| 95 |
+
| `llm/mistral_adapter.py` | 198 | Idem + table `_TEXT_ONLY_MODELS` (dégradé multimodal). | web factory. | Logs verbeux ; instance client par appel. | **G+I**. |
|
| 96 |
+
| `vlm/__init__.py` | 42 | Façade ré-export. | 0 (hors tests). | — | **G+I**. |
|
| 97 |
+
| `vlm/base.py` | 245 | `BaseVLMAdapter(BaseLLMAdapter)` : `IMAGE→RAW_TEXT`, override `execute` + prompts ; **garde-fou MRO** (`__init_subclass__`). | 0 instanciateur (2 docs). | — | **G+I** (porter à la tranche `zero_shot`). Conserver le garde MRO. |
|
| 98 |
+
| `vlm/openai_vlm.py` | 22 | `(BaseVLMAdapter, OpenAIAdapter)` + `name`. | **0**. | — | **I/S** : ~90 % hérité, **0 conso** → ne **pas** porter spéculativement (porter à la tranche zero_shot). |
|
| 99 |
+
| `vlm/anthropic_vlm.py` | 32 | idem. | **0**. | — | **I/S** idem. |
|
| 100 |
+
| `vlm/mistral_vlm.py` | 26 | idem + `default_model=pixtral`. | **0**. | — | **I/S** idem. |
|
| 101 |
+
| `vlm/ollama_vlm.py` | 26 | idem + `default_model=llava`. | **0**. | — | **I/S** idem. |
|
| 102 |
+
|
| 103 |
+
### `corpus/`
|
| 104 |
+
|
| 105 |
+
| Fichier | LOC | Source / sortie | Consommateurs réels | Bugs / dette | Verdict |
|
| 106 |
+
|---|---|---|---|---|---|
|
| 107 |
+
| `__init__.py` | 24 | Docstring « nommer/localiser (image, GT) » ; `__all__=[]`. | 0. | — | **G** (mince). |
|
| 108 |
+
| `_http.py` | 270 | urllib + **anti-SSRF** (`validate_http_url`, blocage IP réservées, revalidation redirects) + retry deadline-aware. | iiif, gallica, escriptorium. | `install_opener` = **effet de bord global au process** (`:159`). Boucle retry ≈ `_retry`. | **G+M** : brique sécurité réutilisable (invariant `CLAUDE.md`) ; retirer l'`install_opener` global ; unifier le retry. |
|
| 109 |
+
| `_fallback_log.py` | 98 | Journal mémoire des dégradations d'importeur. | `consume`←app `_benchmark_converter` → **détecteur narratif**. `record`←htr_united/hf. | Seul débouché = narratif. | **S** (narratif supprimé `CLAUDE.md` §6 → plus de consommateur ; remplacer par `logger.warning`). |
|
| 110 |
+
| `iiif.py` | 567 | Manifeste IIIF v2/v3 → **`Corpus`**. | CLI (`import iiif`) + web + gallica. | >400. **Fuite de fichiers temp** si `output_dir=None` (`NamedTemporaryFile(delete=False)`, `:465`). `tqdm` lazy. | **G+I+M** : trim<400 ; corriger la fuite temp. Référence du contrat « → Corpus ». |
|
| 111 |
+
| `gallica.py` | 563 | SRU+IIIF+OCR brut BnF → **`Corpus`** (délègue à `IIIFImporter`). | web seulement. | >400. **Bug latent** `selected_indices[i]+1` (additionne `IIIFCanvas`+int, `:415`). `_fetch_url` force `retries=1`. Beaucoup de surface morte (`search_gallica`, `get_metadata`…). | **M+I** : corriger le bug ; **S** des fonctions sans conso ; trim<400. |
|
| 112 |
+
| `escriptorium.py` | 571 | API REST eScriptorium → **`Corpus`** + écriture distante (export couche OCR). | web (`import_document` seul). | >400. **BUG dur** `Corpus(source=...)` (champ = `source_path`, `:450`) → `TypeError` au 1ᵉʳ appel réel, **masqué par les mocks**. `image_path:str` ≠ domain `Path`. ~40 % surface API morte. | **M+I** : corriger le bug ; **S** `export_benchmark_as_layer`/`list_*`/`connect_*` (0 conso) ; trim<400. |
|
| 113 |
+
| `htr_united.py` | 488 | Catalogue HTR-United (GitHub) → **dict-manifeste** (pas `Corpus`) + GT extraits (sans images). | web. | >400. **Pas de `_http`** (ni anti-SSRF ni retry). `except (URLError, Exception)`. ZIP **chargé entier en RAM**. `_DEMO_CATALOGUE`=**113 LOC de données fabriquées** dans le code. | **M+I** : sortie unifiée `Corpus` ; **données démo → `data/` (S du code)** ; passer par `_http` ; borner le download. |
|
| 114 |
+
| `huggingface.py` | 464 | HF Hub (lib `datasets`) → **dict-manifeste** + paires `.jpg`/`.gt.txt`. | web. | >400. **Monkey-patch au load** (`_patch_dataset_replace_source`, `:456`). `except (ImportError, Exception): return 0` **muet** (lib absente indistinguable de dataset cassé). GT orphelin si `image.save` échoue. `_REFERENCE_DATASETS`=**109 LOC** en dur. Docstring annonce des fonctions inexistantes. | **M+I** : sortie unifiée `Corpus` ; extra `[hf]` + message clair ; supprimer le monkey-patch ; données → `data/`. |
|
| 115 |
+
|
| 116 |
+
### `storage/`
|
| 117 |
+
|
| 118 |
+
| Fichier | LOC | Backend / type | Consommateurs réels | Bugs / dette | Verdict |
|
| 119 |
+
|---|---|---|---|---|---|
|
| 120 |
+
| `__init__.py` | 58 | Façade ré-export. Docstring annonce 2 cibles **non livrées** (migration `web/jobs`, rapatriement history SQLite). | — | — | **G**. |
|
| 121 |
+
| `artifact_store.py` | 417 | **Filesystem** (`index.jsonl` + `artifacts/<key>.json` + `payloads/<key>.bin`) indexé par `ArtifactKey.hash_hex()`. Expose `StoredArtifact{key, artifact, payload}`. | **0 direct** : consommé via le **Protocol `ArtifactCachePort`** (`pipeline/cache_protocol.py`) injecté dans l'executor (inversion de dépendance). | >400. **`put` non atomique** (3 écritures sous simple `Lock`) ; **pas de `fsync`** (réimplémente un écriture inférieure au lieu d'utiliser `_atomic_io`) ; **path-traversal non gardé** (`f"{key}.json"`) ; index non borné ; pas de concurrence multi-process. | **G+M** : **utiliser `_atomic_io`** ; **valider la clé / `validated_path`** (invariant `CLAUDE.md`) ; implémente un **port défini en couche 4**. Tranche *cache*. |
|
| 122 |
+
| `job_store.py` | 489 | **SQLite** (table `jobs`, WAL, `schema_version`). `JobRecord`, `JobStore`. États `pending→running→{complete\|error\|cancelled\|interrupted}`. | **1** : `app/services/job_runner.py`. | >400. **Doublon** : un **2ᵉ `JobStore` legacy** vit en `interfaces/web/jobs.py:103` et c'est **lui** que l'app web tournante câble (`state.py:138`). `LIMIT` interpolé (neutralisé par `int()`). Pas de validation des transitions. | **G+M** : **UN seul** job store (celui-ci) ; **S** le doublon legacy (couche 8, hors périmètre mais à acter) — ⚠️ mais le canonical **n'a ni `job_events` ni reprise SSE `Last-Event-ID`** (assumé `job_store.py:48-54`), capacités que porte le legacy `web/jobs.py` **et dont dépend le stream SSE du web** (cf. `ANALYSE_COUCHE_8` §0/§1.3 D-γ) → **réabsorber l'événementiel AVANT** de supprimer le legacy ; valider les transitions. Tranche *web/async*. |
|
| 123 |
+
|
| 124 |
+
`StoredArtifact` : **non-candidat domain confirmé** (`domain/artifact_key.py:22-24` : « infrastructures, pas des types purs » ; le pipeline le touche via Protocol, pas en direct). Reste en `adapters/storage`.
|
| 125 |
+
|
| 126 |
+
## 1.4 Dette transverse vérifiée (durable)
|
| 127 |
+
|
| 128 |
+
| # | Dette | Preuve | Portée |
|
| 129 |
+
|---|---|---|---|
|
| 130 |
+
| **D-A** | **`control: RunControl` jamais honoré** : tous les `execute()` OCR + LLM/VLM cloud le reçoivent mais **n'appellent jamais** `register_cancel_handle`. **Seul `ollama_adapter` câble l'annulation** (via `_httpx_helpers`). | `base.py:82-86` documente le contrat ; aucun adapter ne l'honore sauf ollama. | Gap d'annulation cross-thread généralisé. |
|
| 131 |
+
| **D-B** | **Deadline asymétrique** : Tesseract (timeout subprocess) + Azure-REST (polling clampé) la respectent ; **tous les paths SDK** (Azure SDK, Google SDK, Mistral chat) l'ignorent (limite SDK) ; kraken/calamari/pero l'ignorent totalement. | `azure_doc_intel.py:366-379` (bon) vs `:261` (SDK ignore) ; `mistral_ocr.py:310-316`. | Un run ML local/SDK peut dépasser le budget en silence. |
|
| 132 |
+
| **D-C** | **Retry implémenté 3×** : `_retry.call_with_retry`, `llm/base.py:384-429` (inline), `corpus/_http.download_url`. | grep. | Triple maintenance du même algorithme. |
|
| 133 |
+
| **D-D** | **Deux piles HTTP** dans la même couche : `httpx` (`_httpx_helpers`+`_retry`, pour OCR/LLM cloud) vs `urllib` (`corpus/_http`, anti-SSRF, pour importeurs). Aucune ne réutilise l'autre. | grep : `corpus/*` n'importe jamais `_retry`/`_httpx_helpers`. | Scission lib/menace **assumée** mais coûteuse. |
|
| 134 |
+
| **D-E** | **`__init__` à effet de bord** : `corpus/_http.py:159` fait `install_opener` **global** au process à l'import. | `:153-161`. | Viole l'esprit « `__init__` minces, aucun effet de bord » (`CLAUDE.md` §7). |
|
| 135 |
+
| **D-F** | **Fracture du contrat de sortie corpus** : iiif/gallica/escriptorium → `Corpus` ; **htr_united/huggingface → `dict`-manifeste** + effets disque. 3/5 écrivent des fichiers. | tableaux §1.3-corpus. | Pas de contrat unique d'ingestion. |
|
| 136 |
+
| **D-G** | **Code mort / surface non câblée** : `atomic_write_bytes` ; exports `_retry` sans conso ; ~40 % d'`escriptorium.py` ; commodités `gallica`/`hf` ; `JobRecord.is_terminal/is_live` ; `_MIGRATIONS` vide. | greps agents. | « pas de consommateur = supprimé ». |
|
| 137 |
+
| **D-H** | **Données fabriquées dans le code** : `_DEMO_CATALOGUE` (113 LOC) + `_REFERENCE_DATASETS` (109 LOC) = ~220 LOC de fausses données servies en repli. | `htr_united.py:44`, `huggingface.py`. | Risque d'afficher du faux contenu ; doit être de la **donnée** (`data/`) ou supprimé. |
|
| 138 |
+
| **D-I** | **Bugs latents masqués par les mocks** : `escriptorium Corpus(source=...)` (`:450`) ; `gallica selected_indices[i]+1` (`:415`). | lecture code + tests mockés. | Régression garantie au 1ᵉʳ appel réel → à corriger au portage. |
|
| 139 |
+
| **D-J** | **`execution_mode` : déjà retiré** (« mensonge structurel », commit `5e13c0d`, mai 2026 — attribut décoratif jamais lu par le runner thread-only). 0 occurrence prod ; garde-fou de test anti-résurrection. | git show. | **Leçon** : ne **jamais** réintroduire d'attribut « au cas où » non câblé. |
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
# PARTIE 2 — RÉORGANISATION CIBLE Cinoc (périssable — « à confirmer à la tranche »)
|
| 144 |
+
|
| 145 |
+
> Conforme `CLAUDE.md` : **deux axes**, **tranches verticales** (jamais « finir la couche de haut en bas »), **budgets <400 LOC**, **rupture nette zéro shim**, **narrative supprimé**, **« pas de consommateur = supprimé »**. La couche 5 se **remplit par tranches** (chaque adapter naît avec le pipeline qui le consomme), pas en bloc.
|
| 146 |
+
|
| 147 |
+
## 2.1 Enveloppe (axe 1 — dimensionnée plein-scope, mais **définie en couche 4**)
|
| 148 |
+
|
| 149 |
+
Le **contrat de module exécutable** n'est **pas** un livrable de la couche 5 : il vit en **couche 4** (`pipeline`, cf. `CLAUDE.md` D1). La couche 5 ne fait que **l'implémenter**. À acter quand on conçoit la couche 4 (rappelé ici car il façonne tous les adapters) :
|
| 150 |
+
|
| 151 |
+
- **`Protocol` unique** ≈ `StepExecutor` de Picarones : `name`, **`version`** (➕ vs Picarones, repro `RunManifest`), `input_types`/`output_types` (`frozenset[ArtifactType]`), `execute(inputs, params, context, control) → dict`.
|
| 152 |
+
- **`BaseModule` supprimé** (vestigial). Les ABC `Base*Adapter` deviennent de **simples mixins d'implémentation** (défauts + helpers + `_write_text_artifact()`), **pas** un contrat.
|
| 153 |
+
- **`effective_output_types`** (garanti ⊂ maximal) **conservé** — sinon `missing_output` casse les steps quand un extra opt-in (Tesseract `CONFIDENCES`/`ALTO_XML`) n'est pas produit.
|
| 154 |
+
- **Trancher la dette d'annulation `control`** (D-A/D-B) : soit honorer `control` partout où l'opération est annulable, soit assumer explicitement « best-effort deadline » (documenté, testé). Ne pas traîner un paramètre non câblé.
|
| 155 |
+
|
| 156 |
+
**Registre + découverte (couche `app`, cf. `CLAUDE.md` §3)** : remplacer la cascade `if/elif` + les factories CLI/web dispersées par **un registre type-driven `adapter_name → factory`** + **découverte entry-points `cinoc.modules`** + `register()` local. **Seul point d'extension tiers = les briques de pipeline** (OCR/HTR/VLM/post-correction/segmentation/ordre de lecture/NER). **Corpus, métriques, sections, stores = first-party, non pluggables.**
|
| 157 |
+
|
| 158 |
+
## 2.2 Surface (axe 2 — incrémentale, par tranches)
|
| 159 |
+
|
| 160 |
+
**Starter pack** (le strict nécessaire au squelette ambulant) :
|
| 161 |
+
|
| 162 |
+
| Brique | Fichiers source | Extra | Tranche |
|
| 163 |
+
|---|---|---|---|
|
| 164 |
+
| `precomputed` (OCR 0 dép) | `ocr/precomputed.py` | — | **squelette texte** (corpus pré-calculé → CER) |
|
| 165 |
+
| `tesseract` (OCR binaire) | `ocr/tesseract.py` + `confidences.py` | binaire système | tôt (1ᵉʳ vrai moteur) |
|
| 166 |
+
| `openai` + `ollama` (LLM) | `llm/base.py` + `openai_adapter.py` + `ollama_adapter.py` | `[llm]` (openai) ; `httpx`=cœur (ollama) | tranche OCR+LLM `text_only` |
|
| 167 |
+
| utils racine | `_atomic_io`(text), `_retry`(corrigé+unifié), `output_paths`, `_image`, `_httpx_helpers` | — | portés **en bloc** avec le starter |
|
| 168 |
+
|
| 169 |
+
**Incrémental (porter à la tranche qui le consomme)** : anthropic/mistral (LLM) ; kraken/calamari/pero/google/azure/mistral_ocr (OCR — extras dédiés) ; **VLM** (tranche `zero_shot` — ~90 % gratuit une fois le LLM porté, **0 conso aujourd'hui → ne pas porter avant**) ; importeurs corpus (un par un) ; `artifact_store` (tranche *cache*) ; `job_store` (tranche *web/async*) ; **store longitudinal *tidy*** (tranche *longitudinal*, **rapatrié** de `evaluation/metrics/history.py` → `adapters/storage`, cf. `MIGRATION_COUCHE_3.md` §8).
|
| 170 |
+
|
| 171 |
+
## 2.3 Arborescence cible proposée (à confirmer)
|
| 172 |
+
|
| 173 |
+
```
|
| 174 |
+
cinoc/adapters/
|
| 175 |
+
├── _atomic_io.py atomic_write_text (+fsync) [G, -atomic_write_bytes]
|
| 176 |
+
├── _retry.py retry unifié, jitter corrigé [M, unifie les 3]
|
| 177 |
+
├── _http.py transport + anti-SSRF + retry [fusion _httpx_helpers + corpus/_http ?]
|
| 178 |
+
├── _image.py downscale (PIL lazy) [G]
|
| 179 |
+
├── output_paths.py resolve_output_path [G]
|
| 180 |
+
├── ocr/ base(mixin)+precomputed+tesseract [starter] ; reste incrémental
|
| 181 |
+
│ └── confidences.py (ConfidenceToken→domain à terme)
|
| 182 |
+
├── llm/ base+openai+ollama [starter] ; anthropic/mistral incrémental
|
| 183 |
+
├── vlm/ base+manifestes — tranche zero_shot uniquement
|
| 184 |
+
├── corpus/ importeurs first-party, sortie UNIQUE = Corpus, 1 par tranche
|
| 185 |
+
│ [_fallback_log SUPPRIMÉ ; données démo → data/]
|
| 186 |
+
└── storage/ artifact_store + job_store (UNIQUE) + history tidy (rapatrié)
|
| 187 |
+
```
|
| 188 |
+
*Note* : la **factory/registre** ne vit **pas** ici mais en `app/` (résolution `adapter_name`) ; la couche 5 ne fait qu'exposer des classes implémentant le `Protocol` couche 4.
|
| 189 |
+
|
| 190 |
+
## 2.4 Application des 5 garde-fous
|
| 191 |
+
|
| 192 |
+
- **Rupture nette / zéro shim** : un seul `JobStore` (S le legacy `web/jobs.py`) ; un seul contrat de sortie corpus (`Corpus`, S les dict-manifestes) ; pas de `BaseModule` ; une seule pile HTTP si possible (D-D).
|
| 193 |
+
- **Budgets <400** : `llm/base.py`(582), `escriptorium`(571), `iiif`(567), `gallica`(563), `tesseract`(548), `job_store`(489), `htr_united`(488), `huggingface`(464), `azure`(425), `artifact_store`(417) **dépassent**. La plupart **rétrécissent** après retrait du code mort (D-G), des données démo (D-H) et dédup du boilerplate (`_write_text_artifact`, retry, HTTP). Sinon split ou entrée justifiée `test_file_budgets`.
|
| 194 |
+
- **Pas de consommateur = supprimé** : `atomic_write_bytes`, `_fallback_log`, surface morte d'escriptorium/gallica/hf, exports `_retry` inutiles, VLM concrets tant que `zero_shot` n'existe pas.
|
| 195 |
+
- **Tests d'archi jour 1** : whitelist `adapters/` = `domain` + `pipeline` + `formats` (+ `evaluation` pour le type `Corpus` chargé, **à expliciter** — Picarones le tolérait par wildcard) ; `no-side-effect-import` (tue le `install_opener` global D-E) ; `no-broad-except` (htr/hf `except Exception`) ; `file-budgets`.
|
| 196 |
+
- **Feature entière + élaguée** : chaque adapter porté **avec** sa correction de bug (D-I), son extra optionnel, son message clair d'absence de dép, sans annotation de sprint.
|
| 197 |
+
|
| 198 |
+
## 2.5 Libs cibles & arbitrages (périssable — « à confirmer à la tranche »)
|
| 199 |
+
|
| 200 |
+
> Rappel : la minceur de la couche vient surtout de l'**élagage** (code mort, données démo, dédup), **pas** de l'ajout de libs. Une lib ne paie que si elle **retire plus de code que le wrapper qu'elle impose**. Contreparties transverses à toute dép : **supply-chain** (cf. `fastapi==0.136.3` / `MAL-2026-4750` exclue dans le `pyproject` Picarones ; `pip-audit` à tenir), **reproductibilité** (`dependencies_lock` du `RunManifest` → **épingler serré**), **code tiers in-process** (entry-points).
|
| 201 |
+
|
| 202 |
+
| Lib / appel | Verdict | Raison / condition |
|
| 203 |
+
|---|---|---|
|
| 204 |
+
| `httpx` (transport REST unique) | ✅ **conditionnel** | Pour le REST à **auth simple** (ollama, mistral `/v1/ocr`, HF, eScriptorium, htr_united, openai-compatible). Unifie les 2 piles HTTP (D-D), corrige la deadline (D-B). **Garder le SDK** pour Google/Azure (auth JWT service-account / AD lourde). Anti-SSRF toujours à coder (transport dédié, **sans `install_opener` global** — D-E). |
|
| 205 |
+
| Drop dual SDK/REST cloud | ✅ **conditionnel** | Seulement où le SDK n'offre ni timeout-par-appel ni annulation **et** l'auth est simple. Pas systématique. |
|
| 206 |
+
| `huggingface_hub` | ✅ **extra `[hf]`** | Remplace le REST urllib maison de `huggingface.py` (pagination tronquée, `except` muet, monkey-patch — D-G). Lib lourde/mouvante → confinée à l'extra, épinglée. |
|
| 207 |
+
| `importlib.metadata.entry_points` (stdlib) | ✅ **gaté** | Outil du point d'extension `cinoc.modules`. **Désactiver la découverte en mode public** (in-process = sécurité, `CLAUDE.md` §3) ; capter la **version** du plugin pour le `RunManifest`. |
|
| 208 |
+
| `pydantic` v2 | ✅ **configs** / ❌ **par-token** | OK pour les **configs d'adapter** (petites, 1×, aligne le repo). **Pas** pour `ConfidenceToken` (milliers/page → coût de validation, cf. `MIGRATION_COUCHE_2.md` G-D) : `TypedDict`/`dataclass(slots=True)`, valider une fois à la frontière. |
|
| 209 |
+
| `tenacity` (retry) | ❌ **garder maison** | Le retry doit être deadline/cancel-aware (`clamp_to_remaining`, `RunControl`), honorer `Retry-After`, et **testable déterministe** (horloge injectable). Tenacity impose un wrapper custom qui annule le gain. `_retry.py` maison (bug jitter corrigé) reste plus aligné. |
|
| 210 |
+
| anti-SSRF · parseur IIIF · subprocess Tesseract | ❌ **garder maison** | Pas de lib mainstream fiable (SSRF, IIIF) ; le subprocess `pytesseract` est **ce qui honore la deadline** pour Tesseract — un binding C-API (`tesserocr`) la perdrait. |
|
| 211 |
+
| ORM (SQLAlchemy) · async (`anyio`) | ❌ | `sqlite3` brut suffit (2 tables) ; runner **thread-only** acté (retrait `execution_mode`, D-J) → async = rupture injustifiée. |
|
| 212 |
+
|
| 213 |
+
**Cible LOC** (rappel, périssable) : enveloppe + starter pack utile ≈ **3 700 LOC** ; parité complète nettoyée ≈ **6 500–7 000 LOC** (vs **9 682** Picarones, ≈ **−30 %**). La couche se remplit par tranches — elle n'a jamais besoin de revoir les 9,7k. *(Détail des leviers de coupe : §1.4 + §2.4.)*
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
# PARTIE 3 — RISQUES DE TRANSFERT & DETTES (avec détection)
|
| 218 |
+
|
| 219 |
+
| Risque | Détail | Comment détecter / désamorcer |
|
| 220 |
+
|---|---|---|
|
| 221 |
+
| **R-1 Contrat couche 4 manquant** | La couche 5 ne peut être portée **qu'après** le `Protocol` de module (couche 4) + `RunContext`/`RunControl`/`Deadline`. | Construire d'abord la tranche pipeline minimale ; un adapter starter (`precomputed`) sert de **premier client** du `Protocol`. |
|
| 222 |
+
| **R-2 `version` absent** | Picarones n'a pas d'attribut `version` sur les adapters → repro `RunManifest` incomplète. | Test : `RunManifest` doit capter `name+version` de chaque step ; refuser un module sans `version`. |
|
| 223 |
+
| **R-3 Annulation/deadline (D-A/D-B)** | `control` non câblé partout sauf ollama ; deadline ignorée par SDK/ML local. | Test d'annulation : lancer un step long, déclencher `control.cancel()`, vérifier l'arrêt < N s. Décider par adapter : annulable ou « best-effort » documenté. |
|
| 224 |
+
| **R-4 `ConfidenceToken`** | TypedDict local mot-niveau, échelle [0,1], normalisation Tesseract. À promouvoir en `domain` **quand** la métrique calibration le consomme. | Ne pas figer la forme avant le consommateur (backlog domain). Test de normalisation 0-100→0-1 + rejet <0/>100. |
|
| 225 |
+
| **R-5 Sortie corpus non uniforme (D-F)** | Choisir **`Corpus`** comme sortie unique ; pousser l'**orchestration disque** (download→write→manifest) en **service `app`**, garder en couche 5 le **transport + parsing schéma**. | Test : tout importeur retourne un `Corpus` valide (pas de `dict`). Séparer « localiser » (adapter) de « matérialiser » (app). |
|
| 226 |
+
| **R-6 Sécurité (anti-SSRF + chemins)** | `corpus/_http` porte l'anti-SSRF ; `artifact_store`/eScriptorium ne valident pas les chemins/URL. Invariant `CLAUDE.md` : `validated_path()` + URL contrôlée. | Test : URL loopback/IP réservée rejetée ; clé d'artefact `../` rejetée ; pas d'`install_opener` global (no-side-effect-import). |
|
| 227 |
+
| **R-7 Bugs latents (D-I)** | ✅ **entièrement clos**. `Corpus(source=)` eScriptorium (sortie `CorpusSpec`, `source` ∈ `metadata` → `TypeError` impossible). `selected_indices[i]+1` Gallica : mapping vue↔`f{n}` direct (position 1-based = numéro de vue), **aucune indirection** portée. Les deux prouvés non-mockés (`test_escriptorium_local_server.py`, `test_gallica_local_server.py`). | **Test d'intégration non-mocké** : loopback réel (`http.server`) par source — préféré au `live` quand la source exige auth/instance privée. |
|
| 228 |
+
| **R-8 Dérive de budget** | Fichiers riches >400. | `test_file_budgets` actif dès le 1ᵉʳ commit d'adapters ; budgets honnêtes, pas de gonflement silencieux. |
|
| 229 |
+
| **R-9 Couplage cross-famille** | `mistral_ocr` importe `llm.base.normalize_llm_content`. | Extraire la normalisation de contenu LLM en helper neutre partagé ; test d'archi sur les imports inter-sous-paquets. |
|
| 230 |
+
| **R-10 Deux JobStores** | Le legacy `interfaces/web/jobs.py` est celui réellement câblé. | Au portage : un seul store (`adapters/storage`), l'app web le consomme ; supprimer le legacy ; test qu'aucun `JobStore` ne vit hors `adapters/storage`. **⚠️ Le canonical doit d'abord réabsorber la table d'événements + la reprise `Last-Event-ID`** (absentes aujourd'hui), sinon le **stream SSE régresse** (cf. `ANALYSE_COUCHE_8` §0). |
|
| 231 |
+
| **R-11 Données démo en dur (D-H)** | ~220 LOC de faux catalogues. | Les sortir en `data/*.yaml` **ou** supprimer ; un repli démo ne doit jamais se faire passer pour de la vraie donnée (champ `is_demo` explicite). |
|
| 232 |
+
| **R-12 Numérique non rétro-compatible** | Comme la couche 2 (`MIGRATION_COUCHE_2.md` MIG-2) : Cinoc change normalisation/contrats → **toute « validation » par égalité de chiffres avec Picarones est invalide**. | Goldens **refaits**, pas hérités. |
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Résumé pour la session de CONSTRUCTION (3-5 points)
|
| 237 |
+
|
| 238 |
+
1. **Le « double contrat » à tuer n'est pas un héritage** : Picarones a déjà **un** contrat runtime unique = `StepExecutor` (`pipeline/protocols.py`, `execute(inputs, params, context, control)`), que les adapters implémentent par duck-typing. `BaseModule` est **vestigial** (à supprimer sans perte). ⇒ Le `Protocol` couche 4 de Cinoc ≈ `StepExecutor` **+ un attribut `version`** ; les `Base*Adapter` deviennent de simples **mixins d'implémentation**.
|
| 239 |
+
2. **Construire par tranches, starter pack d'abord** : `precomputed` + `tesseract`(+`confidences`) + `openai`+`ollama`, avec les utils racine (`_atomic_io`/`_retry` corrigé/`output_paths`/`_image`/`_httpx_helpers`) portés **en bloc**. Tout le reste (kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM**, importeurs, stores) est **incrémental — porté à la tranche qui le consomme**, jamais d'avance. **VLM = 0 consommateur aujourd'hui → ne pas porter avant la tranche `zero_shot`.**
|
| 240 |
+
3. **Dettes à corriger AU portage (pas après)** : annulation `control` non câblée (D-A) + deadline asymétrique (D-B) à trancher ; **3 implémentations de retry à unifier** + bug jitter>plafond ; **bugs latents** `Corpus(source=)`/Gallica masqués par les mocks ; `artifact_store` doit utiliser `_atomic_io`+`fsync` et **valider les chemins** ; **un seul `JobStore`**.
|
| 241 |
+
4. **Élagage discipliné** : **SUPPRIMER** `_fallback_log` (son seul débouché, le narratif, est supprimé), `atomic_write_bytes`, la surface morte (escriptorium/gallica/hf), `execution_mode` (déjà parti) ; **sortir** les ~220 LOC de données démo en `data/` ; **sortie corpus unique = `Corpus`** (fin des dict-manifestes). Registre/découverte = **couche `app`** (entry-points `cinoc.modules`), **seul point d'extension tiers = les briques de pipeline**.
|
| 242 |
+
5. **Placement & dépendances** : les importeurs de corpus restent **first-party en couche 5** (transport + parsing → `Corpus`), l'**orchestration disque** remonte en service `app` ; le **store SQLite longitudinal/tidy** est **rapatrié** de `evaluation/` vers `adapters/storage` (cf. `MIGRATION_COUCHE_3.md`). Whitelist d'archi `adapters/` à **expliciter** (inclut `evaluation` pour le type `Corpus` chargé).
|
| 243 |
+
|
| 244 |
+
## DoD vivante (couche 5) — **autorité de détail** ; le `MIGRATION_PLAN.md` indexe
|
| 245 |
+
|
| 246 |
+
> **T8 (D-066)** : tous les modules du socle renvoient `StepOutput` ; les adapters LLM/VLM remontent `tokens_in/out` (OpenAI/Mistral `usage.prompt/completion_tokens`, Anthropic `usage.input/output_tokens`, Ollama `prompt_eval_count`/`eval_count`) via `LLMCompletion` — un seul point de logique (`_base.run_llm_step`).
|
| 247 |
+
|
| 248 |
+
> **T12 (D-070)** : tesseract publie l'artefact `CONFIDENCES` (sidecar JSON de `ConfidenceToken`, `image_to_data` conf÷100, best-effort → sidecar vide + warning). Preuves : `tests/adapters/test_tesseract.py`.
|
| 249 |
+
|
| 250 |
+
> **T14 (D-072)** : `+ ocr/kraken.py` (HTR, extra `[kraken]`, `model` requis, RAW_TEXT+CONFIDENCES) `+ ocr/mistral_ocr.py` (cloud à la page, kind `mistral_ocr`). Preuves : `tests/adapters/test_kraken.py` · `test_mistral_ocr.py`.
|
| 251 |
+
|
| 252 |
+
> Tri-état : `[x]` fait **+ preuve** · `[ ]` à faire · `[~]` différé/réserve + raison.
|
| 253 |
+
> Maj dans le **même commit** que le code. **Statut : ✅ T3 complet** — `precomputed` + `tesseract` + **`openai`** + **`ollama`** (LLM post-correction) verts, annulation câblée (D-A clos) **+ `mistral`** (post-correction cloud, extra `[mistral]`, secret `MISTRAL_API_KEY`) ; stores/VLM **par tranches**. **+ T5 `layout/`** : `precomputed` (source LAYOUT + recognizer région), `assembler` (`AltoAssembler`), **`crop`** (`crop_region` PIL — découpe réelle des blocs du pipeline hybride seg→OCR ; `PIL` ajouté à la whitelist archi `adapters`). — *preuves : `tests/adapters/layout/test_crop.py`, `…/test_alto_assembler.py`, `tests/pipeline/test_hybrid_real_bnl.py` (live)*. **+ T7 `corpus/`** : 1ᵉʳ importeur **IIIF** — `_http` (transport `httpx` durci anti-SSRF : http(s) seul, rejet userinfo + IP non publiques, redirections re-validées, taille plafonnée, **aucun `install_opener` global** ; **anti-DNS-rebinding fermé** D-051 : résolution unique → IP validées **épinglées** via `network_backend` `httpcore` custom (`_PinnedBackend`), URL/`Host`/SNI/cert TLS inchangés, ré-épinglage par redirection — `httpcore` whitelisté en archi adapters + extras ; **`download` en flux disque** D-052 : `.part` au fil de l'eau + `os.replace` atomique + cap `IMAGE_MAX_BYTES`, aucun fichier partiel ; `download` accepte `headers` — média eScriptorium **même-hôte** seul porte le jeton) + `iiif` (parser manifeste v2/v3 → `IIIFImage`, **images seules**, pur) ; matérialisation disque → **`CorpusSpec`** en `app/corpus_import.py` (R-5 : localiser ≠ matérialiser). Sortie **unique `CorpusSpec`** (fin des dict-manifestes). — *preuves : `tests/adapters/corpus/{test_iiif_parse,test_http_ssrf,test_iiif_local_server}.py` (transport réel loopback), `…/test_iiif_live.py` (distant, opt-in), `tests/app/test_corpus_import.py`*. **+ eScriptorium** (porteur de **GT** → corpus **scorable**) : `escriptorium.py` (auth token, pagination `next`, `extract_gt_text` couche transcription, pur ; `_http.fetch_json` gagne `headers`) + `import_escriptorium_corpus` (image + `.gt.txt` → `GroundTruthRef` RAW_TEXT). **R-7/D-I fermé** : pas de `Corpus(source=)` (la sortie est `CorpusSpec`, `source` ∈ `metadata`) — prouvé **non-mocké** (test loopback : auth header + pagination 2 pages + GT lignes/`content`). — *preuves : `tests/adapters/corpus/{test_escriptorium_parse,test_escriptorium_local_server}.py`, `tests/app/test_corpus_import_escriptorium.py`*. **+ Gallica** : `gallica.py` (`normalize_ark` + `manifest_url` **IIIF `/iiif/`** + `fetch_ocr_text` via **ALTO** `RequestDigitalElement` + `alto_to_text` ; `_http.fetch_bytes` ajouté pour parser l'ALTO en octets) — **images réutilisent `IIIFImporter`**, OCR = **référence étiquetée** : type domaine `REFERENCE_TEXT` (D-053, ≠ GT manuelle) + `gt_source=gallica_ocr` — non scoré par défaut, vue référence dédiée (opt-in). **2 bugs prod corrigés (D-058, révélés par la capture réelle)** : URL manifeste `/iiif/` manquant (→ 403, masqué par les mocks) ; `texteBrut` ALTCHA-gated → bascule ALTO (rupture nette, `texteBrut`/`_looks_like_html` supprimés). **R-7 clos** : mapping vue *i* ↔ `f{i}` lu dans l'URL d'image (`/f{n}/`), **sans indirection**, prouvé non-mocké sur **vraies pages Gallica** (`tests/fixtures/gallica_alto/`, *Pourceaugnac*). — *preuves : `tests/adapters/corpus/{test_gallica_parse,test_gallica_alto,test_gallica_local_server}.py`, `tests/app/test_corpus_import_gallica.py`*. **+ HTR-United & HuggingFace** (**découverte**, pas matérialisation) : `htr_united.py` (`fetch_catalogue` YAML via `_http.fetch_text` + `parse_catalogue` pur + recherche + **repli démo `is_demo`** ; `yaml` ajouté à la whitelist adapters) ; `huggingface.py` (`search_reference` socle intégré + `HuggingFaceCatalogue.search` API publique **best-effort** via `_http.fetch_json`, `source` ∈ {reference, api}). **+ import HF matérialisant** (D-054, Lot D) : `stream_pages` (convention Cinoc `image`+`ground_truth`, lib `datasets` en extra `[huggingface]`, `streaming=True`, `Image(decode=False)` → octets sans PIL, `loader` injectable) → `app.import_hf_corpus` (GT `RAW_TEXT` réelle) + endpoint `/api/corpus/import/huggingface` (CSRF, gate public 403, non-conforme 422, extra absent 409). **Importeurs cœur complets.** — *preuves : `tests/adapters/corpus/{test_htr_united,test_htr_united_local_server,test_huggingface,test_huggingface_local_server}.py`*. **+ T7 `storage/history_store.py`** : `HistoryStore` SQLite **longitudinal** — `add(HistoryRecord…)` (idempotent, PK `(run_id,pipeline,view,metric)`), `history(pipeline,view,metric)` (chronologique), `regressions(view,metric, threshold, higher_is_better)` (2 derniers runs, direction-aware). Persistance **pure de lignes primitives** (n'importe pas `RunResult`) ; 1 connexion/opération (thread-safe). — *preuves : `tests/adapters/storage/test_history_store.py` (vrai SQLite : round-trip, idempotence, persistance inter-instances, régression/seuil/sens)*
|
| 254 |
+
|
| 255 |
+
**Enveloppe :**
|
| 256 |
+
- [x] `precomputed` implémente le `Module` Protocol (couche 4) **directement**, avec `name`/`version`. — *preuve : `tests/adapters/test_precomputed.py` (`isinstance(.., Module)` ; lecture `<stem>.<label>.txt` → `RAW_TEXT` + `content_hash` ; UTF-8 strict ; annulation)*
|
| 257 |
+
- [x] **1ᵉʳ vrai moteur : `tesseract`** (implémente `Module` directement ; `lang` anti-injection, timeout borné par la deadline, écrit dans le **workspace** ; pytesseract = **extra**, invocation **mockable** → CI sans binaire). — *preuve : `test_tesseract` (mock + validations + workspace + annulation) ; `test_live` opt-in*
|
| 258 |
+
- [x] **2ᵉ famille de module : LLM post-correction** (`openai`, mode `text_only`) — `RAW_TEXT → CORRECTED_TEXT` ; SDK extra, invocation **mockable** (CI sans clé) ; **pipeline multi-étapes OCR→LLM** prouvé (`CORRECTED_TEXT` **non vide**, bug historique). — *preuve : `test_openai` + `test_llm_pipeline`*
|
| 259 |
+
- [x] **2ᵉ LLM (généralisation prouvée) : `ollama`** — *même* contrat que `openai`, sortie `CORRECTED_TEXT`, **aucun cas particulier** ; transport `httpx` = **extra**, `_invoke_ollama` **mockable** (CI sans serveur). **Référence d'annulation câblée** : `_invoke_ollama` enregistre `client.close` via `RunControl.register_cancel_handle` → `trigger_cancel` ferme la connexion en vol ; « annulation vs panne » tranchée en sondant `is_cancelled` (`_fail_or_cancel`, **fiable** — remplace l'heuristique de message fragile, **D-A clos**). — *preuve : `test_ollama` (conformité Module + mock + workspace + annulation + `_fail_or_cancel` cancel/panne) ; mécanisme `register_cancel_handle` : `test_run_control` (fire-on-cancel · immédiat-si-déjà-annulé · once · ordre)*
|
| 260 |
+
- [~] VLM/`zero_shot` (0 conso) · `Base*Adapter` mixins · confidences/ALTO · `unregister`/déduplication des handles (accumulation bénigne, runs bornés) : **différés**.
|
| 261 |
+
|
| 262 |
+
**Garde-fous :**
|
| 263 |
+
- [x] `layer_dependencies` (whitelist `adapters` explicitée : domain+pipeline+formats) · `no_side_effect_imports` · `no_broad_except` · `file_budgets`. — *preuve : `test_adapters_imports_are_allowed` + suite archi verte.* `[~]` `install_opener` global (D-E) : tué à la tranche HTTP.
|
| 264 |
+
|
| 265 |
+
**Validation par tranche :** `MIGRATION_PLAN.md` §3 — starter pack via `Module` (T1-T3) · **1 seul `JobStore` avec SSE/`job_events` réabsorbés** (T4, cf. §0/D-γ) · bugs latents `Corpus(source=)`/`selected_indices+1` corrigés + test `live` (T7).
|
| 266 |
+
|
| 267 |
+
- [~] **Différé** : kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM (0 conso → pas avant `zero_shot`)**, importeurs (1/tranche, sortie unique `Corpus`), stores. **Supprimer** `_fallback_log` + données démo en dur (→`data/`).
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
*Tous les verdicts de la Partie 1 sont marqués **PROVISOIRE — à confirmer au build** : le contact du code corrige souvent l'analyse.*
|
cinoc/adapters/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Couche 5 — ``adapters`` : modules concrets (starter pack).
|
| 2 |
+
|
| 3 |
+
Chaque adapter implémente directement le ``Module`` Protocol (couche 4). Les
|
| 4 |
+
dépendances lourdes (SDK LLM, moteurs OCR) sont des extras optionnels, importés
|
| 5 |
+
dans le module qui les utilise — jamais ici. ``__init__`` mince, sans effet de
|
| 6 |
+
bord.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
cinoc/adapters/_resilience.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Résilience des appels réseau (couche 5, adapters) : cap de concurrence + retry.
|
| 2 |
+
|
| 3 |
+
Deux mécanismes, **par défaut, sans configuration**, pour qu'un run depuis l'UI
|
| 4 |
+
web (n'importe qui) ne sature pas une API instable :
|
| 5 |
+
|
| 6 |
+
- **Cap de concurrence par fournisseur** (``network_slot``) : un sémaphore borné
|
| 7 |
+
*par fournisseur* (Mistral, OpenAI… chacun le sien) → au plus N appels réseau
|
| 8 |
+
simultanés, quel que soit le nombre de threads du runner. C'est le fix
|
| 9 |
+
structurel du pool global partagé (Pero local + LLM réseau dans un même run).
|
| 10 |
+
- **Retry borné** (``call_resilient``) : sur 429/5xx, attend ``Retry-After`` si le
|
| 11 |
+
serveur l'indique, sinon un backoff exponentiel borné + gigue, puis recommence.
|
| 12 |
+
|
| 13 |
+
Stdlib pure (aucune dépendance). **Déterminisme (invariant §12)** : ces mécanismes
|
| 14 |
+
ne changent que le *timing*, jamais la valeur renvoyée → le ``RunResult`` reste
|
| 15 |
+
byte-identique. ``N`` = ``CINOC_NETWORK_CONCURRENCY`` (défaut 3, plancher 1).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
from collections.abc import Callable
|
| 26 |
+
from contextlib import AbstractContextManager
|
| 27 |
+
from typing import TypeVar
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
T = TypeVar("T")
|
| 32 |
+
|
| 33 |
+
#: Plafond par défaut d'appels réseau concurrents **par fournisseur** (anti-429).
|
| 34 |
+
_DEFAULT_LIMIT = 3
|
| 35 |
+
_LIMIT_ENV = "CINOC_NETWORK_CONCURRENCY"
|
| 36 |
+
#: Politique de retry : tentatives + backoff exponentiel borné (secondes).
|
| 37 |
+
_DEFAULT_ATTEMPTS = 5
|
| 38 |
+
_BASE_BACKOFF = 1.0
|
| 39 |
+
_MAX_BACKOFF = 30.0
|
| 40 |
+
#: Statuts HTTP retentés : 429 (rate limit) + 5xx transitoires (instabilité).
|
| 41 |
+
_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
| 42 |
+
|
| 43 |
+
#: Registre process-global des sémaphores, créés paresseusement (aucun effet de
|
| 44 |
+
#: bord à l'import). Protégé par un verrou : ``network_slot`` est thread-safe.
|
| 45 |
+
_SEMAPHORES: dict[str, threading.BoundedSemaphore] = {}
|
| 46 |
+
_REGISTRY_LOCK = threading.Lock()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _network_limit() -> int:
|
| 50 |
+
"""Plafond effectif depuis l'environnement (défaut ``_DEFAULT_LIMIT``)."""
|
| 51 |
+
raw = os.environ.get(_LIMIT_ENV)
|
| 52 |
+
if raw is None:
|
| 53 |
+
return _DEFAULT_LIMIT
|
| 54 |
+
try:
|
| 55 |
+
value = int(raw)
|
| 56 |
+
except ValueError:
|
| 57 |
+
logger.warning(
|
| 58 |
+
"[resilience] %s=%r invalide (entier attendu) — défaut %d.",
|
| 59 |
+
_LIMIT_ENV,
|
| 60 |
+
raw,
|
| 61 |
+
_DEFAULT_LIMIT,
|
| 62 |
+
)
|
| 63 |
+
return _DEFAULT_LIMIT
|
| 64 |
+
return max(1, value)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def network_slot(provider: str) -> AbstractContextManager[bool]:
|
| 68 |
+
"""Sémaphore borné **du fournisseur** : ``with network_slot("mistral"): …``.
|
| 69 |
+
|
| 70 |
+
Au plus ``_network_limit()`` détenteurs simultanés par fournisseur. Chaque
|
| 71 |
+
fournisseur a son quota propre (un run OpenAI + Mistral ne les fait pas se
|
| 72 |
+
partager le budget). Le plafond est figé à la première création du fournisseur
|
| 73 |
+
(lecture d'environnement unique, déterministe pour la durée du process)."""
|
| 74 |
+
with _REGISTRY_LOCK:
|
| 75 |
+
sem = _SEMAPHORES.get(provider)
|
| 76 |
+
if sem is None:
|
| 77 |
+
sem = threading.BoundedSemaphore(_network_limit())
|
| 78 |
+
_SEMAPHORES[provider] = sem
|
| 79 |
+
return sem
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _default_jitter() -> float:
|
| 83 |
+
return random.uniform(0.0, 1.0)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _backoff(attempt: int, jitter: Callable[[], float]) -> float:
|
| 87 |
+
"""Backoff exponentiel borné + gigue (désynchronise les threads concurrents)."""
|
| 88 |
+
return min(_MAX_BACKOFF, _BASE_BACKOFF * (2.0**attempt)) + jitter()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def http_is_retryable(exc: BaseException) -> bool:
|
| 92 |
+
"""Vrai si l'exception SDK porte un statut HTTP transitoire (429/5xx).
|
| 93 |
+
|
| 94 |
+
Duck-typing assumé : les SDK httpx (mistralai/openai/anthropic) exposent
|
| 95 |
+
``status_code``. Une erreur sans statut (réseau coupé, bug local) n'est **pas**
|
| 96 |
+
retentée — on ne masque pas un vrai défaut."""
|
| 97 |
+
return getattr(exc, "status_code", None) in _RETRYABLE_STATUS
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def http_retry_after(exc: BaseException) -> float | None:
|
| 101 |
+
"""Délai ``Retry-After`` (secondes, borné) si le serveur l'indique, sinon ``None``.
|
| 102 |
+
|
| 103 |
+
Sonde l'en-tête sur ``exc.response.headers`` puis ``exc.headers`` ; tolère
|
| 104 |
+
toute absence/forme invalide (→ ``None`` → l'appelant calcule un backoff)."""
|
| 105 |
+
for holder in (getattr(exc, "response", None), exc):
|
| 106 |
+
headers = getattr(holder, "headers", None)
|
| 107 |
+
if headers is None or not hasattr(headers, "get"):
|
| 108 |
+
continue
|
| 109 |
+
raw = headers.get("retry-after")
|
| 110 |
+
if raw is None:
|
| 111 |
+
continue
|
| 112 |
+
try:
|
| 113 |
+
seconds = float(raw)
|
| 114 |
+
except (TypeError, ValueError):
|
| 115 |
+
continue
|
| 116 |
+
if seconds >= 0:
|
| 117 |
+
return min(_MAX_BACKOFF, seconds)
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def call_resilient(
|
| 122 |
+
provider: str,
|
| 123 |
+
fn: Callable[[], T],
|
| 124 |
+
*,
|
| 125 |
+
is_retryable: Callable[[BaseException], bool] = http_is_retryable,
|
| 126 |
+
retry_after: Callable[[BaseException], float | None] = http_retry_after,
|
| 127 |
+
attempts: int = _DEFAULT_ATTEMPTS,
|
| 128 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 129 |
+
jitter: Callable[[], float] = _default_jitter,
|
| 130 |
+
) -> T:
|
| 131 |
+
"""Exécute ``fn`` sous le quota du fournisseur, avec retry borné sur 429/5xx.
|
| 132 |
+
|
| 133 |
+
Acquiert ``network_slot(provider)`` **par tentative** (relâché pendant
|
| 134 |
+
l'attente → on ne gèle pas un quota en dormant). Sur erreur retentable et tant
|
| 135 |
+
qu'il reste des tentatives : attend ``Retry-After`` si fourni, sinon un backoff
|
| 136 |
+
exponentiel borné + gigue, puis recommence. Sinon l'exception remonte
|
| 137 |
+
(l'appelant la traduit en ``AdapterStepError`` → unité isolée par le runner).
|
| 138 |
+
``sleep``/``jitter`` injectables (tests déterministes). **Ne change que le
|
| 139 |
+
*timing*, jamais la valeur renvoyée.**"""
|
| 140 |
+
for attempt in range(attempts):
|
| 141 |
+
delay = 0.0
|
| 142 |
+
with network_slot(provider):
|
| 143 |
+
try:
|
| 144 |
+
return fn()
|
| 145 |
+
except Exception as exc:
|
| 146 |
+
if not is_retryable(exc) or attempt == attempts - 1:
|
| 147 |
+
raise
|
| 148 |
+
after = retry_after(exc)
|
| 149 |
+
delay = after if after is not None else _backoff(attempt, jitter)
|
| 150 |
+
logger.warning(
|
| 151 |
+
"[resilience] %s : tentative %d/%d échouée (%s) — "
|
| 152 |
+
"nouvelle tentative dans %.1f s.",
|
| 153 |
+
provider,
|
| 154 |
+
attempt + 1,
|
| 155 |
+
attempts,
|
| 156 |
+
type(exc).__name__,
|
| 157 |
+
delay,
|
| 158 |
+
)
|
| 159 |
+
sleep(delay) # hors du créneau : on ne retient pas le quota en dormant
|
| 160 |
+
raise AssertionError( # pragma: no cover — la boucle retourne ou relance avant
|
| 161 |
+
"call_resilient : boucle épuisée sans issue"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
__all__ = [
|
| 166 |
+
"call_resilient",
|
| 167 |
+
"http_is_retryable",
|
| 168 |
+
"http_retry_after",
|
| 169 |
+
"network_slot",
|
| 170 |
+
]
|
cinoc/adapters/_workspace.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chemins de sortie dans le workspace (couche 5, partagé par les écrivains).
|
| 2 |
+
|
| 3 |
+
Les adapters qui **produisent** un artefact (OCR, post-correction LLM) y déposent
|
| 4 |
+
un fichier. Le ``stem`` dérivé du ``document_id`` est **injectif** et sans
|
| 5 |
+
séparateur de chemin : encodage type URL (``quote``), **réversible** donc
|
| 6 |
+
injectif, qui neutralise ``/`` (→ ``%2F``) et l'octet nul (→ ``%00``). Deux ids
|
| 7 |
+
distincts ne peuvent jamais produire le même nom de fichier (et donc s'écraser) —
|
| 8 |
+
y compris les cas limites ``a/_b`` vs ``a_/b`` qu'un simple échappement maison
|
| 9 |
+
collisionnerait. Les ids courants (alphanumériques) restent lisibles.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from urllib.parse import quote
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def safe_document_stem(document_id: str) -> str:
|
| 19 |
+
"""``document_id`` → composant de nom de fichier **injectif** et sans ``/``."""
|
| 20 |
+
return quote(document_id, safe="")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def workspace_artifact_path(
|
| 24 |
+
workspace_uri: str, document_id: str, label: str, suffix: str
|
| 25 |
+
) -> Path:
|
| 26 |
+
"""Chemin ``<workspace>/<stem>.<label>.<suffix>`` (``stem`` injectif)."""
|
| 27 |
+
stem = safe_document_stem(document_id)
|
| 28 |
+
return Path(workspace_uri) / f"{stem}.{label}.{suffix}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
__all__ = ["safe_document_stem", "workspace_artifact_path"]
|
cinoc/adapters/corpus/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeurs de corpus first-party (couche 5).
|
| 2 |
+
|
| 3 |
+
Chaque importeur **localise** des ressources (transport + parsing de schéma) et
|
| 4 |
+
retourne un type neutre ; la **matérialisation disque → ``CorpusSpec``** vit en
|
| 5 |
+
couche ``app``. Module mince, aucun effet de bord à l'import.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.corpus.escriptorium import (
|
| 11 |
+
EScriptoriumImporter,
|
| 12 |
+
EScriptoriumPage,
|
| 13 |
+
)
|
| 14 |
+
from cinoc.adapters.corpus.gallica import GallicaImporter, normalize_ark
|
| 15 |
+
from cinoc.adapters.corpus.htr_united import (
|
| 16 |
+
HTRUnitedCatalogue,
|
| 17 |
+
HTRUnitedEntry,
|
| 18 |
+
fetch_catalogue,
|
| 19 |
+
)
|
| 20 |
+
from cinoc.adapters.corpus.huggingface import (
|
| 21 |
+
HuggingFaceCatalogue,
|
| 22 |
+
HuggingFaceDataset,
|
| 23 |
+
)
|
| 24 |
+
from cinoc.adapters.corpus.iiif import IIIFImage, IIIFImporter, parse_manifest
|
| 25 |
+
|
| 26 |
+
__all__ = [
|
| 27 |
+
"EScriptoriumImporter",
|
| 28 |
+
"EScriptoriumPage",
|
| 29 |
+
"GallicaImporter",
|
| 30 |
+
"HTRUnitedCatalogue",
|
| 31 |
+
"HTRUnitedEntry",
|
| 32 |
+
"HuggingFaceCatalogue",
|
| 33 |
+
"HuggingFaceDataset",
|
| 34 |
+
"IIIFImage",
|
| 35 |
+
"IIIFImporter",
|
| 36 |
+
"fetch_catalogue",
|
| 37 |
+
"normalize_ark",
|
| 38 |
+
"parse_manifest",
|
| 39 |
+
]
|
cinoc/adapters/corpus/_http.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transport HTTP **durci anti-SSRF** pour les importeurs de corpus (couche 5).
|
| 2 |
+
|
| 3 |
+
Un importeur va chercher des ressources à une URL **fournie par l'utilisateur** :
|
| 4 |
+
le risque est qu'on serve de proxy vers le réseau interne (SSRF) ou qu'on télécharge
|
| 5 |
+
sans borne (DoS mémoire/disque). Défenses, toutes testées :
|
| 6 |
+
|
| 7 |
+
- **schéma** ``http``/``https`` seuls (pas de ``file://``, ``gopher://``…) ;
|
| 8 |
+
- **pas de userinfo** dans l'URL ;
|
| 9 |
+
- **résolution DNS validée** : *toutes* les IP résolues doivent être **publiques**
|
| 10 |
+
(rejet loopback / privé / link-local / réservé / multicast / non-spécifié) ;
|
| 11 |
+
une IP **littérale** n'est pas résolue du tout, et une adresse qui **encapsule**
|
| 12 |
+
une IPv4 (IPv4-mapped, NAT64 bien connu) est jugée sur cette IPv4 ;
|
| 13 |
+
- **redirections re-validées** à chaque saut (une 30x ne peut pas pointer vers
|
| 14 |
+
l'interne) ; nombre de sauts borné ;
|
| 15 |
+
- **taille plafonnée** au fil de l'eau (on ne fait pas confiance à
|
| 16 |
+
``Content-Length``).
|
| 17 |
+
|
| 18 |
+
**DNS-rebinding fermé — épinglage d'IP.** ``assert_public_url`` résout l'hôte
|
| 19 |
+
**une seule fois**, valide **toutes** les IP, et **retourne** ces IP. La
|
| 20 |
+
connexion vise alors directement l'IP **épinglée** (transport ``httpx`` à
|
| 21 |
+
``network_backend`` custom, cf. :class:`_PinnedBackend`) : il n'y a **pas de
|
| 22 |
+
seconde résolution** que pourrait détourner un DNS hostile. Le nom d'hôte de
|
| 23 |
+
l'URL reste inchangé, donc le ``Host`` envoyé, le **SNI** et la vérification du
|
| 24 |
+
certificat TLS portent toujours sur le vrai hôte (aucun override fragile) —
|
| 25 |
+
seule la cible TCP est figée. L'épinglage est ré-appliqué **à chaque saut** de
|
| 26 |
+
redirection. Le **gate « mode public » (403)** côté interface reste la première
|
| 27 |
+
barrière (les imports distants y sont refusés) ; l'épinglage est la défense en
|
| 28 |
+
profondeur, désormais étanche.
|
| 29 |
+
|
| 30 |
+
Auth & redirections : les en-têtes (jeton) ne suivent **pas** un changement
|
| 31 |
+
d'hôte (cf. ``_stream_validated``). Une seule pile HTTP (``httpx`` + son moteur
|
| 32 |
+
``httpcore``) ; **aucun état global** (pas d'``install_opener``).
|
| 33 |
+
"""
|
| 34 |
+
|
| 35 |
+
from __future__ import annotations
|
| 36 |
+
|
| 37 |
+
import contextlib
|
| 38 |
+
import ipaddress
|
| 39 |
+
import json
|
| 40 |
+
import os
|
| 41 |
+
import socket
|
| 42 |
+
import typing
|
| 43 |
+
from collections.abc import Iterator
|
| 44 |
+
from pathlib import Path
|
| 45 |
+
from urllib.parse import urlsplit
|
| 46 |
+
|
| 47 |
+
import httpcore
|
| 48 |
+
import httpx
|
| 49 |
+
|
| 50 |
+
from cinoc.domain.errors import CinocError
|
| 51 |
+
|
| 52 |
+
#: Plafonds (généreux mais bornés — un Space gratuit n'est pas un entrepôt).
|
| 53 |
+
MANIFEST_MAX_BYTES = 16 * 1024 * 1024
|
| 54 |
+
IMAGE_MAX_BYTES = 50 * 1024 * 1024
|
| 55 |
+
DEFAULT_TIMEOUT = 30.0
|
| 56 |
+
_MAX_REDIRECTS = 5
|
| 57 |
+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class CorpusHttpError(CinocError):
|
| 61 |
+
"""Échec de transport d'un importeur de corpus."""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
class SsrfError(CorpusHttpError):
|
| 65 |
+
"""L'URL cible une ressource interdite (schéma, userinfo ou IP non publique)."""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
class HttpFetchError(CorpusHttpError):
|
| 69 |
+
"""La requête a échoué (statut, redirection cassée, dépassement de taille)."""
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
#: Préfixe NAT64 « bien connu » (RFC 6052 §2.1) : les **32 derniers bits**
|
| 73 |
+
#: portent l'IPv4 traduite. Sur un réseau IPv6-seul (DNS64 — macOS et beaucoup
|
| 74 |
+
#: de réseaux mobiles), *toute* résolution d'un hôte IPv4 passe par là. Juger
|
| 75 |
+
#: l'enveloppe IPv6 au lieu de l'IPv4 embarquée revient à refuser Gallica, IIIF
|
| 76 |
+
#: et HuggingFace : ``64:ff9b::`` commence par un octet nul, donc tombe dans
|
| 77 |
+
#: ``::/8``, que ``ipaddress`` classe **réservé**.
|
| 78 |
+
_NAT64_WELL_KNOWN = ipaddress.IPv6Network("64:ff9b::/96")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _embedded_ipv4(
|
| 82 |
+
ip: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
| 83 |
+
) -> ipaddress.IPv4Address | None:
|
| 84 |
+
"""L'IPv4 réellement visée par ``ip``, ou ``None`` si elle n'en encapsule pas.
|
| 85 |
+
|
| 86 |
+
Deux encapsulations existent : ``::ffff:a.b.c.d`` (IPv4-mapped) et le NAT64
|
| 87 |
+
bien connu. Un préfixe NAT64 **propre au site** (RFC 6052 §2.2) est
|
| 88 |
+
indétectable et reste jugé comme l'IPv6 globale qu'il est.
|
| 89 |
+
"""
|
| 90 |
+
mapped = getattr(ip, "ipv4_mapped", None)
|
| 91 |
+
if mapped is not None:
|
| 92 |
+
return typing.cast(ipaddress.IPv4Address, mapped)
|
| 93 |
+
if isinstance(ip, ipaddress.IPv6Address) and ip in _NAT64_WELL_KNOWN:
|
| 94 |
+
return ipaddress.IPv4Address(int(ip) & 0xFFFFFFFF)
|
| 95 |
+
return None
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _is_public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
| 99 |
+
"""``ip`` vise-t-elle une ressource publique ?
|
| 100 |
+
|
| 101 |
+
Une adresse qui **encapsule** une IPv4 est jugée sur cette IPv4 : le filtre
|
| 102 |
+
ne se laisse donc pas contourner par l'enveloppe (``64:ff9b::7f00:1`` est
|
| 103 |
+
du loopback, et refusé) ni ne refuse à tort une cible publique traduite.
|
| 104 |
+
"""
|
| 105 |
+
embedded = _embedded_ipv4(ip)
|
| 106 |
+
if embedded is not None:
|
| 107 |
+
ip = embedded
|
| 108 |
+
return not (
|
| 109 |
+
ip.is_private
|
| 110 |
+
or ip.is_loopback
|
| 111 |
+
or ip.is_link_local
|
| 112 |
+
or ip.is_reserved
|
| 113 |
+
or ip.is_multicast
|
| 114 |
+
or ip.is_unspecified
|
| 115 |
+
)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def assert_public_url(url: str) -> tuple[str, ...]:
|
| 119 |
+
"""Valide ``url`` et **retourne les IP publiques** vers lesquelles épingler.
|
| 120 |
+
|
| 121 |
+
Résout l'hôte **une seule fois** et exige que **toutes** les IP retournées
|
| 122 |
+
soient publiques : un nom qui résout (même partiellement) vers l'interne est
|
| 123 |
+
rejeté (``SsrfError``). Les IP validées sont retournées pour que l'appelant
|
| 124 |
+
**épingle** la connexion dessus (anti-DNS-rebinding) — pas de seconde
|
| 125 |
+
résolution. Pour une IP littérale, retourne cette IP (aucun DNS réel).
|
| 126 |
+
"""
|
| 127 |
+
parts = urlsplit(url)
|
| 128 |
+
if parts.scheme not in _ALLOWED_SCHEMES:
|
| 129 |
+
raise SsrfError(f"schéma non autorisé : {parts.scheme!r} (http/https seuls).")
|
| 130 |
+
if parts.username or parts.password:
|
| 131 |
+
raise SsrfError("userinfo interdit dans l'URL.")
|
| 132 |
+
host = parts.hostname
|
| 133 |
+
if not host:
|
| 134 |
+
raise SsrfError("URL sans hôte.")
|
| 135 |
+
try:
|
| 136 |
+
literal = ipaddress.ip_address(host)
|
| 137 |
+
except ValueError:
|
| 138 |
+
literal = None # type: ignore[assignment]
|
| 139 |
+
if literal is not None:
|
| 140 |
+
# Promesse tenue : une IP littérale ne passe **pas** par le résolveur.
|
| 141 |
+
# Y passer laissait un DNS64 substituer une adresse NAT64 à l'IP écrite
|
| 142 |
+
# noir sur blanc dans l'URL — et la rejeter.
|
| 143 |
+
if not _is_public(literal):
|
| 144 |
+
raise SsrfError(f"IP non publique : {host}.")
|
| 145 |
+
return (host,)
|
| 146 |
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
| 147 |
+
try:
|
| 148 |
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
| 149 |
+
except OSError as exc:
|
| 150 |
+
raise SsrfError(f"résolution DNS impossible pour {host!r} : {exc}.") from exc
|
| 151 |
+
pins: list[str] = []
|
| 152 |
+
for info in infos:
|
| 153 |
+
addr = str(info[4][0]) # sockaddr[0] = adresse textuelle (IPv4/IPv6)
|
| 154 |
+
if not _is_public(ipaddress.ip_address(addr)):
|
| 155 |
+
raise SsrfError(
|
| 156 |
+
f"hôte {host!r} résout vers une IP non publique ({addr})."
|
| 157 |
+
)
|
| 158 |
+
if addr not in pins:
|
| 159 |
+
pins.append(addr)
|
| 160 |
+
return tuple(pins)
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
class _PinnedBackend(httpcore.SyncBackend):
|
| 164 |
+
"""Épingle la cible TCP sur des IP **pré-validées** (anti-DNS-rebinding).
|
| 165 |
+
|
| 166 |
+
``httpcore`` appelle ``connect_tcp(host, port)`` avec le **nom d'hôte** de
|
| 167 |
+
l'origine ; on substitue les IP déjà résolues+validées, sans repasser par le
|
| 168 |
+
DNS. Le nom d'hôte de l'URL restant inchangé, le SNI et la vérification du
|
| 169 |
+
certificat TLS (pilotés par ``httpcore`` depuis l'origin) portent toujours
|
| 170 |
+
sur le **vrai hôte** — donc aucun override fragile. On essaie les IP dans
|
| 171 |
+
l'ordre (comme ``socket.create_connection`` le ferait), préservant le
|
| 172 |
+
repli multi-adresses.
|
| 173 |
+
"""
|
| 174 |
+
|
| 175 |
+
def __init__(self, ips: tuple[str, ...]) -> None:
|
| 176 |
+
super().__init__()
|
| 177 |
+
self._ips = ips
|
| 178 |
+
|
| 179 |
+
def connect_tcp(
|
| 180 |
+
self,
|
| 181 |
+
host: str,
|
| 182 |
+
port: int,
|
| 183 |
+
timeout: float | None = None,
|
| 184 |
+
local_address: str | None = None,
|
| 185 |
+
socket_options: typing.Any = None,
|
| 186 |
+
) -> httpcore.NetworkStream:
|
| 187 |
+
last_exc: httpcore.ConnectError | None = None
|
| 188 |
+
for ip in self._ips:
|
| 189 |
+
try:
|
| 190 |
+
return super().connect_tcp(
|
| 191 |
+
ip,
|
| 192 |
+
port,
|
| 193 |
+
timeout=timeout,
|
| 194 |
+
local_address=local_address,
|
| 195 |
+
socket_options=socket_options,
|
| 196 |
+
)
|
| 197 |
+
except httpcore.ConnectError as exc:
|
| 198 |
+
last_exc = exc
|
| 199 |
+
assert last_exc is not None # _ips est non vide (cf. _make_client)
|
| 200 |
+
raise last_exc
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _pinned_transport(ips: tuple[str, ...]) -> httpx.HTTPTransport:
|
| 204 |
+
"""Transport ``httpx`` dont le backend réseau épingle ``ips``.
|
| 205 |
+
|
| 206 |
+
``httpx`` n'expose pas ``network_backend`` ; on le pose sur le pool interne
|
| 207 |
+
(seul point d'injection). Le ``isinstance`` fait échouer **bruyamment** si
|
| 208 |
+
une future version de ``httpx`` change la forme du pool, plutôt que de
|
| 209 |
+
contourner silencieusement l'épinglage.
|
| 210 |
+
"""
|
| 211 |
+
transport = httpx.HTTPTransport()
|
| 212 |
+
pool = transport._pool
|
| 213 |
+
if not isinstance(pool, httpcore.ConnectionPool): # pragma: no cover
|
| 214 |
+
transport.close()
|
| 215 |
+
raise CorpusHttpError("épinglage IP indisponible : transport httpx inattendu.")
|
| 216 |
+
pool._network_backend = _PinnedBackend(ips)
|
| 217 |
+
return transport
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _make_client(pins: tuple[str, ...] | None, timeout: float) -> httpx.Client:
|
| 221 |
+
"""Client mono-saut. ``pins`` vide/None → résolution httpx normale.
|
| 222 |
+
|
| 223 |
+
Le cas ``None`` n'arrive qu'en test (``assert_public_url`` neutralisé en
|
| 224 |
+
no-op pour viser le loopback) ; en production il est toujours non vide.
|
| 225 |
+
"""
|
| 226 |
+
if not pins:
|
| 227 |
+
return httpx.Client(timeout=timeout, follow_redirects=False)
|
| 228 |
+
return httpx.Client(
|
| 229 |
+
transport=_pinned_transport(pins), timeout=timeout, follow_redirects=False
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
@contextlib.contextmanager
|
| 234 |
+
def _stream_validated(
|
| 235 |
+
url: str, *, timeout: float, headers: dict[str, str] | None = None
|
| 236 |
+
) -> Iterator[httpx.Response]:
|
| 237 |
+
"""GET en streaming, **épinglé à l'IP validée** et re-validé à chaque saut.
|
| 238 |
+
|
| 239 |
+
Chaque saut (origine + redirections, bornées) : résout+valide une fois via
|
| 240 |
+
``assert_public_url``, épingle la connexion sur l'IP retournée, puis envoie.
|
| 241 |
+
Les en-têtes fournis (ex. ``Authorization: Token …``) ne sont **jamais**
|
| 242 |
+
propagés vers un **hôte différent** de l'origine (fuite de credentials sur
|
| 243 |
+
redirection). Un client ``httpx`` distinct est créé par saut (épinglage
|
| 244 |
+
propre à l'IP du saut) ; tous sont fermés à la sortie du contexte.
|
| 245 |
+
"""
|
| 246 |
+
origin_host = urlsplit(url).hostname
|
| 247 |
+
current = url
|
| 248 |
+
with contextlib.ExitStack() as stack:
|
| 249 |
+
for _ in range(_MAX_REDIRECTS + 1):
|
| 250 |
+
pins = assert_public_url(current)
|
| 251 |
+
# Auth conservée seulement tant qu'on reste sur l'hôte d'origine.
|
| 252 |
+
hop_headers = headers if urlsplit(current).hostname == origin_host else None
|
| 253 |
+
client = stack.enter_context(_make_client(pins, timeout))
|
| 254 |
+
request = client.build_request("GET", current, headers=hop_headers)
|
| 255 |
+
response = client.send(request, stream=True)
|
| 256 |
+
if response.is_redirect:
|
| 257 |
+
location = response.headers.get("location", "")
|
| 258 |
+
response.close()
|
| 259 |
+
if not location:
|
| 260 |
+
raise HttpFetchError(
|
| 261 |
+
f"redirection sans 'Location' depuis {current!r}."
|
| 262 |
+
)
|
| 263 |
+
current = str(request.url.join(location))
|
| 264 |
+
continue
|
| 265 |
+
try:
|
| 266 |
+
yield response
|
| 267 |
+
finally:
|
| 268 |
+
response.close()
|
| 269 |
+
return
|
| 270 |
+
raise HttpFetchError(f"trop de redirections (> {_MAX_REDIRECTS}) pour {url!r}.")
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
def _read_capped(response: httpx.Response, max_bytes: int) -> bytes:
|
| 274 |
+
chunks: list[bytes] = []
|
| 275 |
+
total = 0
|
| 276 |
+
for chunk in response.iter_bytes():
|
| 277 |
+
total += len(chunk)
|
| 278 |
+
if total > max_bytes:
|
| 279 |
+
raise HttpFetchError(f"réponse dépasse le plafond de {max_bytes} octets.")
|
| 280 |
+
chunks.append(chunk)
|
| 281 |
+
return b"".join(chunks)
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
def fetch_json(
|
| 285 |
+
url: str,
|
| 286 |
+
*,
|
| 287 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 288 |
+
headers: dict[str, str] | None = None,
|
| 289 |
+
) -> object:
|
| 290 |
+
"""Récupère et parse un JSON ; anti-SSRF + plafond ``MANIFEST_MAX_BYTES``.
|
| 291 |
+
|
| 292 |
+
``headers`` permet l'authentification d'API (ex. ``Authorization: Token …``) ;
|
| 293 |
+
il n'est jamais journalisé (les messages d'erreur ne portent que l'URL).
|
| 294 |
+
"""
|
| 295 |
+
try:
|
| 296 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 297 |
+
response.raise_for_status()
|
| 298 |
+
raw = _read_capped(response, MANIFEST_MAX_BYTES)
|
| 299 |
+
except httpx.HTTPStatusError as exc:
|
| 300 |
+
raise HttpFetchError(
|
| 301 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 302 |
+
) from exc
|
| 303 |
+
except httpx.HTTPError as exc:
|
| 304 |
+
raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
|
| 305 |
+
try:
|
| 306 |
+
return json.loads(raw)
|
| 307 |
+
except json.JSONDecodeError as exc:
|
| 308 |
+
raise HttpFetchError(f"réponse non-JSON depuis {url!r} : {exc}.") from exc
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def download(
|
| 312 |
+
url: str,
|
| 313 |
+
dest: Path,
|
| 314 |
+
*,
|
| 315 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 316 |
+
headers: dict[str, str] | None = None,
|
| 317 |
+
) -> None:
|
| 318 |
+
"""Télécharge ``url`` vers ``dest`` en **flux disque** ; anti-SSRF + plafond.
|
| 319 |
+
|
| 320 |
+
Écrit au fil de l'eau dans un fichier ``.part`` voisin (jamais tout le corps
|
| 321 |
+
en RAM), plafonne à ``IMAGE_MAX_BYTES``, puis **renomme atomiquement**
|
| 322 |
+
(``os.replace``) vers ``dest``. À la moindre erreur (statut, dépassement de
|
| 323 |
+
plafond, I/O, annulation), le ``.part`` est supprimé : **aucun fichier
|
| 324 |
+
partiel** n'est laissé derrière, et ``dest`` n'apparaît qu'une fois complet.
|
| 325 |
+
|
| 326 |
+
``headers`` (ex. ``Authorization``) n'est transmis qu'à l'hôte d'origine —
|
| 327 |
+
``_stream_validated`` le retire sur une redirection cross-hôte (cf. D-050).
|
| 328 |
+
"""
|
| 329 |
+
part = dest.with_name(dest.name + ".part")
|
| 330 |
+
try:
|
| 331 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 332 |
+
try:
|
| 333 |
+
response.raise_for_status()
|
| 334 |
+
except httpx.HTTPStatusError as exc:
|
| 335 |
+
raise HttpFetchError(
|
| 336 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 337 |
+
) from exc
|
| 338 |
+
total = 0
|
| 339 |
+
with part.open("wb") as handle:
|
| 340 |
+
for chunk in response.iter_bytes():
|
| 341 |
+
total += len(chunk)
|
| 342 |
+
if total > IMAGE_MAX_BYTES:
|
| 343 |
+
raise HttpFetchError(
|
| 344 |
+
f"réponse dépasse le plafond de {IMAGE_MAX_BYTES} octets."
|
| 345 |
+
)
|
| 346 |
+
handle.write(chunk)
|
| 347 |
+
os.replace(part, dest)
|
| 348 |
+
except BaseException:
|
| 349 |
+
part.unlink(missing_ok=True)
|
| 350 |
+
raise
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def fetch_bytes(
|
| 354 |
+
url: str,
|
| 355 |
+
*,
|
| 356 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 357 |
+
headers: dict[str, str] | None = None,
|
| 358 |
+
) -> bytes:
|
| 359 |
+
"""Récupère un corps **binaire** en mémoire ; anti-SSRF + plafond manifeste.
|
| 360 |
+
|
| 361 |
+
Pour les ressources qui doivent être parsées depuis des **octets** (ex. ALTO
|
| 362 |
+
Gallica : lxml refuse un ``str`` portant une déclaration d'encodage XML).
|
| 363 |
+
"""
|
| 364 |
+
try:
|
| 365 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 366 |
+
response.raise_for_status()
|
| 367 |
+
return _read_capped(response, MANIFEST_MAX_BYTES)
|
| 368 |
+
except httpx.HTTPStatusError as exc:
|
| 369 |
+
raise HttpFetchError(
|
| 370 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 371 |
+
) from exc
|
| 372 |
+
except httpx.HTTPError as exc:
|
| 373 |
+
# Échec de transport (connexion/timeout/protocole) : on l'enveloppe en
|
| 374 |
+
# HttpFetchError pour que les appelants n'aient qu'un type réseau à gérer
|
| 375 |
+
# (jamais une exception httpx brute qui échapperait au repli).
|
| 376 |
+
raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
|
| 377 |
+
|
| 378 |
+
|
| 379 |
+
def fetch_text(
|
| 380 |
+
url: str,
|
| 381 |
+
*,
|
| 382 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 383 |
+
headers: dict[str, str] | None = None,
|
| 384 |
+
) -> str:
|
| 385 |
+
"""Récupère un corps **texte** (UTF-8, erreurs remplacées) ; anti-SSRF + plafond.
|
| 386 |
+
|
| 387 |
+
Pour les endpoints qui ne servent pas du JSON (ex. catalogue HTR-United).
|
| 388 |
+
"""
|
| 389 |
+
# Accept #15 : un corps distant peut contenir des octets non-UTF-8 isolés ;
|
| 390 |
+
# `errors="replace"` insère U+FFFD plutôt que d'échouer sur une donnée mal
|
| 391 |
+
# encodée (le contenu reste exploitable, pas une GT manuelle).
|
| 392 |
+
return fetch_bytes(url, timeout=timeout, headers=headers).decode(
|
| 393 |
+
"utf-8", errors="replace"
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
__all__ = [
|
| 398 |
+
"CorpusHttpError",
|
| 399 |
+
"HttpFetchError",
|
| 400 |
+
"IMAGE_MAX_BYTES",
|
| 401 |
+
"MANIFEST_MAX_BYTES",
|
| 402 |
+
"SsrfError",
|
| 403 |
+
"assert_public_url",
|
| 404 |
+
"download",
|
| 405 |
+
"fetch_bytes",
|
| 406 |
+
"fetch_json",
|
| 407 |
+
"fetch_text",
|
| 408 |
+
]
|
cinoc/adapters/corpus/escriptorium.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **eScriptorium** (API REST) — couche 5.
|
| 2 |
+
|
| 3 |
+
Rôle : **localiser** les pages d'un document eScriptorium (auth par token →
|
| 4 |
+
liste paginée des *parts* + leur transcription) et retourner un type neutre. La
|
| 5 |
+
**matérialisation disque** (download image + écriture GT → ``CorpusSpec``) vit en
|
| 6 |
+
couche ``app`` (``app/corpus_import.py``).
|
| 7 |
+
|
| 8 |
+
Contrairement à IIIF, eScriptorium porte une **vérité-terrain** (la couche de
|
| 9 |
+
transcription choisie) → le corpus importé est **scorable** (CER/WER).
|
| 10 |
+
|
| 11 |
+
API consommée (lecture seule) :
|
| 12 |
+
|
| 13 |
+
- ``GET /api/documents/{pk}/parts/`` → pages (paginé) ;
|
| 14 |
+
- ``GET /api/documents/{pk}/parts/{pk}/transcriptions/`` → transcriptions.
|
| 15 |
+
|
| 16 |
+
Dette de la source **non reprise** : l'export distant de couche OCR, le listing
|
| 17 |
+
projets/connexion (surface morte ~40 %), et surtout le bug latent
|
| 18 |
+
``Corpus(source=…)`` — ici la sortie est un ``CorpusSpec`` (``source`` va dans
|
| 19 |
+
``metadata``), structurellement à l'abri.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any
|
| 27 |
+
from urllib.parse import urljoin, urlsplit
|
| 28 |
+
|
| 29 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
#: Couche de transcription par défaut (libellé eScriptorium usuel pour la GT).
|
| 34 |
+
DEFAULT_LAYER = "manual"
|
| 35 |
+
#: Borne de pagination (un document = au plus quelques milliers de pages).
|
| 36 |
+
_MAX_PAGES = 10_000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class EScriptoriumPage:
|
| 41 |
+
"""Une page (*part*) : son URL d'image absolue + son texte GT (peut être vide)."""
|
| 42 |
+
|
| 43 |
+
pk: int
|
| 44 |
+
image_url: str
|
| 45 |
+
gt_text: str
|
| 46 |
+
title: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _image_uri(item: dict[str, Any], base_url: str) -> str:
|
| 50 |
+
"""Extrait l'URL d'image d'une *part* (champ ``image`` : str ou objet)."""
|
| 51 |
+
img = item.get("image")
|
| 52 |
+
if isinstance(img, dict):
|
| 53 |
+
img = img.get("uri") or img.get("url") or ""
|
| 54 |
+
if not isinstance(img, str) or not img:
|
| 55 |
+
return ""
|
| 56 |
+
return urljoin(base_url, img)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _layer_name(transcription: dict[str, Any]) -> str:
|
| 60 |
+
nested = transcription.get("transcription")
|
| 61 |
+
if isinstance(nested, dict):
|
| 62 |
+
return str(nested.get("name", ""))
|
| 63 |
+
return str(transcription.get("name", ""))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_gt_text(transcriptions: list[dict[str, Any]], layer: str) -> str:
|
| 67 |
+
"""Texte GT de la couche ``layer`` (lignes jointes, ou ``content``). Pur.
|
| 68 |
+
|
| 69 |
+
``layer`` vide → première transcription rencontrée. Renvoie ``""`` si la
|
| 70 |
+
couche demandée est absente.
|
| 71 |
+
"""
|
| 72 |
+
for transcription in transcriptions:
|
| 73 |
+
if layer and _layer_name(transcription) != layer:
|
| 74 |
+
continue
|
| 75 |
+
lines = transcription.get("lines") or []
|
| 76 |
+
if lines:
|
| 77 |
+
return "\n".join(
|
| 78 |
+
str(line.get("content", ""))
|
| 79 |
+
for line in lines
|
| 80 |
+
if isinstance(line, dict) and line.get("content")
|
| 81 |
+
)
|
| 82 |
+
return str(transcription.get("content", "") or "")
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class EScriptoriumImporter:
|
| 87 |
+
"""Localise les pages d'un document eScriptorium (auth + API, **sans disque**)."""
|
| 88 |
+
|
| 89 |
+
name = "escriptorium"
|
| 90 |
+
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
base_url: str,
|
| 94 |
+
token: str,
|
| 95 |
+
*,
|
| 96 |
+
layer: str = DEFAULT_LAYER,
|
| 97 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 98 |
+
) -> None:
|
| 99 |
+
self._base_url = base_url.rstrip("/")
|
| 100 |
+
self._token = token
|
| 101 |
+
self._layer = layer
|
| 102 |
+
self._timeout = timeout
|
| 103 |
+
|
| 104 |
+
def _headers(self) -> dict[str, str]:
|
| 105 |
+
# Le token n'est jamais journalisé (cf. _http : erreurs sans en-têtes).
|
| 106 |
+
return {"Authorization": f"Token {self._token}", "Accept": "application/json"}
|
| 107 |
+
|
| 108 |
+
def _get(self, url: str) -> dict[str, Any]:
|
| 109 |
+
data = fetch_json(url, timeout=self._timeout, headers=self._headers())
|
| 110 |
+
if not isinstance(data, dict):
|
| 111 |
+
raise HttpFetchError(
|
| 112 |
+
f"réponse eScriptorium inattendue (objet JSON attendu) : {url!r}."
|
| 113 |
+
)
|
| 114 |
+
return data
|
| 115 |
+
|
| 116 |
+
def _paginate(self, path: str) -> list[dict[str, Any]]:
|
| 117 |
+
"""Suit les liens ``next`` jusqu'à épuisement, **sans quitter l'hôte**.
|
| 118 |
+
|
| 119 |
+
Le ``next`` est contrôlé par le serveur : on **n'envoie jamais le jeton**
|
| 120 |
+
vers un hôte différent de ``base_url`` (un ``next`` cross-hôte est ignoré).
|
| 121 |
+
"""
|
| 122 |
+
base_host = urlsplit(self._base_url).hostname
|
| 123 |
+
results: list[dict[str, Any]] = []
|
| 124 |
+
url: str | None = f"{self._base_url}/api/{path.lstrip('/')}"
|
| 125 |
+
for _ in range(_MAX_PAGES):
|
| 126 |
+
if not url:
|
| 127 |
+
break
|
| 128 |
+
data = self._get(url)
|
| 129 |
+
page = data.get("results")
|
| 130 |
+
results.extend(page if isinstance(page, list) else [])
|
| 131 |
+
nxt = data.get("next")
|
| 132 |
+
if not (isinstance(nxt, str) and nxt):
|
| 133 |
+
break
|
| 134 |
+
if urlsplit(nxt).hostname != base_host:
|
| 135 |
+
logger.warning(
|
| 136 |
+
"[escriptorium] lien 'next' hors hôte ignoré (pas de jeton "
|
| 137 |
+
"envoyé à un tiers) : %s",
|
| 138 |
+
nxt,
|
| 139 |
+
)
|
| 140 |
+
break
|
| 141 |
+
url = nxt
|
| 142 |
+
return results
|
| 143 |
+
|
| 144 |
+
def _transcriptions(self, doc_pk: int, part_pk: int) -> list[dict[str, Any]]:
|
| 145 |
+
url = f"{self._base_url}/api/documents/{doc_pk}/parts/{part_pk}/transcriptions/"
|
| 146 |
+
data = fetch_json(url, timeout=self._timeout, headers=self._headers())
|
| 147 |
+
if isinstance(data, list):
|
| 148 |
+
return [t for t in data if isinstance(t, dict)]
|
| 149 |
+
if isinstance(data, dict):
|
| 150 |
+
page = data.get("results")
|
| 151 |
+
if isinstance(page, list):
|
| 152 |
+
return [t for t in page if isinstance(t, dict)]
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
def fetch_pages(self, doc_pk: int) -> tuple[EScriptoriumPage, ...]:
|
| 156 |
+
pages: list[EScriptoriumPage] = []
|
| 157 |
+
for item in self._paginate(f"documents/{doc_pk}/parts/"):
|
| 158 |
+
pk = item.get("pk")
|
| 159 |
+
if not isinstance(pk, int):
|
| 160 |
+
# Accept #14 : une *part* sans pk entier (schéma inattendu) est
|
| 161 |
+
# ignorée — on ne devine pas un identifiant. Avertir, pas planter.
|
| 162 |
+
logger.warning(
|
| 163 |
+
"[escriptorium] part sans 'pk' entier ignorée (doc %s) : %r",
|
| 164 |
+
doc_pk,
|
| 165 |
+
item.get("pk"),
|
| 166 |
+
)
|
| 167 |
+
continue
|
| 168 |
+
transcriptions = self._transcriptions(doc_pk, pk)
|
| 169 |
+
pages.append(
|
| 170 |
+
EScriptoriumPage(
|
| 171 |
+
pk=pk,
|
| 172 |
+
image_url=_image_uri(item, self._base_url),
|
| 173 |
+
gt_text=extract_gt_text(transcriptions, self._layer),
|
| 174 |
+
title=str(item.get("title", "") or f"part {pk}"),
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
return tuple(pages)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
__all__ = [
|
| 181 |
+
"DEFAULT_LAYER",
|
| 182 |
+
"EScriptoriumImporter",
|
| 183 |
+
"EScriptoriumPage",
|
| 184 |
+
"extract_gt_text",
|
| 185 |
+
]
|
cinoc/adapters/corpus/gallica.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **Gallica** (BnF) — couche 5.
|
| 2 |
+
|
| 3 |
+
Gallica expose ses images via un **manifeste IIIF** (réutilisé tel quel par
|
| 4 |
+
``IIIFImporter``) et son OCR par page via l'endpoint **ALTO** officiel
|
| 5 |
+
``RequestDigitalElement``. Cet adapter ne porte que la logique propre à Gallica :
|
| 6 |
+
normalisation de l'**ARK**, URL du manifeste IIIF, et récupération de l'OCR d'une
|
| 7 |
+
page (ALTO → texte en ordre de lecture).
|
| 8 |
+
|
| 9 |
+
Choix : **ALTO** plutôt que ``texteBrut``. L'endpoint ``f{n}.texteBrut`` est
|
| 10 |
+
désormais protégé par un challenge anti-bot (ALTCHA) en accès automatisé, donc
|
| 11 |
+
inutilisable en import ; ``RequestDigitalElement?...&E=ALTO`` reste accessible et
|
| 12 |
+
plus riche (on en extrait le texte via le parseur ALTO de la couche 2).
|
| 13 |
+
|
| 14 |
+
Bug latent de la source **corrigé** : la numérotation page→OCR ne passe plus par
|
| 15 |
+
une indirection ``selected_indices[i]+1`` (source d'un décalage) — c'est la
|
| 16 |
+
**vue Gallica** lue dans l'URL d'image IIIF (``/f{n}/``) qui *est* le ``Deb`` de
|
| 17 |
+
la requête ALTO. Le mapping est fait par le service ``app`` et prouvé par test.
|
| 18 |
+
|
| 19 |
+
⚠️ L'OCR Gallica **n'est pas une vérité-terrain manuelle** : c'est la transcription
|
| 20 |
+
automatique de la BnF, utilisable comme *référence* (baseline) à condition de
|
| 21 |
+
l'étiqueter (``gt_source=gallica_ocr`` dans les métadonnées du corpus).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
import re
|
| 28 |
+
from urllib.parse import urlencode
|
| 29 |
+
|
| 30 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_bytes
|
| 31 |
+
from cinoc.domain.errors import CinocError, FormatError
|
| 32 |
+
from cinoc.formats.alto import (
|
| 33 |
+
AltoBlock,
|
| 34 |
+
AltoComposedBlock,
|
| 35 |
+
AltoDocument,
|
| 36 |
+
AltoTextBlock,
|
| 37 |
+
parse_alto,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
#: Hôte Gallica de production (surchargeable pour les tests transport).
|
| 43 |
+
GALLICA_BASE = "https://gallica.bnf.fr"
|
| 44 |
+
|
| 45 |
+
#: ARK normalisé : ``naID/identifiant`` (ex. ``12148/btv1b8453561w``).
|
| 46 |
+
_ARK_RE = re.compile(r"^[0-9]+/[A-Za-z0-9_.\-]+$")
|
| 47 |
+
|
| 48 |
+
#: Numéro de **vue** Gallica dans une URL d'image IIIF (``…/ark:/12148/X/f3/…``).
|
| 49 |
+
_VUE_RE = re.compile(r"/f(\d+)/")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class GallicaArkError(CinocError):
|
| 53 |
+
"""ARK Gallica malformé."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def normalize_ark(ark: str) -> str:
|
| 57 |
+
"""Normalise un ARK (``ark:/12148/x`` | ``12148/x`` → ``12148/x``). Pur."""
|
| 58 |
+
value = ark.strip()
|
| 59 |
+
if value.startswith("ark:/"):
|
| 60 |
+
value = value[len("ark:/"):]
|
| 61 |
+
value = value.strip("/")
|
| 62 |
+
if not _ARK_RE.match(value):
|
| 63 |
+
raise GallicaArkError(
|
| 64 |
+
f"ARK invalide : {ark!r}. Forme attendue 'naID/identifiant' "
|
| 65 |
+
"(ex. '12148/btv1b8453561w')."
|
| 66 |
+
)
|
| 67 |
+
return value
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def vue_number(image_url: str) -> int | None:
|
| 71 |
+
"""Numéro de vue Gallica extrait d'une URL d'image (``/f{n}/``), sinon ``None``.
|
| 72 |
+
|
| 73 |
+
C'est la source **autoritaire** pour apparier une image à son OCR : se fier à
|
| 74 |
+
la position dans la liste d'images est faux dès qu'un canvas sans image est
|
| 75 |
+
sauté par le parseur IIIF (décalage de page).
|
| 76 |
+
"""
|
| 77 |
+
match = _VUE_RE.search(image_url)
|
| 78 |
+
return int(match.group(1)) if match else None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _block_lines(blocks: tuple[AltoBlock, ...], out: list[str]) -> None:
|
| 82 |
+
"""Aplatit les ``<TextBlock>`` (récursif sur les ``<ComposedBlock>``) en lignes."""
|
| 83 |
+
for block in blocks:
|
| 84 |
+
if isinstance(block, AltoTextBlock):
|
| 85 |
+
for line in block.lines:
|
| 86 |
+
text = " ".join(s.content for s in line.strings if s.content)
|
| 87 |
+
if text:
|
| 88 |
+
out.append(text)
|
| 89 |
+
elif isinstance(block, AltoComposedBlock):
|
| 90 |
+
_block_lines(block.blocks, out)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def alto_to_text(document: AltoDocument) -> str:
|
| 94 |
+
"""Texte d'un ALTO en **ordre de lecture** : lignes (``String/@CONTENT``) jointes.
|
| 95 |
+
|
| 96 |
+
Pas de dé-césure (le ``texteBrut`` historique n'en faisait pas non plus) : on
|
| 97 |
+
rend le contenu brut, ligne par ligne.
|
| 98 |
+
"""
|
| 99 |
+
lines: list[str] = []
|
| 100 |
+
for page in document.pages:
|
| 101 |
+
_block_lines(page.blocks, lines)
|
| 102 |
+
return "\n".join(lines)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class GallicaImporter:
|
| 106 |
+
"""Localise un document Gallica : URL de manifeste IIIF + OCR ALTO par page."""
|
| 107 |
+
|
| 108 |
+
name = "gallica"
|
| 109 |
+
|
| 110 |
+
def __init__(
|
| 111 |
+
self,
|
| 112 |
+
ark: str,
|
| 113 |
+
*,
|
| 114 |
+
base_url: str = GALLICA_BASE,
|
| 115 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 116 |
+
) -> None:
|
| 117 |
+
self.ark = normalize_ark(ark)
|
| 118 |
+
self._base_url = base_url.rstrip("/")
|
| 119 |
+
self._timeout = timeout
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def _identifier(self) -> str:
|
| 123 |
+
"""Identifiant de la numérisation (ARK sans le naID) — param ``O`` de l'ALTO."""
|
| 124 |
+
return self.ark.split("/", 1)[1]
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def manifest_url(self) -> str:
|
| 128 |
+
return f"{self._base_url}/iiif/ark:/{self.ark}/manifest.json"
|
| 129 |
+
|
| 130 |
+
def alto_url(self, page: int) -> str:
|
| 131 |
+
"""URL ALTO officielle d'une vue (``RequestDigitalElement``)."""
|
| 132 |
+
query = urlencode({"O": self._identifier, "E": "ALTO", "Deb": page})
|
| 133 |
+
return f"{self._base_url}/RequestDigitalElement?{query}"
|
| 134 |
+
|
| 135 |
+
def fetch_ocr_text(self, page: int) -> str:
|
| 136 |
+
"""OCR d'une vue (1-based) via ALTO → texte. ``""`` si absent ou illisible."""
|
| 137 |
+
try:
|
| 138 |
+
data = fetch_bytes(self.alto_url(page), timeout=self._timeout)
|
| 139 |
+
except HttpFetchError as exc:
|
| 140 |
+
logger.warning(
|
| 141 |
+
"[gallica] ALTO indisponible pour %s f%d : %s", self.ark, page, exc
|
| 142 |
+
)
|
| 143 |
+
return ""
|
| 144 |
+
try:
|
| 145 |
+
return alto_to_text(parse_alto(data))
|
| 146 |
+
except FormatError as exc: # ALTO malformé ou XML invalide
|
| 147 |
+
logger.warning(
|
| 148 |
+
"[gallica] ALTO illisible pour %s f%d : %s", self.ark, page, exc
|
| 149 |
+
)
|
| 150 |
+
return ""
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
__all__ = [
|
| 154 |
+
"GALLICA_BASE",
|
| 155 |
+
"GallicaArkError",
|
| 156 |
+
"GallicaImporter",
|
| 157 |
+
"alto_to_text",
|
| 158 |
+
"normalize_ark",
|
| 159 |
+
"vue_number",
|
| 160 |
+
]
|
cinoc/adapters/corpus/htr_united.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Catalogue **HTR-United** (découverte de corpus GT) — couche 5.
|
| 2 |
+
|
| 3 |
+
HTR-United publie un index communautaire de corpus de vérité-terrain HTR/OCR sous
|
| 4 |
+
forme d'un fichier YAML versionné sur GitHub. Cet adapter **découvre** (télécharge
|
| 5 |
+
l'index + recherche), il ne **matérialise pas** un corpus (les entrées pointent
|
| 6 |
+
vers des dépôts ALTO/PAGE — matérialisation = incrémentale, ultérieure).
|
| 7 |
+
|
| 8 |
+
Repli **hors-ligne** assumé : si l'index distant est injoignable ou illisible, on
|
| 9 |
+
sert un **socle de démonstration** intégré (petit, pas un dump) avec
|
| 10 |
+
``source="demo"`` / ``is_demo=True`` — l'appelant sait que ce n'est pas le
|
| 11 |
+
catalogue à jour (alimente la future UI de sélection de corpus).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, CorpusHttpError, fetch_text
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
#: Index communautaire (YAML brut sur GitHub).
|
| 26 |
+
CATALOGUE_URL = (
|
| 27 |
+
"https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class HTRUnitedEntry:
|
| 33 |
+
"""Une entrée du catalogue (métadonnées de découverte, pas le corpus)."""
|
| 34 |
+
|
| 35 |
+
id: str
|
| 36 |
+
title: str
|
| 37 |
+
url: str
|
| 38 |
+
description: str
|
| 39 |
+
languages: tuple[str, ...]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
#: Socle de démonstration minimal (corpus HTR-United réels) — repli hors-ligne.
|
| 43 |
+
_DEMO_ENTRIES: tuple[HTRUnitedEntry, ...] = (
|
| 44 |
+
HTRUnitedEntry(
|
| 45 |
+
id="cremma-medieval",
|
| 46 |
+
title="CREMMA Medieval",
|
| 47 |
+
url="https://github.com/HTR-United/cremma-medieval",
|
| 48 |
+
description="Manuscrits médiévaux français et latins.",
|
| 49 |
+
languages=("fr", "la"),
|
| 50 |
+
),
|
| 51 |
+
HTRUnitedEntry(
|
| 52 |
+
id="lectaurep-repertoires",
|
| 53 |
+
title="LECTAUREP Répertoires",
|
| 54 |
+
url="https://github.com/HTR-United/lectaurep-repertoires",
|
| 55 |
+
description="Répertoires de notaires manuscrits (XIXe-XXe).",
|
| 56 |
+
languages=("fr",),
|
| 57 |
+
),
|
| 58 |
+
HTRUnitedEntry(
|
| 59 |
+
id="caroline-minuscule",
|
| 60 |
+
title="Caroline Minuscule",
|
| 61 |
+
url="https://github.com/HTR-United/CemmaCarolineMinuscule",
|
| 62 |
+
description="Minuscule caroline latine.",
|
| 63 |
+
languages=("la",),
|
| 64 |
+
),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _as_languages(raw: object) -> tuple[str, ...]:
|
| 69 |
+
"""Normalise un champ langue (str, liste de str, ou liste de dicts)."""
|
| 70 |
+
if isinstance(raw, str):
|
| 71 |
+
return (raw,)
|
| 72 |
+
if isinstance(raw, list):
|
| 73 |
+
out: list[str] = []
|
| 74 |
+
for item in raw:
|
| 75 |
+
if isinstance(item, str):
|
| 76 |
+
out.append(item)
|
| 77 |
+
elif isinstance(item, dict):
|
| 78 |
+
value = item.get("iso") or item.get("lang") or item.get("label")
|
| 79 |
+
if value:
|
| 80 |
+
out.append(str(value))
|
| 81 |
+
return tuple(out)
|
| 82 |
+
return ()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _entry_from_dict(item: dict[str, object]) -> HTRUnitedEntry | None:
|
| 86 |
+
url = str(item.get("url", "") or "")
|
| 87 |
+
title = str(item.get("title", "") or "")
|
| 88 |
+
if not url and not title:
|
| 89 |
+
return None
|
| 90 |
+
entry_id = str(item.get("id") or "") or url.rstrip("/").rsplit("/", 1)[-1] or title
|
| 91 |
+
return HTRUnitedEntry(
|
| 92 |
+
id=entry_id,
|
| 93 |
+
title=title or entry_id,
|
| 94 |
+
url=url,
|
| 95 |
+
description=str(item.get("description", "") or ""),
|
| 96 |
+
languages=_as_languages(item.get("language")),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def parse_catalogue(raw_yaml: str) -> tuple[HTRUnitedEntry, ...]:
|
| 101 |
+
"""Parse l'index YAML HTR-United → entrées. Pur (tolérant au schéma)."""
|
| 102 |
+
data = yaml.safe_load(raw_yaml)
|
| 103 |
+
if isinstance(data, dict):
|
| 104 |
+
for key in ("data", "projects", "catalog", "catalogue"):
|
| 105 |
+
if isinstance(data.get(key), list):
|
| 106 |
+
data = data[key]
|
| 107 |
+
break
|
| 108 |
+
if not isinstance(data, list):
|
| 109 |
+
return ()
|
| 110 |
+
entries = [
|
| 111 |
+
_entry_from_dict(item) for item in data if isinstance(item, dict)
|
| 112 |
+
]
|
| 113 |
+
return tuple(e for e in entries if e is not None)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class HTRUnitedCatalogue:
|
| 118 |
+
"""Index HTR-United chargé, avec sa provenance (``remote`` | ``demo``)."""
|
| 119 |
+
|
| 120 |
+
entries: tuple[HTRUnitedEntry, ...]
|
| 121 |
+
source: str
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def is_demo(self) -> bool:
|
| 125 |
+
return self.source == "demo"
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def from_demo(cls) -> HTRUnitedCatalogue:
|
| 129 |
+
return cls(entries=_DEMO_ENTRIES, source="demo")
|
| 130 |
+
|
| 131 |
+
def search(
|
| 132 |
+
self, query: str = "", language: str | None = None
|
| 133 |
+
) -> tuple[HTRUnitedEntry, ...]:
|
| 134 |
+
results = self.entries
|
| 135 |
+
if query:
|
| 136 |
+
q = query.lower()
|
| 137 |
+
results = tuple(
|
| 138 |
+
e
|
| 139 |
+
for e in results
|
| 140 |
+
if q in e.title.lower()
|
| 141 |
+
or q in e.description.lower()
|
| 142 |
+
or q in e.id.lower()
|
| 143 |
+
or any(q in lg.lower() for lg in e.languages)
|
| 144 |
+
)
|
| 145 |
+
if language:
|
| 146 |
+
lang = language.lower()
|
| 147 |
+
results = tuple(
|
| 148 |
+
e for e in results if any(lang in lg.lower() for lg in e.languages)
|
| 149 |
+
)
|
| 150 |
+
return results
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def fetch_catalogue(
|
| 154 |
+
*, catalogue_url: str = CATALOGUE_URL, timeout: float = DEFAULT_TIMEOUT
|
| 155 |
+
) -> HTRUnitedCatalogue:
|
| 156 |
+
"""Télécharge l'index HTR-United ; **repli démo** si indisponible/illisible."""
|
| 157 |
+
try:
|
| 158 |
+
raw = fetch_text(catalogue_url, timeout=timeout)
|
| 159 |
+
entries = parse_catalogue(raw)
|
| 160 |
+
except (CorpusHttpError, yaml.YAMLError) as exc:
|
| 161 |
+
logger.warning(
|
| 162 |
+
"[htr_united] index distant indisponible (%s) — repli démo : %s",
|
| 163 |
+
catalogue_url,
|
| 164 |
+
exc,
|
| 165 |
+
)
|
| 166 |
+
return HTRUnitedCatalogue.from_demo()
|
| 167 |
+
return HTRUnitedCatalogue(entries=entries, source="remote")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
__all__ = [
|
| 171 |
+
"CATALOGUE_URL",
|
| 172 |
+
"HTRUnitedCatalogue",
|
| 173 |
+
"HTRUnitedEntry",
|
| 174 |
+
"fetch_catalogue",
|
| 175 |
+
"parse_catalogue",
|
| 176 |
+
]
|
cinoc/adapters/corpus/huggingface.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Découverte de datasets HTR/OCR sur **HuggingFace Hub** — couche 5.
|
| 2 |
+
|
| 3 |
+
Recherche (découverte), pas matérialisation : on liste des datasets candidats
|
| 4 |
+
(socle de **référence** intégré + API publique du Hub). Le téléchargement effectif
|
| 5 |
+
d'un dataset (lib ``datasets``, dépendance lourde) est un extra **ultérieur**
|
| 6 |
+
(`cinoc[huggingface]`), hors de cette tranche.
|
| 7 |
+
|
| 8 |
+
L'appel API est **best-effort** : s'il échoue, on renvoie le socle de référence
|
| 9 |
+
(``source="reference"``) en journalisant — l'utilisateur n'est jamais bloqué, et
|
| 10 |
+
sait d'où vient chaque résultat (``source`` : ``reference`` | ``api``).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from collections.abc import Callable, Iterable, Iterator
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path, PurePosixPath
|
| 19 |
+
from urllib.parse import urlencode
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.corpus._http import (
|
| 22 |
+
DEFAULT_TIMEOUT,
|
| 23 |
+
IMAGE_MAX_BYTES,
|
| 24 |
+
CorpusHttpError,
|
| 25 |
+
fetch_json,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.errors import CinocError
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
HF_API_BASE = "https://huggingface.co/api"
|
| 32 |
+
|
| 33 |
+
#: Tag de convention apposé aux datasets **curés Cinoc** (carte HF) : il rend un
|
| 34 |
+
#: dataset de référence **découvrable** sur un compte sans coller son ``repo_id``.
|
| 35 |
+
#: Émis par ``app.dataset_standardize`` (front-matter), filtré par ``discover_curated``.
|
| 36 |
+
CURATED_DATASET_TAG = "cinoc-corpus"
|
| 37 |
+
|
| 38 |
+
#: Convention Cinoc (cf. ``docs/corpus_huggingface.md``) : un dataset importable
|
| 39 |
+
#: porte **au minimum** une colonne image et une colonne vérité-terrain. La
|
| 40 |
+
#: segmentation est une extension *future* (non requise ici).
|
| 41 |
+
CINOC_IMAGE_COLUMN = "image"
|
| 42 |
+
CINOC_GT_COLUMN = "ground_truth"
|
| 43 |
+
|
| 44 |
+
#: Extensions image reconnues (sinon ``.jpg`` par défaut) pour nommer le fichier.
|
| 45 |
+
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2", ".webp"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class HuggingFaceDataset:
|
| 50 |
+
"""Un dataset candidat (métadonnées de découverte)."""
|
| 51 |
+
|
| 52 |
+
dataset_id: str
|
| 53 |
+
title: str
|
| 54 |
+
description: str
|
| 55 |
+
languages: tuple[str, ...]
|
| 56 |
+
downloads: int
|
| 57 |
+
source: str # "reference" | "api"
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def hf_url(self) -> str:
|
| 61 |
+
return f"https://huggingface.co/datasets/{self.dataset_id}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass(frozen=True)
|
| 65 |
+
class CuratedDatasetRef:
|
| 66 |
+
"""Un dataset **curé Cinoc** découvert sur un compte HF (tag de convention).
|
| 67 |
+
|
| 68 |
+
Métadonnées d'affichage **et** d'import en un clic : ``repo_id`` (+ ``revision``
|
| 69 |
+
si l'API la fournit, pour épingler la repro) alimentent directement
|
| 70 |
+
``import_curated_hf_corpus``."""
|
| 71 |
+
|
| 72 |
+
repo_id: str
|
| 73 |
+
title: str
|
| 74 |
+
revision: str | None
|
| 75 |
+
last_modified: str
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _ref(
|
| 79 |
+
dataset_id: str, title: str, desc: str, langs: tuple[str, ...], dl: int
|
| 80 |
+
) -> HuggingFaceDataset:
|
| 81 |
+
return HuggingFaceDataset(
|
| 82 |
+
dataset_id=dataset_id,
|
| 83 |
+
title=title,
|
| 84 |
+
description=desc,
|
| 85 |
+
languages=langs,
|
| 86 |
+
downloads=dl,
|
| 87 |
+
source="reference",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
#: Socle de référence minimal (datasets HTR/OCR patrimoniaux connus).
|
| 92 |
+
_REFERENCE_DATASETS: tuple[HuggingFaceDataset, ...] = (
|
| 93 |
+
_ref(
|
| 94 |
+
"Teklia/NorHand", "NorHand", "Écriture manuscrite norvégienne.", ("no",), 1200
|
| 95 |
+
),
|
| 96 |
+
_ref(
|
| 97 |
+
"Teklia/IAM-line",
|
| 98 |
+
"IAM (lignes)",
|
| 99 |
+
"Référence anglaise manuscrite.",
|
| 100 |
+
("en",),
|
| 101 |
+
8400,
|
| 102 |
+
),
|
| 103 |
+
_ref(
|
| 104 |
+
"bnf/gallica-presse-xix",
|
| 105 |
+
"Gallica Presse XIXe",
|
| 106 |
+
"Journaux numérisés XIXe (OCR + images).",
|
| 107 |
+
("fr",),
|
| 108 |
+
15200,
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _matches(ds: HuggingFaceDataset, query: str, language: str | None) -> bool:
|
| 114 |
+
if query:
|
| 115 |
+
q = query.lower()
|
| 116 |
+
if not (
|
| 117 |
+
q in ds.title.lower()
|
| 118 |
+
or q in ds.description.lower()
|
| 119 |
+
or q in ds.dataset_id.lower()
|
| 120 |
+
or any(q in lg.lower() for lg in ds.languages)
|
| 121 |
+
):
|
| 122 |
+
return False
|
| 123 |
+
if language and not any(language.lower() in lg.lower() for lg in ds.languages):
|
| 124 |
+
return False
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def search_reference(
|
| 129 |
+
query: str = "", language: str | None = None
|
| 130 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 131 |
+
"""Filtre le socle de référence intégré. Pur (sans réseau)."""
|
| 132 |
+
return tuple(ds for ds in _REFERENCE_DATASETS if _matches(ds, query, language))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _parse_api(payload: object) -> tuple[HuggingFaceDataset, ...]:
|
| 136 |
+
"""Mappe la réponse de l'API HF (liste de dicts) → datasets. Pur."""
|
| 137 |
+
if not isinstance(payload, list):
|
| 138 |
+
return ()
|
| 139 |
+
out: list[HuggingFaceDataset] = []
|
| 140 |
+
for item in payload:
|
| 141 |
+
if not isinstance(item, dict):
|
| 142 |
+
continue
|
| 143 |
+
dataset_id = str(item.get("id", "") or "")
|
| 144 |
+
if not dataset_id:
|
| 145 |
+
continue
|
| 146 |
+
downloads = item.get("downloads", 0)
|
| 147 |
+
out.append(
|
| 148 |
+
HuggingFaceDataset(
|
| 149 |
+
dataset_id=dataset_id,
|
| 150 |
+
title=dataset_id,
|
| 151 |
+
description="",
|
| 152 |
+
languages=(),
|
| 153 |
+
downloads=int(downloads) if isinstance(downloads, int) else 0,
|
| 154 |
+
source="api",
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
+
return tuple(out)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class HuggingFaceCatalogue:
|
| 161 |
+
"""Recherche de datasets : socle de référence + API publique (best-effort)."""
|
| 162 |
+
|
| 163 |
+
name = "huggingface"
|
| 164 |
+
|
| 165 |
+
def __init__(
|
| 166 |
+
self, *, api_base: str = HF_API_BASE, timeout: float = DEFAULT_TIMEOUT
|
| 167 |
+
) -> None:
|
| 168 |
+
self._api_base = api_base.rstrip("/")
|
| 169 |
+
self._timeout = timeout
|
| 170 |
+
|
| 171 |
+
def _fetch_api(
|
| 172 |
+
self, query: str, language: str | None, limit: int
|
| 173 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 174 |
+
params: dict[str, str] = {
|
| 175 |
+
"task_categories": "image-to-text",
|
| 176 |
+
"limit": str(min(limit, 50)),
|
| 177 |
+
}
|
| 178 |
+
if query:
|
| 179 |
+
params["search"] = query
|
| 180 |
+
if language:
|
| 181 |
+
params["language"] = language
|
| 182 |
+
url = f"{self._api_base}/datasets?{urlencode(params)}"
|
| 183 |
+
return _parse_api(fetch_json(url, timeout=self._timeout))
|
| 184 |
+
|
| 185 |
+
def search(
|
| 186 |
+
self,
|
| 187 |
+
query: str = "",
|
| 188 |
+
language: str | None = None,
|
| 189 |
+
*,
|
| 190 |
+
limit: int = 20,
|
| 191 |
+
use_reference: bool = True,
|
| 192 |
+
include_api: bool = True,
|
| 193 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 194 |
+
results: list[HuggingFaceDataset] = []
|
| 195 |
+
if use_reference:
|
| 196 |
+
results.extend(search_reference(query, language))
|
| 197 |
+
if include_api:
|
| 198 |
+
seen = {ds.dataset_id for ds in results}
|
| 199 |
+
try:
|
| 200 |
+
for ds in self._fetch_api(query, language, limit):
|
| 201 |
+
if ds.dataset_id not in seen:
|
| 202 |
+
results.append(ds)
|
| 203 |
+
seen.add(ds.dataset_id)
|
| 204 |
+
except CorpusHttpError as exc:
|
| 205 |
+
logger.warning(
|
| 206 |
+
"[huggingface] recherche API indisponible — socle de référence "
|
| 207 |
+
"seul : %s",
|
| 208 |
+
exc,
|
| 209 |
+
)
|
| 210 |
+
# Accept #9 : socle de référence d'abord, API ensuite, puis troncature
|
| 211 |
+
# finale à `limit` — la référence (curée) prime sur l'API si l'union
|
| 212 |
+
# dépasse. Choix de découverte (display), pas de classement par score.
|
| 213 |
+
return tuple(results[:limit])
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class HuggingFaceConventionError(CinocError):
|
| 217 |
+
"""Le dataset ne respecte pas la **convention Cinoc** (colonnes attendues)."""
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class HuggingFaceUnavailableError(CinocError):
|
| 221 |
+
"""La lib ``datasets`` (extra ``[huggingface]``) n'est pas installée."""
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@dataclass(frozen=True)
|
| 225 |
+
class HFPage:
|
| 226 |
+
"""Une page **streamée** : octets image encodés + extension + texte GT.
|
| 227 |
+
|
| 228 |
+
Type neutre à la frontière adapter→app : l'image est livrée en **octets**
|
| 229 |
+
(jamais un objet PIL) pour que la matérialisation disque (couche 6) n'ait
|
| 230 |
+
aucune dépendance image.
|
| 231 |
+
"""
|
| 232 |
+
|
| 233 |
+
image_bytes: bytes
|
| 234 |
+
image_ext: str
|
| 235 |
+
gt_text: str
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
#: Charge un dataset en streaming → itérable de lignes. Injectable (test).
|
| 239 |
+
HFLoader = Callable[[str, str], Iterable[object]]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _default_loader(dataset_id: str, split: str) -> Iterable[object]:
|
| 243 |
+
"""Charge le dataset HF **en streaming** (jamais de snapshot local complet).
|
| 244 |
+
|
| 245 |
+
Import **paresseux** de ``datasets`` (extra ``[huggingface]``) : sans l'extra,
|
| 246 |
+
lève ``HuggingFaceUnavailableError`` (message actionnable). Les images sont
|
| 247 |
+
castées en ``Image(decode=False)`` → octets bruts (pas de décodage PIL).
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
from datasets import Image, load_dataset # type: ignore[import-not-found]
|
| 251 |
+
except ImportError as exc:
|
| 252 |
+
raise HuggingFaceUnavailableError(
|
| 253 |
+
"import HuggingFace indisponible : installer 'cinoc[huggingface]' "
|
| 254 |
+
"(lib 'datasets')."
|
| 255 |
+
) from exc
|
| 256 |
+
dataset = load_dataset(dataset_id, split=split, streaming=True)
|
| 257 |
+
features = getattr(dataset, "features", None) or {}
|
| 258 |
+
if CINOC_IMAGE_COLUMN in features:
|
| 259 |
+
dataset = dataset.cast_column(CINOC_IMAGE_COLUMN, Image(decode=False))
|
| 260 |
+
return dataset # type: ignore[no-any-return]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _ext_from_path(path: object) -> str:
|
| 264 |
+
if isinstance(path, str) and path:
|
| 265 |
+
suffix = PurePosixPath(path).suffix.lower()
|
| 266 |
+
if suffix in _IMAGE_EXTS:
|
| 267 |
+
return suffix
|
| 268 |
+
return ".jpg"
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _extract_image(value: object) -> tuple[bytes, str]:
|
| 272 |
+
"""Octets image + extension depuis une cellule ``image`` (``decode=False``)."""
|
| 273 |
+
if isinstance(value, dict):
|
| 274 |
+
raw = value.get("bytes")
|
| 275 |
+
if isinstance(raw, bytes) and raw:
|
| 276 |
+
return raw, _ext_from_path(value.get("path"))
|
| 277 |
+
raise HuggingFaceConventionError(
|
| 278 |
+
f"colonne {CINOC_IMAGE_COLUMN!r} : octets image absents (le dataset "
|
| 279 |
+
"doit stocker l'image en octets ; cf. convention Cinoc)."
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _validate_columns(row: dict[str, object]) -> None:
|
| 284 |
+
missing = [c for c in (CINOC_IMAGE_COLUMN, CINOC_GT_COLUMN) if c not in row]
|
| 285 |
+
if missing:
|
| 286 |
+
raise HuggingFaceConventionError(
|
| 287 |
+
f"dataset non conforme à la convention Cinoc : colonnes manquantes "
|
| 288 |
+
f"{missing} (attendu : {CINOC_IMAGE_COLUMN!r} + {CINOC_GT_COLUMN!r})."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def stream_pages(
|
| 293 |
+
dataset_id: str,
|
| 294 |
+
*,
|
| 295 |
+
split: str = "train",
|
| 296 |
+
limit: int | None = None,
|
| 297 |
+
loader: HFLoader | None = None,
|
| 298 |
+
) -> Iterator[HFPage]:
|
| 299 |
+
"""Streame les pages d'un dataset HF conforme à la **convention Cinoc**.
|
| 300 |
+
|
| 301 |
+
Page-par-page (``streaming=True`` côté ``datasets``) : on ne télécharge jamais
|
| 302 |
+
le snapshot complet ; ``limit`` borne le nombre de pages lues. La conformité
|
| 303 |
+
(colonnes ``image`` + ``ground_truth``) est validée sur la **1ʳᵉ ligne**. Une
|
| 304 |
+
image dépassant ``IMAGE_MAX_BYTES`` est **ignorée** (avertie), pas fatale.
|
| 305 |
+
|
| 306 |
+
``loader`` (``(dataset_id, split) → itérable de lignes``) est injectable pour
|
| 307 |
+
tester sans la lib ``datasets`` ; défaut = streaming réel.
|
| 308 |
+
"""
|
| 309 |
+
load = loader or _default_loader
|
| 310 |
+
validated = False
|
| 311 |
+
count = 0
|
| 312 |
+
for row in load(dataset_id, split):
|
| 313 |
+
if not isinstance(row, dict):
|
| 314 |
+
continue
|
| 315 |
+
if not validated:
|
| 316 |
+
_validate_columns(row)
|
| 317 |
+
validated = True
|
| 318 |
+
image_bytes, ext = _extract_image(row[CINOC_IMAGE_COLUMN])
|
| 319 |
+
if len(image_bytes) > IMAGE_MAX_BYTES:
|
| 320 |
+
logger.warning(
|
| 321 |
+
"[huggingface] image > %d octets ignorée (dataset %s).",
|
| 322 |
+
IMAGE_MAX_BYTES,
|
| 323 |
+
dataset_id,
|
| 324 |
+
)
|
| 325 |
+
continue
|
| 326 |
+
gt_value = row.get(CINOC_GT_COLUMN)
|
| 327 |
+
gt_text = gt_value if isinstance(gt_value, str) else ""
|
| 328 |
+
yield HFPage(image_bytes=image_bytes, image_ext=ext, gt_text=gt_text)
|
| 329 |
+
count += 1
|
| 330 |
+
if limit is not None and count >= limit:
|
| 331 |
+
return
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
#: Récupère un JSON depuis une URL. Injectable → ``discover_curated`` se teste
|
| 335 |
+
#: sans réseau (le défaut tape l'API HF via ``fetch_json``).
|
| 336 |
+
CuratedFetcher = Callable[[str], object]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _parse_curated(payload: object, *, tag: str) -> tuple[CuratedDatasetRef, ...]:
|
| 340 |
+
"""Réponse ``/api/datasets`` (liste de dicts) → datasets curés. Pure.
|
| 341 |
+
|
| 342 |
+
Re-filtre **défensivement** sur le tag (si la liste porte ``tags``) : on ne
|
| 343 |
+
présente que de vrais layouts Cinoc, jamais un dataset quelconque du compte."""
|
| 344 |
+
if not isinstance(payload, list):
|
| 345 |
+
return ()
|
| 346 |
+
out: list[CuratedDatasetRef] = []
|
| 347 |
+
for item in payload:
|
| 348 |
+
if not isinstance(item, dict):
|
| 349 |
+
continue
|
| 350 |
+
repo_id = str(item.get("id", "") or "")
|
| 351 |
+
if not repo_id:
|
| 352 |
+
continue
|
| 353 |
+
tags = item.get("tags")
|
| 354 |
+
if isinstance(tags, list) and tag not in tags:
|
| 355 |
+
continue
|
| 356 |
+
sha = item.get("sha")
|
| 357 |
+
out.append(
|
| 358 |
+
CuratedDatasetRef(
|
| 359 |
+
repo_id=repo_id,
|
| 360 |
+
title=str(item.get("id", repo_id)),
|
| 361 |
+
revision=str(sha) if isinstance(sha, str) and sha else None,
|
| 362 |
+
last_modified=str(item.get("lastModified", "") or ""),
|
| 363 |
+
)
|
| 364 |
+
)
|
| 365 |
+
return tuple(out)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def whoami(
|
| 369 |
+
token: str,
|
| 370 |
+
*,
|
| 371 |
+
api_base: str = HF_API_BASE,
|
| 372 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 373 |
+
fetcher: Callable[[str], object] | None = None,
|
| 374 |
+
) -> str | None:
|
| 375 |
+
"""Handle du compte HF derrière un ``token`` (``/api/whoami-v2``). Best-effort.
|
| 376 |
+
|
| 377 |
+
Sert à faire **apparaître automatiquement** les datasets curés de l'opérateur
|
| 378 |
+
sans qu'il colle son identifiant : on résout son handle depuis sa clé. Échec
|
| 379 |
+
réseau / token invalide → ``None`` (repli sur ``CINOC_HF_AUTHOR`` ou l'import
|
| 380 |
+
manuel). Le token n'est jamais journalisé (cf. ``_http``)."""
|
| 381 |
+
if not token:
|
| 382 |
+
return None
|
| 383 |
+
get = fetcher or (
|
| 384 |
+
lambda url: fetch_json(
|
| 385 |
+
url, timeout=timeout, headers={"Authorization": f"Bearer {token}"}
|
| 386 |
+
)
|
| 387 |
+
)
|
| 388 |
+
try:
|
| 389 |
+
payload = get(f"{api_base.rstrip('/')}/whoami-v2")
|
| 390 |
+
except CorpusHttpError as exc:
|
| 391 |
+
logger.warning("[huggingface] whoami indisponible : %s", exc)
|
| 392 |
+
return None
|
| 393 |
+
if isinstance(payload, dict):
|
| 394 |
+
name = payload.get("name")
|
| 395 |
+
if isinstance(name, str) and name.strip():
|
| 396 |
+
return name.strip()
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def discover_curated(
|
| 401 |
+
author: str,
|
| 402 |
+
*,
|
| 403 |
+
tag: str = CURATED_DATASET_TAG,
|
| 404 |
+
api_base: str = HF_API_BASE,
|
| 405 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 406 |
+
fetcher: CuratedFetcher | None = None,
|
| 407 |
+
) -> tuple[CuratedDatasetRef, ...]:
|
| 408 |
+
"""Liste les datasets **curés Cinoc** d'un compte HF (``author`` + ``tag``).
|
| 409 |
+
|
| 410 |
+
Best-effort : API injoignable → ``()`` en journalisant (l'UI retombe sur
|
| 411 |
+
l'import manuel par ``repo_id``, jamais un blocage). C'est ce qui fait
|
| 412 |
+
« apparaître » un dataset publié sans coller son identifiant : on filtre le
|
| 413 |
+
compte par le tag de convention."""
|
| 414 |
+
if not author:
|
| 415 |
+
return ()
|
| 416 |
+
get = fetcher or (lambda url: fetch_json(url, timeout=timeout))
|
| 417 |
+
params = {"author": author, "filter": tag, "full": "true", "limit": "100"}
|
| 418 |
+
url = f"{api_base.rstrip('/')}/datasets?{urlencode(params)}"
|
| 419 |
+
try:
|
| 420 |
+
payload = get(url)
|
| 421 |
+
except CorpusHttpError as exc:
|
| 422 |
+
logger.warning(
|
| 423 |
+
"[huggingface] découverte curée indisponible (compte %s) : %s",
|
| 424 |
+
author,
|
| 425 |
+
exc,
|
| 426 |
+
)
|
| 427 |
+
return ()
|
| 428 |
+
return _parse_curated(payload, tag=tag)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def snapshot_curated_layout(
|
| 432 |
+
repo_id: str, dest: Path, revision: str | None
|
| 433 |
+
) -> Path: # pragma: no cover -- réseau réel (test 'live')
|
| 434 |
+
"""Snapshot **partiel** d'un dataset curé Cinoc publié sur HF.
|
| 435 |
+
|
| 436 |
+
Ne rapatrie que ``corpus.json`` + ``ground_truth/`` (scorables en local) ; les
|
| 437 |
+
images restent des **références distantes** : le dataset embarque un layout
|
| 438 |
+
**IIIF statique** (Image API 3 level0, cf. ``app.dataset_standardize``) servi
|
| 439 |
+
par la racine ``resolve`` HF épinglée à la révision — l'URL est donc un lien
|
| 440 |
+
``huggingface.co`` dont le chemin suit la syntaxe IIIF. Le rapport les affiche
|
| 441 |
+
(saveur « liens », D-094) et l'orchestrateur les **matérialise au run** (fetch
|
| 442 |
+
durci) pour les moteurs. ``huggingface_hub`` (extra ``[huggingface]``) est
|
| 443 |
+
importé paresseusement → sans l'extra, ``HuggingFaceUnavailableError``
|
| 444 |
+
actionnable ; dépôt/révision injoignable → ``CorpusHttpError`` (→ 422 web).
|
| 445 |
+
"""
|
| 446 |
+
try:
|
| 447 |
+
from huggingface_hub import snapshot_download # type: ignore[import-not-found]
|
| 448 |
+
from huggingface_hub.utils import ( # type: ignore[import-not-found]
|
| 449 |
+
HfHubHTTPError,
|
| 450 |
+
)
|
| 451 |
+
except ImportError as exc:
|
| 452 |
+
raise HuggingFaceUnavailableError(
|
| 453 |
+
"import curé HF indisponible : installer 'cinoc[huggingface]' "
|
| 454 |
+
"(lib 'huggingface_hub')."
|
| 455 |
+
) from exc
|
| 456 |
+
try:
|
| 457 |
+
local = snapshot_download(
|
| 458 |
+
repo_id,
|
| 459 |
+
repo_type="dataset",
|
| 460 |
+
revision=revision,
|
| 461 |
+
allow_patterns=["corpus.json", "ground_truth/*"],
|
| 462 |
+
local_dir=str(dest),
|
| 463 |
+
)
|
| 464 |
+
except HfHubHTTPError as exc:
|
| 465 |
+
raise CorpusHttpError(
|
| 466 |
+
f"dataset curé HF {repo_id!r} injoignable : {exc}"
|
| 467 |
+
) from exc
|
| 468 |
+
return Path(local)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
__all__ = [
|
| 472 |
+
"HF_API_BASE",
|
| 473 |
+
"CINOC_GT_COLUMN",
|
| 474 |
+
"CINOC_IMAGE_COLUMN",
|
| 475 |
+
"CURATED_DATASET_TAG",
|
| 476 |
+
"CuratedDatasetRef",
|
| 477 |
+
"HFPage",
|
| 478 |
+
"HuggingFaceCatalogue",
|
| 479 |
+
"HuggingFaceConventionError",
|
| 480 |
+
"HuggingFaceDataset",
|
| 481 |
+
"HuggingFaceUnavailableError",
|
| 482 |
+
"discover_curated",
|
| 483 |
+
"search_reference",
|
| 484 |
+
"snapshot_curated_layout",
|
| 485 |
+
"stream_pages",
|
| 486 |
+
"whoami",
|
| 487 |
+
]
|
cinoc/adapters/corpus/iiif.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **IIIF** (Presentation API v2 & v3) — couche 5.
|
| 2 |
+
|
| 3 |
+
Rôle : **localiser** les pages d'un manifeste (parsing du schéma → URLs d'image).
|
| 4 |
+
La **matérialisation disque** (téléchargement → ``CorpusSpec``) vit en couche
|
| 5 |
+
``app`` (``app/corpus_import.py``) : un adapter parle protocole, pas filesystem.
|
| 6 |
+
|
| 7 |
+
Périmètre : **images seules**. Un manifeste IIIF porte des pages numérisées, pas
|
| 8 |
+
de transcription ; le corpus importé est donc sans vérité-terrain (OCR exécutable,
|
| 9 |
+
métriques non applicables tant qu'une GT n'est pas appariée). C'est un choix
|
| 10 |
+
assumé de la tranche, pas un manque.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class IIIFImage:
|
| 23 |
+
"""Une page du manifeste : son URL d'image + un label lisible."""
|
| 24 |
+
|
| 25 |
+
image_url: str
|
| 26 |
+
label: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _extract_label(raw: object) -> str:
|
| 30 |
+
"""Aplati les formats de label IIIF (str, liste, map de langue v3)."""
|
| 31 |
+
if isinstance(raw, str):
|
| 32 |
+
return raw
|
| 33 |
+
if isinstance(raw, list) and raw:
|
| 34 |
+
return _extract_label(raw[0])
|
| 35 |
+
if isinstance(raw, dict):
|
| 36 |
+
for lang in ("fr", "en", "none", "@value"):
|
| 37 |
+
val = raw.get(lang)
|
| 38 |
+
if val:
|
| 39 |
+
return str(val[0]) if isinstance(val, list) and val else str(val)
|
| 40 |
+
for val in raw.values():
|
| 41 |
+
return _extract_label(val)
|
| 42 |
+
return str(raw) if raw else ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _service_image_url(service: object) -> str:
|
| 46 |
+
"""Construit ``{service}/full/max/0/default.jpg`` depuis un service Image API."""
|
| 47 |
+
if isinstance(service, list):
|
| 48 |
+
service = service[0] if service else {}
|
| 49 |
+
if not isinstance(service, dict):
|
| 50 |
+
return ""
|
| 51 |
+
svc_id = service.get("@id") or service.get("id") or ""
|
| 52 |
+
if not isinstance(svc_id, str) or not svc_id:
|
| 53 |
+
return ""
|
| 54 |
+
return f"{svc_id.rstrip('/')}/full/max/0/default.jpg"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _image_url_v2(canvas: dict[str, Any]) -> str:
|
| 58 |
+
images = canvas.get("images") or []
|
| 59 |
+
if not images or not isinstance(images[0], dict):
|
| 60 |
+
return ""
|
| 61 |
+
resource = images[0].get("resource", {})
|
| 62 |
+
if not isinstance(resource, dict):
|
| 63 |
+
return ""
|
| 64 |
+
direct = resource.get("@id", "")
|
| 65 |
+
if isinstance(direct, str) and direct and not direct.endswith("/info.json"):
|
| 66 |
+
return direct
|
| 67 |
+
return _service_image_url(resource.get("service", {}))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _image_url_v3(canvas: dict[str, Any]) -> str:
|
| 71 |
+
for annotation_page in canvas.get("items") or []:
|
| 72 |
+
for annotation in annotation_page.get("items") or []:
|
| 73 |
+
body = annotation.get("body", {})
|
| 74 |
+
if isinstance(body, list):
|
| 75 |
+
body = body[0] if body else {}
|
| 76 |
+
if not isinstance(body, dict):
|
| 77 |
+
continue
|
| 78 |
+
url = body.get("id") or body.get("@id") or ""
|
| 79 |
+
if isinstance(url, str) and url and body.get("type") == "Image":
|
| 80 |
+
return url
|
| 81 |
+
service_url = _service_image_url(body.get("service", []))
|
| 82 |
+
if service_url:
|
| 83 |
+
return service_url
|
| 84 |
+
if isinstance(url, str) and url:
|
| 85 |
+
return url
|
| 86 |
+
return ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _detect_version(manifest: dict[str, Any]) -> int:
|
| 90 |
+
context = manifest.get("@context", "")
|
| 91 |
+
contexts = context if isinstance(context, list) else [context]
|
| 92 |
+
if any(isinstance(c, str) and "presentation/3" in c for c in contexts):
|
| 93 |
+
return 3
|
| 94 |
+
if manifest.get("type") == "Manifest" and "items" in manifest:
|
| 95 |
+
return 3
|
| 96 |
+
return 2
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def parse_manifest(manifest: dict[str, Any]) -> tuple[IIIFImage, ...]:
|
| 100 |
+
"""Parse un manifeste IIIF (v2/v3 auto-détecté) → pages avec image. Pur."""
|
| 101 |
+
if _detect_version(manifest) == 3:
|
| 102 |
+
canvases = manifest.get("items") or []
|
| 103 |
+
extract = _image_url_v3
|
| 104 |
+
else:
|
| 105 |
+
sequences = manifest.get("sequences") or []
|
| 106 |
+
canvases = sequences[0].get("canvases", []) if sequences else []
|
| 107 |
+
extract = _image_url_v2
|
| 108 |
+
images: list[IIIFImage] = []
|
| 109 |
+
for i, canvas in enumerate(canvases, start=1):
|
| 110 |
+
if not isinstance(canvas, dict):
|
| 111 |
+
continue
|
| 112 |
+
url = extract(canvas)
|
| 113 |
+
if not url:
|
| 114 |
+
continue
|
| 115 |
+
label = _extract_label(canvas.get("label", f"canvas_{i}")) or f"canvas_{i}"
|
| 116 |
+
images.append(IIIFImage(image_url=url, label=label))
|
| 117 |
+
return tuple(images)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class IIIFImporter:
|
| 121 |
+
"""Localise les pages d'un manifeste IIIF (fetch + parse, **sans disque**)."""
|
| 122 |
+
|
| 123 |
+
name = "iiif"
|
| 124 |
+
|
| 125 |
+
def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None:
|
| 126 |
+
self._timeout = timeout
|
| 127 |
+
|
| 128 |
+
def fetch_images(self, manifest_url: str) -> tuple[IIIFImage, ...]:
|
| 129 |
+
manifest = fetch_json(manifest_url, timeout=self._timeout)
|
| 130 |
+
if not isinstance(manifest, dict):
|
| 131 |
+
raise HttpFetchError(
|
| 132 |
+
f"manifeste IIIF {manifest_url!r} : objet JSON attendu."
|
| 133 |
+
)
|
| 134 |
+
return parse_manifest(manifest)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
__all__ = ["IIIFImage", "IIIFImporter", "parse_manifest"]
|
cinoc/adapters/images.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vignettes du rapport : image → dérivé JPEG redimensionné (couche 5, adapter).
|
| 2 |
+
|
| 3 |
+
Le redimensionnement utilise **Pillow** (interdit en ``reports`` — couche 7) ; il
|
| 4 |
+
vit donc ici. Deux saveurs partagent le **même cœur** ``_render_jpeg`` :
|
| 5 |
+
|
| 6 |
+
- ``thumbnail_data_uri`` → **data-URI** embarqué dans le HTML (saveur fichier
|
| 7 |
+
unique) ; les octets ne touchent jamais le ``RunResult``.
|
| 8 |
+
- ``thumbnail_to_file`` → écrit le dérivé dans un dossier d'assets et renvoie un
|
| 9 |
+
**href relatif** (saveur dossier : HTML léger à liens relatifs).
|
| 10 |
+
|
| 11 |
+
**Dégradé gracieux** : si Pillow n'est pas installé (extra ``[images]``), si le
|
| 12 |
+
fichier manque ou est illisible → ``None`` (le rapport retombe sur l'aperçu
|
| 13 |
+
synthétique). Aucune exception large.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import base64
|
| 19 |
+
import io
|
| 20 |
+
import logging
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def thumbnail_bytes(source: str | Path, *, max_px: int = 280) -> bytes | None:
|
| 27 |
+
"""Octets JPEG d'une image locale redimensionnée ; ``None`` si indisponible.
|
| 28 |
+
|
| 29 |
+
Cœur partagé des **trois** saveurs — data-URI (fichier unique), fichier
|
| 30 |
+
(dossier) et **servie** (le web renvoie ces octets tels quels) : redimensionne
|
| 31 |
+
pour que le
|
| 32 |
+
plus grand côté ≤ ``max_px`` (jamais agrandi), convertit en RGB, encode JPEG.
|
| 33 |
+
``None`` (avec ``logger.warning``) si Pillow absent, fichier introuvable, ou
|
| 34 |
+
image illisible — le rendu reste fonctionnel (aperçu synthétique)."""
|
| 35 |
+
path = Path(source)
|
| 36 |
+
if not path.is_file():
|
| 37 |
+
return None
|
| 38 |
+
try:
|
| 39 |
+
from PIL import Image, UnidentifiedImageError
|
| 40 |
+
except ImportError as exc:
|
| 41 |
+
logger.warning(
|
| 42 |
+
"[images] Pillow indisponible (%s) — vignettes omises, aperçu "
|
| 43 |
+
"synthétique. Installer l'extra ``[images]``.",
|
| 44 |
+
exc,
|
| 45 |
+
)
|
| 46 |
+
return None
|
| 47 |
+
try:
|
| 48 |
+
with Image.open(path) as opened:
|
| 49 |
+
rgb = opened.convert("RGB")
|
| 50 |
+
rgb.thumbnail((max_px, max_px))
|
| 51 |
+
buffer = io.BytesIO()
|
| 52 |
+
rgb.save(buffer, format="JPEG", quality=82, optimize=True)
|
| 53 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 54 |
+
logger.warning("[images] image illisible %s : %s — vignette omise.", path, exc)
|
| 55 |
+
return None
|
| 56 |
+
return buffer.getvalue()
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def thumbnail_data_uri(source: str | Path, *, max_px: int = 280) -> str | None:
|
| 60 |
+
"""Vignette JPEG **data-URI** d'une image locale ; ``None`` si indisponible.
|
| 61 |
+
|
| 62 |
+
Saveur fichier unique : les octets sont inlinés dans le HTML. ``None`` si le
|
| 63 |
+
dérivé n'a pu être produit (Pillow absent, fichier manquant/illisible)."""
|
| 64 |
+
raw = thumbnail_bytes(source, max_px=max_px)
|
| 65 |
+
if raw is None:
|
| 66 |
+
return None
|
| 67 |
+
payload = base64.b64encode(raw).decode("ascii")
|
| 68 |
+
return f"data:image/jpeg;base64,{payload}"
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
def thumbnail_to_file(
|
| 72 |
+
source: str | Path, dest_dir: str | Path, stem: str, *, max_px: int = 280
|
| 73 |
+
) -> str | None:
|
| 74 |
+
"""Écrit la vignette JPEG dans ``dest_dir/<stem>.jpg`` ; renvoie un href relatif.
|
| 75 |
+
|
| 76 |
+
Saveur dossier : le dérivé est **un fichier sur disque** (pas inliné), le HTML
|
| 77 |
+
n'en porte qu'une **référence relative**. L'href renvoyé est relatif au
|
| 78 |
+
**parent** de ``dest_dir`` (``f"{dest_dir.name}/{stem}.jpg"``, séparateur ``/``
|
| 79 |
+
portable) : l'appelant garantit que le HTML est un voisin de ``dest_dir`` (cf.
|
| 80 |
+
``app.report_images.write_report_bundle``). ``None`` si le dérivé n'a pu être
|
| 81 |
+
produit (dégradé gracieux, aucun fichier écrit, aucun dossier créé)."""
|
| 82 |
+
raw = thumbnail_bytes(source, max_px=max_px)
|
| 83 |
+
if raw is None:
|
| 84 |
+
return None
|
| 85 |
+
folder = Path(dest_dir)
|
| 86 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
(folder / f"{stem}.jpg").write_bytes(raw)
|
| 88 |
+
return f"{folder.name}/{stem}.jpg"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def iiif_derivative(
|
| 92 |
+
source: str | Path, dest_path: str | Path, *, width: int
|
| 93 |
+
) -> tuple[int, int] | None:
|
| 94 |
+
"""Écrit un dérivé JPEG de **largeur exacte** ``width`` (jamais agrandi) à
|
| 95 |
+
``dest_path`` ; renvoie ``(largeur, hauteur)`` réels, ou ``None`` (dégradé).
|
| 96 |
+
|
| 97 |
+
Variante **largeur-fixe** de ``_render_jpeg`` (qui borne le plus grand côté) :
|
| 98 |
+
une taille IIIF Image API ``full/{w},/`` exige une largeur exacte, pas un
|
| 99 |
+
cadrage. Convertit en RGB, ré-échantillonne (Lanczos), crée les dossiers
|
| 100 |
+
parents. ``None`` (avec ``logger.warning``) si Pillow absent, fichier
|
| 101 |
+
introuvable ou image illisible — l'outil appelant décide d'échouer ou non."""
|
| 102 |
+
path = Path(source)
|
| 103 |
+
if not path.is_file():
|
| 104 |
+
logger.warning("[images] image introuvable %s — dérivé IIIF omis.", path)
|
| 105 |
+
return None
|
| 106 |
+
try:
|
| 107 |
+
from PIL import Image, UnidentifiedImageError
|
| 108 |
+
except ImportError as exc:
|
| 109 |
+
logger.warning(
|
| 110 |
+
"[images] Pillow indisponible (%s) — dérivé IIIF omis. Extra ``[images]``.",
|
| 111 |
+
exc,
|
| 112 |
+
)
|
| 113 |
+
return None
|
| 114 |
+
try:
|
| 115 |
+
with Image.open(path) as opened:
|
| 116 |
+
rgb = opened.convert("RGB")
|
| 117 |
+
orig_w, orig_h = rgb.size
|
| 118 |
+
target_w = min(width, orig_w) # jamais agrandi
|
| 119 |
+
target_h = max(1, round(orig_h * target_w / orig_w))
|
| 120 |
+
resized = rgb.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
| 121 |
+
dest = Path(dest_path)
|
| 122 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
resized.save(dest, format="JPEG", quality=82, optimize=True)
|
| 124 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 125 |
+
logger.warning(
|
| 126 |
+
"[images] image illisible %s : %s — dérivé IIIF omis.", path, exc
|
| 127 |
+
)
|
| 128 |
+
return None
|
| 129 |
+
return (target_w, target_h)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
__all__ = [
|
| 133 |
+
"iiif_derivative",
|
| 134 |
+
"thumbnail_bytes",
|
| 135 |
+
"thumbnail_data_uri",
|
| 136 |
+
"thumbnail_to_file",
|
| 137 |
+
]
|
cinoc/adapters/layout/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters de mise en page (couche 5) — socle ``precomputed``."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
cinoc/adapters/layout/_base.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contrat **partagé** des segmenteurs de mise en page (couche 5).
|
| 2 |
+
|
| 3 |
+
Tout segmenteur (PP-DocLayout maison, segmenteur distant HF, futurs) produit la
|
| 4 |
+
même forme : un détecteur rend des **régions brutes** (``LayoutDetection``), qu'on
|
| 5 |
+
convertit en ``CanonicalLayout`` neutre (``to_canonical_layout``) puis qu'on
|
| 6 |
+
sérialise en artefact ``LAYOUT`` (``layout_step_output``, la *queue* d'``execute``).
|
| 7 |
+
|
| 8 |
+
Ces pièces sont **agnostiques du moteur** : elles vivaient dans ``pp_doclayout``
|
| 9 |
+
(le segmenteur maison), ce qui forçait le segmenteur distant à importer un module
|
| 10 |
+
PaddleX-spécifique pour les réutiliser. Extraites ici, les deux adapters en
|
| 11 |
+
dépendent sans se coupler l'un à l'autre.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import Callable
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.domain.layout import BBox, CanonicalLayout, Geometry, LayoutPage, Region
|
| 23 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class DetectedRegion:
|
| 28 |
+
"""Une région détectée : étiquette, boîte (pixels) et score de confiance."""
|
| 29 |
+
|
| 30 |
+
label: str
|
| 31 |
+
x: int
|
| 32 |
+
y: int
|
| 33 |
+
width: int
|
| 34 |
+
height: int
|
| 35 |
+
score: float
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class LayoutDetection:
|
| 40 |
+
"""Sortie brute d'un détecteur : dimensions de page + régions détectées."""
|
| 41 |
+
|
| 42 |
+
page_width: int
|
| 43 |
+
page_height: int
|
| 44 |
+
regions: tuple[DetectedRegion, ...]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
#: Détecteur **injectable** : chemin image → détection. Le défaut (PaddleX, appel
|
| 48 |
+
#: distant…) est résolu paresseusement à l'exécution ; les tests injectent un faux.
|
| 49 |
+
DetectorFn = Callable[[str], LayoutDetection]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def to_canonical_layout(
|
| 53 |
+
detection: LayoutDetection, *, min_score: float
|
| 54 |
+
) -> CanonicalLayout:
|
| 55 |
+
"""Détections → ``CanonicalLayout`` (1 page, régions **sans lignes**).
|
| 56 |
+
|
| 57 |
+
Filtre au seuil de score, **trie** par position (haut→bas puis gauche→droite)
|
| 58 |
+
pour un ordre de lecture **déterministe** indépendant de l'ordre de détection,
|
| 59 |
+
et numérote les régions ``r1..rN``. Le ``label`` du modèle devient le
|
| 60 |
+
``region_type`` neutre (``None`` si vide).
|
| 61 |
+
"""
|
| 62 |
+
kept = [r for r in detection.regions if r.score >= min_score]
|
| 63 |
+
ordered = sorted(kept, key=lambda r: (r.y, r.x))
|
| 64 |
+
regions = tuple(
|
| 65 |
+
Region(
|
| 66 |
+
id=f"r{index + 1}",
|
| 67 |
+
region_type=r.label or None,
|
| 68 |
+
geometry=Geometry(
|
| 69 |
+
bbox=BBox(x=r.x, y=r.y, width=r.width, height=r.height)
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
for index, r in enumerate(ordered)
|
| 73 |
+
)
|
| 74 |
+
page = LayoutPage(
|
| 75 |
+
width=detection.page_width or None,
|
| 76 |
+
height=detection.page_height or None,
|
| 77 |
+
regions=regions,
|
| 78 |
+
reading_order=tuple(region.id for region in regions),
|
| 79 |
+
)
|
| 80 |
+
return CanonicalLayout(pages=(page,))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def layout_step_output(
|
| 84 |
+
layout: CanonicalLayout, context: RunContext, name: str
|
| 85 |
+
) -> StepOutput:
|
| 86 |
+
"""Sérialise un ``CanonicalLayout`` → artefact ``LAYOUT`` (tail d'``execute``
|
| 87 |
+
partagé par tous les segmenteurs : PP-DocLayout, segmenteur distant…)."""
|
| 88 |
+
if context.workspace_uri is None:
|
| 89 |
+
raise AdapterStepError(f"{name} : workspace requis (RunContext.workspace_uri).")
|
| 90 |
+
payload = layout.model_dump_json().encode("utf-8")
|
| 91 |
+
output_path = workspace_artifact_path(
|
| 92 |
+
context.workspace_uri, context.document_id, name, "layout.json"
|
| 93 |
+
)
|
| 94 |
+
output_path.write_bytes(payload)
|
| 95 |
+
return StepOutput(
|
| 96 |
+
artifacts={
|
| 97 |
+
ArtifactType.LAYOUT: Artifact(
|
| 98 |
+
id=f"{context.document_id}:{name}:layout",
|
| 99 |
+
document_id=context.document_id,
|
| 100 |
+
type=ArtifactType.LAYOUT,
|
| 101 |
+
uri=str(output_path),
|
| 102 |
+
content_hash=compute_content_hash(payload),
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
__all__ = [
|
| 109 |
+
"DetectedRegion",
|
| 110 |
+
"DetectorFn",
|
| 111 |
+
"LayoutDetection",
|
| 112 |
+
"layout_step_output",
|
| 113 |
+
"to_canonical_layout",
|
| 114 |
+
]
|
cinoc/adapters/layout/_saknussemm_bridge.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Pont ``CanonicalLayout`` → ``DocumentManifest`` de ``saknussemm``.
|
| 2 |
+
|
| 3 |
+
Isolé de l'adapter pour une raison de contrat : c'est **la** traduction entre
|
| 4 |
+
deux modèles de mise en page, et elle a un coût mesurable. Vérifiée sur les deux
|
| 5 |
+
corpus du dépôt (1088 lignes, ``37-GT-BNL`` + ``BnF-bpt6k3265015q``) : rôle de
|
| 6 |
+
césure, texte de ligne, confiance et ``SUBS_CONTENT`` identiques à ce que le
|
| 7 |
+
parser de ``saknussemm`` obtient en lisant l'ALTO lui-même.
|
| 8 |
+
|
| 9 |
+
Cette parité **dépend** du fait que ``alto_to_layout`` transporte les ``SP``,
|
| 10 |
+
les ``HYP`` et les ``SUBS``. Avant qu'il le fasse, la même conversion perdait
|
| 11 |
+
36 % des rôles de césure et faussait 21 % des textes sur le corpus BnF.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from typing import TYPE_CHECKING, Any
|
| 17 |
+
|
| 18 |
+
from cinoc.domain.errors import AdapterStepError
|
| 19 |
+
|
| 20 |
+
if TYPE_CHECKING: # pragma: no cover
|
| 21 |
+
from cinoc.domain.layout import CanonicalLayout, Region
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _flatten_regions(regions: tuple[Region, ...]) -> list[Region]:
|
| 25 |
+
"""Régions imbriquées aplaties : un bloc ALTO composé porte ses lignes dans
|
| 26 |
+
ses enfants, et ``saknussemm`` raisonne sur des blocs plats."""
|
| 27 |
+
out: list[Region] = []
|
| 28 |
+
for region in regions:
|
| 29 |
+
if region.lines:
|
| 30 |
+
out.append(region)
|
| 31 |
+
out.extend(_flatten_regions(region.regions))
|
| 32 |
+
return out
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def layout_to_manifest(layout: CanonicalLayout, *, document_id: str) -> Any:
|
| 36 |
+
"""Traduit une mise en page neutre en manifeste ``saknussemm``.
|
| 37 |
+
|
| 38 |
+
Lève si une ligne n'a pas d'identifiant : ``saknussemm`` fait de
|
| 39 |
+
``(page_id, line_id)`` **l'identité** d'une ligne, pas son rang. Une ligne
|
| 40 |
+
anonyme ne peut donc pas traverser la bibliothèque, et le dire ici nomme la
|
| 41 |
+
vraie cause.
|
| 42 |
+
"""
|
| 43 |
+
from saknussemm.core.pairing import ( # type: ignore[import-not-found] # noqa: PLC0415
|
| 44 |
+
HYPHEN_CHARS,
|
| 45 |
+
link_hyphen_pairs,
|
| 46 |
+
trailing_hyphen_char,
|
| 47 |
+
)
|
| 48 |
+
from saknussemm.core.schemas import ( # type: ignore[import-not-found] # noqa: PLC0415
|
| 49 |
+
BlockManifest,
|
| 50 |
+
Coords,
|
| 51 |
+
DocumentManifest,
|
| 52 |
+
HyphenRole,
|
| 53 |
+
LineManifest,
|
| 54 |
+
PageManifest,
|
| 55 |
+
)
|
| 56 |
+
|
| 57 |
+
pages = []
|
| 58 |
+
for page_index, lpage in enumerate(layout.pages):
|
| 59 |
+
page_id = f"P_{page_index + 1:04d}"
|
| 60 |
+
blocks, lines = [], []
|
| 61 |
+
order = 0
|
| 62 |
+
for block_index, region in enumerate(_flatten_regions(lpage.regions)):
|
| 63 |
+
block_id = region.id or f"B_{block_index + 1:04d}"
|
| 64 |
+
line_ids = []
|
| 65 |
+
for line_index, line in enumerate(region.lines):
|
| 66 |
+
if not line.id:
|
| 67 |
+
raise AdapterStepError(
|
| 68 |
+
"layout_to_manifest : une ligne du bloc "
|
| 69 |
+
f"{block_id!r} n'a pas d'identifiant. saknussemm fait "
|
| 70 |
+
"de (page, ligne) une identité, pas un rang."
|
| 71 |
+
)
|
| 72 |
+
confidences = [
|
| 73 |
+
w.confidence for w in line.words if w.confidence is not None
|
| 74 |
+
]
|
| 75 |
+
first = line.words[0] if line.words else None
|
| 76 |
+
last = line.words[-1] if line.words else None
|
| 77 |
+
lm = LineManifest(
|
| 78 |
+
line_id=line.id,
|
| 79 |
+
page_id=page_id,
|
| 80 |
+
block_id=block_id,
|
| 81 |
+
line_order_global=order,
|
| 82 |
+
line_order_in_block=line_index,
|
| 83 |
+
coords=_coords(Coords, line),
|
| 84 |
+
ocr_text=line.text,
|
| 85 |
+
# ``None`` et non ``len(words)`` : en ALTO la géométrie par
|
| 86 |
+
# jeton se redistribue à n'importe quel compte de mots, donc
|
| 87 |
+
# le compte n'est pas une contrainte de projection.
|
| 88 |
+
word_count=None,
|
| 89 |
+
ocr_confidence=(
|
| 90 |
+
sum(confidences) / len(confidences) if confidences else None
|
| 91 |
+
),
|
| 92 |
+
)
|
| 93 |
+
_set_hyphenation(
|
| 94 |
+
lm,
|
| 95 |
+
HyphenRole,
|
| 96 |
+
trailing_hyphen_char,
|
| 97 |
+
HYPHEN_CHARS,
|
| 98 |
+
first_subs=_subs(first),
|
| 99 |
+
last_subs=_subs(last),
|
| 100 |
+
)
|
| 101 |
+
lines.append(lm)
|
| 102 |
+
line_ids.append(line.id)
|
| 103 |
+
order += 1
|
| 104 |
+
blocks.append(
|
| 105 |
+
BlockManifest(
|
| 106 |
+
block_id=block_id,
|
| 107 |
+
page_id=page_id,
|
| 108 |
+
block_order=block_index,
|
| 109 |
+
coords=_coords(Coords, region),
|
| 110 |
+
line_ids=line_ids,
|
| 111 |
+
)
|
| 112 |
+
)
|
| 113 |
+
link_hyphen_pairs(lines)
|
| 114 |
+
pages.append(
|
| 115 |
+
PageManifest(
|
| 116 |
+
page_id=page_id,
|
| 117 |
+
source_file=f"{document_id}#{page_index}",
|
| 118 |
+
page_index=page_index,
|
| 119 |
+
page_width=lpage.width or 0,
|
| 120 |
+
page_height=lpage.height or 0,
|
| 121 |
+
blocks=blocks,
|
| 122 |
+
lines=lines,
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
+
return DocumentManifest(
|
| 126 |
+
document_id=document_id,
|
| 127 |
+
source_files=[p.source_file for p in pages],
|
| 128 |
+
pages=pages,
|
| 129 |
+
)
|
| 130 |
+
|
| 131 |
+
|
| 132 |
+
def _subs(word: Any) -> tuple[str | None, str | None]:
|
| 133 |
+
"""``(SUBS_TYPE, SUBS_CONTENT)`` d'un mot, ou ``(None, None)`` sans mot."""
|
| 134 |
+
if word is None:
|
| 135 |
+
return None, None
|
| 136 |
+
return word.subs_type, word.subs_content
|
| 137 |
+
|
| 138 |
+
|
| 139 |
+
def _coords(coords_cls: Any, element: Any) -> Any:
|
| 140 |
+
box = element.geometry.bbox if element.geometry else None
|
| 141 |
+
return coords_cls(
|
| 142 |
+
hpos=box.x if box else 0,
|
| 143 |
+
vpos=box.y if box else 0,
|
| 144 |
+
width=box.width if box else 0,
|
| 145 |
+
height=box.height if box else 0,
|
| 146 |
+
)
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
def _set_hyphenation(
|
| 150 |
+
lm: Any,
|
| 151 |
+
hyphen_role: Any,
|
| 152 |
+
trailing: Any,
|
| 153 |
+
hyphen_chars: Any,
|
| 154 |
+
*,
|
| 155 |
+
first_subs: tuple[str | None, str | None],
|
| 156 |
+
last_subs: tuple[str | None, str | None],
|
| 157 |
+
) -> None:
|
| 158 |
+
"""Deux sources, comme le parser ALTO de ``saknussemm`` : le ``SUBS_TYPE``
|
| 159 |
+
(explicite) et une marque de coupure en fin de ligne (heuristique)."""
|
| 160 |
+
first_type, first_content = first_subs
|
| 161 |
+
last_type, last_content = last_subs
|
| 162 |
+
is_part2 = first_type == "HypPart2"
|
| 163 |
+
is_part1 = last_type == "HypPart1" or (
|
| 164 |
+
trailing(lm.ocr_text, hyphen_chars) is not None
|
| 165 |
+
)
|
| 166 |
+
if is_part1 and is_part2:
|
| 167 |
+
lm.hyphen_role = hyphen_role.BOTH
|
| 168 |
+
lm.hyphen_source_explicit = True
|
| 169 |
+
lm.hyphen_subs_content = first_content
|
| 170 |
+
lm.hyphen_forward_explicit = last_type == "HypPart1"
|
| 171 |
+
lm.hyphen_forward_subs_content = last_content
|
| 172 |
+
elif is_part2:
|
| 173 |
+
lm.hyphen_role = hyphen_role.PART2
|
| 174 |
+
lm.hyphen_source_explicit = True
|
| 175 |
+
lm.hyphen_subs_content = first_content
|
| 176 |
+
elif is_part1:
|
| 177 |
+
lm.hyphen_role = hyphen_role.PART1
|
| 178 |
+
lm.hyphen_source_explicit = last_type == "HypPart1"
|
| 179 |
+
lm.hyphen_subs_content = last_content
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
def manifest_page_ids(manifest: Any) -> list[str]:
|
| 183 |
+
"""Les ``page_id`` fabriqués ci-dessus, dans l'ordre des pages du layout."""
|
| 184 |
+
return [page.page_id for page in manifest.pages]
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
__all__ = ["layout_to_manifest", "manifest_page_ids"]
|
cinoc/adapters/layout/alto_source.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AltoLayoutSource`` — ``IMAGE → LAYOUT`` depuis un ALTO déjà là (couche 5).
|
| 2 |
+
|
| 3 |
+
Le banc sait produire une mise en page (segmenteurs) et sait en consommer une
|
| 4 |
+
(``to_text``, ``assembler``, métriques de structure). Il ne savait pas **en
|
| 5 |
+
lire une qui existe déjà** : un corpus patrimonial arrive presque toujours
|
| 6 |
+
accompagné de son ALTO, et rien ne permettait de le faire entrer autrement
|
| 7 |
+
qu'aplati en texte.
|
| 8 |
+
|
| 9 |
+
Convention de ``PrecomputedLayoutSource`` reprise telle quelle — l'ALTO est
|
| 10 |
+
cherché **à côté de l'image**, ``<stem>.xml`` près de ``<stem>.png``. C'est la
|
| 11 |
+
façon dont les corpus de ce dépôt sont rangés, et ça évite d'ajouter un type
|
| 12 |
+
d'entrée initial au planificateur.
|
| 13 |
+
|
| 14 |
+
Ce que la lecture conserve, et qui n'a de valeur que conservé : l'identifiant
|
| 15 |
+
de ligne (une identité, pas un rang), la géométrie, la confiance du moteur
|
| 16 |
+
d'origine, et la césure (``SUBS_TYPE``/``SUBS_CONTENT`` + la marque portée par
|
| 17 |
+
``<HYP>``). Un post-correcteur qui recolle un mot coupé a besoin des quatre.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 26 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 27 |
+
from cinoc.domain.errors import AdapterStepError, CinocError
|
| 28 |
+
from cinoc.domain.layout import CanonicalLayout, Line, Region
|
| 29 |
+
from cinoc.formats.alto.layout_map import alto_to_layout
|
| 30 |
+
from cinoc.formats.alto.parser import parse_alto
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
|
| 37 |
+
#: Extensions tentées à côté de l'image, dans l'ordre.
|
| 38 |
+
_SUFFIXES = (".xml", ".alto.xml")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
class AltoLayoutSource:
|
| 42 |
+
"""Lit l'ALTO voisin de l'image et le projette en ``CanonicalLayout``.
|
| 43 |
+
|
| 44 |
+
``ocr_sidecar`` remplace le **texte** des lignes par une lecture d'OCR
|
| 45 |
+
réelle, en gardant de l'ALTO tout le reste — identité, géométrie, césure.
|
| 46 |
+
Sans lui, un corpus à vérité terrain fait entrer sa propre référence dans
|
| 47 |
+
le banc : le correcteur n'a rien à corriger et le CER vaut zéro **par
|
| 48 |
+
construction**. Un zéro tautologique ressemble à un excellent résultat, ce
|
| 49 |
+
qui en fait le plus trompeur de tous.
|
| 50 |
+
|
| 51 |
+
Format attendu (celui de ``scripts/ocr_corpus.py``) : ``{"ocr": {fichier:
|
| 52 |
+
{line_id: texte}}}``. Une ligne absente du sidecar **garde son texte
|
| 53 |
+
d'ALTO** et est comptée ; une ligne présente mais **vide** est une lecture
|
| 54 |
+
d'OCR à part entière — le moteur n'a rien reconnu — et remplace donc le
|
| 55 |
+
texte. Confondre les deux crédite le moteur d'une ligne qu'il n'a pas lue.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
def __init__(self, *, ocr_sidecar: str = "") -> None:
|
| 59 |
+
self._sidecar = ocr_sidecar
|
| 60 |
+
|
| 61 |
+
@property
|
| 62 |
+
def name(self) -> str:
|
| 63 |
+
return "alto_source"
|
| 64 |
+
|
| 65 |
+
@property
|
| 66 |
+
def version(self) -> str:
|
| 67 |
+
return _VERSION
|
| 68 |
+
|
| 69 |
+
@property
|
| 70 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 71 |
+
return frozenset({ArtifactType.IMAGE})
|
| 72 |
+
|
| 73 |
+
@property
|
| 74 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 75 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 76 |
+
|
| 77 |
+
def _locate(self, image_path: Path) -> Path:
|
| 78 |
+
for suffix in _SUFFIXES:
|
| 79 |
+
candidate = image_path.with_name(image_path.stem + suffix)
|
| 80 |
+
if candidate.is_file():
|
| 81 |
+
return candidate
|
| 82 |
+
attendus = " ou ".join(repr(image_path.stem + s) for s in _SUFFIXES)
|
| 83 |
+
raise AdapterStepError(
|
| 84 |
+
f"{self.name} : aucun ALTO près de {image_path.name!r} "
|
| 85 |
+
f"({attendus} attendu dans {image_path.parent})."
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
def _read_sidecar(self, alto_name: str) -> dict[str, str]:
|
| 89 |
+
chemin = Path(self._sidecar)
|
| 90 |
+
try:
|
| 91 |
+
data = json.loads(chemin.read_text(encoding="utf-8"))
|
| 92 |
+
except (OSError, ValueError) as exc:
|
| 93 |
+
raise AdapterStepError(
|
| 94 |
+
f"{self.name} : sidecar OCR illisible ({chemin}) — {exc}"
|
| 95 |
+
) from exc
|
| 96 |
+
par_fichier = data.get("ocr") if isinstance(data, dict) else None
|
| 97 |
+
if not isinstance(par_fichier, dict):
|
| 98 |
+
raise AdapterStepError(
|
| 99 |
+
f"{self.name} : {chemin.name!r} n'a pas de section 'ocr'."
|
| 100 |
+
)
|
| 101 |
+
lectures = par_fichier.get(alto_name)
|
| 102 |
+
if not isinstance(lectures, dict):
|
| 103 |
+
raise AdapterStepError(
|
| 104 |
+
f"{self.name} : le sidecar ne connaît pas {alto_name!r}."
|
| 105 |
+
)
|
| 106 |
+
return {str(k): str(v) for k, v in lectures.items()}
|
| 107 |
+
|
| 108 |
+
def execute(
|
| 109 |
+
self,
|
| 110 |
+
inputs: dict[ArtifactType, Artifact],
|
| 111 |
+
params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
|
| 112 |
+
context: RunContext,
|
| 113 |
+
control: RunControl,
|
| 114 |
+
) -> StepOutput:
|
| 115 |
+
control.raise_if_cancelled()
|
| 116 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 117 |
+
if image is None or image.uri is None:
|
| 118 |
+
raise AdapterStepError(
|
| 119 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 120 |
+
)
|
| 121 |
+
alto_path = self._locate(Path(image.uri))
|
| 122 |
+
try:
|
| 123 |
+
layout = alto_to_layout(parse_alto(alto_path.read_bytes()))
|
| 124 |
+
except CinocError as exc:
|
| 125 |
+
raise AdapterStepError(
|
| 126 |
+
f"{self.name} : {alto_path.name!r} illisible — {exc}"
|
| 127 |
+
) from exc
|
| 128 |
+
if self._sidecar:
|
| 129 |
+
layout, remplacees, total = _apply_ocr(
|
| 130 |
+
layout, self._read_sidecar(alto_path.name)
|
| 131 |
+
)
|
| 132 |
+
if remplacees == 0 and total:
|
| 133 |
+
# Un sidecar qui ne touche AUCUNE ligne est presque toujours un
|
| 134 |
+
# mauvais appariement de clés, pas un OCR parfait. Le dire ici
|
| 135 |
+
# évite de publier un CER nul en croyant mesurer une correction.
|
| 136 |
+
raise AdapterStepError(
|
| 137 |
+
f"{self.name} : le sidecar ne couvre aucune des {total} "
|
| 138 |
+
f"lignes de {alto_path.name!r} — clés de fichier ou de "
|
| 139 |
+
"ligne incompatibles."
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
# Une ligne sans ``id`` n'a pas d'identité stable, et un post-correcteur
|
| 143 |
+
# qui reçoit un tel layout ne peut pas rendre ses décisions ligne à
|
| 144 |
+
# ligne. On le dit ici plutôt que de laisser l'étape suivante échouer
|
| 145 |
+
# sur une cause qui n'est pas la sienne.
|
| 146 |
+
sans_id = sum(
|
| 147 |
+
1
|
| 148 |
+
for page in layout.pages
|
| 149 |
+
for region in page.regions
|
| 150 |
+
for line in region.lines
|
| 151 |
+
if not line.id
|
| 152 |
+
)
|
| 153 |
+
if sans_id:
|
| 154 |
+
raise AdapterStepError(
|
| 155 |
+
f"{self.name} : {alto_path.name!r} porte {sans_id} ligne(s) "
|
| 156 |
+
"sans attribut ID. Sans identifiant, une ligne n'a pas "
|
| 157 |
+
"d'identité stable d'un artefact à l'autre."
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
payload = layout.model_dump_json().encode("utf-8")
|
| 161 |
+
# Sans espace de travail, on écrit près de la source — même repli que
|
| 162 |
+
# ``alto_assembler``.
|
| 163 |
+
out = (
|
| 164 |
+
workspace_artifact_path(
|
| 165 |
+
context.workspace_uri, context.document_id, self.name, "layout.json"
|
| 166 |
+
)
|
| 167 |
+
if context.workspace_uri
|
| 168 |
+
else alto_path.with_name(f"{alto_path.stem}.{self.name}.layout.json")
|
| 169 |
+
)
|
| 170 |
+
out.parent.mkdir(parents=True, exist_ok=True)
|
| 171 |
+
out.write_bytes(payload)
|
| 172 |
+
return StepOutput(
|
| 173 |
+
artifacts={
|
| 174 |
+
ArtifactType.LAYOUT: Artifact(
|
| 175 |
+
id=f"{context.document_id}:{self.name}:layout",
|
| 176 |
+
document_id=context.document_id,
|
| 177 |
+
type=ArtifactType.LAYOUT,
|
| 178 |
+
uri=str(out),
|
| 179 |
+
content_hash=compute_content_hash(payload),
|
| 180 |
+
)
|
| 181 |
+
}
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _apply_ocr(
|
| 186 |
+
layout: CanonicalLayout, lectures: dict[str, str]
|
| 187 |
+
) -> tuple[CanonicalLayout, int, int]:
|
| 188 |
+
"""Remplace le texte des lignes connues du sidecar. Renvoie ``(layout,
|
| 189 |
+
remplacées, total)`` — les compteurs servent à refuser un appariement vide.
|
| 190 |
+
|
| 191 |
+
Les **mots sont abandonnés** sur une ligne remplacée : leur géométrie
|
| 192 |
+
décrivait les caractères de la vérité terrain, pas ceux que l'OCR a lus.
|
| 193 |
+
"""
|
| 194 |
+
remplacees = 0
|
| 195 |
+
total = 0
|
| 196 |
+
|
| 197 |
+
def region(reg: Region) -> Region:
|
| 198 |
+
nonlocal remplacees, total
|
| 199 |
+
lignes = []
|
| 200 |
+
for line in reg.lines:
|
| 201 |
+
total += 1
|
| 202 |
+
if line.id is not None and line.id in lectures:
|
| 203 |
+
remplacees += 1
|
| 204 |
+
lignes.append(
|
| 205 |
+
Line(
|
| 206 |
+
id=line.id,
|
| 207 |
+
text=lectures[line.id],
|
| 208 |
+
geometry=line.geometry,
|
| 209 |
+
baseline=line.baseline,
|
| 210 |
+
words=(),
|
| 211 |
+
confidence=line.confidence,
|
| 212 |
+
)
|
| 213 |
+
)
|
| 214 |
+
else:
|
| 215 |
+
lignes.append(line)
|
| 216 |
+
return reg.model_copy(
|
| 217 |
+
update={
|
| 218 |
+
"lines": tuple(lignes),
|
| 219 |
+
"regions": tuple(region(r) for r in reg.regions),
|
| 220 |
+
}
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
pages = tuple(
|
| 224 |
+
page.model_copy(update={"regions": tuple(region(r) for r in page.regions)})
|
| 225 |
+
for page in layout.pages
|
| 226 |
+
)
|
| 227 |
+
return layout.model_copy(update={"pages": pages}), remplacees, total
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
__all__ = ["AltoLayoutSource"]
|
cinoc/adapters/layout/assembler.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AltoAssembler`` — assemblage ``LAYOUT → ALTO_XML`` (couche 5).
|
| 2 |
+
|
| 3 |
+
Troisième étage du pipeline de segmentation (``CLAUDE.md`` §3 : *assemblage
|
| 4 |
+
LAYOUT → ALTO_XML*). C'est un **constructeur d'ALTO** au sens du même `Module`
|
| 5 |
+
Protocol que les autres briques : il prend un ``CanonicalLayout`` **rempli**
|
| 6 |
+
(sortie du fan-out, sérialisé JSON) et émet de l'ALTO 4 déterministe
|
| 7 |
+
(``layout_to_alto`` + ``write_alto`` octet-stable).
|
| 8 |
+
|
| 9 |
+
L'assembleur vit en couche 5 (et non `pipeline`) parce qu'il dépend de `formats`
|
| 10 |
+
(`write_alto`) — un import légal pour un adapter, interdit à `pipeline`.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 18 |
+
from cinoc.domain.errors import AdapterStepError
|
| 19 |
+
from cinoc.domain.layout import CanonicalLayout
|
| 20 |
+
from cinoc.formats.alto import write_alto
|
| 21 |
+
from cinoc.formats.alto.layout_map import layout_to_alto
|
| 22 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 23 |
+
from cinoc.pipeline.run_control import RunControl
|
| 24 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 25 |
+
|
| 26 |
+
_VERSION = "1.0"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AltoAssembler:
|
| 30 |
+
"""Assemble un ``LAYOUT`` rempli en ``ALTO_XML`` (ALTO 4, déterministe)."""
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def name(self) -> str:
|
| 34 |
+
return "alto_assembler"
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def version(self) -> str:
|
| 38 |
+
return _VERSION
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 42 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 46 |
+
return frozenset({ArtifactType.ALTO_XML})
|
| 47 |
+
|
| 48 |
+
def execute(
|
| 49 |
+
self,
|
| 50 |
+
inputs: dict[ArtifactType, Artifact],
|
| 51 |
+
params: dict[str, ParamValue],
|
| 52 |
+
context: RunContext,
|
| 53 |
+
control: RunControl,
|
| 54 |
+
) -> StepOutput:
|
| 55 |
+
control.raise_if_cancelled()
|
| 56 |
+
layout_art = inputs.get(ArtifactType.LAYOUT)
|
| 57 |
+
if layout_art is None or layout_art.uri is None:
|
| 58 |
+
raise AdapterStepError(
|
| 59 |
+
f"{self.name} : artefact LAYOUT manquant ou sans URI."
|
| 60 |
+
)
|
| 61 |
+
layout_path = Path(layout_art.uri)
|
| 62 |
+
try:
|
| 63 |
+
layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
|
| 64 |
+
except (OSError, ValueError) as exc:
|
| 65 |
+
raise AdapterStepError(
|
| 66 |
+
f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
|
| 67 |
+
) from exc
|
| 68 |
+
payload = write_alto(layout_to_alto(layout))
|
| 69 |
+
out_dir = (
|
| 70 |
+
Path(context.workspace_uri)
|
| 71 |
+
if context.workspace_uri
|
| 72 |
+
else layout_path.parent
|
| 73 |
+
)
|
| 74 |
+
out_path = out_dir / f"{context.document_id.replace('/', '_')}.alto.xml"
|
| 75 |
+
out_path.write_bytes(payload)
|
| 76 |
+
return StepOutput(
|
| 77 |
+
artifacts={
|
| 78 |
+
ArtifactType.ALTO_XML: Artifact(
|
| 79 |
+
id=f"{context.document_id}:{self.name}:alto_xml",
|
| 80 |
+
document_id=context.document_id,
|
| 81 |
+
type=ArtifactType.ALTO_XML,
|
| 82 |
+
uri=str(out_path),
|
| 83 |
+
content_hash=compute_content_hash(payload),
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
__all__ = ["AltoAssembler"]
|
cinoc/adapters/layout/crop.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``crop_region`` — découpe l'image d'un bloc pour l'OCR par région (couche 5).
|
| 2 |
+
|
| 3 |
+
Brique du pipeline **hybride** « segmentation externe → OCR des blocs » : le
|
| 4 |
+
fan-out (couche 4) fournit une **boîte relative** ``(x0, y0, x1, y1) ∈ [0, 1]``
|
| 5 |
+
(la couche 4 a déjà neutralisé les unités, ALTO ``mm10`` vs pixels) ; ici on
|
| 6 |
+
ouvre l'image, on convertit en pixels et on découpe — le crop réel vit en
|
| 7 |
+
couche 5 car il dépend de ``PIL`` (interdit à ``pipeline``).
|
| 8 |
+
|
| 9 |
+
``Pillow`` est un extra optionnel, importé **paresseusement** : importer ce
|
| 10 |
+
module n'exige pas la lib, seule l'exécution.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from urllib.parse import quote
|
| 17 |
+
|
| 18 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 19 |
+
from cinoc.domain.errors import AdapterStepError
|
| 20 |
+
from cinoc.pipeline.types import RunContext
|
| 21 |
+
|
| 22 |
+
_RelBox = tuple[float, float, float, float]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def crop_region(
|
| 26 |
+
page_image: Artifact, rel_bbox: _RelBox, region_id: str, context: RunContext
|
| 27 |
+
) -> Artifact:
|
| 28 |
+
"""Découpe ``rel_bbox`` (relatif) de l'image page → artefact ``IMAGE`` du bloc."""
|
| 29 |
+
if page_image.uri is None:
|
| 30 |
+
raise AdapterStepError("crop_region : image page sans URI.")
|
| 31 |
+
if context.workspace_uri is None:
|
| 32 |
+
raise AdapterStepError("crop_region : workspace requis pour écrire le crop.")
|
| 33 |
+
try:
|
| 34 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 35 |
+
except ImportError as exc:
|
| 36 |
+
raise AdapterStepError(
|
| 37 |
+
"crop_region : Pillow non installé (pip install 'cinoc[tesseract]')."
|
| 38 |
+
) from exc
|
| 39 |
+
try:
|
| 40 |
+
with Image.open(page_image.uri) as image:
|
| 41 |
+
width, height = image.size
|
| 42 |
+
box = (
|
| 43 |
+
max(0, min(width, round(rel_bbox[0] * width))),
|
| 44 |
+
max(0, min(height, round(rel_bbox[1] * height))),
|
| 45 |
+
max(0, min(width, round(rel_bbox[2] * width))),
|
| 46 |
+
max(0, min(height, round(rel_bbox[3] * height))),
|
| 47 |
+
)
|
| 48 |
+
if box[2] <= box[0] or box[3] <= box[1]:
|
| 49 |
+
raise AdapterStepError(
|
| 50 |
+
f"crop_region : boîte vide pour la région {region_id!r}."
|
| 51 |
+
)
|
| 52 |
+
crop = image.crop(box)
|
| 53 |
+
stem = quote(region_id, safe="")
|
| 54 |
+
out_path = Path(context.workspace_uri) / (
|
| 55 |
+
f"{quote(context.document_id, safe='')}.{stem}.crop.png"
|
| 56 |
+
)
|
| 57 |
+
crop.save(out_path)
|
| 58 |
+
except OSError as exc:
|
| 59 |
+
raise AdapterStepError(
|
| 60 |
+
f"crop_region : image illisible ({page_image.uri!r}) : {exc}"
|
| 61 |
+
) from exc
|
| 62 |
+
return Artifact(
|
| 63 |
+
id=f"{context.document_id}:crop:{region_id}",
|
| 64 |
+
document_id=context.document_id,
|
| 65 |
+
type=ArtifactType.IMAGE,
|
| 66 |
+
uri=str(out_path),
|
| 67 |
+
content_hash=compute_content_hash(out_path.read_bytes()),
|
| 68 |
+
region_id=region_id,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
__all__ = ["crop_region"]
|
cinoc/adapters/layout/pp_doclayout.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PPDocLayoutSegmenter`` — segmenteur de mise en page réel (PP-DocLayout).
|
| 2 |
+
|
| 3 |
+
Détecte les **régions** d'une page (titre, paragraphe, figure, tableau…) et émet
|
| 4 |
+
un ``LAYOUT`` (``CanonicalLayout`` à régions **sans lignes** — la reconnaissance
|
| 5 |
+
les remplit ensuite, cf. fan-out couche 4). Implémente le ``Module`` Protocol
|
| 6 |
+
**directement**, comme les autres adapters.
|
| 7 |
+
|
| 8 |
+
Module **maison du socle** (≠ plugin tiers découvert) : enregistré par
|
| 9 |
+
``register_default_modules`` → disponible **même en mode public** sur le Space
|
| 10 |
+
(contrairement aux plugins via entry-points, fail-closed). Son SDK (PaddleX,
|
| 11 |
+
Apache-2.0) est un **extra optionnel** (``cinoc[segment]``), importé
|
| 12 |
+
**paresseusement** : importer ce module n'exige ni la lib ni les poids — seule
|
| 13 |
+
l'exécution les requiert.
|
| 14 |
+
|
| 15 |
+
Le détecteur est **injectable** (``detector=``) : la conversion détections →
|
| 16 |
+
``CanonicalLayout`` se teste sans SDK ni poids (CI), le vrai modèle reste un test
|
| 17 |
+
``live``/``slow`` opt-in. Déterminisme (§12) : à modèle + poids + params figés et
|
| 18 |
+
seuil de score fixe, la sortie est stable ; l'ordre de lecture est **trié**
|
| 19 |
+
(haut→bas puis gauche→droite), indépendant de l'ordre de détection.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters.layout._base import (
|
| 25 |
+
DetectedRegion,
|
| 26 |
+
DetectorFn,
|
| 27 |
+
LayoutDetection,
|
| 28 |
+
layout_step_output,
|
| 29 |
+
to_canonical_layout,
|
| 30 |
+
)
|
| 31 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 32 |
+
from cinoc.domain.errors import AdapterStepError
|
| 33 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 34 |
+
from cinoc.pipeline.run_control import RunControl
|
| 35 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 36 |
+
|
| 37 |
+
_VERSION = "1.0"
|
| 38 |
+
_DEFAULT_MIN_SCORE = 0.5
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
#: Modèle PP-DocLayout par défaut. La variante **légère** ``PP-DocLayout-S``
|
| 42 |
+
#: (poids ~10× plus petits) est bakable dans l'image Space pour **tester
|
| 43 |
+
#: l'option** sans peser sur le free-tier ; ``-L`` reste le défaut qualité.
|
| 44 |
+
#: Surchargé par ``CINOC_PPDOCLAYOUT_MODEL`` (lu par le builder) ou ``model=``.
|
| 45 |
+
_DEFAULT_MODEL = "PP-DocLayout-L"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _detect_with_paddle( # pragma: no cover -- SDK + poids requis (test 'live')
|
| 49 |
+
image_path: str, model: str = _DEFAULT_MODEL
|
| 50 |
+
) -> LayoutDetection:
|
| 51 |
+
"""Détecte la mise en page via **PaddleX PP-DocLayout**. Isolé → mockable.
|
| 52 |
+
|
| 53 |
+
SDK absent → ``AdapterStepError`` explicite (l'extra ``[segment]`` manque) ;
|
| 54 |
+
le module reste listable, il ne plante pas à l'import. Le corps qui exécute le
|
| 55 |
+
modèle n'est exercé que par un test ``live`` (SDK + poids installés). ``model``
|
| 56 |
+
choisit la variante (``-L`` qualité / ``-S`` légère)."""
|
| 57 |
+
try:
|
| 58 |
+
from paddlex import create_model # type: ignore[import-not-found]
|
| 59 |
+
except ImportError as exc:
|
| 60 |
+
raise AdapterStepError(
|
| 61 |
+
"pp_doclayout : PaddleX non installé "
|
| 62 |
+
"(pip install 'cinoc[segment]' + poids PP-DocLayout)."
|
| 63 |
+
) from exc
|
| 64 |
+
results = list(create_model(model).predict(image_path, batch_size=1))
|
| 65 |
+
if not results:
|
| 66 |
+
return LayoutDetection(page_width=0, page_height=0, regions=())
|
| 67 |
+
boxes = results[0].get("boxes", [])
|
| 68 |
+
regions = tuple(
|
| 69 |
+
DetectedRegion(
|
| 70 |
+
label=str(box.get("label", "")),
|
| 71 |
+
x=int(box["coordinate"][0]),
|
| 72 |
+
y=int(box["coordinate"][1]),
|
| 73 |
+
width=int(box["coordinate"][2]) - int(box["coordinate"][0]),
|
| 74 |
+
height=int(box["coordinate"][3]) - int(box["coordinate"][1]),
|
| 75 |
+
score=float(box.get("score", 0.0)),
|
| 76 |
+
)
|
| 77 |
+
for box in boxes
|
| 78 |
+
)
|
| 79 |
+
shape = results[0].get("input_img_shape") or (0, 0)
|
| 80 |
+
return LayoutDetection(
|
| 81 |
+
page_width=int(shape[1]), page_height=int(shape[0]), regions=regions
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class PPDocLayoutSegmenter:
|
| 86 |
+
"""Segmenteur PP-DocLayout : ``IMAGE → LAYOUT`` (régions sans lignes)."""
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
*,
|
| 91 |
+
model: str = _DEFAULT_MODEL,
|
| 92 |
+
min_score: float = _DEFAULT_MIN_SCORE,
|
| 93 |
+
detector: DetectorFn | None = None,
|
| 94 |
+
) -> None:
|
| 95 |
+
if not 0.0 <= min_score <= 1.0:
|
| 96 |
+
raise AdapterStepError(
|
| 97 |
+
f"PPDocLayoutSegmenter : min_score ∈ [0, 1], reçu {min_score}."
|
| 98 |
+
)
|
| 99 |
+
self._model = model
|
| 100 |
+
self._min_score = min_score
|
| 101 |
+
self._detector = detector
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def name(self) -> str:
|
| 105 |
+
return "pp_doclayout"
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def version(self) -> str:
|
| 109 |
+
return _VERSION
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 113 |
+
return frozenset({ArtifactType.IMAGE})
|
| 114 |
+
|
| 115 |
+
@property
|
| 116 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 117 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 118 |
+
|
| 119 |
+
def _detect(self, image_path: str) -> LayoutDetection:
|
| 120 |
+
if self._detector is not None:
|
| 121 |
+
return self._detector(image_path)
|
| 122 |
+
return _detect_with_paddle(image_path, self._model)
|
| 123 |
+
|
| 124 |
+
def execute(
|
| 125 |
+
self,
|
| 126 |
+
inputs: dict[ArtifactType, Artifact],
|
| 127 |
+
params: dict[str, ParamValue],
|
| 128 |
+
context: RunContext,
|
| 129 |
+
control: RunControl,
|
| 130 |
+
) -> StepOutput:
|
| 131 |
+
control.raise_if_cancelled()
|
| 132 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 133 |
+
if image is None or image.uri is None:
|
| 134 |
+
raise AdapterStepError(
|
| 135 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 136 |
+
)
|
| 137 |
+
if context.workspace_uri is None:
|
| 138 |
+
raise AdapterStepError(
|
| 139 |
+
f"{self.name} : workspace requis (RunContext.workspace_uri)."
|
| 140 |
+
)
|
| 141 |
+
layout = to_canonical_layout(
|
| 142 |
+
self._detect(image.uri), min_score=self._min_score
|
| 143 |
+
)
|
| 144 |
+
return layout_step_output(layout, context, self.name)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
__all__ = ["PPDocLayoutSegmenter"]
|
cinoc/adapters/layout/precomputed.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Socle ``precomputed`` de la tranche segmentation (couche 5, 0 dép, 0 réseau).
|
| 2 |
+
|
| 3 |
+
Deux briques, **même ``Module`` Protocol** que les autres adapters, qui font
|
| 4 |
+
tourner le squelette segmentation **sans segmenteur ni OCR réels** :
|
| 5 |
+
|
| 6 |
+
- ``PrecomputedLayoutSource`` : ``IMAGE → LAYOUT`` — rejoue une mise en page
|
| 7 |
+
pré-calculée (régions seules) depuis ``<stem>.layout.json``.
|
| 8 |
+
- ``PrecomputedRegionRecognizer`` : ``IMAGE → RAW_TEXT`` — rejoue le texte d'**une
|
| 9 |
+
région** (clé = ``Artifact.region_id`` de l'IMAGE d'entrée) depuis
|
| 10 |
+
``<stem>.<label>.regions.json`` (``{region_id: texte}``). Appelé **une fois par
|
| 11 |
+
région** par le fan-out (couche 4) ; aucun découpage d'image réel (la donnée
|
| 12 |
+
est figée).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.domain.layout import CanonicalLayout
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
_VERSION = "1.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _require_image(inputs: dict[ArtifactType, Artifact], who: str) -> Path:
|
| 31 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 32 |
+
if image is None or image.uri is None:
|
| 33 |
+
raise AdapterStepError(f"{who} : artefact IMAGE manquant ou sans URI.")
|
| 34 |
+
return Path(image.uri)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class PrecomputedLayoutSource:
|
| 38 |
+
"""Rejoue une mise en page pré-calculée (``<stem>.layout.json``)."""
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def name(self) -> str:
|
| 42 |
+
return "precomputed_layout"
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def version(self) -> str:
|
| 46 |
+
return _VERSION
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 50 |
+
return frozenset({ArtifactType.IMAGE})
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 54 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 55 |
+
|
| 56 |
+
def execute(
|
| 57 |
+
self,
|
| 58 |
+
inputs: dict[ArtifactType, Artifact],
|
| 59 |
+
params: dict[str, ParamValue],
|
| 60 |
+
context: RunContext,
|
| 61 |
+
control: RunControl,
|
| 62 |
+
) -> StepOutput:
|
| 63 |
+
control.raise_if_cancelled()
|
| 64 |
+
image_path = _require_image(inputs, self.name)
|
| 65 |
+
layout_path = image_path.parent / f"{image_path.stem}.layout.json"
|
| 66 |
+
if not layout_path.is_file():
|
| 67 |
+
raise AdapterStepError(
|
| 68 |
+
f"{self.name} : mise en page pré-calculée introuvable — "
|
| 69 |
+
f"{layout_path.name!r} attendu près de {image_path.name!r}."
|
| 70 |
+
)
|
| 71 |
+
data = layout_path.read_bytes()
|
| 72 |
+
try:
|
| 73 |
+
CanonicalLayout.model_validate_json(data)
|
| 74 |
+
except ValueError as exc:
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"{self.name} : {layout_path.name!r} n'est pas un "
|
| 77 |
+
f"CanonicalLayout valide : {exc}"
|
| 78 |
+
) from exc
|
| 79 |
+
return StepOutput(
|
| 80 |
+
artifacts={
|
| 81 |
+
ArtifactType.LAYOUT: Artifact(
|
| 82 |
+
id=f"{context.document_id}:{self.name}:layout",
|
| 83 |
+
document_id=context.document_id,
|
| 84 |
+
type=ArtifactType.LAYOUT,
|
| 85 |
+
uri=str(layout_path),
|
| 86 |
+
content_hash=compute_content_hash(data),
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class PrecomputedRegionRecognizer:
|
| 93 |
+
"""Rejoue le texte d'une région (``<stem>.<label>.regions.json``)."""
|
| 94 |
+
|
| 95 |
+
def __init__(self, *, source_label: str) -> None:
|
| 96 |
+
if not source_label or not all(
|
| 97 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 98 |
+
):
|
| 99 |
+
raise AdapterStepError(
|
| 100 |
+
f"PrecomputedRegionRecognizer : source_label invalide "
|
| 101 |
+
f"{source_label!r} (alphanumérique + _ - uniquement)."
|
| 102 |
+
)
|
| 103 |
+
self._label = source_label
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def name(self) -> str:
|
| 107 |
+
return f"precomputed_region:{self._label}"
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def version(self) -> str:
|
| 111 |
+
return _VERSION
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 115 |
+
return frozenset({ArtifactType.IMAGE})
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 119 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 120 |
+
|
| 121 |
+
def execute(
|
| 122 |
+
self,
|
| 123 |
+
inputs: dict[ArtifactType, Artifact],
|
| 124 |
+
params: dict[str, ParamValue],
|
| 125 |
+
context: RunContext,
|
| 126 |
+
control: RunControl,
|
| 127 |
+
) -> StepOutput:
|
| 128 |
+
control.raise_if_cancelled()
|
| 129 |
+
image = inputs[ArtifactType.IMAGE]
|
| 130 |
+
if image.region_id is None:
|
| 131 |
+
raise AdapterStepError(
|
| 132 |
+
f"{self.name} : IMAGE sans region_id — la reconnaissance par "
|
| 133 |
+
"région attend une entrée déjà cadrée par le fan-out."
|
| 134 |
+
)
|
| 135 |
+
image_path = _require_image(inputs, self.name)
|
| 136 |
+
regions_path = (
|
| 137 |
+
image_path.parent / f"{image_path.stem}.{self._label}.regions.json"
|
| 138 |
+
)
|
| 139 |
+
if not regions_path.is_file():
|
| 140 |
+
raise AdapterStepError(
|
| 141 |
+
f"{self.name} : textes par région introuvables — "
|
| 142 |
+
f"{regions_path.name!r} attendu près de {image_path.name!r}."
|
| 143 |
+
)
|
| 144 |
+
try:
|
| 145 |
+
mapping = json.loads(regions_path.read_bytes())
|
| 146 |
+
except ValueError as exc:
|
| 147 |
+
raise AdapterStepError(
|
| 148 |
+
f"{self.name} : {regions_path.name!r} JSON invalide : {exc}"
|
| 149 |
+
) from exc
|
| 150 |
+
if not isinstance(mapping, dict) or image.region_id not in mapping:
|
| 151 |
+
raise AdapterStepError(
|
| 152 |
+
f"{self.name} : région {image.region_id!r} absente de "
|
| 153 |
+
f"{regions_path.name!r}."
|
| 154 |
+
)
|
| 155 |
+
text = str(mapping[image.region_id])
|
| 156 |
+
out_dir = (
|
| 157 |
+
Path(context.workspace_uri)
|
| 158 |
+
if context.workspace_uri
|
| 159 |
+
else image_path.parent
|
| 160 |
+
)
|
| 161 |
+
out_path = out_dir / f"{image_path.stem}.{self._label}.{image.region_id}.txt"
|
| 162 |
+
payload = text.encode("utf-8")
|
| 163 |
+
out_path.write_bytes(payload)
|
| 164 |
+
return StepOutput(
|
| 165 |
+
artifacts={
|
| 166 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 167 |
+
id=f"{context.document_id}:{self.name}:{image.region_id}:raw_text",
|
| 168 |
+
document_id=context.document_id,
|
| 169 |
+
type=ArtifactType.RAW_TEXT,
|
| 170 |
+
uri=str(out_path),
|
| 171 |
+
content_hash=compute_content_hash(payload),
|
| 172 |
+
region_id=image.region_id,
|
| 173 |
+
)
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
__all__ = ["PrecomputedLayoutSource", "PrecomputedRegionRecognizer"]
|
cinoc/adapters/layout/remote.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``RemoteSegmenter`` — segmenteur de mise en page **par appel distant**.
|
| 2 |
+
|
| 3 |
+
Au lieu d'embarquer un modèle lourd (poids dans l'image), ce module **délègue** la
|
| 4 |
+
détection de régions à un modèle hébergé ailleurs : un *Inference Endpoint* HF, un
|
| 5 |
+
Space-modèle, ou tout service exposant l'API **object-detection** de Hugging Face.
|
| 6 |
+
Cinoc **orchestre**, le modèle **tourne à distance** — le Space reste léger (pas de
|
| 7 |
+
poids, pas d'inférence CPU locale).
|
| 8 |
+
|
| 9 |
+
C'est l'incarnation « distante » du point d'extension unique de Cinoc : il
|
| 10 |
+
implémente le **même ``Module`` Protocol** que ``PPDocLayoutSegmenter`` (et que les
|
| 11 |
+
plugins tiers), donc il apparaît comme **segmenteur sélectionnable** avant un run.
|
| 12 |
+
On change de modèle en changeant l'``endpoint`` — sans rien réinstaller.
|
| 13 |
+
|
| 14 |
+
Schéma de réponse attendu = sortie **object-detection** HF :
|
| 15 |
+
``[{"label": str, "score": float, "box": {"xmin","ymin","xmax","ymax"}}, …]``.
|
| 16 |
+
|
| 17 |
+
Le détecteur est **injectable** (``detector=``) : la conversion détections →
|
| 18 |
+
``CanonicalLayout`` se teste sans réseau ; le vrai appel HTTP (``httpx`` + garde
|
| 19 |
+
**anti-SSRF** ``assert_public_url``) n'est exercé qu'en test ``live``. ``httpx`` est
|
| 20 |
+
un extra optionnel (``cinoc[serve]``/``[google]``…), importé paresseusement.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from cinoc.adapters.corpus._http import SsrfError, assert_public_url
|
| 28 |
+
from cinoc.adapters.layout._base import (
|
| 29 |
+
DetectedRegion,
|
| 30 |
+
DetectorFn,
|
| 31 |
+
LayoutDetection,
|
| 32 |
+
layout_step_output,
|
| 33 |
+
to_canonical_layout,
|
| 34 |
+
)
|
| 35 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 36 |
+
from cinoc.domain.errors import AdapterStepError
|
| 37 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 38 |
+
from cinoc.pipeline.run_control import RunControl
|
| 39 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 40 |
+
|
| 41 |
+
_VERSION = "1.0"
|
| 42 |
+
_DEFAULT_MIN_SCORE = 0.5
|
| 43 |
+
_DEFAULT_TIMEOUT = 60.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_hf_detections(
|
| 47 |
+
payload: object, *, page_width: int, page_height: int
|
| 48 |
+
) -> LayoutDetection:
|
| 49 |
+
"""Réponse object-detection HF → ``LayoutDetection`` (tolérante aux entrées
|
| 50 |
+
mal formées : les boîtes invalides sont ignorées, jamais une exception)."""
|
| 51 |
+
if not isinstance(payload, list):
|
| 52 |
+
raise AdapterStepError(
|
| 53 |
+
"remote_segmenter : réponse inattendue (liste de détections attendue)."
|
| 54 |
+
)
|
| 55 |
+
regions: list[DetectedRegion] = []
|
| 56 |
+
for item in payload:
|
| 57 |
+
if not isinstance(item, dict):
|
| 58 |
+
continue
|
| 59 |
+
box = item.get("box")
|
| 60 |
+
if not isinstance(box, dict):
|
| 61 |
+
continue
|
| 62 |
+
try:
|
| 63 |
+
x0, y0 = int(box["xmin"]), int(box["ymin"])
|
| 64 |
+
x1, y1 = int(box["xmax"]), int(box["ymax"])
|
| 65 |
+
except (KeyError, TypeError, ValueError):
|
| 66 |
+
continue
|
| 67 |
+
regions.append(
|
| 68 |
+
DetectedRegion(
|
| 69 |
+
label=str(item.get("label", "")),
|
| 70 |
+
x=x0,
|
| 71 |
+
y=y0,
|
| 72 |
+
width=max(0, x1 - x0),
|
| 73 |
+
height=max(0, y1 - y0),
|
| 74 |
+
score=float(item.get("score", 0.0)),
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
return LayoutDetection(
|
| 78 |
+
page_width=page_width, page_height=page_height, regions=tuple(regions)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _image_dims(image_path: str) -> tuple[int, int]:
|
| 83 |
+
"""Dimensions ``(largeur, hauteur)`` de l'image ; ``(0, 0)`` si indisponible
|
| 84 |
+
(Pillow absent / illisible) — la conversion gère les dimensions nulles."""
|
| 85 |
+
try:
|
| 86 |
+
from PIL import Image, UnidentifiedImageError
|
| 87 |
+
except ImportError:
|
| 88 |
+
return (0, 0)
|
| 89 |
+
try:
|
| 90 |
+
with Image.open(image_path) as opened:
|
| 91 |
+
return (int(opened.width), int(opened.height))
|
| 92 |
+
except (UnidentifiedImageError, OSError):
|
| 93 |
+
return (0, 0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class RemoteSegmenter:
|
| 97 |
+
"""Segmenteur ``IMAGE → LAYOUT`` délégué à un endpoint object-detection."""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
*,
|
| 102 |
+
endpoint: str,
|
| 103 |
+
token: str | None = None,
|
| 104 |
+
min_score: float = _DEFAULT_MIN_SCORE,
|
| 105 |
+
timeout: float = _DEFAULT_TIMEOUT,
|
| 106 |
+
detector: DetectorFn | None = None,
|
| 107 |
+
) -> None:
|
| 108 |
+
if not endpoint:
|
| 109 |
+
raise AdapterStepError("remote_segmenter : 'endpoint' requis.")
|
| 110 |
+
if not 0.0 <= min_score <= 1.0:
|
| 111 |
+
raise AdapterStepError(
|
| 112 |
+
f"remote_segmenter : min_score ∈ [0, 1], reçu {min_score}."
|
| 113 |
+
)
|
| 114 |
+
self._endpoint = endpoint
|
| 115 |
+
self._token = token
|
| 116 |
+
self._min_score = min_score
|
| 117 |
+
self._timeout = timeout
|
| 118 |
+
self._detector = detector
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def name(self) -> str:
|
| 122 |
+
return "remote_segmenter"
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def version(self) -> str:
|
| 126 |
+
return _VERSION
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 130 |
+
return frozenset({ArtifactType.IMAGE})
|
| 131 |
+
|
| 132 |
+
@property
|
| 133 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 134 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 135 |
+
|
| 136 |
+
def _detect(self, image_path: str) -> LayoutDetection:
|
| 137 |
+
if self._detector is not None:
|
| 138 |
+
return self._detector(image_path)
|
| 139 |
+
return self._remote_detect(image_path)
|
| 140 |
+
|
| 141 |
+
def _remote_detect( # pragma: no cover -- réseau réel (test 'live')
|
| 142 |
+
self, image_path: str
|
| 143 |
+
) -> LayoutDetection:
|
| 144 |
+
try:
|
| 145 |
+
assert_public_url(self._endpoint)
|
| 146 |
+
except SsrfError as exc:
|
| 147 |
+
raise AdapterStepError(
|
| 148 |
+
f"remote_segmenter : endpoint refusé (anti-SSRF) : {exc}"
|
| 149 |
+
) from exc
|
| 150 |
+
try:
|
| 151 |
+
import httpx
|
| 152 |
+
except ImportError as exc:
|
| 153 |
+
raise AdapterStepError(
|
| 154 |
+
"remote_segmenter : httpx absent (extra réseau requis)."
|
| 155 |
+
) from exc
|
| 156 |
+
headers = {"Content-Type": "application/octet-stream"}
|
| 157 |
+
if self._token:
|
| 158 |
+
headers["Authorization"] = f"Bearer {self._token}"
|
| 159 |
+
try:
|
| 160 |
+
with httpx.Client(timeout=self._timeout) as client:
|
| 161 |
+
response = client.post(
|
| 162 |
+
self._endpoint,
|
| 163 |
+
content=Path(image_path).read_bytes(),
|
| 164 |
+
headers=headers,
|
| 165 |
+
)
|
| 166 |
+
response.raise_for_status()
|
| 167 |
+
payload = response.json()
|
| 168 |
+
except httpx.HTTPError as exc:
|
| 169 |
+
raise AdapterStepError(
|
| 170 |
+
f"remote_segmenter : appel à l'endpoint échoué : {exc}"
|
| 171 |
+
) from exc
|
| 172 |
+
width, height = _image_dims(image_path)
|
| 173 |
+
return parse_hf_detections(payload, page_width=width, page_height=height)
|
| 174 |
+
|
| 175 |
+
def execute(
|
| 176 |
+
self,
|
| 177 |
+
inputs: dict[ArtifactType, Artifact],
|
| 178 |
+
params: dict[str, ParamValue],
|
| 179 |
+
context: RunContext,
|
| 180 |
+
control: RunControl,
|
| 181 |
+
) -> StepOutput:
|
| 182 |
+
control.raise_if_cancelled()
|
| 183 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 184 |
+
if image is None or image.uri is None:
|
| 185 |
+
raise AdapterStepError(
|
| 186 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 187 |
+
)
|
| 188 |
+
layout = to_canonical_layout(
|
| 189 |
+
self._detect(image.uri), min_score=self._min_score
|
| 190 |
+
)
|
| 191 |
+
return layout_step_output(layout, context, self.name)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
__all__ = ["RemoteSegmenter", "parse_hf_detections"]
|
cinoc/adapters/layout/saknussemm_correct.py
ADDED
|
@@ -0,0 +1,324 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``SaknussemmCorrector`` — ``LAYOUT → LAYOUT + CORRECTED_TEXT`` (couche 5).
|
| 2 |
+
|
| 3 |
+
La post-correction du banc était **texte plat → texte plat** : on aplatissait
|
| 4 |
+
avant de corriger, donc l'identité de ligne, la géométrie et la césure étaient
|
| 5 |
+
détruites *avant* que le modèle voie quoi que ce soit. Cette étape corrige
|
| 6 |
+
**dans** la mise en page : chaque ligne garde son identifiant d'un bout à
|
| 7 |
+
l'autre, et l'appariement avant/après est **connu** au lieu d'être deviné.
|
| 8 |
+
|
| 9 |
+
Deux sorties, et c'est délibéré :
|
| 10 |
+
|
| 11 |
+
* ``LAYOUT`` — la mise en page corrigée, que ``alto_assembler`` sait rendre en
|
| 12 |
+
ALTO et que les métriques de structure savent noter ;
|
| 13 |
+
* ``CORRECTED_TEXT`` — le même contenu aplati, **pour que le bilan de correction
|
| 14 |
+
existant fonctionne sans être modifié**. Il cherche un ``RAW_TEXT`` et un
|
| 15 |
+
``CORRECTED_TEXT`` dans les sorties du pipeline ; les lui donner coûte une
|
| 16 |
+
projection et évite d'inventer un type d'artefact pour ça ;
|
| 17 |
+
* ``DECISIONS`` — ce qui a été changé, ce qui a été **refusé**, et pourquoi.
|
| 18 |
+
Un texte corrigé ne dit pas si une ligne est intacte parce qu'une garde l'a
|
| 19 |
+
protégée ou parce que rien n'a été proposé : deux situations que rien ne
|
| 20 |
+
distingue une fois le texte écrit.
|
| 21 |
+
|
| 22 |
+
Ce que la bibliothèque décide, l'étape le rapporte sans le retoucher : une ligne
|
| 23 |
+
refusée par une garde ressort **avec son texte d'origine**. C'est le principe de
|
| 24 |
+
`saknussemm` — l'application décide, le modèle informe — et le contredire ici
|
| 25 |
+
reviendrait à réintroduire en aval ce que les gardes ont écarté.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import json
|
| 31 |
+
from pathlib import Path
|
| 32 |
+
from typing import Any
|
| 33 |
+
|
| 34 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 35 |
+
from cinoc.adapters.layout.to_text import _page_text
|
| 36 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 37 |
+
from cinoc.domain.errors import AdapterStepError
|
| 38 |
+
from cinoc.domain.layout import CanonicalLayout, Line, Region
|
| 39 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 40 |
+
from cinoc.pipeline.run_control import RunControl
|
| 41 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 42 |
+
|
| 43 |
+
_VERSION = "1.0"
|
| 44 |
+
|
| 45 |
+
#: Producteurs câblés. ``rules`` ne demande rien (déterministe, hors ligne) ;
|
| 46 |
+
#: ``ollama`` parle à un serveur local, sans clé d'API. Les fournisseurs
|
| 47 |
+
#: distants s'ajouteront quand un run les demandera — pas avant.
|
| 48 |
+
_PRODUCERS = ("rules", "ollama")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _require_saknussemm() -> Any:
|
| 52 |
+
try:
|
| 53 |
+
import saknussemm # type: ignore[import-not-found] # noqa: PLC0415
|
| 54 |
+
except ImportError as exc: # pragma: no cover - dépend de l'installation
|
| 55 |
+
raise AdapterStepError(
|
| 56 |
+
"saknussemm_correct : la bibliothèque 'saknussemm' n'est pas "
|
| 57 |
+
"installée — `pip install cinoc[saknussemm]`."
|
| 58 |
+
) from exc
|
| 59 |
+
return saknussemm
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class SaknussemmCorrector:
|
| 63 |
+
"""Corrige un ``CanonicalLayout`` ligne à ligne, identités préservées."""
|
| 64 |
+
|
| 65 |
+
def __init__(
|
| 66 |
+
self,
|
| 67 |
+
*,
|
| 68 |
+
label: str,
|
| 69 |
+
producer: str = "rules",
|
| 70 |
+
model: str = "",
|
| 71 |
+
host: str = "http://localhost:11434",
|
| 72 |
+
) -> None:
|
| 73 |
+
if producer not in _PRODUCERS:
|
| 74 |
+
raise AdapterStepError(
|
| 75 |
+
f"SaknussemmCorrector : producteur {producer!r} inconnu "
|
| 76 |
+
f"(attendu : {', '.join(_PRODUCERS)})."
|
| 77 |
+
)
|
| 78 |
+
if producer == "ollama" and not model:
|
| 79 |
+
raise AdapterStepError(
|
| 80 |
+
"SaknussemmCorrector : le producteur 'ollama' exige un `model`."
|
| 81 |
+
)
|
| 82 |
+
self._label = label
|
| 83 |
+
self._producer = producer
|
| 84 |
+
self._model = model
|
| 85 |
+
self._host = host
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def name(self) -> str:
|
| 89 |
+
return f"saknussemm:{self._label}"
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def version(self) -> str:
|
| 93 |
+
return _VERSION
|
| 94 |
+
|
| 95 |
+
@property
|
| 96 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 97 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 98 |
+
|
| 99 |
+
@property
|
| 100 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 101 |
+
return frozenset(
|
| 102 |
+
{
|
| 103 |
+
ArtifactType.LAYOUT,
|
| 104 |
+
ArtifactType.CORRECTED_TEXT,
|
| 105 |
+
ArtifactType.DECISIONS,
|
| 106 |
+
}
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
# -- producteur ---------------------------------------------------------
|
| 110 |
+
|
| 111 |
+
def _build_producer(self) -> Any:
|
| 112 |
+
if self._producer == "rules":
|
| 113 |
+
from saknussemm.producers.rules import ( # type: ignore[import-not-found] # noqa: PLC0415
|
| 114 |
+
RulesProducer,
|
| 115 |
+
default_french_ocr_rules,
|
| 116 |
+
)
|
| 117 |
+
|
| 118 |
+
return RulesProducer(default_french_ocr_rules())
|
| 119 |
+
|
| 120 |
+
from saknussemm.producers.llm_edit import ( # type: ignore[import-not-found] # noqa: PLC0415, E501
|
| 121 |
+
LLMEditProducer,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
from cinoc.adapters.llm.ollama_structured import ( # noqa: PLC0415
|
| 125 |
+
OllamaStructuredClient,
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
return LLMEditProducer(
|
| 129 |
+
OllamaStructuredClient(host=self._host), api_key="", model=self._model
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
# -- exécution ----------------------------------------------------------
|
| 133 |
+
|
| 134 |
+
def execute(
|
| 135 |
+
self,
|
| 136 |
+
inputs: dict[ArtifactType, Artifact],
|
| 137 |
+
params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
|
| 138 |
+
context: RunContext,
|
| 139 |
+
control: RunControl,
|
| 140 |
+
) -> StepOutput:
|
| 141 |
+
control.raise_if_cancelled()
|
| 142 |
+
artifact = inputs.get(ArtifactType.LAYOUT)
|
| 143 |
+
if artifact is None or artifact.uri is None:
|
| 144 |
+
raise AdapterStepError(f"{self.name} : artefact LAYOUT sans URI.")
|
| 145 |
+
try:
|
| 146 |
+
layout = CanonicalLayout.model_validate_json(
|
| 147 |
+
Path(artifact.uri).read_bytes()
|
| 148 |
+
)
|
| 149 |
+
except (OSError, ValueError) as exc:
|
| 150 |
+
raise AdapterStepError(
|
| 151 |
+
f"{self.name} : {artifact.uri!r} n'est pas un CanonicalLayout "
|
| 152 |
+
f"lisible — {exc}"
|
| 153 |
+
) from exc
|
| 154 |
+
_require_saknussemm()
|
| 155 |
+
|
| 156 |
+
from saknussemm.core.pipeline import ( # type: ignore[import-not-found] # noqa: PLC0415, E501
|
| 157 |
+
CorrectionPipeline,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
from cinoc.adapters.layout._saknussemm_bridge import ( # noqa: PLC0415
|
| 161 |
+
layout_to_manifest,
|
| 162 |
+
manifest_page_ids,
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
manifest = layout_to_manifest(layout, document_id=context.document_id)
|
| 166 |
+
control.raise_if_cancelled()
|
| 167 |
+
|
| 168 |
+
class _Observer:
|
| 169 |
+
def on_event(self, event_type: str, payload: dict[str, Any]) -> None:
|
| 170 |
+
pass
|
| 171 |
+
|
| 172 |
+
pipeline = CorrectionPipeline(
|
| 173 |
+
producer=self._build_producer(), observer=_Observer()
|
| 174 |
+
)
|
| 175 |
+
# ``source_files`` vide : on ne réécrit aucun XML. L'artefact de sortie
|
| 176 |
+
# est la mise en page, et ``alto_assembler`` sait en faire un ALTO — le
|
| 177 |
+
# moteur n'a donc rien à rendre lui-même.
|
| 178 |
+
result = pipeline.run_sync(document_manifest=manifest, source_files={})
|
| 179 |
+
|
| 180 |
+
decided = {
|
| 181 |
+
(outcome.page_id, outcome.line_id): outcome.decision.final_text
|
| 182 |
+
for outcome in result.report.lines
|
| 183 |
+
}
|
| 184 |
+
corrected = _apply(layout, decided, manifest_page_ids(manifest))
|
| 185 |
+
return self._emit(corrected, _decisions(result, context.document_id), context)
|
| 186 |
+
|
| 187 |
+
def _emit(
|
| 188 |
+
self, layout: CanonicalLayout, decisions: bytes, context: RunContext
|
| 189 |
+
) -> StepOutput:
|
| 190 |
+
payload = layout.model_dump_json().encode("utf-8")
|
| 191 |
+
text = _flatten(layout).encode("utf-8")
|
| 192 |
+
layout_path = self._out(context, "layout.json")
|
| 193 |
+
text_path = self._out(context, "corrected.txt")
|
| 194 |
+
decisions_path = self._out(context, "decisions.json")
|
| 195 |
+
layout_path.parent.mkdir(parents=True, exist_ok=True)
|
| 196 |
+
layout_path.write_bytes(payload)
|
| 197 |
+
text_path.write_bytes(text)
|
| 198 |
+
decisions_path.write_bytes(decisions)
|
| 199 |
+
return StepOutput(
|
| 200 |
+
artifacts={
|
| 201 |
+
ArtifactType.LAYOUT: Artifact(
|
| 202 |
+
id=f"{context.document_id}:{self.name}:layout",
|
| 203 |
+
document_id=context.document_id,
|
| 204 |
+
type=ArtifactType.LAYOUT,
|
| 205 |
+
uri=str(layout_path),
|
| 206 |
+
content_hash=compute_content_hash(payload),
|
| 207 |
+
),
|
| 208 |
+
ArtifactType.CORRECTED_TEXT: Artifact(
|
| 209 |
+
id=f"{context.document_id}:{self.name}:text",
|
| 210 |
+
document_id=context.document_id,
|
| 211 |
+
type=ArtifactType.CORRECTED_TEXT,
|
| 212 |
+
uri=str(text_path),
|
| 213 |
+
content_hash=compute_content_hash(text),
|
| 214 |
+
),
|
| 215 |
+
ArtifactType.DECISIONS: Artifact(
|
| 216 |
+
id=f"{context.document_id}:{self.name}:decisions",
|
| 217 |
+
document_id=context.document_id,
|
| 218 |
+
type=ArtifactType.DECISIONS,
|
| 219 |
+
uri=str(decisions_path),
|
| 220 |
+
content_hash=compute_content_hash(decisions),
|
| 221 |
+
),
|
| 222 |
+
}
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
def _out(self, context: RunContext, suffix: str) -> Path:
|
| 226 |
+
if context.workspace_uri:
|
| 227 |
+
return workspace_artifact_path(
|
| 228 |
+
context.workspace_uri, context.document_id, self._label, suffix
|
| 229 |
+
)
|
| 230 |
+
return Path(f"{context.document_id.replace('/', '_')}.{self._label}.{suffix}")
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _decisions(result: object, document_id: str) -> bytes:
|
| 234 |
+
"""Sérialise ce que la bibliothèque a décidé, ligne par ligne.
|
| 235 |
+
|
| 236 |
+
Le **statut** distingue trois situations qu'un texte corrigé confond :
|
| 237 |
+
la ligne a changé, une garde a refusé un changement proposé, ou rien n'a
|
| 238 |
+
été proposé. Sans cette distinction, un correcteur prudent et un correcteur
|
| 239 |
+
inerte rendent le même artefact.
|
| 240 |
+
"""
|
| 241 |
+
lignes = []
|
| 242 |
+
for outcome in result.report.lines: # type: ignore[attr-defined]
|
| 243 |
+
decision = outcome.decision
|
| 244 |
+
propose = outcome.proposal.output_text if outcome.proposal else None
|
| 245 |
+
raison = decision.reason
|
| 246 |
+
lignes.append(
|
| 247 |
+
{
|
| 248 |
+
"document_id": document_id,
|
| 249 |
+
"page_id": outcome.page_id,
|
| 250 |
+
"line_id": outcome.line_id,
|
| 251 |
+
"status": decision.status,
|
| 252 |
+
"source_text": outcome.source_text,
|
| 253 |
+
"final_text": decision.final_text,
|
| 254 |
+
"proposed_text": propose,
|
| 255 |
+
"hyphen_role": outcome.hyphen_role,
|
| 256 |
+
"reason_code": raison.code if raison else None,
|
| 257 |
+
"reason_detail": raison.detail if raison else None,
|
| 258 |
+
}
|
| 259 |
+
)
|
| 260 |
+
return json.dumps(
|
| 261 |
+
{"document_id": document_id, "lines": lignes}, ensure_ascii=False
|
| 262 |
+
).encode("utf-8")
|
| 263 |
+
|
| 264 |
+
|
| 265 |
+
def _apply(
|
| 266 |
+
layout: CanonicalLayout,
|
| 267 |
+
decided: dict[tuple[str, str], str],
|
| 268 |
+
page_ids: list[str],
|
| 269 |
+
) -> CanonicalLayout:
|
| 270 |
+
"""Repose les textes décidés sur la mise en page, identités à l'appui.
|
| 271 |
+
|
| 272 |
+
Les mots sont **abandonnés sur une ligne modifiée** : leur géométrie décrit
|
| 273 |
+
des caractères qui ne sont plus là. Les garder ferait dire à l'artefact une
|
| 274 |
+
position que rien ne soutient — le contraire de ce que la structure sert à
|
| 275 |
+
porter.
|
| 276 |
+
"""
|
| 277 |
+
|
| 278 |
+
def region(reg: Region, page_id: str) -> Region:
|
| 279 |
+
lines = []
|
| 280 |
+
for line in reg.lines:
|
| 281 |
+
texte = decided.get((page_id, line.id or ""))
|
| 282 |
+
if texte is None or texte == line.text:
|
| 283 |
+
lines.append(line)
|
| 284 |
+
continue
|
| 285 |
+
lines.append(
|
| 286 |
+
Line(
|
| 287 |
+
id=line.id,
|
| 288 |
+
text=texte,
|
| 289 |
+
geometry=line.geometry,
|
| 290 |
+
baseline=line.baseline,
|
| 291 |
+
words=(),
|
| 292 |
+
confidence=line.confidence,
|
| 293 |
+
)
|
| 294 |
+
)
|
| 295 |
+
return reg.model_copy(
|
| 296 |
+
update={
|
| 297 |
+
"lines": tuple(lines),
|
| 298 |
+
"regions": tuple(region(r, page_id) for r in reg.regions),
|
| 299 |
+
}
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
pages = tuple(
|
| 303 |
+
page.model_copy(
|
| 304 |
+
update={"regions": tuple(region(r, page_ids[i]) for r in page.regions)}
|
| 305 |
+
)
|
| 306 |
+
for i, page in enumerate(layout.pages)
|
| 307 |
+
)
|
| 308 |
+
return layout.model_copy(update={"pages": pages})
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _flatten(layout: CanonicalLayout) -> str:
|
| 312 |
+
"""Aplatit **avec la fonction de ``to_text``**, pas avec une copie.
|
| 313 |
+
|
| 314 |
+
Le bilan de correction compare le ``RAW_TEXT`` produit par ``to_text`` au
|
| 315 |
+
``CORRECTED_TEXT`` produit ici : deux conventions d'aplatissement
|
| 316 |
+
différentes fausseraient la comparaison sans rien casser de visible. Une
|
| 317 |
+
première version réécrivait la boucle et ajoutait 41 lignes vides sur le
|
| 318 |
+
corpus BnF — les régions sans texte — ce qui aurait décalé tout
|
| 319 |
+
l'appariement de lignes.
|
| 320 |
+
"""
|
| 321 |
+
return "\n".join(_page_text(page) for page in layout.pages)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
__all__ = ["SaknussemmCorrector"]
|
cinoc/adapters/layout/to_text.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``LayoutToTextExtractor`` — ``LAYOUT rempli → RAW_TEXT`` (couche 5).
|
| 2 |
+
|
| 3 |
+
Quatrième étage **optionnel** d'un pipeline hybride : une fois les régions
|
| 4 |
+
reconnues (fan-out → ``CanonicalLayout`` rempli), on aplatit le texte dans
|
| 5 |
+
**l'ordre de lecture** pour le rendre **scorable** comme n'importe quelle sortie
|
| 6 |
+
``RAW_TEXT`` (CER/WER à la page). C'est ce qui permet à un run « segmenteur →
|
| 7 |
+
reconnaissance par bloc » d'être un **concurrent du Banc d'essai** comparé à un
|
| 8 |
+
OCR à plat, et plus seulement un ALTO produit dans son coin.
|
| 9 |
+
|
| 10 |
+
Déterministe : ordre = ``reading_order`` de la page (repli sur l'ordre déclaré),
|
| 11 |
+
lignes d'une région jointes par ``\\n``, régions par ``\\n``, pages par ``\\n``.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 19 |
+
from cinoc.domain.errors import AdapterStepError
|
| 20 |
+
from cinoc.domain.layout import CanonicalLayout, LayoutPage
|
| 21 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 22 |
+
from cinoc.pipeline.run_control import RunControl
|
| 23 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 24 |
+
|
| 25 |
+
_VERSION = "1.0"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _page_text(page: LayoutPage) -> str:
|
| 29 |
+
"""Texte d'une page, régions dans l'ordre de lecture (repli ordre déclaré)."""
|
| 30 |
+
by_id = {region.id: region for region in page.regions}
|
| 31 |
+
ordered = [by_id[rid] for rid in page.reading_order if rid in by_id]
|
| 32 |
+
seen = {region.id for region in ordered}
|
| 33 |
+
ordered.extend(region for region in page.regions if region.id not in seen)
|
| 34 |
+
blocks: list[str] = []
|
| 35 |
+
for region in ordered:
|
| 36 |
+
lines = "\n".join(line.text for line in region.lines if line.text)
|
| 37 |
+
if lines:
|
| 38 |
+
blocks.append(lines)
|
| 39 |
+
return "\n".join(blocks)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LayoutToTextExtractor:
|
| 43 |
+
"""Aplatit un ``LAYOUT`` rempli en ``RAW_TEXT`` (ordre de lecture, déterministe)."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, label: str) -> None:
|
| 46 |
+
self._label = label
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def name(self) -> str:
|
| 50 |
+
return f"layout_to_text:{self._label}"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def version(self) -> str:
|
| 54 |
+
return _VERSION
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 58 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 62 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 63 |
+
|
| 64 |
+
def execute(
|
| 65 |
+
self,
|
| 66 |
+
inputs: dict[ArtifactType, Artifact],
|
| 67 |
+
params: dict[str, ParamValue],
|
| 68 |
+
context: RunContext,
|
| 69 |
+
control: RunControl,
|
| 70 |
+
) -> StepOutput:
|
| 71 |
+
control.raise_if_cancelled()
|
| 72 |
+
layout_art = inputs.get(ArtifactType.LAYOUT)
|
| 73 |
+
if layout_art is None or layout_art.uri is None:
|
| 74 |
+
raise AdapterStepError(
|
| 75 |
+
f"{self.name} : artefact LAYOUT manquant ou sans URI."
|
| 76 |
+
)
|
| 77 |
+
layout_path = Path(layout_art.uri)
|
| 78 |
+
try:
|
| 79 |
+
layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
|
| 80 |
+
except (OSError, ValueError) as exc:
|
| 81 |
+
raise AdapterStepError(
|
| 82 |
+
f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
|
| 83 |
+
) from exc
|
| 84 |
+
text = "\n".join(_page_text(page) for page in layout.pages)
|
| 85 |
+
payload = text.encode("utf-8")
|
| 86 |
+
out_dir = (
|
| 87 |
+
Path(context.workspace_uri)
|
| 88 |
+
if context.workspace_uri
|
| 89 |
+
else layout_path.parent
|
| 90 |
+
)
|
| 91 |
+
out_path = out_dir / f"{context.document_id.replace('/', '_')}.layout.txt"
|
| 92 |
+
out_path.write_bytes(payload)
|
| 93 |
+
return StepOutput(
|
| 94 |
+
artifacts={
|
| 95 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 96 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 97 |
+
document_id=context.document_id,
|
| 98 |
+
type=ArtifactType.RAW_TEXT,
|
| 99 |
+
uri=str(out_path),
|
| 100 |
+
content_hash=compute_content_hash(payload),
|
| 101 |
+
)
|
| 102 |
+
}
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["LayoutToTextExtractor"]
|
cinoc/adapters/llm/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters LLM/VLM (couche 5) — post-correction texte/image + transcription VLM.
|
| 2 |
+
|
| 3 |
+
Modes par adapter (``PipelineMode``) : ``text_only`` (tous), ``text_and_image``
|
| 4 |
+
et ``zero_shot`` (openai, mistral, anthropic — vision). ``ollama`` reste
|
| 5 |
+
``text_only`` (socle local minimal).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.llm.anthropic import AnthropicAdapter
|
| 11 |
+
from cinoc.adapters.llm.mistral import MistralAdapter
|
| 12 |
+
from cinoc.adapters.llm.ollama import OllamaAdapter
|
| 13 |
+
from cinoc.adapters.llm.openai import OpenAIAdapter
|
| 14 |
+
|
| 15 |
+
__all__ = ["AnthropicAdapter", "MistralAdapter", "OllamaAdapter", "OpenAIAdapter"]
|
cinoc/adapters/llm/_base.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers partagés des adapters LLM/VLM (post-correction + transcription).
|
| 2 |
+
|
| 3 |
+
Les adapters concrets (openai, anthropic, mistral, ollama) implémentent le
|
| 4 |
+
``Module`` Protocol et ne fournissent que leurs **appels réseau** (isolés,
|
| 5 |
+
mockables). Toute la logique commune — choix des entrées/sorties selon le
|
| 6 |
+
**mode**, chargement du texte OCR et/ou de l'image, écriture de l'artefact —
|
| 7 |
+
vit **ici**, dans ``run_llm_step`` : un seul endroit, aucun mode dupliqué par
|
| 8 |
+
fournisseur (anti « 8+8+8 », cf. CLAUDE.md §8.8).
|
| 9 |
+
|
| 10 |
+
Modes (``PipelineMode``, source de vérité unique en ``domain``) :
|
| 11 |
+
|
| 12 |
+
- ``text_only`` — ``RAW_TEXT`` → ``CORRECTED_TEXT`` (le LLM ne voit pas l'image).
|
| 13 |
+
- ``text_and_image`` — ``{RAW_TEXT, IMAGE}`` → ``CORRECTED_TEXT`` (le VLM voit l'image).
|
| 14 |
+
- ``zero_shot`` — ``IMAGE`` → ``RAW_TEXT`` (transcription directe, sans OCR amont).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from base64 import b64encode
|
| 20 |
+
from collections.abc import Callable
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, NamedTuple, cast
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 25 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 26 |
+
from cinoc.domain.errors import AdapterStepError
|
| 27 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 28 |
+
from cinoc.domain.usage import ResourceUsage
|
| 29 |
+
from cinoc.formats.text import read_plaintext
|
| 30 |
+
from cinoc.pipeline.run_control import RunControl
|
| 31 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 32 |
+
|
| 33 |
+
#: Prompt de post-correction (modes ``text_only`` / ``text_and_image``) ;
|
| 34 |
+
#: ``{ocr_text}`` est substitué par le texte OCR amont.
|
| 35 |
+
DEFAULT_CORRECTION_PROMPT = (
|
| 36 |
+
"Tu es un correcteur de transcriptions OCR de documents patrimoniaux. "
|
| 37 |
+
"Corrige uniquement les erreurs manifestes de reconnaissance, sans "
|
| 38 |
+
"reformuler ni moderniser l'orthographe historique. Réponds par le seul "
|
| 39 |
+
"texte corrigé.\n\n{ocr_text}"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
#: Prompt de transcription directe (mode ``zero_shot`` : le VLM lit l'image).
|
| 43 |
+
DEFAULT_TRANSCRIPTION_PROMPT = (
|
| 44 |
+
"Transcris fidèlement le texte de cette image de document patrimonial. "
|
| 45 |
+
"Conserve l'orthographe historique et la ponctuation d'origine. N'ajoute "
|
| 46 |
+
"aucun commentaire : réponds par le seul texte transcrit."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
#: Extension de fichier → media type (sous-ensemble image courant).
|
| 50 |
+
_MEDIA_TYPES = {
|
| 51 |
+
".png": "image/png",
|
| 52 |
+
".jpg": "image/jpeg",
|
| 53 |
+
".jpeg": "image/jpeg",
|
| 54 |
+
".webp": "image/webp",
|
| 55 |
+
".gif": "image/gif",
|
| 56 |
+
".tif": "image/tiff",
|
| 57 |
+
".tiff": "image/tiff",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
class LLMCompletion(NamedTuple):
|
| 61 |
+
"""Réponse d'un fournisseur : texte + jetons consommés (si exposés)."""
|
| 62 |
+
|
| 63 |
+
text: str
|
| 64 |
+
tokens_in: int | None = None
|
| 65 |
+
tokens_out: int | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
#: Appel d'un LLM texte : ``prompt → réponse`` (fournisseur + modèle capturés).
|
| 69 |
+
TextInvoke = Callable[[str], LLMCompletion]
|
| 70 |
+
#: Appel d'un VLM : ``prompt, media_type, image_b64 → réponse``.
|
| 71 |
+
VisionInvoke = Callable[[str, str, str], LLMCompletion]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def usage_tokens(value: Any) -> int | None:
|
| 75 |
+
"""Coercition prudente d'un compteur de jetons SDK (``None`` si absent)."""
|
| 76 |
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
| 77 |
+
return None
|
| 78 |
+
return value
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def normalize_llm_content(raw: Any) -> str:
|
| 82 |
+
"""Aplati une réponse LLM (``str`` ou liste de blocs) en chaîne."""
|
| 83 |
+
if raw is None:
|
| 84 |
+
return ""
|
| 85 |
+
if isinstance(raw, str):
|
| 86 |
+
return raw
|
| 87 |
+
if isinstance(raw, list):
|
| 88 |
+
parts: list[str] = []
|
| 89 |
+
for chunk in raw:
|
| 90 |
+
if isinstance(chunk, str):
|
| 91 |
+
parts.append(chunk)
|
| 92 |
+
elif isinstance(chunk, dict) and isinstance(chunk.get("text"), str):
|
| 93 |
+
parts.append(chunk["text"])
|
| 94 |
+
else:
|
| 95 |
+
text = getattr(chunk, "text", None)
|
| 96 |
+
if isinstance(text, str):
|
| 97 |
+
parts.append(text)
|
| 98 |
+
return "".join(parts)
|
| 99 |
+
return str(raw)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def validate_llm_label(label: str, adapter: str) -> str:
|
| 103 |
+
"""Valide un label d'adapter LLM (alphanum + ``_ -``) et le renvoie.
|
| 104 |
+
|
| 105 |
+
Le label compose le ``name`` du module (``<kind>:<label>``) ; le restreindre
|
| 106 |
+
garde les noms de modules sûrs et stables (provenance ``RunManifest``).
|
| 107 |
+
"""
|
| 108 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 109 |
+
raise AdapterStepError(
|
| 110 |
+
f"{adapter} : label invalide {label!r} (alphanum + _ -)."
|
| 111 |
+
)
|
| 112 |
+
return label
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def validate_role(role: str, adapter: str, supported: frozenset[str]) -> PipelineMode:
|
| 116 |
+
"""Valide un mode d'adapter contre ceux que le fournisseur supporte."""
|
| 117 |
+
if role not in supported:
|
| 118 |
+
raise AdapterStepError(
|
| 119 |
+
f"{adapter} : mode {role!r} non supporté "
|
| 120 |
+
f"(modes : {', '.join(sorted(supported))})."
|
| 121 |
+
)
|
| 122 |
+
return cast(PipelineMode, role)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def llm_input_types(role: PipelineMode) -> frozenset[ArtifactType]:
|
| 126 |
+
"""Types d'entrée requis par le ``role`` (mode du pipeline)."""
|
| 127 |
+
if role == "text_only":
|
| 128 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 129 |
+
if role == "text_and_image":
|
| 130 |
+
return frozenset({ArtifactType.RAW_TEXT, ArtifactType.IMAGE})
|
| 131 |
+
return frozenset({ArtifactType.IMAGE}) # zero_shot
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def llm_output_type(role: PipelineMode) -> ArtifactType:
|
| 135 |
+
"""Type produit : ``RAW_TEXT`` en transcription, sinon ``CORRECTED_TEXT``."""
|
| 136 |
+
return (
|
| 137 |
+
ArtifactType.RAW_TEXT
|
| 138 |
+
if role == "zero_shot"
|
| 139 |
+
else ArtifactType.CORRECTED_TEXT
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def default_prompt_for_role(role: PipelineMode) -> str:
|
| 144 |
+
"""Prompt par défaut adapté au mode (transcription vs correction)."""
|
| 145 |
+
return (
|
| 146 |
+
DEFAULT_TRANSCRIPTION_PROMPT
|
| 147 |
+
if role == "zero_shot"
|
| 148 |
+
else DEFAULT_CORRECTION_PROMPT
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def build_prompt(template: str, ocr_text: str) -> str:
|
| 153 |
+
return template.replace("{ocr_text}", ocr_text)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def load_ocr_text(inputs: dict[ArtifactType, Artifact], adapter_name: str) -> str:
|
| 157 |
+
artifact = inputs.get(ArtifactType.RAW_TEXT)
|
| 158 |
+
if artifact is None or artifact.uri is None:
|
| 159 |
+
raise AdapterStepError(
|
| 160 |
+
f"{adapter_name} : input RAW_TEXT manquant ou sans URI."
|
| 161 |
+
)
|
| 162 |
+
return read_plaintext(Path(artifact.uri).read_bytes())
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def load_image_b64(
|
| 166 |
+
inputs: dict[ArtifactType, Artifact], adapter_name: str
|
| 167 |
+
) -> tuple[str, str]:
|
| 168 |
+
"""Charge l'``IMAGE`` d'entrée → ``(media_type, base64)`` pour un VLM."""
|
| 169 |
+
artifact = inputs.get(ArtifactType.IMAGE)
|
| 170 |
+
if artifact is None or artifact.uri is None:
|
| 171 |
+
raise AdapterStepError(
|
| 172 |
+
f"{adapter_name} : input IMAGE manquant ou sans URI."
|
| 173 |
+
)
|
| 174 |
+
path = Path(artifact.uri)
|
| 175 |
+
media_type = _MEDIA_TYPES.get(path.suffix.lower(), "image/png")
|
| 176 |
+
return media_type, b64encode(path.read_bytes()).decode("ascii")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def write_text_artifact(
|
| 180 |
+
workspace_uri: str,
|
| 181 |
+
document_id: str,
|
| 182 |
+
label: str,
|
| 183 |
+
adapter_name: str,
|
| 184 |
+
text: str,
|
| 185 |
+
*,
|
| 186 |
+
output_type: ArtifactType,
|
| 187 |
+
) -> dict[ArtifactType, Artifact]:
|
| 188 |
+
"""Écrit ``text`` dans le workspace → artefact du type produit par le mode."""
|
| 189 |
+
is_corrected = output_type == ArtifactType.CORRECTED_TEXT
|
| 190 |
+
suffix = "corrected.txt" if is_corrected else "raw.txt"
|
| 191 |
+
output_path = workspace_artifact_path(
|
| 192 |
+
workspace_uri, document_id, label, suffix
|
| 193 |
+
)
|
| 194 |
+
output_path.write_text(text, encoding="utf-8")
|
| 195 |
+
return {
|
| 196 |
+
output_type: Artifact(
|
| 197 |
+
id=f"{document_id}:{adapter_name}:{output_type.value}",
|
| 198 |
+
document_id=document_id,
|
| 199 |
+
type=output_type,
|
| 200 |
+
uri=str(output_path),
|
| 201 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 202 |
+
)
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def run_llm_step(
|
| 207 |
+
*,
|
| 208 |
+
role: PipelineMode,
|
| 209 |
+
label: str,
|
| 210 |
+
name: str,
|
| 211 |
+
prompt: str,
|
| 212 |
+
inputs: dict[ArtifactType, Artifact],
|
| 213 |
+
context: RunContext,
|
| 214 |
+
control: RunControl,
|
| 215 |
+
text_invoke: TextInvoke,
|
| 216 |
+
vision_invoke: VisionInvoke | None,
|
| 217 |
+
) -> StepOutput:
|
| 218 |
+
"""Exécute une étape LLM/VLM selon le ``role`` — logique de mode unique.
|
| 219 |
+
|
| 220 |
+
Aiguille sur le mode : charge les bonnes entrées (texte et/ou image), appelle
|
| 221 |
+
l'invocation fournisseur idoine, écrit l'artefact du bon type. ``zero_shot``
|
| 222 |
+
et ``text_and_image`` exigent ``vision_invoke`` (un fournisseur texte-seul,
|
| 223 |
+
ex. ollama, passe ``None`` → ces modes sont refusés proprement). Les jetons
|
| 224 |
+
exposés par le fournisseur remontent dans ``StepOutput.usage``.
|
| 225 |
+
"""
|
| 226 |
+
control.raise_if_cancelled()
|
| 227 |
+
if context.workspace_uri is None:
|
| 228 |
+
raise AdapterStepError(
|
| 229 |
+
f"{name} : workspace requis (RunContext.workspace_uri)."
|
| 230 |
+
)
|
| 231 |
+
if role == "zero_shot":
|
| 232 |
+
if vision_invoke is None:
|
| 233 |
+
raise AdapterStepError(f"{name} : mode zero_shot requiert un VLM (vision).")
|
| 234 |
+
media_type, image_b64 = load_image_b64(inputs, name)
|
| 235 |
+
completion = vision_invoke(prompt, media_type, image_b64)
|
| 236 |
+
elif role == "text_and_image":
|
| 237 |
+
if vision_invoke is None:
|
| 238 |
+
raise AdapterStepError(
|
| 239 |
+
f"{name} : mode text_and_image requiert un VLM (vision)."
|
| 240 |
+
)
|
| 241 |
+
ocr_text = load_ocr_text(inputs, name)
|
| 242 |
+
media_type, image_b64 = load_image_b64(inputs, name)
|
| 243 |
+
completion = vision_invoke(
|
| 244 |
+
build_prompt(prompt, ocr_text), media_type, image_b64
|
| 245 |
+
)
|
| 246 |
+
else: # text_only
|
| 247 |
+
ocr_text = load_ocr_text(inputs, name)
|
| 248 |
+
completion = text_invoke(build_prompt(prompt, ocr_text))
|
| 249 |
+
artifacts = write_text_artifact(
|
| 250 |
+
context.workspace_uri,
|
| 251 |
+
context.document_id,
|
| 252 |
+
label,
|
| 253 |
+
name,
|
| 254 |
+
completion.text,
|
| 255 |
+
output_type=llm_output_type(role),
|
| 256 |
+
)
|
| 257 |
+
usage = (
|
| 258 |
+
ResourceUsage(
|
| 259 |
+
tokens_in=completion.tokens_in, tokens_out=completion.tokens_out
|
| 260 |
+
)
|
| 261 |
+
if completion.tokens_in is not None or completion.tokens_out is not None
|
| 262 |
+
else None
|
| 263 |
+
)
|
| 264 |
+
return StepOutput(artifacts=artifacts, usage=usage)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
__all__ = [
|
| 268 |
+
"DEFAULT_CORRECTION_PROMPT",
|
| 269 |
+
"DEFAULT_TRANSCRIPTION_PROMPT",
|
| 270 |
+
"LLMCompletion",
|
| 271 |
+
"TextInvoke",
|
| 272 |
+
"VisionInvoke",
|
| 273 |
+
"build_prompt",
|
| 274 |
+
"default_prompt_for_role",
|
| 275 |
+
"llm_input_types",
|
| 276 |
+
"llm_output_type",
|
| 277 |
+
"load_image_b64",
|
| 278 |
+
"load_ocr_text",
|
| 279 |
+
"normalize_llm_content",
|
| 280 |
+
"run_llm_step",
|
| 281 |
+
"usage_tokens",
|
| 282 |
+
"validate_llm_label",
|
| 283 |
+
"validate_role",
|
| 284 |
+
"write_text_artifact",
|
| 285 |
+
]
|
cinoc/adapters/llm/anthropic.py
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AnthropicAdapter`` — post-correction LLM **et** transcription VLM (Claude).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
|
| 4 |
+
``text_only`` (texte → corrigé), ``text_and_image`` (Claude vision : image +
|
| 5 |
+
texte → corrigé), ``zero_shot`` (Claude vision : image → texte). Clé
|
| 6 |
+
``ANTHROPIC_API_KEY`` ; SDK ``anthropic`` = **extra** importé paresseusement dans
|
| 7 |
+
``_invoke_anthropic`` / ``_invoke_anthropic_vision`` (isolés → **mockables**, CI
|
| 8 |
+
sans clé ni réseau). La ``Deadline`` borne l'appel.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any, ClassVar
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
_DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
| 37 |
+
_MAX_TOKENS = 4096
|
| 38 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _completion_from_message(response: Any) -> LLMCompletion:
|
| 42 |
+
"""Réponse SDK ``messages.create`` → ``LLMCompletion`` (texte + jetons).
|
| 43 |
+
|
| 44 |
+
**Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
|
| 45 |
+
(``input_tokens``/``output_tokens``, **noms propres à Claude**) est **testable
|
| 46 |
+
sur cassette** (étape 2d) ; le client live (``# pragma: no cover``) ne le
|
| 47 |
+
permettrait pas. Jetons → économie (coût cloud réel).
|
| 48 |
+
"""
|
| 49 |
+
usage = getattr(response, "usage", None)
|
| 50 |
+
return LLMCompletion(
|
| 51 |
+
normalize_llm_content(getattr(response, "content", None)),
|
| 52 |
+
usage_tokens(getattr(usage, "input_tokens", None)),
|
| 53 |
+
usage_tokens(getattr(usage, "output_tokens", None)),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _anthropic_client( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 58 |
+
model: str, content: object, deadline: Deadline
|
| 59 |
+
) -> LLMCompletion:
|
| 60 |
+
"""Appel ``messages.create`` partagé (texte ou multimodal)."""
|
| 61 |
+
import os
|
| 62 |
+
|
| 63 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 64 |
+
if not api_key:
|
| 65 |
+
raise AdapterStepError("anthropic : ANTHROPIC_API_KEY manquante.")
|
| 66 |
+
try:
|
| 67 |
+
from anthropic import ( # type: ignore[import-not-found]
|
| 68 |
+
Anthropic,
|
| 69 |
+
AnthropicError,
|
| 70 |
+
)
|
| 71 |
+
except ImportError as exc:
|
| 72 |
+
raise AdapterStepError(
|
| 73 |
+
"anthropic : SDK non installé (pip install 'cinoc[anthropic]')."
|
| 74 |
+
) from exc
|
| 75 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 76 |
+
timeout = deadline.as_sdk_timeout()
|
| 77 |
+
if timeout is not None:
|
| 78 |
+
client_kwargs["timeout"] = timeout
|
| 79 |
+
try:
|
| 80 |
+
# SDK anthropic typé strictement ; dicts valides à l'exécution (tests live).
|
| 81 |
+
client = Anthropic(**client_kwargs) # type: ignore[arg-type]
|
| 82 |
+
|
| 83 |
+
def _create() -> Any:
|
| 84 |
+
return client.messages.create(
|
| 85 |
+
model=model,
|
| 86 |
+
max_tokens=_MAX_TOKENS,
|
| 87 |
+
messages=[{"role": "user", "content": content}], # type: ignore[typeddict-item]
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Cap de concurrence (≤ N appels Anthropic simultanés) + retry borné sur
|
| 91 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 92 |
+
response = call_resilient("anthropic", _create)
|
| 93 |
+
except AnthropicError as exc:
|
| 94 |
+
raise AdapterStepError(
|
| 95 |
+
f"anthropic a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 96 |
+
) from exc
|
| 97 |
+
return _completion_from_message(response)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _invoke_anthropic( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 101 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 102 |
+
) -> LLMCompletion:
|
| 103 |
+
return _anthropic_client(model, prompt, deadline)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _invoke_anthropic_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 107 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 108 |
+
) -> LLMCompletion:
|
| 109 |
+
content = [
|
| 110 |
+
{"type": "text", "text": prompt},
|
| 111 |
+
{
|
| 112 |
+
"type": "image",
|
| 113 |
+
"source": {
|
| 114 |
+
"type": "base64",
|
| 115 |
+
"media_type": media_type,
|
| 116 |
+
"data": image_b64,
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
]
|
| 120 |
+
return _anthropic_client(model, content, deadline)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class AnthropicAdapter:
|
| 124 |
+
"""Adapter Anthropic multi-mode (post-correction texte/image, transcription)."""
|
| 125 |
+
|
| 126 |
+
#: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
|
| 127 |
+
#: code qui les rend possibles : la capacité vision est une closure passée à
|
| 128 |
+
#: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
|
| 129 |
+
#: dérive — c'est arrivé (D-233).
|
| 130 |
+
SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
|
| 131 |
+
{"text_only", "text_and_image", "zero_shot"}
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def __init__(
|
| 136 |
+
self,
|
| 137 |
+
*,
|
| 138 |
+
label: str,
|
| 139 |
+
model: str = _DEFAULT_MODEL,
|
| 140 |
+
role: str = "text_only",
|
| 141 |
+
prompt: str | None = None,
|
| 142 |
+
) -> None:
|
| 143 |
+
self._label = validate_llm_label(label, "AnthropicAdapter")
|
| 144 |
+
self._model = model
|
| 145 |
+
self._role: PipelineMode = validate_role(role, "AnthropicAdapter", _SUPPORTED)
|
| 146 |
+
self._prompt = (
|
| 147 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 148 |
+
)
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def name(self) -> str:
|
| 152 |
+
return f"anthropic:{self._label}"
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def version(self) -> str:
|
| 156 |
+
return _VERSION
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 160 |
+
return llm_input_types(self._role)
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 164 |
+
return frozenset({llm_output_type(self._role)})
|
| 165 |
+
|
| 166 |
+
def execute(
|
| 167 |
+
self,
|
| 168 |
+
inputs: dict[ArtifactType, Artifact],
|
| 169 |
+
params: dict[str, ParamValue],
|
| 170 |
+
context: RunContext,
|
| 171 |
+
control: RunControl,
|
| 172 |
+
) -> StepOutput:
|
| 173 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 174 |
+
return _invoke_anthropic(
|
| 175 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 176 |
+
)
|
| 177 |
+
|
| 178 |
+
def vision_invoke(
|
| 179 |
+
prompt: str, media_type: str, image_b64: str
|
| 180 |
+
) -> LLMCompletion:
|
| 181 |
+
return _invoke_anthropic_vision(
|
| 182 |
+
model=self._model,
|
| 183 |
+
prompt=prompt,
|
| 184 |
+
media_type=media_type,
|
| 185 |
+
image_b64=image_b64,
|
| 186 |
+
deadline=context.deadline,
|
| 187 |
+
)
|
| 188 |
+
|
| 189 |
+
return run_llm_step(
|
| 190 |
+
role=self._role,
|
| 191 |
+
label=self._label,
|
| 192 |
+
name=self.name,
|
| 193 |
+
prompt=self._prompt,
|
| 194 |
+
inputs=inputs,
|
| 195 |
+
context=context,
|
| 196 |
+
control=control,
|
| 197 |
+
text_invoke=text_invoke,
|
| 198 |
+
vision_invoke=vision_invoke,
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
__all__ = ["AnthropicAdapter"]
|
cinoc/adapters/llm/mistral.py
ADDED
|
@@ -0,0 +1,230 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralAdapter`` — post-correction LLM **et** transcription VLM (API Mistral).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
|
| 4 |
+
``text_only`` (texte → corrigé), ``text_and_image`` (Pixtral : image + texte →
|
| 5 |
+
corrigé), ``zero_shot`` (Pixtral : image → texte). Clé ``MISTRAL_API_KEY`` ; SDK
|
| 6 |
+
``mistralai`` = **extra** importé paresseusement dans ``_invoke_mistral`` /
|
| 7 |
+
``_invoke_mistral_vision`` (isolés → **mockables**, CI sans clé ni réseau).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Any, ClassVar
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
_VERSION = "1.0"
|
| 38 |
+
_DEFAULT_MODEL = "mistral-small-latest"
|
| 39 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _completion_from_chat(response: Any) -> LLMCompletion:
|
| 43 |
+
"""Réponse SDK ``chat.complete`` → ``LLMCompletion`` (texte + jetons).
|
| 44 |
+
|
| 45 |
+
**Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
|
| 46 |
+
(``prompt_tokens``/``completion_tokens``) est **testable sur cassette** (étape
|
| 47 |
+
2d) ; le client live (``# pragma: no cover``) ne le permettrait pas. Jetons →
|
| 48 |
+
économie (coût cloud réel).
|
| 49 |
+
"""
|
| 50 |
+
usage = getattr(response, "usage", None)
|
| 51 |
+
tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
|
| 52 |
+
tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
|
| 53 |
+
choices = getattr(response, "choices", None)
|
| 54 |
+
if not choices:
|
| 55 |
+
return LLMCompletion("", tokens_in, tokens_out)
|
| 56 |
+
return LLMCompletion(
|
| 57 |
+
normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _mistral_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 62 |
+
model: str, content: object, deadline: Deadline
|
| 63 |
+
) -> LLMCompletion:
|
| 64 |
+
"""Appel ``chat.complete`` partagé (texte ou multimodal)."""
|
| 65 |
+
import os
|
| 66 |
+
|
| 67 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 68 |
+
if not api_key:
|
| 69 |
+
raise AdapterStepError("mistral : MISTRAL_API_KEY manquante.")
|
| 70 |
+
try:
|
| 71 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 72 |
+
except ImportError as exc:
|
| 73 |
+
raise AdapterStepError(
|
| 74 |
+
"mistral : SDK non installé (pip install 'cinoc[mistral]')."
|
| 75 |
+
) from exc
|
| 76 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 77 |
+
timeout = deadline.as_sdk_timeout()
|
| 78 |
+
if timeout is not None:
|
| 79 |
+
client_kwargs["timeout_ms"] = int(timeout * 1000)
|
| 80 |
+
try:
|
| 81 |
+
# Le SDK mistralai 1.x type strictement ses kwargs/messages ; on lui
|
| 82 |
+
# passe des dicts valides à l'exécution (cf. tests live). ``ignore`` ciblé.
|
| 83 |
+
client = Mistral(**client_kwargs) # type: ignore[arg-type]
|
| 84 |
+
|
| 85 |
+
def _complete() -> Any:
|
| 86 |
+
return client.chat.complete(
|
| 87 |
+
model=model,
|
| 88 |
+
messages=[{"role": "user", "content": content}], # type: ignore[arg-type]
|
| 89 |
+
temperature=0.0,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Cap de concurrence (≤ N appels Mistral simultanés) + retry borné sur
|
| 93 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 94 |
+
response = call_resilient("mistral", _complete)
|
| 95 |
+
except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
|
| 96 |
+
raise AdapterStepError(
|
| 97 |
+
f"mistral a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 98 |
+
) from exc
|
| 99 |
+
return _completion_from_chat(response)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _invoke_mistral( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 103 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 104 |
+
) -> LLMCompletion:
|
| 105 |
+
return _mistral_client(model, prompt, deadline)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _invoke_mistral_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 109 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 110 |
+
) -> LLMCompletion:
|
| 111 |
+
content = [
|
| 112 |
+
{"type": "text", "text": prompt},
|
| 113 |
+
{"type": "image_url", "image_url": f"data:{media_type};base64,{image_b64}"},
|
| 114 |
+
]
|
| 115 |
+
return _mistral_client(model, content, deadline)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class MistralAdapter:
|
| 119 |
+
"""Adapter Mistral multi-mode (post-correction texte/image, transcription VLM)."""
|
| 120 |
+
|
| 121 |
+
#: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
|
| 122 |
+
#: code qui les rend possibles : la capacité vision est une closure passée à
|
| 123 |
+
#: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
|
| 124 |
+
#: dérive — c'est arrivé (D-233).
|
| 125 |
+
SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
|
| 126 |
+
{"text_only", "text_and_image", "zero_shot"}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def __init__(
|
| 131 |
+
self,
|
| 132 |
+
*,
|
| 133 |
+
label: str,
|
| 134 |
+
model: str = _DEFAULT_MODEL,
|
| 135 |
+
role: str = "text_only",
|
| 136 |
+
prompt: str | None = None,
|
| 137 |
+
) -> None:
|
| 138 |
+
self._label = validate_llm_label(label, "MistralAdapter")
|
| 139 |
+
self._model = model
|
| 140 |
+
self._role: PipelineMode = validate_role(role, "MistralAdapter", _SUPPORTED)
|
| 141 |
+
self._prompt = (
|
| 142 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def name(self) -> str:
|
| 147 |
+
return f"mistral:{self._label}"
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def version(self) -> str:
|
| 151 |
+
return _VERSION
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 155 |
+
return llm_input_types(self._role)
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 159 |
+
return frozenset({llm_output_type(self._role)})
|
| 160 |
+
|
| 161 |
+
def execute(
|
| 162 |
+
self,
|
| 163 |
+
inputs: dict[ArtifactType, Artifact],
|
| 164 |
+
params: dict[str, ParamValue],
|
| 165 |
+
context: RunContext,
|
| 166 |
+
control: RunControl,
|
| 167 |
+
) -> StepOutput:
|
| 168 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 169 |
+
return _invoke_mistral(
|
| 170 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
def vision_invoke(
|
| 174 |
+
prompt: str, media_type: str, image_b64: str
|
| 175 |
+
) -> LLMCompletion:
|
| 176 |
+
return _invoke_mistral_vision(
|
| 177 |
+
model=self._model,
|
| 178 |
+
prompt=prompt,
|
| 179 |
+
media_type=media_type,
|
| 180 |
+
image_b64=image_b64,
|
| 181 |
+
deadline=context.deadline,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return run_llm_step(
|
| 185 |
+
role=self._role,
|
| 186 |
+
label=self._label,
|
| 187 |
+
name=self.name,
|
| 188 |
+
prompt=self._prompt,
|
| 189 |
+
inputs=inputs,
|
| 190 |
+
context=context,
|
| 191 |
+
control=control,
|
| 192 |
+
text_invoke=text_invoke,
|
| 193 |
+
vision_invoke=vision_invoke,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
def list_mistral_models(*, timeout: float = 5.0) -> tuple[str, ...]:
|
| 198 |
+
"""IDs des modèles disponibles pour la clé Mistral courante (``models.list``).
|
| 199 |
+
|
| 200 |
+
**Best-effort, commodité d'UI** : ``MISTRAL_API_KEY`` absente, SDK absent ou
|
| 201 |
+
erreur réseau → ``()`` (l'interface retombe sur la saisie libre). Rien de
|
| 202 |
+
hardcodé : la liste vient **directement de l'API Mistral**.
|
| 203 |
+
"""
|
| 204 |
+
import os
|
| 205 |
+
|
| 206 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 207 |
+
if not api_key:
|
| 208 |
+
return ()
|
| 209 |
+
try:
|
| 210 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 211 |
+
except ImportError:
|
| 212 |
+
return ()
|
| 213 |
+
try:
|
| 214 |
+
client = Mistral(api_key=api_key, timeout_ms=int(timeout * 1000))
|
| 215 |
+
response = client.models.list()
|
| 216 |
+
except Exception as exc: # noqa: BLE001 — voir rationale ci-dessous
|
| 217 |
+
# Catch-all **assumé** : commodité d'UI qui ne doit jamais casser la
|
| 218 |
+
# page du composeur. Le SDK mistralai **enveloppe** ses erreurs HTTP
|
| 219 |
+
# (auth/429/5xx) dans ses propres types + propage des erreurs httpx pour
|
| 220 |
+
# le transport ; narrower au seul ``httpx.HTTPError`` (comme ollama, qui
|
| 221 |
+
# appelle httpx en direct) laisserait fuir une erreur SDK et casserait la
|
| 222 |
+
# page. On trace (§7) et on retombe sur la saisie libre.
|
| 223 |
+
logger.warning("[mistral] liste des modèles indisponible : %s", exc)
|
| 224 |
+
return ()
|
| 225 |
+
data = getattr(response, "data", None) or ()
|
| 226 |
+
ids = [m.id for m in data if isinstance(getattr(m, "id", None), str)]
|
| 227 |
+
return tuple(sorted(set(ids)))
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
__all__ = ["MistralAdapter", "list_mistral_models"]
|
cinoc/adapters/llm/ollama.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OllamaAdapter`` — post-correction LLM via un serveur ollama local.
|
| 2 |
+
|
| 3 |
+
Deuxième famille LLM : prouve que le ``Module`` Protocol **généralise au-delà
|
| 4 |
+
d'openai** — même contrat ``RAW_TEXT`` → ``CORRECTED_TEXT``, sortie identique,
|
| 5 |
+
aucun cas particulier. Transport ``httpx`` (extra), importé paresseusement et
|
| 6 |
+
**isolé** dans ``_invoke_ollama`` (→ mockable, CI sans serveur).
|
| 7 |
+
|
| 8 |
+
C'est l'**implémentation de référence de l'annulation câblée** : ``_invoke_ollama``
|
| 9 |
+
enregistre ``client.close`` via ``RunControl.register_cancel_handle`` ; un
|
| 10 |
+
``trigger_cancel`` ferme la connexion et **interrompt la requête en vol**. La
|
| 11 |
+
distinction « annulation vs vraie panne réseau » se fait en sondant
|
| 12 |
+
``is_cancelled`` (``_fail_or_cancel``) — pas par une heuristique de message
|
| 13 |
+
fragile (dette de l'implémentation source, corrigée au portage).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import ClassVar, NoReturn
|
| 19 |
+
|
| 20 |
+
from cinoc.adapters.llm._base import (
|
| 21 |
+
LLMCompletion,
|
| 22 |
+
default_prompt_for_role,
|
| 23 |
+
llm_input_types,
|
| 24 |
+
llm_output_type,
|
| 25 |
+
normalize_llm_content,
|
| 26 |
+
run_llm_step,
|
| 27 |
+
usage_tokens,
|
| 28 |
+
validate_llm_label,
|
| 29 |
+
validate_role,
|
| 30 |
+
)
|
| 31 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 32 |
+
from cinoc.domain.deadline import Deadline
|
| 33 |
+
from cinoc.domain.errors import AdapterStepError
|
| 34 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 35 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 36 |
+
from cinoc.pipeline.run_control import RunControl
|
| 37 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 38 |
+
|
| 39 |
+
_VERSION = "1.0"
|
| 40 |
+
_DEFAULT_MODEL = "llama3"
|
| 41 |
+
#: Les trois modes, comme les fournisseurs distants. ``zero_shot`` a un sens
|
| 42 |
+
#: particulier ici : un modèle d'OCR local (churro, par exemple) transcrit
|
| 43 |
+
#: l'image sans OCR amont.
|
| 44 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 45 |
+
|
| 46 |
+
_DEFAULT_HOST = "http://localhost:11434"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _fail_or_cancel(control: RunControl, model: str, exc: Exception) -> NoReturn:
|
| 50 |
+
"""Traduit un échec d'appel ollama.
|
| 51 |
+
|
| 52 |
+
Si l'annulation a été déclenchée, le ``client.close`` enregistré a fait
|
| 53 |
+
échouer la requête en vol : on lève alors ``RunCancelledError`` (via
|
| 54 |
+
``raise_if_cancelled``). Sinon c'est une vraie panne → ``AdapterStepError``.
|
| 55 |
+
Sonder ``is_cancelled`` est **fiable**, là où l'implémentation source
|
| 56 |
+
devinait par le message d'exception (heuristique fragile, D-A).
|
| 57 |
+
"""
|
| 58 |
+
control.raise_if_cancelled()
|
| 59 |
+
raise AdapterStepError(f"ollama a échoué ({model}) : {exc}") from exc
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _invoke_ollama( # pragma: no cover -- réseau (serveur ollama ; cf. marqueur 'live')
|
| 63 |
+
*,
|
| 64 |
+
model: str,
|
| 65 |
+
prompt: str,
|
| 66 |
+
host: str,
|
| 67 |
+
deadline: Deadline,
|
| 68 |
+
control: RunControl,
|
| 69 |
+
image_b64: str | None = None,
|
| 70 |
+
) -> LLMCompletion:
|
| 71 |
+
try:
|
| 72 |
+
import httpx # type: ignore[import-not-found]
|
| 73 |
+
except ImportError as exc:
|
| 74 |
+
raise AdapterStepError(
|
| 75 |
+
"ollama : httpx non installé (pip install 'cinoc[ollama]')."
|
| 76 |
+
) from exc
|
| 77 |
+
# L'image voyage dans un tableau ``images`` **du message**, en base64 nu —
|
| 78 |
+
# pas d'URI ``data:``, pas de type de média : ollama ne suit pas la
|
| 79 |
+
# convention d'OpenAI ici, et un ``data:`` passé tel quel est ignoré en
|
| 80 |
+
# silence (le modèle répond alors sans avoir rien vu).
|
| 81 |
+
demande: dict[str, object] = {"role": "user", "content": prompt}
|
| 82 |
+
if image_b64 is not None:
|
| 83 |
+
demande["images"] = [image_b64]
|
| 84 |
+
payload = {"model": model, "messages": [demande], "stream": False}
|
| 85 |
+
client = httpx.Client(timeout=deadline.as_sdk_timeout())
|
| 86 |
+
# Annulation câblée : fermer le client interrompt la requête en vol.
|
| 87 |
+
control.register_cancel_handle(client.close)
|
| 88 |
+
try:
|
| 89 |
+
# **/api/chat** (et non /api/generate) : applique le gabarit de chat du
|
| 90 |
+
# modèle. Indispensable pour les modèles *instruct* (ex. churro), qui
|
| 91 |
+
# échouent sur /api/generate (complétion brute). C'est l'endpoint
|
| 92 |
+
# qu'utilise ``ollama run``.
|
| 93 |
+
response = client.post(f"{host}/api/chat", json=payload)
|
| 94 |
+
response.raise_for_status()
|
| 95 |
+
data = response.json()
|
| 96 |
+
except httpx.HTTPError as exc:
|
| 97 |
+
_fail_or_cancel(control, model, exc)
|
| 98 |
+
finally:
|
| 99 |
+
client.close()
|
| 100 |
+
# Ollama peut renvoyer une **erreur en HTTP 200** (modèle introuvable sous ce
|
| 101 |
+
# nom, mémoire insuffisante…) : ``raise_for_status`` ne la voit pas. On la
|
| 102 |
+
# remonte explicitement, sinon la « correction » serait un texte vide silencieux.
|
| 103 |
+
if isinstance(data, dict) and data.get("error"):
|
| 104 |
+
raise AdapterStepError(f"ollama a échoué ({model}) : {data['error']}")
|
| 105 |
+
message = data.get("message") if isinstance(data, dict) else None
|
| 106 |
+
content = message.get("content") if isinstance(message, dict) else None
|
| 107 |
+
# /api/chat expose la consommation : prompt_eval_count / eval_count.
|
| 108 |
+
counts = data if isinstance(data, dict) else {}
|
| 109 |
+
return LLMCompletion(
|
| 110 |
+
normalize_llm_content(content),
|
| 111 |
+
usage_tokens(counts.get("prompt_eval_count")),
|
| 112 |
+
usage_tokens(counts.get("eval_count")),
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def list_installed_models(
|
| 117 |
+
host: str = _DEFAULT_HOST, *, timeout: float = 3.0
|
| 118 |
+
) -> tuple[str, ...]:
|
| 119 |
+
"""Noms des modèles installés sur le serveur ollama (``GET /api/tags``).
|
| 120 |
+
|
| 121 |
+
**Best-effort, commodité d'UI** : serveur injoignable, ``httpx`` absent ou
|
| 122 |
+
réponse inattendue → ``()`` (l'interface retombe alors sur la saisie libre).
|
| 123 |
+
Aucune exception propagée. Le ``name`` renvoyé est le **tag exact** servable à
|
| 124 |
+
``/api/generate`` (ex. ``gemma3:1b`` ou ``hf.co/…/churro-3B-GGUF:Q4_K_M``).
|
| 125 |
+
"""
|
| 126 |
+
try:
|
| 127 |
+
import httpx # type: ignore[import-not-found]
|
| 128 |
+
except ImportError:
|
| 129 |
+
return ()
|
| 130 |
+
try:
|
| 131 |
+
with httpx.Client(timeout=timeout) as client:
|
| 132 |
+
response = client.get(f"{host}/api/tags")
|
| 133 |
+
response.raise_for_status()
|
| 134 |
+
data = response.json()
|
| 135 |
+
except (httpx.HTTPError, ValueError):
|
| 136 |
+
return ()
|
| 137 |
+
models = data.get("models") if isinstance(data, dict) else None
|
| 138 |
+
if not isinstance(models, list):
|
| 139 |
+
return ()
|
| 140 |
+
return tuple(
|
| 141 |
+
m["name"]
|
| 142 |
+
for m in models
|
| 143 |
+
if isinstance(m, dict) and isinstance(m.get("name"), str)
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class OllamaAdapter:
|
| 148 |
+
"""LLM/VLM local (ollama), trois modes comme les fournisseurs distants.
|
| 149 |
+
|
| 150 |
+
L'adapter était ``text_only`` : un modèle vision installé en local ne
|
| 151 |
+
pouvait pas concourir au banc, alors que des poids figés sont **plus**
|
| 152 |
+
reproductibles qu'un instantané d'API susceptible d'être déprécié.
|
| 153 |
+
"""
|
| 154 |
+
|
| 155 |
+
#: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
|
| 156 |
+
#: code qui les rend possibles : la capacité vision est une closure passée à
|
| 157 |
+
#: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
|
| 158 |
+
#: dérive — c'est arrivé (D-233).
|
| 159 |
+
SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
|
| 160 |
+
{"text_only", "text_and_image", "zero_shot"}
|
| 161 |
+
)
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
def __init__(
|
| 165 |
+
self,
|
| 166 |
+
*,
|
| 167 |
+
label: str,
|
| 168 |
+
model: str = _DEFAULT_MODEL,
|
| 169 |
+
host: str = _DEFAULT_HOST,
|
| 170 |
+
role: str = "text_only",
|
| 171 |
+
prompt: str | None = None,
|
| 172 |
+
) -> None:
|
| 173 |
+
self._label = validate_llm_label(label, "OllamaAdapter")
|
| 174 |
+
self._model = model
|
| 175 |
+
self._host = host
|
| 176 |
+
self._role: PipelineMode = validate_role(role, "OllamaAdapter", _SUPPORTED)
|
| 177 |
+
self._prompt = (
|
| 178 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 179 |
+
)
|
| 180 |
+
|
| 181 |
+
@property
|
| 182 |
+
def name(self) -> str:
|
| 183 |
+
return f"ollama:{self._label}"
|
| 184 |
+
|
| 185 |
+
@property
|
| 186 |
+
def version(self) -> str:
|
| 187 |
+
return _VERSION
|
| 188 |
+
|
| 189 |
+
@property
|
| 190 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 191 |
+
return llm_input_types(self._role)
|
| 192 |
+
|
| 193 |
+
@property
|
| 194 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 195 |
+
return frozenset({llm_output_type(self._role)})
|
| 196 |
+
|
| 197 |
+
def execute(
|
| 198 |
+
self,
|
| 199 |
+
inputs: dict[ArtifactType, Artifact],
|
| 200 |
+
params: dict[str, ParamValue],
|
| 201 |
+
context: RunContext,
|
| 202 |
+
control: RunControl,
|
| 203 |
+
) -> StepOutput:
|
| 204 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 205 |
+
return _invoke_ollama(
|
| 206 |
+
model=self._model,
|
| 207 |
+
prompt=prompt,
|
| 208 |
+
host=self._host,
|
| 209 |
+
deadline=context.deadline,
|
| 210 |
+
control=control,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
def vision_invoke(
|
| 214 |
+
prompt: str, media_type: str, image_b64: str
|
| 215 |
+
) -> LLMCompletion:
|
| 216 |
+
# ``media_type`` est ignoré : ollama devine le format des octets.
|
| 217 |
+
del media_type
|
| 218 |
+
return _invoke_ollama(
|
| 219 |
+
model=self._model,
|
| 220 |
+
prompt=prompt,
|
| 221 |
+
host=self._host,
|
| 222 |
+
deadline=context.deadline,
|
| 223 |
+
control=control,
|
| 224 |
+
image_b64=image_b64,
|
| 225 |
+
)
|
| 226 |
+
|
| 227 |
+
return run_llm_step(
|
| 228 |
+
role=self._role,
|
| 229 |
+
label=self._label,
|
| 230 |
+
name=self.name,
|
| 231 |
+
prompt=self._prompt,
|
| 232 |
+
inputs=inputs,
|
| 233 |
+
context=context,
|
| 234 |
+
control=control,
|
| 235 |
+
text_invoke=text_invoke,
|
| 236 |
+
vision_invoke=vision_invoke,
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
__all__ = ["OllamaAdapter"]
|
cinoc/adapters/llm/ollama_structured.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OllamaStructuredClient`` — sortie JSON contrainte, serveur local.
|
| 2 |
+
|
| 3 |
+
``saknussemm`` ne consomme qu'une seule capacité d'un modèle
|
| 4 |
+
(``StructuredCompletionClient``) : rendre le JSON décrit par un schéma. Ce
|
| 5 |
+
client la fournit depuis un ``ollama`` local — aucune clé d'API, poids figés,
|
| 6 |
+
donc plus reproductible qu'un instantané d'API qui peut être déprécié.
|
| 7 |
+
|
| 8 |
+
Deux détails qui ne sont pas des détails :
|
| 9 |
+
|
| 10 |
+
* ``num_ctx`` est **explicite**. Le défaut d'``ollama`` est 2048 jetons et la
|
| 11 |
+
troncature est **silencieuse** : le JSON revient coupé au milieu d'une chaîne,
|
| 12 |
+
et l'erreur ressemble à une hallucination du modèle alors que c'est le serveur
|
| 13 |
+
qui a coupé.
|
| 14 |
+
* les échecs de transport sont traduits en ``ProviderTransientError`` /
|
| 15 |
+
``ProviderPermanentError``. La recouvrabilité de ``saknussemm`` est une liste
|
| 16 |
+
blanche : une exception brute est traitée comme un bug et **fait échouer** le
|
| 17 |
+
run au lieu d'être réessayée en boucle.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import json
|
| 23 |
+
from typing import Any
|
| 24 |
+
|
| 25 |
+
_DEFAULT_HOST = "http://localhost:11434"
|
| 26 |
+
#: Le défaut d'ollama (2048) tronque en silence une page de journal.
|
| 27 |
+
_DEFAULT_NUM_CTX = 8192
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class OllamaStructuredClient:
|
| 31 |
+
"""``complete_structured`` sur ``/api/chat``, schéma JSON contraint."""
|
| 32 |
+
|
| 33 |
+
def __init__(
|
| 34 |
+
self,
|
| 35 |
+
*,
|
| 36 |
+
host: str = _DEFAULT_HOST,
|
| 37 |
+
num_ctx: int = _DEFAULT_NUM_CTX,
|
| 38 |
+
timeout: float = 300.0,
|
| 39 |
+
) -> None:
|
| 40 |
+
self._host = host.rstrip("/")
|
| 41 |
+
self._num_ctx = num_ctx
|
| 42 |
+
self._timeout = timeout
|
| 43 |
+
|
| 44 |
+
async def complete_structured(
|
| 45 |
+
self,
|
| 46 |
+
api_key: str, # noqa: ARG002 — un serveur local n'en a pas
|
| 47 |
+
model: str,
|
| 48 |
+
system_prompt: str,
|
| 49 |
+
user_payload: dict[str, Any],
|
| 50 |
+
json_schema: dict[str, Any],
|
| 51 |
+
temperature: float = 0.0,
|
| 52 |
+
) -> tuple[dict[str, Any], Any]:
|
| 53 |
+
import httpx # noqa: PLC0415
|
| 54 |
+
|
| 55 |
+
# Les classes concrètes vivent en ``core.protocols``, à côté du contrat
|
| 56 |
+
# qui les lève — ``errors`` n'ancre que leur base ``ProviderError``.
|
| 57 |
+
from saknussemm.core.protocols import ( # type: ignore[import-not-found] # noqa: PLC0415
|
| 58 |
+
ProviderPermanentError,
|
| 59 |
+
ProviderTransientError,
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
body = {
|
| 63 |
+
"model": model,
|
| 64 |
+
"messages": [
|
| 65 |
+
{"role": "system", "content": system_prompt},
|
| 66 |
+
{
|
| 67 |
+
"role": "user",
|
| 68 |
+
"content": json.dumps(user_payload, ensure_ascii=False),
|
| 69 |
+
},
|
| 70 |
+
],
|
| 71 |
+
"stream": False,
|
| 72 |
+
"format": _bare_schema(json_schema),
|
| 73 |
+
"options": {"temperature": temperature, "num_ctx": self._num_ctx},
|
| 74 |
+
}
|
| 75 |
+
try:
|
| 76 |
+
async with httpx.AsyncClient(timeout=self._timeout) as client:
|
| 77 |
+
response = await client.post(f"{self._host}/api/chat", json=body)
|
| 78 |
+
response.raise_for_status()
|
| 79 |
+
data = response.json()
|
| 80 |
+
except httpx.HTTPStatusError as exc:
|
| 81 |
+
status = exc.response.status_code
|
| 82 |
+
message = f"ollama a répondu {status} ({model})"
|
| 83 |
+
# 4xx = la requête est fautive, la réessayer la referait échouer.
|
| 84 |
+
if 400 <= status < 500:
|
| 85 |
+
raise ProviderPermanentError(message) from exc
|
| 86 |
+
raise ProviderTransientError(message) from exc
|
| 87 |
+
except (httpx.TimeoutException, httpx.TransportError) as exc:
|
| 88 |
+
raise ProviderTransientError(
|
| 89 |
+
f"ollama injoignable ({model}) : {exc}"
|
| 90 |
+
) from exc
|
| 91 |
+
|
| 92 |
+
content = (data.get("message") or {}).get("content") or ""
|
| 93 |
+
try:
|
| 94 |
+
parsed = json.loads(content)
|
| 95 |
+
except json.JSONDecodeError as exc:
|
| 96 |
+
raise ProviderTransientError(
|
| 97 |
+
f"ollama ({model}) n'a pas rendu du JSON — réponse tronquée ou "
|
| 98 |
+
f"hors schéma : {content[:200]!r}"
|
| 99 |
+
) from exc
|
| 100 |
+
if not isinstance(parsed, dict):
|
| 101 |
+
raise ProviderTransientError(
|
| 102 |
+
f"ollama ({model}) a rendu {type(parsed).__name__}, pas un objet."
|
| 103 |
+
)
|
| 104 |
+
return parsed, None
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
def _bare_schema(json_schema: dict[str, Any]) -> dict[str, Any]:
|
| 108 |
+
"""``format`` d'ollama attend le schéma **nu**, pas l'enveloppe OpenAI."""
|
| 109 |
+
direct = json_schema.get("schema")
|
| 110 |
+
if isinstance(direct, dict):
|
| 111 |
+
return direct
|
| 112 |
+
inner = json_schema.get("json_schema")
|
| 113 |
+
if isinstance(inner, dict):
|
| 114 |
+
nested = inner.get("schema")
|
| 115 |
+
if isinstance(nested, dict):
|
| 116 |
+
return nested
|
| 117 |
+
return json_schema
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
__all__ = ["OllamaStructuredClient"]
|
cinoc/adapters/llm/openai.py
ADDED
|
@@ -0,0 +1,197 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OpenAIAdapter`` — post-correction LLM **et** transcription VLM (API OpenAI).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol. Trois modes (``PipelineMode``) selon le rôle
|
| 4 |
+
passé à la construction : ``text_only`` (``RAW_TEXT`` → ``CORRECTED_TEXT``),
|
| 5 |
+
``text_and_image`` (image + texte → corrigé), ``zero_shot`` (image → texte). La
|
| 6 |
+
clé vient de ``OPENAI_API_KEY`` ; le SDK ``openai`` est un **extra** importé
|
| 7 |
+
paresseusement dans ``_invoke_openai`` / ``_invoke_openai_vision`` (isolés →
|
| 8 |
+
**mockables**, CI sans clé ni réseau). La ``Deadline`` borne l'appel.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any, ClassVar
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
_DEFAULT_MODEL = "gpt-4o-mini"
|
| 37 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _completion_from_chat(response: Any) -> LLMCompletion:
|
| 41 |
+
"""Réponse SDK ``chat.completions`` → ``LLMCompletion`` (texte + jetons).
|
| 42 |
+
|
| 43 |
+
**Pur** (aucun réseau, aucun import SDK) → la cartographie usage→jetons
|
| 44 |
+
(``prompt_tokens``/``completion_tokens``) est **testable sur une réponse de
|
| 45 |
+
cassette**, ce que le client live (``# pragma: no cover``) ne permet pas. Les
|
| 46 |
+
jetons alimentent l'économie (coût réel par modèle).
|
| 47 |
+
"""
|
| 48 |
+
usage = getattr(response, "usage", None)
|
| 49 |
+
tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
|
| 50 |
+
tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
|
| 51 |
+
choices = getattr(response, "choices", None)
|
| 52 |
+
if not choices:
|
| 53 |
+
return LLMCompletion("", tokens_in, tokens_out)
|
| 54 |
+
return LLMCompletion(
|
| 55 |
+
normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _openai_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 60 |
+
model: str, messages: list[dict[str, object]], deadline: Deadline
|
| 61 |
+
) -> LLMCompletion:
|
| 62 |
+
"""Appel ``chat.completions`` partagé (texte ou multimodal)."""
|
| 63 |
+
import os
|
| 64 |
+
|
| 65 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 66 |
+
if not api_key:
|
| 67 |
+
raise AdapterStepError("openai : OPENAI_API_KEY manquante.")
|
| 68 |
+
try:
|
| 69 |
+
from openai import OpenAI, OpenAIError # type: ignore[import-not-found]
|
| 70 |
+
except ImportError as exc:
|
| 71 |
+
raise AdapterStepError(
|
| 72 |
+
"openai : SDK non installé (pip install 'cinoc[openai]')."
|
| 73 |
+
) from exc
|
| 74 |
+
kwargs: dict[str, object] = {
|
| 75 |
+
"model": model,
|
| 76 |
+
"messages": messages,
|
| 77 |
+
"temperature": 0.0,
|
| 78 |
+
}
|
| 79 |
+
timeout = deadline.as_sdk_timeout()
|
| 80 |
+
if timeout is not None:
|
| 81 |
+
kwargs["timeout"] = timeout
|
| 82 |
+
try:
|
| 83 |
+
# SDK openai typé strictement ; kwargs valides à l'exécution (tests live).
|
| 84 |
+
client = OpenAI(api_key=api_key)
|
| 85 |
+
|
| 86 |
+
def _create() -> Any:
|
| 87 |
+
return client.chat.completions.create(**kwargs) # type: ignore[call-overload]
|
| 88 |
+
|
| 89 |
+
# Cap de concurrence (≤ N appels OpenAI simultanés) + retry borné sur
|
| 90 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 91 |
+
response = call_resilient("openai", _create)
|
| 92 |
+
except OpenAIError as exc:
|
| 93 |
+
raise AdapterStepError(
|
| 94 |
+
f"openai a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 95 |
+
) from exc
|
| 96 |
+
return _completion_from_chat(response)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _invoke_openai( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 100 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 101 |
+
) -> LLMCompletion:
|
| 102 |
+
return _openai_client(model, [{"role": "user", "content": prompt}], deadline)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _invoke_openai_vision( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 106 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 107 |
+
) -> LLMCompletion:
|
| 108 |
+
content = [
|
| 109 |
+
{"type": "text", "text": prompt},
|
| 110 |
+
{
|
| 111 |
+
"type": "image_url",
|
| 112 |
+
"image_url": {"url": f"data:{media_type};base64,{image_b64}"},
|
| 113 |
+
},
|
| 114 |
+
]
|
| 115 |
+
return _openai_client(model, [{"role": "user", "content": content}], deadline)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class OpenAIAdapter:
|
| 119 |
+
"""Adapter OpenAI multi-mode (post-correction texte/image, transcription VLM)."""
|
| 120 |
+
|
| 121 |
+
#: Modes que cet adapter implémente **réellement**. Déclaré ici, à côté du
|
| 122 |
+
#: code qui les rend possibles : la capacité vision est une closure passée à
|
| 123 |
+
#: ``run_llm_step``, invérifiable de l'extérieur. Une liste tenue ailleurs
|
| 124 |
+
#: dérive — c'est arrivé (D-233).
|
| 125 |
+
SUPPORTED_MODES: ClassVar[frozenset[PipelineMode]] = frozenset(
|
| 126 |
+
{"text_only", "text_and_image", "zero_shot"}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def __init__(
|
| 131 |
+
self,
|
| 132 |
+
*,
|
| 133 |
+
label: str,
|
| 134 |
+
model: str = _DEFAULT_MODEL,
|
| 135 |
+
role: str = "text_only",
|
| 136 |
+
prompt: str | None = None,
|
| 137 |
+
) -> None:
|
| 138 |
+
self._label = validate_llm_label(label, "OpenAIAdapter")
|
| 139 |
+
self._model = model
|
| 140 |
+
self._role: PipelineMode = validate_role(role, "OpenAIAdapter", _SUPPORTED)
|
| 141 |
+
self._prompt = (
|
| 142 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def name(self) -> str:
|
| 147 |
+
return f"openai:{self._label}"
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def version(self) -> str:
|
| 151 |
+
return _VERSION
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 155 |
+
return llm_input_types(self._role)
|
| 156 |
+
|
| 157 |
+
@property
|
| 158 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 159 |
+
return frozenset({llm_output_type(self._role)})
|
| 160 |
+
|
| 161 |
+
def execute(
|
| 162 |
+
self,
|
| 163 |
+
inputs: dict[ArtifactType, Artifact],
|
| 164 |
+
params: dict[str, ParamValue],
|
| 165 |
+
context: RunContext,
|
| 166 |
+
control: RunControl,
|
| 167 |
+
) -> StepOutput:
|
| 168 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 169 |
+
return _invoke_openai(
|
| 170 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
def vision_invoke(
|
| 174 |
+
prompt: str, media_type: str, image_b64: str
|
| 175 |
+
) -> LLMCompletion:
|
| 176 |
+
return _invoke_openai_vision(
|
| 177 |
+
model=self._model,
|
| 178 |
+
prompt=prompt,
|
| 179 |
+
media_type=media_type,
|
| 180 |
+
image_b64=image_b64,
|
| 181 |
+
deadline=context.deadline,
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
return run_llm_step(
|
| 185 |
+
role=self._role,
|
| 186 |
+
label=self._label,
|
| 187 |
+
name=self.name,
|
| 188 |
+
prompt=self._prompt,
|
| 189 |
+
inputs=inputs,
|
| 190 |
+
context=context,
|
| 191 |
+
control=control,
|
| 192 |
+
text_invoke=text_invoke,
|
| 193 |
+
vision_invoke=vision_invoke,
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
__all__ = ["OpenAIAdapter"]
|
cinoc/adapters/ner/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Adapters NER : extraction d'entités nommées (couche 5)."""
|
cinoc/adapters/ner/spacy_extractor.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``SpacyNerExtractor`` — extraction d'entités nommées via spaCy (couche 5).
|
| 2 |
+
|
| 3 |
+
Brique de pipeline ``RAW_TEXT|CORRECTED_TEXT → ENTITIES`` (la NER est une étape
|
| 4 |
+
explicite, CLAUDE.md §3). Implémente le ``Module`` Protocol **directement**. Le
|
| 5 |
+
SDK ``spacy`` + le **modèle** sont un extra optionnel (``cinoc[ner]`` + ``python
|
| 6 |
+
-m spacy download <modèle>``), importés paresseusement.
|
| 7 |
+
|
| 8 |
+
**Anti-silence (réparation R14 amont, bug du modèle source).** Si spaCy ou le
|
| 9 |
+
modèle manque, l'adapter **lève** ``AdapterStepError`` avec le message
|
| 10 |
+
d'installation — il ne renvoie **jamais** ``[]`` (le défaut de la source : un
|
| 11 |
+
benchmark qui « réussit » avec un rappel 0 sans message). L'orchestrateur isole
|
| 12 |
+
alors ce (concurrent × document) ; le document reste simplement non scoré.
|
| 13 |
+
|
| 14 |
+
L'artefact ``ENTITIES`` embarque **le texte d'entrée** (``{"text", "entities"}``)
|
| 15 |
+
— c'est lui qui rend la reprojection R14 auto-suffisante côté évaluation.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 25 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 26 |
+
from cinoc.domain.errors import AdapterStepError
|
| 27 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 28 |
+
from cinoc.pipeline.run_control import RunControl
|
| 29 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
_VERSION = "1.0"
|
| 34 |
+
|
| 35 |
+
#: Normalisation des labels spaCy vers les conventions courtes HIPE/CoNLL.
|
| 36 |
+
_DEFAULT_LABEL_MAPPING: dict[str, str] = {
|
| 37 |
+
"PERSON": "PER",
|
| 38 |
+
"PER": "PER",
|
| 39 |
+
"LOC": "LOC",
|
| 40 |
+
"GPE": "LOC",
|
| 41 |
+
"ORG": "ORG",
|
| 42 |
+
"DATE": "DATE",
|
| 43 |
+
"TIME": "DATE",
|
| 44 |
+
"MISC": "MISC",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#: Texte d'entrée accepté : sortie OCR brute **ou** corrigée par un LLM.
|
| 48 |
+
_INPUT_TYPES = frozenset({ArtifactType.RAW_TEXT, ArtifactType.CORRECTED_TEXT})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class SpacyNerExtractor:
|
| 52 |
+
"""NER spaCy : ``RAW_TEXT|CORRECTED_TEXT`` → ``ENTITIES`` (fail-closed)."""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
*,
|
| 57 |
+
label: str,
|
| 58 |
+
model: str = "fr_core_news_sm",
|
| 59 |
+
loader: Any | None = None,
|
| 60 |
+
) -> None:
|
| 61 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 62 |
+
raise AdapterStepError(f"ner : label invalide {label!r}.")
|
| 63 |
+
self._label = label
|
| 64 |
+
self._model = model
|
| 65 |
+
#: Injectable en test : ``(model_name) -> nlp`` ; défaut = ``spacy.load``.
|
| 66 |
+
self._loader = loader
|
| 67 |
+
self._nlp: Any | None = None
|
| 68 |
+
self._model_version: str | None = None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def name(self) -> str:
|
| 72 |
+
return f"ner:{self._label}"
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def version(self) -> str:
|
| 76 |
+
return _VERSION
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 80 |
+
return _INPUT_TYPES
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 84 |
+
return frozenset({ArtifactType.ENTITIES})
|
| 85 |
+
|
| 86 |
+
def system_binaries(self) -> dict[str, str]:
|
| 87 |
+
"""Versions spaCy + modèle **si déjà chargés** (reproductibilité).
|
| 88 |
+
|
| 89 |
+
Best-effort sans effet de bord : ne déclenche aucun chargement — renvoie
|
| 90 |
+
``{}`` tant que le modèle n'a pas tourné. Fusionné au ``RunManifest`` par
|
| 91 |
+
l'orchestrateur (duck-typing, comme le binaire tesseract).
|
| 92 |
+
"""
|
| 93 |
+
if self._nlp is None:
|
| 94 |
+
return {}
|
| 95 |
+
out: dict[str, str] = {}
|
| 96 |
+
if self._model_version is not None:
|
| 97 |
+
out[f"spacy_model:{self._model}"] = self._model_version
|
| 98 |
+
try:
|
| 99 |
+
import spacy # type: ignore[import-not-found]
|
| 100 |
+
|
| 101 |
+
out["spacy"] = str(spacy.__version__)
|
| 102 |
+
except ImportError:
|
| 103 |
+
pass
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
def _load(self) -> Any:
|
| 107 |
+
"""Charge le modèle (idempotent). Fail-closed : lève si SDK/modèle absent."""
|
| 108 |
+
if self._nlp is not None:
|
| 109 |
+
return self._nlp
|
| 110 |
+
if self._loader is not None:
|
| 111 |
+
self._nlp = self._loader(self._model)
|
| 112 |
+
self._model_version = self._probe_model_version(self._nlp)
|
| 113 |
+
return self._nlp
|
| 114 |
+
try:
|
| 115 |
+
import spacy # type: ignore[import-not-found]
|
| 116 |
+
except ImportError as exc:
|
| 117 |
+
raise AdapterStepError(
|
| 118 |
+
"ner : spaCy non installé — `pip install 'cinoc[ner]'`."
|
| 119 |
+
) from exc
|
| 120 |
+
try:
|
| 121 |
+
self._nlp = spacy.load(self._model)
|
| 122 |
+
except OSError as exc:
|
| 123 |
+
raise AdapterStepError(
|
| 124 |
+
f"ner : modèle spaCy {self._model!r} introuvable — "
|
| 125 |
+
f"`python -m spacy download {self._model}`."
|
| 126 |
+
) from exc
|
| 127 |
+
self._model_version = self._probe_model_version(self._nlp)
|
| 128 |
+
return self._nlp
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _probe_model_version(nlp: Any) -> str | None:
|
| 132 |
+
meta = getattr(nlp, "meta", None)
|
| 133 |
+
if isinstance(meta, dict):
|
| 134 |
+
version = meta.get("version")
|
| 135 |
+
return str(version) if version is not None else None
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
def _extract(self, text: str) -> list[dict[str, object]]:
|
| 139 |
+
nlp = self._load()
|
| 140 |
+
doc = nlp(text)
|
| 141 |
+
entities: list[dict[str, object]] = []
|
| 142 |
+
for ent in doc.ents:
|
| 143 |
+
label = _DEFAULT_LABEL_MAPPING.get(ent.label_, ent.label_)
|
| 144 |
+
entities.append(
|
| 145 |
+
{
|
| 146 |
+
"label": label,
|
| 147 |
+
"start": int(ent.start_char),
|
| 148 |
+
"end": int(ent.end_char),
|
| 149 |
+
"text": ent.text,
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
return entities
|
| 153 |
+
|
| 154 |
+
def execute(
|
| 155 |
+
self,
|
| 156 |
+
inputs: dict[ArtifactType, Artifact],
|
| 157 |
+
params: dict[str, ParamValue],
|
| 158 |
+
context: RunContext,
|
| 159 |
+
control: RunControl,
|
| 160 |
+
) -> StepOutput:
|
| 161 |
+
control.raise_if_cancelled()
|
| 162 |
+
source = inputs.get(ArtifactType.CORRECTED_TEXT) or inputs.get(
|
| 163 |
+
ArtifactType.RAW_TEXT
|
| 164 |
+
)
|
| 165 |
+
if source is None or source.uri is None:
|
| 166 |
+
raise AdapterStepError(
|
| 167 |
+
f"{self.name} : artefact texte (RAW_TEXT/CORRECTED_TEXT) manquant."
|
| 168 |
+
)
|
| 169 |
+
if context.workspace_uri is None:
|
| 170 |
+
raise AdapterStepError(f"{self.name} : workspace requis.")
|
| 171 |
+
text = _read_text(source.uri, self.name)
|
| 172 |
+
entities = self._extract(text)
|
| 173 |
+
payload = json.dumps(
|
| 174 |
+
{"text": text, "entities": entities}, ensure_ascii=False
|
| 175 |
+
).encode("utf-8")
|
| 176 |
+
out_path = workspace_artifact_path(
|
| 177 |
+
context.workspace_uri, context.document_id, self._label, "entities.json"
|
| 178 |
+
)
|
| 179 |
+
out_path.write_bytes(payload)
|
| 180 |
+
return StepOutput(
|
| 181 |
+
artifacts={
|
| 182 |
+
ArtifactType.ENTITIES: Artifact(
|
| 183 |
+
id=f"{context.document_id}:{self.name}:entities",
|
| 184 |
+
document_id=context.document_id,
|
| 185 |
+
type=ArtifactType.ENTITIES,
|
| 186 |
+
uri=str(out_path),
|
| 187 |
+
content_hash=compute_content_hash(payload),
|
| 188 |
+
)
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _read_text(uri: str, name: str) -> str:
|
| 194 |
+
from pathlib import Path
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
return Path(uri).read_text(encoding="utf-8")
|
| 198 |
+
except (OSError, UnicodeDecodeError) as exc:
|
| 199 |
+
raise AdapterStepError(
|
| 200 |
+
f"{name} : texte d'entrée illisible {uri!r} : {exc}"
|
| 201 |
+
) from exc
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
__all__ = ["SpacyNerExtractor"]
|
cinoc/adapters/ocr/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modules OCR/HTR (couche 5)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from cinoc.adapters.ocr.precomputed import PrecomputedTextAdapter
|
| 6 |
+
from cinoc.adapters.ocr.tesseract import TesseractAdapter
|
| 7 |
+
|
| 8 |
+
__all__ = ["PrecomputedTextAdapter", "TesseractAdapter"]
|
cinoc/adapters/ocr/_base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Squelette commun des adapters OCR « image → un seul RAW_TEXT » (couche 5).
|
| 2 |
+
|
| 3 |
+
Le même ``execute()`` était recopié dans chaque adapter OCR mono-sortie : sonde
|
| 4 |
+
d'annulation, garde IMAGE, garde workspace, écriture du ``.txt`` dans le workspace,
|
| 5 |
+
construction de l'artefact ``RAW_TEXT`` (id ``{doc}:{name}:raw_text`` + hash du
|
| 6 |
+
texte). On le mutualise ici (miroir de ``llm/_base``) ; le **détail moteur** (params,
|
| 7 |
+
clé API, deadline) reste dans chaque adapter, porté par la closure ``recognize``.
|
| 8 |
+
|
| 9 |
+
N'est PAS pour les adapters multi-sorties (``tesseract`` : RAW_TEXT + CONFIDENCES
|
| 10 |
+
+ ALTO optionnel) — eux gardent leur ``execute()`` propre. Contrat de l'artefact
|
| 11 |
+
``RAW_TEXT`` figé par ``tests/adapters/test_ocr_artifact_contract_anchor``.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import Callable, Mapping
|
| 17 |
+
|
| 18 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 19 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 20 |
+
from cinoc.domain.errors import AdapterStepError
|
| 21 |
+
from cinoc.pipeline.run_control import RunControl
|
| 22 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_image_to_text(
|
| 26 |
+
*,
|
| 27 |
+
name: str,
|
| 28 |
+
label: str,
|
| 29 |
+
inputs: Mapping[ArtifactType, Artifact],
|
| 30 |
+
context: RunContext,
|
| 31 |
+
control: RunControl,
|
| 32 |
+
recognize: Callable[[str], str],
|
| 33 |
+
) -> StepOutput:
|
| 34 |
+
"""Exécute un OCR image → ``RAW_TEXT`` unique, gardes + persistance mutualisées.
|
| 35 |
+
|
| 36 |
+
``recognize(image_uri) -> texte`` porte la reconnaissance réelle (et ses gardes
|
| 37 |
+
propres : clé API, modèle…). Ordre identique à l'ancien code par adapter :
|
| 38 |
+
annulation → IMAGE → workspace → ``recognize`` → écriture ``.txt`` → artefact.
|
| 39 |
+
"""
|
| 40 |
+
control.raise_if_cancelled()
|
| 41 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 42 |
+
if image is None or image.uri is None:
|
| 43 |
+
raise AdapterStepError(f"{name} : artefact IMAGE manquant ou sans URI.")
|
| 44 |
+
if context.workspace_uri is None:
|
| 45 |
+
raise AdapterStepError(f"{name} : workspace requis.")
|
| 46 |
+
text = recognize(image.uri)
|
| 47 |
+
output_path = workspace_artifact_path(
|
| 48 |
+
context.workspace_uri, context.document_id, label, "txt"
|
| 49 |
+
)
|
| 50 |
+
output_path.write_text(text, encoding="utf-8")
|
| 51 |
+
return StepOutput(
|
| 52 |
+
artifacts={
|
| 53 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 54 |
+
id=f"{context.document_id}:{name}:raw_text",
|
| 55 |
+
document_id=context.document_id,
|
| 56 |
+
type=ArtifactType.RAW_TEXT,
|
| 57 |
+
uri=str(output_path),
|
| 58 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
__all__ = ["run_image_to_text"]
|
cinoc/adapters/ocr/azure_di.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AzureDocIntelAdapter`` — OCR cloud (Azure AI Document Intelligence, modèle Read).
|
| 2 |
+
|
| 3 |
+
Moteur cloud **first-party**. API **REST** (``httpx``) plutôt que le SDK
|
| 4 |
+
``azure-ai-documentintelligence`` (lourd, auth credential) : transport léger,
|
| 5 |
+
**testable par cassette HTTP**. Auth par **clé d'abonnement**
|
| 6 |
+
(``AZURE_DOC_INTEL_KEY``) + endpoint de la ressource (``AZURE_DOC_INTEL_ENDPOINT``).
|
| 7 |
+
Implémente le ``Module`` Protocol **directement** ; ``httpx`` est l'extra
|
| 8 |
+
``cinoc[azure]``, importé **paresseusement** (mockable, CI sans clé ni réseau).
|
| 9 |
+
|
| 10 |
+
Flux **asynchrone** propre à Azure DI (fidèlement modélisé) : ``POST …:analyze``
|
| 11 |
+
renvoie ``202`` + un en-tête ``Operation-Location`` ; on **sonde** cette URL
|
| 12 |
+
(GET) jusqu'à ``status == "succeeded"`` (ou ``failed``/délai). Sortie :
|
| 13 |
+
``analyzeResult.content`` du modèle ``prebuilt-read`` — le texte complet d'une page.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
from base64 import b64encode
|
| 21 |
+
from collections.abc import Callable
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import TYPE_CHECKING
|
| 24 |
+
|
| 25 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 26 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 27 |
+
from cinoc.domain.deadline import Deadline
|
| 28 |
+
from cinoc.domain.errors import AdapterStepError
|
| 29 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 30 |
+
from cinoc.pipeline.run_control import RunControl
|
| 31 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 32 |
+
|
| 33 |
+
if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
|
| 34 |
+
import httpx
|
| 35 |
+
|
| 36 |
+
_VERSION = "1.0"
|
| 37 |
+
_MODEL = "prebuilt-read"
|
| 38 |
+
_API_VERSION = "2024-11-30"
|
| 39 |
+
_ANALYZE_PATH = f"/documentintelligence/documentModels/{_MODEL}:analyze"
|
| 40 |
+
_KEY_HEADER = "Ocp-Apim-Subscription-Key"
|
| 41 |
+
_ENDPOINT_ENV = "AZURE_DOC_INTEL_ENDPOINT"
|
| 42 |
+
_KEY_ENV = "AZURE_DOC_INTEL_KEY"
|
| 43 |
+
#: Borne dure du *polling* (en plus de la ``Deadline``) — évite la boucle infinie
|
| 44 |
+
#: si le service reste indéfiniment ``running`` sous une deadline infinie.
|
| 45 |
+
_MAX_POLLS = 60
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _extract_text(data: object) -> str:
|
| 49 |
+
"""Texte complet de ``analyzeResult.content`` ; page vide → ``""``."""
|
| 50 |
+
if not isinstance(data, dict):
|
| 51 |
+
raise AdapterStepError("azure_di : réponse JSON inattendue (objet).")
|
| 52 |
+
result = data.get("analyzeResult")
|
| 53 |
+
if not isinstance(result, dict):
|
| 54 |
+
raise AdapterStepError("azure_di : 'analyzeResult' absent de la réponse.")
|
| 55 |
+
content = result.get("content")
|
| 56 |
+
return str(content).strip() if content else ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _invoke_azure_di(
|
| 60 |
+
*,
|
| 61 |
+
endpoint: str,
|
| 62 |
+
image_path: str,
|
| 63 |
+
deadline: Deadline,
|
| 64 |
+
key: str,
|
| 65 |
+
transport: httpx.BaseTransport | None = None,
|
| 66 |
+
poll_interval: float = 1.0,
|
| 67 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 68 |
+
) -> str:
|
| 69 |
+
"""Analyse l'image (POST → sonde l'``Operation-Location``) et renvoie le texte.
|
| 70 |
+
|
| 71 |
+
``endpoint`` vient de la **configuration opérateur** (env), pas de
|
| 72 |
+
l'utilisateur → endpoint de confiance, pas de durcissement anti-SSRF (≠
|
| 73 |
+
importeurs de corpus). La clé est en **en-tête** (jamais journalisée : les
|
| 74 |
+
messages d'erreur ne portent que le statut). ``transport``/``sleep`` injectés
|
| 75 |
+
→ cassette **et** sonde déterministes en test (aucune attente réelle).
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
import httpx
|
| 79 |
+
except ImportError as exc:
|
| 80 |
+
raise AdapterStepError(
|
| 81 |
+
"azure_di : httpx non installé (pip install 'cinoc[azure]')."
|
| 82 |
+
) from exc
|
| 83 |
+
content = b64encode(Path(image_path).read_bytes()).decode("ascii")
|
| 84 |
+
analyze_url = endpoint.rstrip("/") + _ANALYZE_PATH
|
| 85 |
+
with httpx.Client(
|
| 86 |
+
timeout=deadline.as_sdk_timeout(), transport=transport
|
| 87 |
+
) as client:
|
| 88 |
+
operation_url = _submit_analysis(
|
| 89 |
+
client, analyze_url=analyze_url, key=key, content=content
|
| 90 |
+
)
|
| 91 |
+
return _poll_result(
|
| 92 |
+
client,
|
| 93 |
+
operation_url=operation_url,
|
| 94 |
+
key=key,
|
| 95 |
+
deadline=deadline,
|
| 96 |
+
poll_interval=poll_interval,
|
| 97 |
+
sleep=sleep,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _submit_analysis(
|
| 102 |
+
client: httpx.Client, *, analyze_url: str, key: str, content: str
|
| 103 |
+
) -> str:
|
| 104 |
+
"""POST l'analyse ; renvoie l'URL ``Operation-Location`` à sonder."""
|
| 105 |
+
import httpx
|
| 106 |
+
|
| 107 |
+
headers = {_KEY_HEADER: key, "Content-Type": "application/json"}
|
| 108 |
+
try:
|
| 109 |
+
response = client.post(
|
| 110 |
+
analyze_url,
|
| 111 |
+
params={"api-version": _API_VERSION},
|
| 112 |
+
headers=headers,
|
| 113 |
+
json={"base64Source": content},
|
| 114 |
+
)
|
| 115 |
+
response.raise_for_status()
|
| 116 |
+
except httpx.HTTPStatusError as exc:
|
| 117 |
+
raise AdapterStepError(
|
| 118 |
+
f"azure_di : statut HTTP {exc.response.status_code} (soumission)."
|
| 119 |
+
) from exc
|
| 120 |
+
except httpx.HTTPError as exc:
|
| 121 |
+
raise AdapterStepError(
|
| 122 |
+
f"azure_di : échec de transport (soumission) : {type(exc).__name__}."
|
| 123 |
+
) from exc
|
| 124 |
+
operation_url = response.headers.get("operation-location")
|
| 125 |
+
if not operation_url:
|
| 126 |
+
raise AdapterStepError("azure_di : réponse sans en-tête Operation-Location.")
|
| 127 |
+
return str(operation_url)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _poll_result(
|
| 131 |
+
client: httpx.Client,
|
| 132 |
+
*,
|
| 133 |
+
operation_url: str,
|
| 134 |
+
key: str,
|
| 135 |
+
deadline: Deadline,
|
| 136 |
+
poll_interval: float,
|
| 137 |
+
sleep: Callable[[float], None],
|
| 138 |
+
) -> str:
|
| 139 |
+
"""Sonde l'``Operation-Location`` jusqu'à un état terminal (ou délai)."""
|
| 140 |
+
import httpx
|
| 141 |
+
|
| 142 |
+
for _ in range(_MAX_POLLS):
|
| 143 |
+
try:
|
| 144 |
+
response = client.get(operation_url, headers={_KEY_HEADER: key})
|
| 145 |
+
response.raise_for_status()
|
| 146 |
+
data = response.json()
|
| 147 |
+
except httpx.HTTPStatusError as exc:
|
| 148 |
+
raise AdapterStepError(
|
| 149 |
+
f"azure_di : statut HTTP {exc.response.status_code} (sonde)."
|
| 150 |
+
) from exc
|
| 151 |
+
except httpx.HTTPError as exc:
|
| 152 |
+
raise AdapterStepError(
|
| 153 |
+
f"azure_di : échec de transport (sonde) : {type(exc).__name__}."
|
| 154 |
+
) from exc
|
| 155 |
+
status = (
|
| 156 |
+
str(data.get("status", "")).lower() if isinstance(data, dict) else ""
|
| 157 |
+
)
|
| 158 |
+
if status == "succeeded":
|
| 159 |
+
return _extract_text(data)
|
| 160 |
+
if status == "failed":
|
| 161 |
+
raise AdapterStepError("azure_di : analyse échouée (status 'failed').")
|
| 162 |
+
if deadline.is_expired():
|
| 163 |
+
break
|
| 164 |
+
sleep(poll_interval)
|
| 165 |
+
raise AdapterStepError("azure_di : analyse non terminée (délai de sonde dépassé).")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class AzureDocIntelAdapter:
|
| 169 |
+
"""OCR cloud Azure DI : ``IMAGE`` → ``RAW_TEXT`` (texte ``prebuilt-read``)."""
|
| 170 |
+
|
| 171 |
+
def __init__(self, *, label: str) -> None:
|
| 172 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 173 |
+
raise AdapterStepError(f"azure_di : label invalide {label!r}.")
|
| 174 |
+
self._label = label
|
| 175 |
+
|
| 176 |
+
@property
|
| 177 |
+
def name(self) -> str:
|
| 178 |
+
return f"azure_di:{self._label}"
|
| 179 |
+
|
| 180 |
+
@property
|
| 181 |
+
def version(self) -> str:
|
| 182 |
+
return _VERSION
|
| 183 |
+
|
| 184 |
+
@property
|
| 185 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 186 |
+
return frozenset({ArtifactType.IMAGE})
|
| 187 |
+
|
| 188 |
+
@property
|
| 189 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 190 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 191 |
+
|
| 192 |
+
def execute(
|
| 193 |
+
self,
|
| 194 |
+
inputs: dict[ArtifactType, Artifact],
|
| 195 |
+
params: dict[str, ParamValue],
|
| 196 |
+
context: RunContext,
|
| 197 |
+
control: RunControl,
|
| 198 |
+
) -> StepOutput:
|
| 199 |
+
def recognize(image_uri: str) -> str:
|
| 200 |
+
endpoint = os.environ.get(_ENDPOINT_ENV)
|
| 201 |
+
key = os.environ.get(_KEY_ENV)
|
| 202 |
+
if not endpoint:
|
| 203 |
+
raise AdapterStepError(f"{self.name} : {_ENDPOINT_ENV} manquant.")
|
| 204 |
+
if not key:
|
| 205 |
+
raise AdapterStepError(f"{self.name} : {_KEY_ENV} manquante.")
|
| 206 |
+
return _invoke_azure_di(
|
| 207 |
+
endpoint=endpoint,
|
| 208 |
+
image_path=image_uri,
|
| 209 |
+
deadline=context.deadline,
|
| 210 |
+
key=key,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
return run_image_to_text(
|
| 214 |
+
name=self.name,
|
| 215 |
+
label=self._label,
|
| 216 |
+
inputs=inputs,
|
| 217 |
+
context=context,
|
| 218 |
+
control=control,
|
| 219 |
+
recognize=recognize,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
__all__ = ["AzureDocIntelAdapter"]
|
cinoc/adapters/ocr/calamari.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``CalamariAdapter`` — moteur OCR réel (calamari-ocr, reconnaissance de lignes).
|
| 2 |
+
|
| 3 |
+
Local et sans clé. Implémente le ``Module`` Protocol **directement** — même
|
| 4 |
+
contrat que le socle, livré in-tree (pas de discrimination socle/plugin). La lib
|
| 5 |
+
``calamari_ocr`` est un **extra optionnel** (``cinoc[calamari]``), importée
|
| 6 |
+
**paresseusement** dans ``_invoke_calamari`` : importer ce module ne requiert rien ;
|
| 7 |
+
seule l'exécution exige la lib + un **modèle** (``model`` = checkpoint, obligatoire).
|
| 8 |
+
|
| 9 |
+
Calamari reconnaît une **ligne** par image : utilisé seul sur une page, il traite
|
| 10 |
+
l'image comme une ligne unique ; son usage naturel est en **recognizer par
|
| 11 |
+
région** dans le pipeline de segmentation (fan-out par bloc). Sortie : ``RAW_TEXT``.
|
| 12 |
+
|
| 13 |
+
**Non déployé au Space** (dép lourde TensorFlow, free-tier) : listé « indisponible »
|
| 14 |
+
sans l'extra, comme Kraken/PERO.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 23 |
+
from cinoc.pipeline.run_control import RunControl
|
| 24 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 25 |
+
|
| 26 |
+
_VERSION = "1.0"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _invoke_calamari( # pragma: no cover -- lib + modèle requis (cf. 'live')
|
| 30 |
+
*, image_path: str, model_path: str
|
| 31 |
+
) -> str:
|
| 32 |
+
"""Reconnaissance Calamari de l'image (ligne) → texte prédit."""
|
| 33 |
+
try:
|
| 34 |
+
import numpy as np # type: ignore[import-not-found]
|
| 35 |
+
from calamari_ocr.ocr.predict.predictor import ( # type: ignore[import-not-found]
|
| 36 |
+
Predictor,
|
| 37 |
+
PredictorParams,
|
| 38 |
+
)
|
| 39 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 40 |
+
except ImportError as exc:
|
| 41 |
+
raise AdapterStepError(
|
| 42 |
+
"calamari : lib non installée (pip install 'cinoc[calamari]')."
|
| 43 |
+
) from exc
|
| 44 |
+
try:
|
| 45 |
+
predictor = Predictor.from_checkpoint(
|
| 46 |
+
params=PredictorParams(), checkpoint=model_path
|
| 47 |
+
)
|
| 48 |
+
except (OSError, ValueError, RuntimeError) as exc: # loader calamari
|
| 49 |
+
raise AdapterStepError(
|
| 50 |
+
f"calamari : modèle illisible {model_path!r} : {exc}"
|
| 51 |
+
) from exc
|
| 52 |
+
image = np.array(Image.open(image_path).convert("L"))
|
| 53 |
+
predictions = predictor.predict_raw([image])
|
| 54 |
+
texts = [str(prediction.sentence) for prediction in predictions]
|
| 55 |
+
return " ".join(texts).strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class CalamariAdapter:
|
| 59 |
+
"""OCR Calamari : ``IMAGE`` → ``RAW_TEXT`` (texte de la ligne reconnue)."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 62 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 63 |
+
raise AdapterStepError(f"calamari : label invalide {label!r}.")
|
| 64 |
+
if not model:
|
| 65 |
+
raise AdapterStepError(
|
| 66 |
+
"calamari : paramètre 'model' (checkpoint) requis."
|
| 67 |
+
)
|
| 68 |
+
self._label = label
|
| 69 |
+
self._model = model
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def name(self) -> str:
|
| 73 |
+
return f"calamari:{self._label}"
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def version(self) -> str:
|
| 77 |
+
return _VERSION
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 81 |
+
return frozenset({ArtifactType.IMAGE})
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 85 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 86 |
+
|
| 87 |
+
def execute(
|
| 88 |
+
self,
|
| 89 |
+
inputs: dict[ArtifactType, Artifact],
|
| 90 |
+
params: dict[str, ParamValue],
|
| 91 |
+
context: RunContext,
|
| 92 |
+
control: RunControl,
|
| 93 |
+
) -> StepOutput:
|
| 94 |
+
return run_image_to_text(
|
| 95 |
+
name=self.name,
|
| 96 |
+
label=self._label,
|
| 97 |
+
inputs=inputs,
|
| 98 |
+
context=context,
|
| 99 |
+
control=control,
|
| 100 |
+
recognize=lambda image_uri: _invoke_calamari(
|
| 101 |
+
image_path=image_uri, model_path=self._model
|
| 102 |
+
),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["CalamariAdapter"]
|
cinoc/adapters/ocr/google_vision.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``GoogleVisionAdapter`` — OCR cloud (Google Cloud Vision REST), facturé à l'image.
|
| 2 |
+
|
| 3 |
+
Moteur cloud **first-party**. On vise l'**API REST** (``httpx``) plutôt que le SDK
|
| 4 |
+
``google-cloud-vision`` (gRPC + auth ADC service-account, lourd et difficile à
|
| 5 |
+
rejouer) : transport léger, **testable par cassette HTTP**, auth par **clé d'API**
|
| 6 |
+
(``GOOGLE_VISION_API_KEY``). Implémente le ``Module`` Protocol **directement** ;
|
| 7 |
+
``httpx`` est l'extra ``cinoc[google]``, importé **paresseusement** dans
|
| 8 |
+
``_invoke_google_vision`` (mockable, CI sans clé ni réseau).
|
| 9 |
+
|
| 10 |
+
Sortie : ``fullTextAnnotation.text`` de ``DOCUMENT_TEXT_DETECTION`` — le texte
|
| 11 |
+
dense d'une page (le bon mode pour des documents, ≠ ``TEXT_DETECTION`` épars).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from base64 import b64encode
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import TYPE_CHECKING
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 22 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 23 |
+
from cinoc.domain.deadline import Deadline
|
| 24 |
+
from cinoc.domain.errors import AdapterStepError
|
| 25 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 26 |
+
from cinoc.pipeline.run_control import RunControl
|
| 27 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 28 |
+
|
| 29 |
+
if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
|
| 30 |
+
import httpx
|
| 31 |
+
|
| 32 |
+
_VERSION = "1.0"
|
| 33 |
+
_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate"
|
| 34 |
+
_API_KEY_ENV = "GOOGLE_VISION_API_KEY"
|
| 35 |
+
_FEATURE = "DOCUMENT_TEXT_DETECTION"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_text(payload: object) -> str:
|
| 39 |
+
"""Texte de la 1ʳᵉ réponse Vision ; erreur par image → ``AdapterStepError``.
|
| 40 |
+
|
| 41 |
+
Forme : ``{"responses": [{"fullTextAnnotation": {"text": …}}]}`` (ou
|
| 42 |
+
``{"error": {"message": …}}`` pour un échec par image). Aucun texte détecté
|
| 43 |
+
(page blanche) → ``""`` — un résultat valide, pas une erreur.
|
| 44 |
+
"""
|
| 45 |
+
if not isinstance(payload, dict):
|
| 46 |
+
raise AdapterStepError("google_vision : réponse JSON inattendue (objet).")
|
| 47 |
+
responses = payload.get("responses")
|
| 48 |
+
if not isinstance(responses, list) or not responses:
|
| 49 |
+
raise AdapterStepError("google_vision : réponse sans 'responses'.")
|
| 50 |
+
first = responses[0]
|
| 51 |
+
if not isinstance(first, dict):
|
| 52 |
+
raise AdapterStepError("google_vision : 'responses[0]' inattendu.")
|
| 53 |
+
error = first.get("error")
|
| 54 |
+
if isinstance(error, dict) and error:
|
| 55 |
+
message = error.get("message", "erreur sans message")
|
| 56 |
+
raise AdapterStepError(f"google_vision : erreur API : {message}")
|
| 57 |
+
annotation = first.get("fullTextAnnotation")
|
| 58 |
+
if isinstance(annotation, dict):
|
| 59 |
+
text = annotation.get("text")
|
| 60 |
+
return str(text).strip() if text else ""
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _invoke_google_vision(
|
| 65 |
+
*,
|
| 66 |
+
image_path: str,
|
| 67 |
+
deadline: Deadline,
|
| 68 |
+
api_key: str,
|
| 69 |
+
transport: httpx.BaseTransport | None = None,
|
| 70 |
+
) -> str:
|
| 71 |
+
"""POST l'image à Vision et renvoie le texte. ``transport`` injecté → cassette.
|
| 72 |
+
|
| 73 |
+
Endpoint **fixe et de confiance** (Google) : pas de durcissement anti-SSRF (≠
|
| 74 |
+
importeurs de corpus, dont l'URL vient de l'utilisateur). La clé d'API est en
|
| 75 |
+
*query string* (jamais journalisée : les messages d'erreur ne portent que le
|
| 76 |
+
statut). ``httpx`` paresseux : importer ce module n'exige pas l'extra.
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
import httpx
|
| 80 |
+
except ImportError as exc:
|
| 81 |
+
raise AdapterStepError(
|
| 82 |
+
"google_vision : httpx non installé (pip install 'cinoc[google]')."
|
| 83 |
+
) from exc
|
| 84 |
+
content = b64encode(Path(image_path).read_bytes()).decode("ascii")
|
| 85 |
+
body = {
|
| 86 |
+
"requests": [
|
| 87 |
+
{"image": {"content": content}, "features": [{"type": _FEATURE}]}
|
| 88 |
+
]
|
| 89 |
+
}
|
| 90 |
+
try:
|
| 91 |
+
with httpx.Client(
|
| 92 |
+
timeout=deadline.as_sdk_timeout(), transport=transport
|
| 93 |
+
) as client:
|
| 94 |
+
response = client.post(_ENDPOINT, params={"key": api_key}, json=body)
|
| 95 |
+
response.raise_for_status()
|
| 96 |
+
data = response.json()
|
| 97 |
+
except httpx.HTTPStatusError as exc:
|
| 98 |
+
raise AdapterStepError(
|
| 99 |
+
f"google_vision : statut HTTP {exc.response.status_code}."
|
| 100 |
+
) from exc
|
| 101 |
+
except httpx.HTTPError as exc:
|
| 102 |
+
raise AdapterStepError(
|
| 103 |
+
f"google_vision : échec de transport : {type(exc).__name__}."
|
| 104 |
+
) from exc
|
| 105 |
+
return _extract_text(data)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class GoogleVisionAdapter:
|
| 109 |
+
"""OCR cloud Google Vision : ``IMAGE`` → ``RAW_TEXT`` (texte dense de la page)."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, *, label: str) -> None:
|
| 112 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 113 |
+
raise AdapterStepError(f"google_vision : label invalide {label!r}.")
|
| 114 |
+
self._label = label
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def name(self) -> str:
|
| 118 |
+
return f"google_vision:{self._label}"
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def version(self) -> str:
|
| 122 |
+
return _VERSION
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 126 |
+
return frozenset({ArtifactType.IMAGE})
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 130 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 131 |
+
|
| 132 |
+
def execute(
|
| 133 |
+
self,
|
| 134 |
+
inputs: dict[ArtifactType, Artifact],
|
| 135 |
+
params: dict[str, ParamValue],
|
| 136 |
+
context: RunContext,
|
| 137 |
+
control: RunControl,
|
| 138 |
+
) -> StepOutput:
|
| 139 |
+
def recognize(image_uri: str) -> str:
|
| 140 |
+
api_key = os.environ.get(_API_KEY_ENV)
|
| 141 |
+
if not api_key:
|
| 142 |
+
raise AdapterStepError(f"{self.name} : {_API_KEY_ENV} manquante.")
|
| 143 |
+
return _invoke_google_vision(
|
| 144 |
+
image_path=image_uri, deadline=context.deadline, api_key=api_key
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return run_image_to_text(
|
| 148 |
+
name=self.name,
|
| 149 |
+
label=self._label,
|
| 150 |
+
inputs=inputs,
|
| 151 |
+
context=context,
|
| 152 |
+
control=control,
|
| 153 |
+
recognize=recognize,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
__all__ = ["GoogleVisionAdapter"]
|
cinoc/adapters/ocr/kraken.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``KrakenAdapter`` — moteur HTR réel (kraken, le moteur d'eScriptorium).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol directement. Couvre le **manuscrit** (HTR),
|
| 4 |
+
là où tesseract couvre l'imprimé. Le SDK ``kraken`` est un **extra optionnel**
|
| 5 |
+
(``cinoc[kraken]``), importé paresseusement dans ``_invoke_kraken`` — importer
|
| 6 |
+
ce module ne requiert rien ; seule l'exécution exige SDK + **modèle** (.mlmodel,
|
| 7 |
+
paramètre ``model`` obligatoire : pas de modèle par défaut embarqué).
|
| 8 |
+
|
| 9 |
+
Sorties : ``RAW_TEXT`` (lignes reconnues, ordre de lecture kraken) +
|
| 10 |
+
``CONFIDENCES`` (un jeton par mot, confiance = moyenne des confiances de
|
| 11 |
+
caractères du mot — best-effort, sidecar vide en dégradé).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.confidence import ConfidenceToken
|
| 22 |
+
from cinoc.domain.errors import AdapterStepError
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_VERSION = "1.0"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def tokens_from_lines(
|
| 33 |
+
lines: list[tuple[str, list[float]]],
|
| 34 |
+
) -> list[ConfidenceToken]:
|
| 35 |
+
"""Jetons mot+confiance depuis (texte de ligne, confiances par caractère).
|
| 36 |
+
|
| 37 |
+
La confiance d'un mot = moyenne des confiances de ses caractères (bornée
|
| 38 |
+
[0,1]) ; un mot sans couverture de confiance est ignoré.
|
| 39 |
+
"""
|
| 40 |
+
tokens: list[ConfidenceToken] = []
|
| 41 |
+
for text, confidences in lines:
|
| 42 |
+
cursor = 0
|
| 43 |
+
for word in text.split():
|
| 44 |
+
start = text.find(word, cursor)
|
| 45 |
+
if start < 0:
|
| 46 |
+
continue
|
| 47 |
+
cursor = start + len(word)
|
| 48 |
+
window = confidences[start : start + len(word)]
|
| 49 |
+
if not window:
|
| 50 |
+
continue
|
| 51 |
+
value = max(0.0, min(sum(window) / len(window), 1.0))
|
| 52 |
+
tokens.append(ConfidenceToken(text=word, confidence=value))
|
| 53 |
+
return tokens
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _invoke_kraken( # pragma: no cover -- SDK + modèle requis (cf. 'live')
|
| 57 |
+
*, image_path: str, model_path: str
|
| 58 |
+
) -> list[tuple[str, list[float]]]:
|
| 59 |
+
"""Segmentation + reconnaissance kraken → (texte, confiances/char) par ligne."""
|
| 60 |
+
try:
|
| 61 |
+
from kraken import ( # type: ignore[import-not-found]
|
| 62 |
+
binarization,
|
| 63 |
+
pageseg,
|
| 64 |
+
rpred,
|
| 65 |
+
)
|
| 66 |
+
from kraken.lib import models # type: ignore[import-not-found]
|
| 67 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 68 |
+
except ImportError as exc:
|
| 69 |
+
raise AdapterStepError(
|
| 70 |
+
"kraken : SDK non installé (pip install 'cinoc[kraken]')."
|
| 71 |
+
) from exc
|
| 72 |
+
try:
|
| 73 |
+
model = models.load_any(model_path)
|
| 74 |
+
except (OSError, ValueError, RuntimeError) as exc: # loader kraken
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"kraken : modèle illisible {model_path!r} : {exc}"
|
| 77 |
+
) from exc
|
| 78 |
+
image = Image.open(image_path)
|
| 79 |
+
binarized = binarization.nlbin(image)
|
| 80 |
+
segmentation = pageseg.segment(binarized)
|
| 81 |
+
out: list[tuple[str, list[float]]] = []
|
| 82 |
+
for record in rpred.rpred(model, binarized, segmentation):
|
| 83 |
+
out.append((str(record), [float(c) for c in record.confidences]))
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class KrakenAdapter:
|
| 88 |
+
"""HTR kraken : ``IMAGE`` → ``RAW_TEXT`` + ``CONFIDENCES``."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 91 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 92 |
+
raise AdapterStepError(f"kraken : label invalide {label!r}.")
|
| 93 |
+
if not model:
|
| 94 |
+
raise AdapterStepError(
|
| 95 |
+
"kraken : paramètre 'model' (chemin .mlmodel) requis."
|
| 96 |
+
)
|
| 97 |
+
self._label = label
|
| 98 |
+
self._model = model
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def name(self) -> str:
|
| 102 |
+
return f"kraken:{self._label}"
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def version(self) -> str:
|
| 106 |
+
return _VERSION
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 110 |
+
return frozenset({ArtifactType.IMAGE})
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 114 |
+
return frozenset({ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES})
|
| 115 |
+
|
| 116 |
+
def execute(
|
| 117 |
+
self,
|
| 118 |
+
inputs: dict[ArtifactType, Artifact],
|
| 119 |
+
params: dict[str, ParamValue],
|
| 120 |
+
context: RunContext,
|
| 121 |
+
control: RunControl,
|
| 122 |
+
) -> StepOutput:
|
| 123 |
+
control.raise_if_cancelled()
|
| 124 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 125 |
+
if image is None or image.uri is None:
|
| 126 |
+
raise AdapterStepError(
|
| 127 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 128 |
+
)
|
| 129 |
+
if context.workspace_uri is None:
|
| 130 |
+
raise AdapterStepError(f"{self.name} : workspace requis.")
|
| 131 |
+
lines = _invoke_kraken(image_path=image.uri, model_path=self._model)
|
| 132 |
+
text = "\n".join(line for line, _ in lines)
|
| 133 |
+
tokens = tokens_from_lines(lines)
|
| 134 |
+
text_path = workspace_artifact_path(
|
| 135 |
+
context.workspace_uri, context.document_id, self._label, "txt"
|
| 136 |
+
)
|
| 137 |
+
text_path.write_text(text, encoding="utf-8")
|
| 138 |
+
sidecar = json.dumps(
|
| 139 |
+
[token.model_dump() for token in tokens], ensure_ascii=False
|
| 140 |
+
).encode("utf-8")
|
| 141 |
+
sidecar_path = workspace_artifact_path(
|
| 142 |
+
context.workspace_uri, context.document_id, self._label,
|
| 143 |
+
"confidences.json",
|
| 144 |
+
)
|
| 145 |
+
sidecar_path.write_bytes(sidecar)
|
| 146 |
+
return StepOutput(
|
| 147 |
+
artifacts={
|
| 148 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 149 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 150 |
+
document_id=context.document_id,
|
| 151 |
+
type=ArtifactType.RAW_TEXT,
|
| 152 |
+
uri=str(text_path),
|
| 153 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 154 |
+
),
|
| 155 |
+
ArtifactType.CONFIDENCES: Artifact(
|
| 156 |
+
id=f"{context.document_id}:{self.name}:confidences",
|
| 157 |
+
document_id=context.document_id,
|
| 158 |
+
type=ArtifactType.CONFIDENCES,
|
| 159 |
+
uri=str(sidecar_path),
|
| 160 |
+
content_hash=compute_content_hash(sidecar),
|
| 161 |
+
),
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
__all__ = ["KrakenAdapter", "tokens_from_lines"]
|
cinoc/adapters/ocr/mistral_ocr.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralOCRAdapter`` — OCR cloud (API Mistral OCR), facturé à la page.
|
| 2 |
+
|
| 3 |
+
Premier moteur **payant à la page** du socle : donne un sens réel à l'axe
|
| 4 |
+
coût du Pareto (économie T10, tarif ``cloud_page_kinds``). Implémente le
|
| 5 |
+
``Module`` Protocol ; SDK ``mistralai`` = extra (``cinoc[mistral]``), importé
|
| 6 |
+
paresseusement dans ``_invoke_mistral_ocr`` (mockable, CI sans clé ni réseau).
|
| 7 |
+
Clé ``MISTRAL_API_KEY``. Sortie : le texte (markdown) des pages, concaténé.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from base64 import b64encode
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 16 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 17 |
+
from cinoc.domain.deadline import Deadline
|
| 18 |
+
from cinoc.domain.errors import AdapterStepError
|
| 19 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 20 |
+
from cinoc.pipeline.run_control import RunControl
|
| 21 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 22 |
+
|
| 23 |
+
_VERSION = "1.0"
|
| 24 |
+
_DEFAULT_MODEL = "mistral-ocr-latest"
|
| 25 |
+
_MEDIA = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
| 26 |
+
".webp": "image/webp", ".tif": "image/tiff", ".tiff": "image/tiff"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _invoke_mistral_ocr( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 30 |
+
*, model: str, image_path: str, deadline: Deadline
|
| 31 |
+
) -> str:
|
| 32 |
+
import os
|
| 33 |
+
|
| 34 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 35 |
+
if not api_key:
|
| 36 |
+
raise AdapterStepError("mistral_ocr : MISTRAL_API_KEY manquante.")
|
| 37 |
+
try:
|
| 38 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 39 |
+
except ImportError as exc:
|
| 40 |
+
raise AdapterStepError(
|
| 41 |
+
"mistral_ocr : SDK non installé (pip install 'cinoc[mistral]')."
|
| 42 |
+
) from exc
|
| 43 |
+
path = Path(image_path)
|
| 44 |
+
media = _MEDIA.get(path.suffix.lower(), "image/png")
|
| 45 |
+
image_b64 = b64encode(path.read_bytes()).decode("ascii")
|
| 46 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 47 |
+
timeout = deadline.as_sdk_timeout()
|
| 48 |
+
if timeout is not None:
|
| 49 |
+
client_kwargs["timeout_ms"] = int(timeout * 1000)
|
| 50 |
+
try:
|
| 51 |
+
client = Mistral(**client_kwargs) # type: ignore[arg-type]
|
| 52 |
+
response = client.ocr.process(
|
| 53 |
+
model=model,
|
| 54 |
+
document={
|
| 55 |
+
"type": "image_url",
|
| 56 |
+
"image_url": f"data:{media};base64,{image_b64}",
|
| 57 |
+
},
|
| 58 |
+
)
|
| 59 |
+
except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
|
| 60 |
+
raise AdapterStepError(
|
| 61 |
+
f"mistral_ocr a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 62 |
+
) from exc
|
| 63 |
+
pages = getattr(response, "pages", None) or []
|
| 64 |
+
return "\n\n".join(
|
| 65 |
+
str(getattr(page, "markdown", "") or "") for page in pages
|
| 66 |
+
).strip()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class MistralOCRAdapter:
|
| 70 |
+
"""OCR cloud Mistral : ``IMAGE`` → ``RAW_TEXT`` (markdown des pages)."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, *, label: str, model: str = _DEFAULT_MODEL) -> None:
|
| 73 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 74 |
+
raise AdapterStepError(f"mistral_ocr : label invalide {label!r}.")
|
| 75 |
+
self._label = label
|
| 76 |
+
self._model = model
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def name(self) -> str:
|
| 80 |
+
return f"mistral_ocr:{self._label}"
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def version(self) -> str:
|
| 84 |
+
return _VERSION
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 88 |
+
return frozenset({ArtifactType.IMAGE})
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 92 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 93 |
+
|
| 94 |
+
def execute(
|
| 95 |
+
self,
|
| 96 |
+
inputs: dict[ArtifactType, Artifact],
|
| 97 |
+
params: dict[str, ParamValue],
|
| 98 |
+
context: RunContext,
|
| 99 |
+
control: RunControl,
|
| 100 |
+
) -> StepOutput:
|
| 101 |
+
return run_image_to_text(
|
| 102 |
+
name=self.name,
|
| 103 |
+
label=self._label,
|
| 104 |
+
inputs=inputs,
|
| 105 |
+
context=context,
|
| 106 |
+
control=control,
|
| 107 |
+
recognize=lambda image_uri: _invoke_mistral_ocr(
|
| 108 |
+
model=self._model, image_path=image_uri, deadline=context.deadline
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
__all__ = ["MistralOCRAdapter"]
|
cinoc/adapters/ocr/pero.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PeroAdapter`` — moteur OCR/HTR réel (pero-ocr).
|
| 2 |
+
|
| 3 |
+
Local et sans clé : couvre l'imprimé ancien et le manuscrit via les modèles PERO,
|
| 4 |
+
là où tesseract couvre l'imprimé moderne. Implémente le ``Module`` Protocol
|
| 5 |
+
**directement** — même contrat que le socle, livré in-tree (décision : pas de
|
| 6 |
+
discrimination socle/plugin ; seule la livraison diffère). La lib ``pero_ocr`` est
|
| 7 |
+
un **extra optionnel** (``cinoc[pero]``), importée **paresseusement** dans
|
| 8 |
+
``_invoke_pero`` : importer ce module ne requiert rien ; seule l'exécution exige la
|
| 9 |
+
lib + un **modèle** (``model`` = chemin du fichier de config PERO, obligatoire —
|
| 10 |
+
pas de modèle embarqué). Sortie : ``RAW_TEXT`` (lignes reconnues, ordre PERO).
|
| 11 |
+
|
| 12 |
+
**Non déployé au Space** (dép lourde, free-tier) : listé « indisponible » sans
|
| 13 |
+
l'extra, comme Kraken — pas d'exécution cloud, pas de clé.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 21 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 22 |
+
from cinoc.domain.errors import AdapterStepError
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
_VERSION = "1.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _invoke_pero( # pragma: no cover -- lib + modèle requis (cf. 'live')
|
| 31 |
+
*, image_path: str, config_path: str
|
| 32 |
+
) -> str:
|
| 33 |
+
"""Mise en page + reconnaissance PERO → texte (lignes jointes, ordre PERO)."""
|
| 34 |
+
try:
|
| 35 |
+
import configparser
|
| 36 |
+
|
| 37 |
+
import cv2 # type: ignore[import-not-found]
|
| 38 |
+
from pero_ocr.document_ocr.layout import ( # type: ignore[import-not-found]
|
| 39 |
+
PageLayout,
|
| 40 |
+
)
|
| 41 |
+
from pero_ocr.document_ocr.page_parser import ( # type: ignore[import-not-found]
|
| 42 |
+
PageParser,
|
| 43 |
+
)
|
| 44 |
+
except ImportError as exc:
|
| 45 |
+
raise AdapterStepError(
|
| 46 |
+
"pero : lib non installée (pip install 'cinoc[pero]')."
|
| 47 |
+
) from exc
|
| 48 |
+
config = configparser.ConfigParser()
|
| 49 |
+
if not config.read(config_path):
|
| 50 |
+
raise AdapterStepError(f"pero : config illisible {config_path!r}.")
|
| 51 |
+
image = cv2.imread(image_path)
|
| 52 |
+
if image is None:
|
| 53 |
+
raise AdapterStepError(f"pero : image illisible {image_path!r}.")
|
| 54 |
+
parser = PageParser(config, config_path=str(Path(config_path).parent))
|
| 55 |
+
layout = PageLayout(id="page", page_size=(image.shape[0], image.shape[1]))
|
| 56 |
+
layout = parser.process_page(image, layout)
|
| 57 |
+
lines = [
|
| 58 |
+
str(line.transcription)
|
| 59 |
+
for region in layout.regions
|
| 60 |
+
for line in region.lines
|
| 61 |
+
if line.transcription
|
| 62 |
+
]
|
| 63 |
+
return "\n".join(lines).strip()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class PeroAdapter:
|
| 67 |
+
"""OCR/HTR PERO : ``IMAGE`` → ``RAW_TEXT`` (lignes reconnues)."""
|
| 68 |
+
|
| 69 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 70 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 71 |
+
raise AdapterStepError(f"pero : label invalide {label!r}.")
|
| 72 |
+
if not model:
|
| 73 |
+
raise AdapterStepError(
|
| 74 |
+
"pero : paramètre 'model' (chemin de config PERO) requis."
|
| 75 |
+
)
|
| 76 |
+
self._label = label
|
| 77 |
+
self._model = model
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def name(self) -> str:
|
| 81 |
+
return f"pero:{self._label}"
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def version(self) -> str:
|
| 85 |
+
return _VERSION
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 89 |
+
return frozenset({ArtifactType.IMAGE})
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 93 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 94 |
+
|
| 95 |
+
def execute(
|
| 96 |
+
self,
|
| 97 |
+
inputs: dict[ArtifactType, Artifact],
|
| 98 |
+
params: dict[str, ParamValue],
|
| 99 |
+
context: RunContext,
|
| 100 |
+
control: RunControl,
|
| 101 |
+
) -> StepOutput:
|
| 102 |
+
return run_image_to_text(
|
| 103 |
+
name=self.name,
|
| 104 |
+
label=self._label,
|
| 105 |
+
inputs=inputs,
|
| 106 |
+
context=context,
|
| 107 |
+
control=control,
|
| 108 |
+
recognize=lambda image_uri: _invoke_pero(
|
| 109 |
+
image_path=image_uri, config_path=self._model
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
__all__ = ["PeroAdapter"]
|
cinoc/adapters/ocr/precomputed.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PrecomputedTextAdapter`` — rejoue un texte OCR pré-calculé depuis le disque.
|
| 2 |
+
|
| 3 |
+
Module à **zéro dépendance et zéro réseau** : il lit ``<stem>.<source_label>.txt``
|
| 4 |
+
à côté de l'image et renvoie un ``RAW_TEXT``. Brique du *starter pack* — il fait
|
| 5 |
+
tourner le squelette ambulant (``cinoc demo``) et les tests déterministes sans
|
| 6 |
+
moteur réel. Implémente le ``Module`` Protocol (couche 4) **directement**.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 14 |
+
from cinoc.domain.errors import AdapterStepError
|
| 15 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 16 |
+
from cinoc.pipeline.run_control import RunControl
|
| 17 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 18 |
+
|
| 19 |
+
_VERSION = "1.0"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PrecomputedTextAdapter:
|
| 23 |
+
"""Lit un texte OCR pré-calculé (``<stem>.<label>.txt`` près de l'image)."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, *, source_label: str) -> None:
|
| 26 |
+
if not source_label or not all(
|
| 27 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 28 |
+
):
|
| 29 |
+
raise AdapterStepError(
|
| 30 |
+
f"PrecomputedTextAdapter : source_label invalide "
|
| 31 |
+
f"{source_label!r} (alphanumérique + _ - uniquement)."
|
| 32 |
+
)
|
| 33 |
+
self._label = source_label
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def name(self) -> str:
|
| 37 |
+
return f"precomputed:{self._label}"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def version(self) -> str:
|
| 41 |
+
return _VERSION
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 45 |
+
return frozenset({ArtifactType.IMAGE})
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 49 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 50 |
+
|
| 51 |
+
def execute(
|
| 52 |
+
self,
|
| 53 |
+
inputs: dict[ArtifactType, Artifact],
|
| 54 |
+
params: dict[str, ParamValue],
|
| 55 |
+
context: RunContext,
|
| 56 |
+
control: RunControl,
|
| 57 |
+
) -> StepOutput:
|
| 58 |
+
control.raise_if_cancelled()
|
| 59 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 60 |
+
if image is None or image.uri is None:
|
| 61 |
+
raise AdapterStepError(
|
| 62 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 63 |
+
)
|
| 64 |
+
image_path = Path(image.uri)
|
| 65 |
+
text_path = image_path.parent / f"{image_path.stem}.{self._label}.txt"
|
| 66 |
+
if not text_path.is_file():
|
| 67 |
+
raise AdapterStepError(
|
| 68 |
+
f"{self.name} : texte pré-calculé introuvable — "
|
| 69 |
+
f"{text_path.name!r} attendu près de {image_path.name!r}."
|
| 70 |
+
)
|
| 71 |
+
data = text_path.read_bytes()
|
| 72 |
+
try:
|
| 73 |
+
data.decode("utf-8")
|
| 74 |
+
except UnicodeDecodeError as exc:
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"{self.name} : {text_path.name!r} n'est pas en UTF-8 : {exc}"
|
| 77 |
+
) from exc
|
| 78 |
+
return StepOutput(
|
| 79 |
+
artifacts={
|
| 80 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 81 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 82 |
+
document_id=context.document_id,
|
| 83 |
+
type=ArtifactType.RAW_TEXT,
|
| 84 |
+
uri=str(text_path),
|
| 85 |
+
content_hash=compute_content_hash(data),
|
| 86 |
+
)
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
__all__ = ["PrecomputedTextAdapter"]
|
cinoc/adapters/ocr/tesseract.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``TesseractAdapter`` — moteur OCR réel (Tesseract 5 via ``pytesseract``).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol (couche 4) **directement**. L'OCR a lieu dans
|
| 4 |
+
le binaire C ``tesseract`` lancé en sous-processus (le thread relâche le GIL).
|
| 5 |
+
``pytesseract`` est un **extra optionnel** (``cinoc[tesseract]``), importé
|
| 6 |
+
**paresseusement** dans ``_invoke_tesseract`` : importer ce module ne requiert ni
|
| 7 |
+
la lib ni le binaire — seule l'exécution les exige.
|
| 8 |
+
|
| 9 |
+
Sécurité : ``lang`` est validé (anti-injection ligne de commande) ; ``timeout``
|
| 10 |
+
est borné par la ``Deadline`` (un sous-processus figé ne doit pas geler le run).
|
| 11 |
+
Les **confidences** par mot (TSV natif, 0-100 → [0,1]) sont écrites en
|
| 12 |
+
sidecar JSON (``ConfidenceToken``) et publiées comme artefact ``CONFIDENCES``
|
| 13 |
+
— best-effort : un échec d'extraction dégrade en sidecar vide, jamais en
|
| 14 |
+
panne de l'OCR.
|
| 15 |
+
|
| 16 |
+
**ALTO natif** (``alto=True``) : Tesseract émet en plus un artefact
|
| 17 |
+
``ALTO_XML`` (géométrie + texte par mot, ``image_to_alto_xml``) — la forme
|
| 18 |
+
ré-importable dans un outil de relecture (eScriptorium, Transkribus). Contrairement
|
| 19 |
+
aux confidences, l'ALTO est ici le **livrable demandé** : un échec d'émission lève
|
| 20 |
+
(``AdapterStepError``), il ne dégrade pas en silence.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import re
|
| 28 |
+
import subprocess
|
| 29 |
+
from collections.abc import Callable
|
| 30 |
+
|
| 31 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 32 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 33 |
+
from cinoc.domain.confidence import ConfidenceToken
|
| 34 |
+
from cinoc.domain.errors import AdapterStepError
|
| 35 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 36 |
+
from cinoc.pipeline.run_control import RunControl
|
| 37 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
_VERSION = "1.0"
|
| 42 |
+
_DEFAULT_TIMEOUT = 120.0
|
| 43 |
+
|
| 44 |
+
#: Codes langue Tesseract : ISO 639-3 (≥3 lettres ASCII), combinables par ``+``
|
| 45 |
+
#: (``fra+lat``). ``lang`` finit sur la ligne de commande tesseract → on refuse
|
| 46 |
+
#: tout caractère interprétable comme flag/séparateur (anti-injection).
|
| 47 |
+
_LANG_RE = re.compile(r"^[a-zA-Z]{3,}(?:\+[a-zA-Z]{3,})*$")
|
| 48 |
+
|
| 49 |
+
#: Lanceur de sous-processus, injectable → version sondable en test sans binaire.
|
| 50 |
+
BinaryRunner = Callable[..., "subprocess.CompletedProcess[str]"]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def tesseract_binary_version(*, run: BinaryRunner = subprocess.run) -> str | None:
|
| 54 |
+
"""Version du binaire ``tesseract`` (1ʳᵉ ligne de ``tesseract --version``).
|
| 55 |
+
|
| 56 |
+
**Best-effort, jamais bloquant** : binaire absent, timeout, ou sortie illisible
|
| 57 |
+
→ ``None`` (jamais une panne). Sert la **reproductibilité** (``RunManifest`` §12) :
|
| 58 |
+
deux runs ne sont comparables qu'à version de binaire égale — la version
|
| 59 |
+
d'*adapter* (``_VERSION``) ne dit rien du Tesseract réellement installé.
|
| 60 |
+
``run`` est injecté → la sonde est déterministe en test, sans le binaire.
|
| 61 |
+
|
| 62 |
+
Tesseract émet sa bannière tantôt sur ``stdout`` tantôt sur ``stderr`` selon le
|
| 63 |
+
build → on lit les deux et garde la 1ʳᵉ ligne non vide (ex. ``tesseract 5.3.0``).
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
completed = run(
|
| 67 |
+
["tesseract", "--version"],
|
| 68 |
+
capture_output=True,
|
| 69 |
+
text=True,
|
| 70 |
+
timeout=10,
|
| 71 |
+
check=False,
|
| 72 |
+
)
|
| 73 |
+
except (OSError, subprocess.SubprocessError):
|
| 74 |
+
return None
|
| 75 |
+
lines = ((completed.stdout or "") + "\n" + (completed.stderr or "")).splitlines()
|
| 76 |
+
for line in lines:
|
| 77 |
+
if line.strip():
|
| 78 |
+
return line.strip()
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _invoke_tesseract( # pragma: no cover -- binaire requis (cf. marqueur 'live')
|
| 83 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 84 |
+
) -> str:
|
| 85 |
+
"""Lance tesseract et renvoie le texte. **Isolé → mockable** (CI sans binaire)."""
|
| 86 |
+
try:
|
| 87 |
+
import pytesseract # type: ignore[import-not-found, import-untyped]
|
| 88 |
+
except ImportError as exc:
|
| 89 |
+
raise AdapterStepError(
|
| 90 |
+
"tesseract : pytesseract non installé "
|
| 91 |
+
"(pip install 'cinoc[tesseract]' + binaire tesseract)."
|
| 92 |
+
) from exc
|
| 93 |
+
config = f"--oem {oem} --psm {psm}"
|
| 94 |
+
try:
|
| 95 |
+
text = pytesseract.image_to_string(
|
| 96 |
+
image_path, lang=lang, config=config, timeout=timeout
|
| 97 |
+
)
|
| 98 |
+
except (
|
| 99 |
+
pytesseract.TesseractNotFoundError,
|
| 100 |
+
pytesseract.TesseractError,
|
| 101 |
+
RuntimeError,
|
| 102 |
+
) as exc:
|
| 103 |
+
raise AdapterStepError(
|
| 104 |
+
f"tesseract a échoué sur {image_path!r} : {type(exc).__name__}: {exc}"
|
| 105 |
+
) from exc
|
| 106 |
+
return str(text).strip()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _invoke_tesseract_alto( # pragma: no cover -- binaire requis ('live')
|
| 110 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 111 |
+
) -> bytes:
|
| 112 |
+
"""ALTO XML natif via ``image_to_alto_xml`` (géométrie + texte par mot).
|
| 113 |
+
|
| 114 |
+
Renvoie les octets XML bruts (déjà conformes ALTO) — destinés à la
|
| 115 |
+
ré-importation dans un outil de relecture. Une panne du binaire lève
|
| 116 |
+
``AdapterStepError`` : l'ALTO est le livrable demandé, pas un extra dégradable.
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
import pytesseract # type: ignore[import-not-found, import-untyped]
|
| 120 |
+
except ImportError as exc:
|
| 121 |
+
raise AdapterStepError(
|
| 122 |
+
"tesseract : pytesseract non installé "
|
| 123 |
+
"(pip install 'cinoc[tesseract]' + binaire tesseract)."
|
| 124 |
+
) from exc
|
| 125 |
+
try:
|
| 126 |
+
xml = pytesseract.image_to_alto_xml(
|
| 127 |
+
image_path, lang=lang, config=f"--oem {oem} --psm {psm}", timeout=timeout
|
| 128 |
+
)
|
| 129 |
+
except (
|
| 130 |
+
pytesseract.TesseractNotFoundError,
|
| 131 |
+
pytesseract.TesseractError,
|
| 132 |
+
RuntimeError,
|
| 133 |
+
) as exc:
|
| 134 |
+
raise AdapterStepError(
|
| 135 |
+
f"tesseract ALTO a échoué sur {image_path!r} : "
|
| 136 |
+
f"{type(exc).__name__}: {exc}"
|
| 137 |
+
) from exc
|
| 138 |
+
return bytes(xml)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _invoke_tesseract_confidences( # pragma: no cover -- binaire requis ('live')
|
| 142 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 143 |
+
) -> list[ConfidenceToken]:
|
| 144 |
+
"""Confidences par mot via ``image_to_data`` (TSV natif, conf 0-100)."""
|
| 145 |
+
import pytesseract # type: ignore[import-not-found]
|
| 146 |
+
|
| 147 |
+
data = pytesseract.image_to_data(
|
| 148 |
+
image_path,
|
| 149 |
+
lang=lang,
|
| 150 |
+
config=f"--psm {psm} --oem {oem}",
|
| 151 |
+
timeout=timeout,
|
| 152 |
+
output_type=pytesseract.Output.DICT,
|
| 153 |
+
)
|
| 154 |
+
tokens: list[ConfidenceToken] = []
|
| 155 |
+
for word, conf in zip(data["text"], data["conf"], strict=False):
|
| 156 |
+
if not isinstance(word, str) or not word.strip():
|
| 157 |
+
continue
|
| 158 |
+
value = float(conf)
|
| 159 |
+
if value < 0: # -1 : entrée non textuelle du TSV
|
| 160 |
+
continue
|
| 161 |
+
tokens.append(
|
| 162 |
+
ConfidenceToken(text=word.strip(), confidence=min(value / 100.0, 1.0))
|
| 163 |
+
)
|
| 164 |
+
return tokens
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class TesseractAdapter:
|
| 168 |
+
"""OCR Tesseract 5 ; écrit le texte dans le workspace, renvoie un ``RAW_TEXT``."""
|
| 169 |
+
|
| 170 |
+
def __init__(
|
| 171 |
+
self,
|
| 172 |
+
*,
|
| 173 |
+
label: str,
|
| 174 |
+
lang: str = "fra",
|
| 175 |
+
psm: int = 6,
|
| 176 |
+
oem: int = 3,
|
| 177 |
+
alto: bool = False,
|
| 178 |
+
) -> None:
|
| 179 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 180 |
+
raise AdapterStepError(
|
| 181 |
+
f"TesseractAdapter : label invalide {label!r} "
|
| 182 |
+
"(alphanumérique + _ - uniquement)."
|
| 183 |
+
)
|
| 184 |
+
if not _LANG_RE.fullmatch(lang):
|
| 185 |
+
raise AdapterStepError(
|
| 186 |
+
f"TesseractAdapter : lang invalide {lang!r} "
|
| 187 |
+
"(ISO 639-3, combinable par '+' : ex. 'fra+lat')."
|
| 188 |
+
)
|
| 189 |
+
if not 0 <= psm <= 13:
|
| 190 |
+
raise AdapterStepError(f"TesseractAdapter : psm ∈ [0, 13], reçu {psm}.")
|
| 191 |
+
if not 0 <= oem <= 3:
|
| 192 |
+
raise AdapterStepError(f"TesseractAdapter : oem ∈ [0, 3], reçu {oem}.")
|
| 193 |
+
self._label = label
|
| 194 |
+
self._lang = lang
|
| 195 |
+
self._psm = psm
|
| 196 |
+
self._oem = oem
|
| 197 |
+
#: Émet en plus un artefact ``ALTO_XML`` (ré-import eScriptorium/Transkribus).
|
| 198 |
+
self._alto = alto
|
| 199 |
+
|
| 200 |
+
@property
|
| 201 |
+
def name(self) -> str:
|
| 202 |
+
return f"tesseract:{self._label}"
|
| 203 |
+
|
| 204 |
+
@property
|
| 205 |
+
def version(self) -> str:
|
| 206 |
+
return _VERSION
|
| 207 |
+
|
| 208 |
+
def system_binaries(self) -> dict[str, str]:
|
| 209 |
+
"""Version du **binaire externe** pour le ``RunManifest`` (reproductibilité).
|
| 210 |
+
|
| 211 |
+
Hook de **provenance optionnel** (hors ``Module`` Protocol, qui reste le
|
| 212 |
+
contrat d'exécution unique) : l'orchestrateur l'appelle par *duck-typing*
|
| 213 |
+
sur les modules qui l'exposent et fusionne le résultat dans
|
| 214 |
+
``system_binaries_lock``. Best-effort : binaire absent → ``{}``.
|
| 215 |
+
"""
|
| 216 |
+
version = tesseract_binary_version()
|
| 217 |
+
return {"tesseract": version} if version is not None else {}
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 221 |
+
return frozenset({ArtifactType.IMAGE})
|
| 222 |
+
|
| 223 |
+
@property
|
| 224 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 225 |
+
types = {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES}
|
| 226 |
+
if self._alto:
|
| 227 |
+
types.add(ArtifactType.ALTO_XML)
|
| 228 |
+
return frozenset(types)
|
| 229 |
+
|
| 230 |
+
def execute(
|
| 231 |
+
self,
|
| 232 |
+
inputs: dict[ArtifactType, Artifact],
|
| 233 |
+
params: dict[str, ParamValue],
|
| 234 |
+
context: RunContext,
|
| 235 |
+
control: RunControl,
|
| 236 |
+
) -> StepOutput:
|
| 237 |
+
control.raise_if_cancelled()
|
| 238 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 239 |
+
if image is None or image.uri is None:
|
| 240 |
+
raise AdapterStepError(
|
| 241 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 242 |
+
)
|
| 243 |
+
if context.workspace_uri is None:
|
| 244 |
+
raise AdapterStepError(
|
| 245 |
+
f"{self.name} : workspace requis (RunContext.workspace_uri)."
|
| 246 |
+
)
|
| 247 |
+
timeout = max(0.001, context.deadline.clamp_to_remaining(_DEFAULT_TIMEOUT))
|
| 248 |
+
text = _invoke_tesseract(
|
| 249 |
+
image_path=image.uri,
|
| 250 |
+
lang=self._lang,
|
| 251 |
+
psm=self._psm,
|
| 252 |
+
oem=self._oem,
|
| 253 |
+
timeout=timeout,
|
| 254 |
+
)
|
| 255 |
+
output_path = workspace_artifact_path(
|
| 256 |
+
context.workspace_uri, context.document_id, self._label, "txt"
|
| 257 |
+
)
|
| 258 |
+
output_path.write_text(text, encoding="utf-8")
|
| 259 |
+
# Confidences best-effort : un échec d'extraction ne doit pas faire
|
| 260 |
+
# échouer un OCR réussi — sidecar vide + avertissement.
|
| 261 |
+
try:
|
| 262 |
+
tokens = _invoke_tesseract_confidences(
|
| 263 |
+
image_path=image.uri,
|
| 264 |
+
lang=self._lang,
|
| 265 |
+
psm=self._psm,
|
| 266 |
+
oem=self._oem,
|
| 267 |
+
timeout=timeout,
|
| 268 |
+
)
|
| 269 |
+
except (
|
| 270 |
+
AdapterStepError,
|
| 271 |
+
ImportError,
|
| 272 |
+
RuntimeError,
|
| 273 |
+
ValueError,
|
| 274 |
+
OSError,
|
| 275 |
+
) as exc:
|
| 276 |
+
logger.warning(
|
| 277 |
+
"[tesseract] confidences dégradées (sidecar vide) : %s", exc
|
| 278 |
+
)
|
| 279 |
+
tokens = []
|
| 280 |
+
sidecar = json.dumps(
|
| 281 |
+
[token.model_dump() for token in tokens], ensure_ascii=False
|
| 282 |
+
).encode("utf-8")
|
| 283 |
+
sidecar_path = workspace_artifact_path(
|
| 284 |
+
context.workspace_uri,
|
| 285 |
+
context.document_id,
|
| 286 |
+
self._label,
|
| 287 |
+
"confidences.json",
|
| 288 |
+
)
|
| 289 |
+
sidecar_path.write_bytes(sidecar)
|
| 290 |
+
artifacts: dict[ArtifactType, Artifact] = {
|
| 291 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 292 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 293 |
+
document_id=context.document_id,
|
| 294 |
+
type=ArtifactType.RAW_TEXT,
|
| 295 |
+
uri=str(output_path),
|
| 296 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 297 |
+
),
|
| 298 |
+
ArtifactType.CONFIDENCES: Artifact(
|
| 299 |
+
id=f"{context.document_id}:{self.name}:confidences",
|
| 300 |
+
document_id=context.document_id,
|
| 301 |
+
type=ArtifactType.CONFIDENCES,
|
| 302 |
+
uri=str(sidecar_path),
|
| 303 |
+
content_hash=compute_content_hash(sidecar),
|
| 304 |
+
),
|
| 305 |
+
}
|
| 306 |
+
if self._alto:
|
| 307 |
+
# ALTO demandé → livrable : un échec lève (≠ confidences best-effort).
|
| 308 |
+
alto_bytes = _invoke_tesseract_alto(
|
| 309 |
+
image_path=image.uri,
|
| 310 |
+
lang=self._lang,
|
| 311 |
+
psm=self._psm,
|
| 312 |
+
oem=self._oem,
|
| 313 |
+
timeout=timeout,
|
| 314 |
+
)
|
| 315 |
+
alto_path = workspace_artifact_path(
|
| 316 |
+
context.workspace_uri,
|
| 317 |
+
context.document_id,
|
| 318 |
+
self._label,
|
| 319 |
+
"alto.xml",
|
| 320 |
+
)
|
| 321 |
+
alto_path.write_bytes(alto_bytes)
|
| 322 |
+
artifacts[ArtifactType.ALTO_XML] = Artifact(
|
| 323 |
+
id=f"{context.document_id}:{self.name}:alto_xml",
|
| 324 |
+
document_id=context.document_id,
|
| 325 |
+
type=ArtifactType.ALTO_XML,
|
| 326 |
+
uri=str(alto_path),
|
| 327 |
+
content_hash=compute_content_hash(alto_bytes),
|
| 328 |
+
)
|
| 329 |
+
return StepOutput(artifacts=artifacts)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
__all__ = ["TesseractAdapter", "tesseract_binary_version"]
|
cinoc/adapters/preprocess/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Prétraitement d'image (couche 5) : ``IMAGE → IMAGE``."""
|
| 2 |
+
|
| 3 |
+
from cinoc.adapters.preprocess.image_prep import ImagePreprocessor
|
| 4 |
+
|
| 5 |
+
__all__ = ["ImagePreprocessor"]
|
cinoc/adapters/preprocess/_ops.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Opérations de prétraitement — **numpy pur**, sans décodage d'image.
|
| 2 |
+
|
| 3 |
+
Séparées de l'adapter pour qu'elles se testent sur des matrices construites à la
|
| 4 |
+
main, sans Pillow et sans fichier. C'est la même discipline que les métriques :
|
| 5 |
+
les maths d'un côté, l'entrée/sortie de l'autre.
|
| 6 |
+
|
| 7 |
+
**Aucun seuil arbitraire.** C'est une contrainte de conception, pas un hasard :
|
| 8 |
+
une mesure de qualité d'image a été retirée de ce dépôt précisément parce que son
|
| 9 |
+
score composite reposait sur des constantes non validées (D-190). Ici, le seuil
|
| 10 |
+
de binarisation est **calculé sur l'image** (Otsu maximise la variance
|
| 11 |
+
inter-classes), et l'angle de redressement est **cherché**, pas postulé. Les deux
|
| 12 |
+
seules constantes sont des **bornes de recherche** — l'amplitude et le pas —, qui
|
| 13 |
+
ne décident de rien : elles disent seulement où l'on cherche.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import numpy as np
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def to_grayscale(image: np.ndarray) -> np.ndarray:
|
| 22 |
+
"""RVB → niveaux de gris (luminance ITU-R BT.601), en ``uint8``.
|
| 23 |
+
|
| 24 |
+
Déjà en gris (2 dimensions) → renvoyé tel quel : l'opération est idempotente,
|
| 25 |
+
ce qui permet de la chaîner sans y penser.
|
| 26 |
+
"""
|
| 27 |
+
if image.ndim == 2:
|
| 28 |
+
return image.astype(np.uint8, copy=False)
|
| 29 |
+
poids = np.array([0.299, 0.587, 0.114], dtype=np.float64)
|
| 30 |
+
gris = image[..., :3].astype(np.float64) @ poids
|
| 31 |
+
return np.clip(np.rint(gris), 0, 255).astype(np.uint8)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def otsu_threshold(gray: np.ndarray) -> int:
|
| 35 |
+
"""Seuil d'Otsu : celui qui **maximise la variance inter-classes**.
|
| 36 |
+
|
| 37 |
+
Calculé sur l'histogramme de l'image, donc propre à elle : deux pages
|
| 38 |
+
différemment exposées reçoivent deux seuils différents, ce qu'une constante
|
| 39 |
+
ne saurait pas faire. Image uniforme → renvoie sa valeur (aucune séparation
|
| 40 |
+
n'a de sens, et un seuil médian inventerait du contraste).
|
| 41 |
+
"""
|
| 42 |
+
histogramme = np.bincount(gray.reshape(-1), minlength=256).astype(np.float64)
|
| 43 |
+
total = histogramme.sum()
|
| 44 |
+
if total == 0:
|
| 45 |
+
return 0
|
| 46 |
+
niveaux = np.arange(256, dtype=np.float64)
|
| 47 |
+
poids_bas = np.cumsum(histogramme)
|
| 48 |
+
poids_haut = total - poids_bas
|
| 49 |
+
somme_totale = float(niveaux @ histogramme)
|
| 50 |
+
somme_bas = np.cumsum(niveaux * histogramme)
|
| 51 |
+
# Classes vides → variance nulle : on les écarte au lieu de diviser par zéro.
|
| 52 |
+
valide = (poids_bas > 0) & (poids_haut > 0)
|
| 53 |
+
if not valide.any():
|
| 54 |
+
return int(gray.reshape(-1)[0])
|
| 55 |
+
moy_bas = np.divide(somme_bas, poids_bas, out=np.zeros(256), where=valide)
|
| 56 |
+
moy_haut = np.divide(
|
| 57 |
+
somme_totale - somme_bas, poids_haut, out=np.zeros(256), where=valide
|
| 58 |
+
)
|
| 59 |
+
inter = poids_bas * poids_haut * (moy_bas - moy_haut) ** 2
|
| 60 |
+
inter[~valide] = -1.0
|
| 61 |
+
return int(np.argmax(inter))
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def binarize(gray: np.ndarray, threshold: int) -> np.ndarray:
|
| 65 |
+
"""Gris → noir et blanc (0 / 255) au seuil donné. Encre = sombre = 0."""
|
| 66 |
+
return np.where(gray > threshold, np.uint8(255), np.uint8(0))
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def estimate_skew(
|
| 70 |
+
gray: np.ndarray, *, amplitude_deg: float = 5.0, pas_deg: float = 0.5
|
| 71 |
+
) -> float:
|
| 72 |
+
"""Angle d'inclinaison, en degrés, par profil de projection.
|
| 73 |
+
|
| 74 |
+
Une page droite a des lignes de texte horizontales : la somme de l'encre par
|
| 75 |
+
rangée y est donc très **contrastée** — des pics sur le texte, des creux
|
| 76 |
+
entre les lignes. Incliner la page lisse ce profil. On cherche l'angle qui
|
| 77 |
+
maximise la variance du profil : c'est celui qui remet les lignes à plat.
|
| 78 |
+
|
| 79 |
+
**L'accumulateur a une hauteur fixe**, dimensionnée pour l'angle maximal, et
|
| 80 |
+
rien n'y est écrêté. C'est la seule façon de comparer des angles entre eux :
|
| 81 |
+
une première version rabattait les rangées hors cadre sur la première et la
|
| 82 |
+
dernière, ce qui y empilait de l'encre et gonflait la variance d'autant plus
|
| 83 |
+
que l'angle était grand. Le maximum tombait alors **toujours** sur la borne
|
| 84 |
+
de recherche — y compris pour une page parfaitement droite.
|
| 85 |
+
|
| 86 |
+
``amplitude_deg`` et ``pas_deg`` bornent la recherche, ils ne la décident
|
| 87 |
+
pas. Amplitude volontairement modeste : au-delà, ce n'est plus une
|
| 88 |
+
inclinaison de numérisation mais une page tournée, qui relève d'une autre
|
| 89 |
+
opération. Image trop petite → ``0.0`` : rien à mesurer, et on ne devine pas.
|
| 90 |
+
"""
|
| 91 |
+
if gray.ndim != 2 or min(gray.shape) < 16:
|
| 92 |
+
return 0.0
|
| 93 |
+
encre = 255.0 - gray.astype(np.float64)
|
| 94 |
+
hauteur, largeur = encre.shape
|
| 95 |
+
pas = max(pas_deg, 0.01)
|
| 96 |
+
marge = int(np.ceil(np.tan(np.radians(amplitude_deg)) * largeur)) + 1
|
| 97 |
+
etendue = hauteur + 2 * marge
|
| 98 |
+
lignes = np.arange(hauteur, dtype=np.float64)[:, None]
|
| 99 |
+
colonnes = np.arange(largeur, dtype=np.float64)[None, :]
|
| 100 |
+
meilleur_angle, meilleure_variance = 0.0, -1.0
|
| 101 |
+
for k in range(-int(amplitude_deg / pas), int(amplitude_deg / pas) + 1):
|
| 102 |
+
angle = k * pas
|
| 103 |
+
# Cisaillement vertical : approxime la rotation pour de petits angles,
|
| 104 |
+
# sans ré-échantillonner l'image — donc sans en inventer les pixels.
|
| 105 |
+
decal = np.tan(np.radians(angle)) * colonnes
|
| 106 |
+
rangs = np.rint(lignes + decal).astype(np.int64) + marge
|
| 107 |
+
profil = np.zeros(etendue, dtype=np.float64)
|
| 108 |
+
np.add.at(profil, rangs.ravel(), encre.ravel())
|
| 109 |
+
variance = float(profil.var())
|
| 110 |
+
if variance > meilleure_variance:
|
| 111 |
+
meilleure_variance, meilleur_angle = variance, angle
|
| 112 |
+
return meilleur_angle
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
__all__ = ["binarize", "estimate_skew", "otsu_threshold", "to_grayscale"]
|
cinoc/adapters/preprocess/image_prep.py
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``ImagePreprocessor`` — ``IMAGE → IMAGE`` (couche 5).
|
| 2 |
+
|
| 3 |
+
Le banc savait lire une image, la segmenter, la transcrire, la corriger. Il ne
|
| 4 |
+
savait pas la **préparer** — alors que tous les systèmes de référence ont cet
|
| 5 |
+
étage : OCR-D en fait une famille entière (binarisation, rognage, débruitage,
|
| 6 |
+
redressement, dé-gondolage), PP-Structure ouvre par un module d'orientation et
|
| 7 |
+
de rectification, et NDNP le pratique avant Tesseract.
|
| 8 |
+
|
| 9 |
+
Sans ce rôle, une question restait sans réponse mesurable : **restaurer avant de
|
| 10 |
+
lire, est-ce que ça paie ?** Un banc ne doit pas y répondre par un score de
|
| 11 |
+
qualité arbitraire — c'est ce qui a fait retirer la mesure de qualité d'image de
|
| 12 |
+
ce dépôt (D-190) — mais en comparant deux pipelines sur le seul critère qui
|
| 13 |
+
tranche, le taux d'erreur.
|
| 14 |
+
|
| 15 |
+
Le pool d'artefacts étant indexé par type, l'``IMAGE`` produite **remplace**
|
| 16 |
+
l'originale pour les étapes suivantes : c'est le comportement voulu. Une étape
|
| 17 |
+
qui tiendrait à l'image d'origine la nomme explicitement dans son ``inputs_from``.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import io
|
| 23 |
+
import logging
|
| 24 |
+
from pathlib import Path
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 30 |
+
from cinoc.adapters.preprocess._ops import (
|
| 31 |
+
binarize,
|
| 32 |
+
estimate_skew,
|
| 33 |
+
otsu_threshold,
|
| 34 |
+
to_grayscale,
|
| 35 |
+
)
|
| 36 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 37 |
+
from cinoc.domain.errors import AdapterStepError
|
| 38 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 39 |
+
from cinoc.pipeline.run_control import RunControl
|
| 40 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
_VERSION = "1.0"
|
| 45 |
+
|
| 46 |
+
#: Opérations disponibles, dans l'ordre où elles ont un sens : on redresse avant
|
| 47 |
+
#: de binariser (une binarisation fige les pixels, une rotation les ré-interpole).
|
| 48 |
+
OPERATIONS: tuple[str, ...] = ("grayscale", "deskew", "binarize")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class ImagePreprocessor:
|
| 52 |
+
"""Prépare une image avant lecture. Déterministe, local, sans modèle.
|
| 53 |
+
|
| 54 |
+
``operations`` est une liste **séparée par des virgules** — les paramètres
|
| 55 |
+
d'adapter sont plats par contrat, et une liste imbriquée n'y entrerait pas.
|
| 56 |
+
L'ordre déclaré est respecté tel quel : c'est un choix de l'utilisateur, et
|
| 57 |
+
deux ordres différents sont deux pipelines différents à comparer.
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
def __init__(
|
| 61 |
+
self,
|
| 62 |
+
*,
|
| 63 |
+
label: str,
|
| 64 |
+
operations: str = "deskew,binarize",
|
| 65 |
+
skew_amplitude_deg: float = 5.0,
|
| 66 |
+
) -> None:
|
| 67 |
+
self._label = label
|
| 68 |
+
self._skew = skew_amplitude_deg
|
| 69 |
+
demandees = [o.strip() for o in operations.split(",") if o.strip()]
|
| 70 |
+
inconnues = [o for o in demandees if o not in OPERATIONS]
|
| 71 |
+
if inconnues:
|
| 72 |
+
raise AdapterStepError(
|
| 73 |
+
f"preprocess : opération(s) inconnue(s) {inconnues} "
|
| 74 |
+
f"(attendu parmi {list(OPERATIONS)})."
|
| 75 |
+
)
|
| 76 |
+
if not demandees:
|
| 77 |
+
raise AdapterStepError(
|
| 78 |
+
"preprocess : aucune opération demandée — une étape qui ne fait "
|
| 79 |
+
"rien fausserait la comparaison en se faisant passer pour un "
|
| 80 |
+
"traitement."
|
| 81 |
+
)
|
| 82 |
+
self._operations = tuple(demandees)
|
| 83 |
+
|
| 84 |
+
@property
|
| 85 |
+
def name(self) -> str:
|
| 86 |
+
return f"preprocess:{self._label}"
|
| 87 |
+
|
| 88 |
+
@property
|
| 89 |
+
def version(self) -> str:
|
| 90 |
+
return _VERSION
|
| 91 |
+
|
| 92 |
+
@property
|
| 93 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 94 |
+
return frozenset({ArtifactType.IMAGE})
|
| 95 |
+
|
| 96 |
+
@property
|
| 97 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 98 |
+
return frozenset({ArtifactType.IMAGE})
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def operations(self) -> tuple[str, ...]:
|
| 102 |
+
return self._operations
|
| 103 |
+
|
| 104 |
+
def execute(
|
| 105 |
+
self,
|
| 106 |
+
inputs: dict[ArtifactType, Artifact],
|
| 107 |
+
params: dict[str, ParamValue], # noqa: ARG002 — contrat Module
|
| 108 |
+
context: RunContext,
|
| 109 |
+
control: RunControl,
|
| 110 |
+
) -> StepOutput:
|
| 111 |
+
control.raise_if_cancelled()
|
| 112 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 113 |
+
if image is None or image.uri is None:
|
| 114 |
+
raise AdapterStepError(
|
| 115 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 116 |
+
)
|
| 117 |
+
source = Path(image.uri)
|
| 118 |
+
if not source.is_file():
|
| 119 |
+
raise AdapterStepError(f"{self.name} : image introuvable ({source}).")
|
| 120 |
+
|
| 121 |
+
try:
|
| 122 |
+
import PIL # noqa: PLC0415
|
| 123 |
+
from PIL import Image, UnidentifiedImageError # noqa: PLC0415
|
| 124 |
+
except ImportError as exc: # pragma: no cover — dépend de l'installation
|
| 125 |
+
raise AdapterStepError(
|
| 126 |
+
f"{self.name} : Pillow requis pour lire l'image (extra [images])."
|
| 127 |
+
) from exc
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
with Image.open(source) as ouverte:
|
| 131 |
+
tableau = _en_tableau(ouverte)
|
| 132 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 133 |
+
raise AdapterStepError(
|
| 134 |
+
f"{self.name} : image illisible ({source.name}) — {exc}"
|
| 135 |
+
) from exc
|
| 136 |
+
|
| 137 |
+
for operation in self._operations:
|
| 138 |
+
control.raise_if_cancelled()
|
| 139 |
+
tableau, note = self._appliquer(operation, tableau, PIL)
|
| 140 |
+
# Journalisé plutôt que silencieux : l'angle réellement mesuré et le
|
| 141 |
+
# seuil réellement calculé sont ce qui distingue deux exécutions sur
|
| 142 |
+
# deux pages. Les taire rendrait le traitement inexplicable.
|
| 143 |
+
logger.info("[preprocess] %s · %s", context.document_id, note)
|
| 144 |
+
|
| 145 |
+
# PNG : la sortie d'une binarisation ne survit pas à une compression avec
|
| 146 |
+
# perte, et un banc ne peut pas mesurer un traitement que l'encodage a
|
| 147 |
+
# déjà modifié.
|
| 148 |
+
tampon = io.BytesIO()
|
| 149 |
+
Image.fromarray(tableau).save(tampon, format="PNG", optimize=True)
|
| 150 |
+
octets = tampon.getvalue()
|
| 151 |
+
|
| 152 |
+
cible = (
|
| 153 |
+
workspace_artifact_path(
|
| 154 |
+
context.workspace_uri, context.document_id, self._label, "png"
|
| 155 |
+
)
|
| 156 |
+
if context.workspace_uri
|
| 157 |
+
else source.with_name(f"{source.stem}.{self._label}.png")
|
| 158 |
+
)
|
| 159 |
+
cible.parent.mkdir(parents=True, exist_ok=True)
|
| 160 |
+
cible.write_bytes(octets)
|
| 161 |
+
return StepOutput(
|
| 162 |
+
artifacts={
|
| 163 |
+
ArtifactType.IMAGE: Artifact(
|
| 164 |
+
id=f"{context.document_id}:{self._label}:image",
|
| 165 |
+
document_id=context.document_id,
|
| 166 |
+
type=ArtifactType.IMAGE,
|
| 167 |
+
uri=str(cible),
|
| 168 |
+
content_hash=compute_content_hash(octets),
|
| 169 |
+
produced_by_step=self._label,
|
| 170 |
+
)
|
| 171 |
+
}
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
def _appliquer(
|
| 175 |
+
self, operation: str, matrice: np.ndarray, pil: Any
|
| 176 |
+
) -> tuple[np.ndarray, str]:
|
| 177 |
+
"""Applique une opération, et rend la note qui dit ce qu'elle a fait."""
|
| 178 |
+
if operation == "grayscale":
|
| 179 |
+
return to_grayscale(matrice), "grayscale"
|
| 180 |
+
if operation == "deskew":
|
| 181 |
+
angle = estimate_skew(to_grayscale(matrice), amplitude_deg=self._skew)
|
| 182 |
+
if angle == 0.0:
|
| 183 |
+
# Le dire : « aucune rotation appliquée » et « inclinaison
|
| 184 |
+
# mesurée nulle » se ressemblent dans un rapport, et ne veulent
|
| 185 |
+
# pas dire la même chose.
|
| 186 |
+
return matrice, "deskew : 0,00° (aucune inclinaison mesurée)"
|
| 187 |
+
tournee = pil.Image.fromarray(matrice).rotate(
|
| 188 |
+
-angle,
|
| 189 |
+
resample=pil.Image.Resampling.BICUBIC,
|
| 190 |
+
expand=False,
|
| 191 |
+
fillcolor=_fond(matrice),
|
| 192 |
+
)
|
| 193 |
+
return np.asarray(tournee), f"deskew : {angle:+.2f}°"
|
| 194 |
+
gris = to_grayscale(matrice)
|
| 195 |
+
seuil = otsu_threshold(gris)
|
| 196 |
+
return binarize(gris, seuil), f"binarize : seuil d'Otsu {seuil}"
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _en_tableau(ouverte: Any) -> np.ndarray:
|
| 200 |
+
"""Image PIL → tableau numpy, en RVB ou en gris (jamais en palette)."""
|
| 201 |
+
converti = ouverte if ouverte.mode in {"L", "RGB"} else ouverte.convert("RGB")
|
| 202 |
+
return np.asarray(converti)
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def _fond(matrice: np.ndarray) -> int | tuple[int, int, int]:
|
| 206 |
+
"""Remplissage d'une rotation : le blanc du support, jamais du noir.
|
| 207 |
+
|
| 208 |
+
Combler avec du noir créerait de fausses zones d'encre dans les coins, que
|
| 209 |
+
la binarisation prendrait ensuite pour du texte.
|
| 210 |
+
"""
|
| 211 |
+
return 255 if matrice.ndim == 2 else (255, 255, 255)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
__all__ = ["OPERATIONS", "ImagePreprocessor"]
|
cinoc/adapters/storage/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stockage (couche 5) : état des jobs de run lancés via le web/CLI long.
|
| 2 |
+
|
| 3 |
+
En mémoire par choix : suffit au lanceur mono-worker du Space. Le journal
|
| 4 |
+
d'événements du ``JobStore`` porte la reprise SSE ``Last-Event-ID`` (réserve
|
| 5 |
+
R-10 d'ANALYSE_COUCHE_5, levée).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.storage.history_store import (
|
| 11 |
+
HistoryRecord,
|
| 12 |
+
HistoryStore,
|
| 13 |
+
Regression,
|
| 14 |
+
)
|
| 15 |
+
from cinoc.adapters.storage.job_store import Job, JobState, JobStore
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"HistoryRecord",
|
| 19 |
+
"HistoryStore",
|
| 20 |
+
"Job",
|
| 21 |
+
"JobState",
|
| 22 |
+
"JobStore",
|
| 23 |
+
"Regression",
|
| 24 |
+
]
|