Spaces:
Sleeping
Sleeping
cinoc-deploy commited on
Commit ·
a2907ce
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 +360 -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 +130 -0
- cinoc/adapters/layout/__init__.py +3 -0
- cinoc/adapters/layout/_base.py +114 -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/to_text.py +106 -0
- cinoc/adapters/llm/__init__.py +15 -0
- cinoc/adapters/llm/_base.py +285 -0
- cinoc/adapters/llm/anthropic.py +193 -0
- cinoc/adapters/llm/mistral.py +221 -0
- cinoc/adapters/llm/ollama.py +195 -0
- cinoc/adapters/llm/openai.py +188 -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/storage/__init__.py +24 -0
- cinoc/adapters/storage/history_store.py +191 -0
- cinoc/adapters/storage/job_store.py +159 -0
- cinoc/adapters/storage/publisher.py +147 -0
- cinoc/app/ANALYSE_COUCHE_6.md +263 -0
- cinoc/app/__init__.py +34 -0
- cinoc/app/alto_store.py +72 -0
- cinoc/app/corpus_import.py +449 -0
.gitattributes
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.pdf filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.woff2 filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.woff filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.otf filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.ttf filter=lfs diff=lfs merge=lfs -text
|
Dockerfile
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Image **moteur** Cinoc pour un Hugging Face Space (SDK: docker).
|
| 2 |
+
#
|
| 3 |
+
# ≠ la vitrine lecture seule (deploy/Dockerfile{,.space}) : celle-ci **bake un vrai
|
| 4 |
+
# moteur OCR gratuit** (Tesseract 5 + langues fra/lat/eng) pour qu'un visiteur du
|
| 5 |
+
# Space public lance un run Tesseract **sans clé ni installation**. Les moteurs
|
| 6 |
+
# cloud et les plugins tiers restent *gated* (mode public, fail-closed côté appli).
|
| 7 |
+
#
|
| 8 |
+
# Contexte de build « racine plate » (Dockerfile / requirements.txt / pyproject.toml
|
| 9 |
+
# / cinoc / reports TOUS à la racine) — assemblé par .github/workflows/deploy-space.yml,
|
| 10 |
+
# comme Dockerfile.space.
|
| 11 |
+
FROM python:3.11-slim
|
| 12 |
+
|
| 13 |
+
# ── Dépendances système : Tesseract 5 + langues + libs image (Pillow) ──────────
|
| 14 |
+
# Les langues sont baquées (pas téléchargées au runtime) : un Space free-tier n'a
|
| 15 |
+
# pas d'accès réseau garanti au démarrage, et le déterminisme exige une version de
|
| 16 |
+
# binaire figée. ``fra`` est le besoin patrimonial central ; ``lat`` (latin) et
|
| 17 |
+
# ``eng`` complètent. Pas de pin de version exacte (Debian point-release rebump
|
| 18 |
+
# fréquemment ``5.3.0-2`` → ``+deb12u1`` → casse le build) : la repro repose sur la
|
| 19 |
+
# capture de ``tesseract --version`` dans ``RunManifest.system_binaries_lock``.
|
| 20 |
+
RUN apt-get update -o Acquire::Retries=3 \
|
| 21 |
+
&& apt-get install -y --no-install-recommends -o Acquire::Retries=3 \
|
| 22 |
+
tesseract-ocr \
|
| 23 |
+
tesseract-ocr-fra \
|
| 24 |
+
tesseract-ocr-lat \
|
| 25 |
+
tesseract-ocr-eng \
|
| 26 |
+
libtiff6 \
|
| 27 |
+
libjpeg62-turbo \
|
| 28 |
+
libpng16-16 \
|
| 29 |
+
libwebp7 \
|
| 30 |
+
&& apt-get clean \
|
| 31 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 32 |
+
|
| 33 |
+
# ── Variables d'exécution ──────────────────────────────────────────────────────
|
| 34 |
+
# TESSDATA_PREFIX : emplacement Debian bookworm des modèles Tesseract 5.
|
| 35 |
+
ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata
|
| 36 |
+
# OMP_THREAD_LIMIT=1 : OBLIGATOIRE sur free-tier 2 vCPU. Tesseract LSTM (oem 3)
|
| 37 |
+
# parallélise via OpenMP ; non borné sur un CPU partagé il **deadlock** (incident
|
| 38 |
+
# documenté côté Picarones, 2026-05-16) — l'OCR gèle et chaque document timeoute.
|
| 39 |
+
# Mono-thread par appel = plus rapide ET déterministe ici.
|
| 40 |
+
ENV OMP_THREAD_LIMIT=1
|
| 41 |
+
# Mode public (verrou *fail-closed*) **non forcé** : par défaut l'instance exécute
|
| 42 |
+
# ses moteurs (cloud compris) avec la clé de l'opérateur — pas de 403. Qui expose
|
| 43 |
+
# un Space **public** et veut protéger sa clé d'un visiteur pose lui-même
|
| 44 |
+
# CINOC_PUBLIC_MODE=true (alors seul le socle gratuit tesseract s'exécute).
|
| 45 |
+
ENV PYTHONUNBUFFERED=1
|
| 46 |
+
|
| 47 |
+
# ── Smoke-test du binaire au BUILD (fail-fast) ─────────────────────────────────
|
| 48 |
+
# Un Tesseract cassé OU figé doit casser le BUILD (le Space garde l'image
|
| 49 |
+
# précédente fonctionnelle), pas déployer une image qui gèle en silence :
|
| 50 |
+
# (a) le binaire charge ses libs et expose la langue ``fra`` (sinon build refusé) ;
|
| 51 |
+
# (b) une reconnaissance réelle sur une image générée se TERMINE sous 30 s — un
|
| 52 |
+
# timeout (rc 124) = deadlock OpenMP / libs incohérentes → build refusé.
|
| 53 |
+
RUN set -eu; \
|
| 54 |
+
timeout 30 tesseract --version; \
|
| 55 |
+
timeout 30 tesseract --list-langs 2>&1 | grep -qx fra; \
|
| 56 |
+
printf 'P1\n16 16\n' > /tmp/smoke.pbm; \
|
| 57 |
+
i=0; while [ "$i" -lt 16 ]; do \
|
| 58 |
+
printf '1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0\n' >> /tmp/smoke.pbm; \
|
| 59 |
+
i=$((i + 1)); \
|
| 60 |
+
done; \
|
| 61 |
+
rc=0; \
|
| 62 |
+
timeout 30 tesseract /tmp/smoke.pbm - -l fra --psm 6 >/dev/null 2>&1 || rc=$?; \
|
| 63 |
+
rm -f /tmp/smoke.pbm; \
|
| 64 |
+
if [ "$rc" = 124 ]; then \
|
| 65 |
+
echo "FATAL: tesseract a gelé (timeout) — build refusé, libs incohérentes." >&2; \
|
| 66 |
+
exit 1; \
|
| 67 |
+
fi
|
| 68 |
+
|
| 69 |
+
# ── Utilisateur non-root (sécurité d'un service exposé) ────────────────────────
|
| 70 |
+
RUN useradd --create-home --uid 1000 cinoc
|
| 71 |
+
WORKDIR /home/cinoc/app
|
| 72 |
+
|
| 73 |
+
# ── Dépendances Python d'abord (cache de couche) ───────────────────────────────
|
| 74 |
+
# requirements.txt fige le cœur + l'extra [serve] + pytesseract (wrapper du binaire).
|
| 75 |
+
COPY requirements.txt ./requirements.txt
|
| 76 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 77 |
+
|
| 78 |
+
# ── Code + paquet (sans deps : tout est déjà figé ci-dessus) ───────────────────
|
| 79 |
+
COPY pyproject.toml README.md ./
|
| 80 |
+
COPY cinoc ./cinoc
|
| 81 |
+
RUN pip install --no-cache-dir --no-deps .
|
| 82 |
+
|
| 83 |
+
# ── Rapports bakés (lecture seule) — dossier toujours présent ──────────────────
|
| 84 |
+
COPY reports ./reports
|
| 85 |
+
ENV CINOC_REPORTS_DIR=/home/cinoc/app/reports
|
| 86 |
+
|
| 87 |
+
USER cinoc
|
| 88 |
+
EXPOSE 7860
|
| 89 |
+
# 0.0.0.0 attendu en conteneur (le réseau est géré par la plateforme).
|
| 90 |
+
CMD ["cinoc", "serve", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Cinoc — banc d'essai OCR/HTR/VLM
|
| 3 |
+
emoji: 📜
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: gray
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
license: apache-2.0
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
# Cinoc — banc d'essai OCR/HTR/VLM
|
| 13 |
+
|
| 14 |
+
Banc d'essai **déterministe** de pipelines de transcription (OCR / HTR / VLM).
|
| 15 |
+
Ce Space **exécute un vrai OCR Tesseract gratuitement** — **sans clé ni
|
| 16 |
+
installation** : déposez un corpus, lancez Tesseract, obtenez un rapport CER/WER/MER
|
| 17 |
+
réel. Il sert aussi des rapports `RunResult` pré-calculés, rendus en HTML autonome.
|
| 18 |
+
|
| 19 |
+
- `/` — liste des rapports disponibles
|
| 20 |
+
- `/library` — préparer un corpus (upload ZIP)
|
| 21 |
+
- `/benchmark` — lancer un run **Tesseract** (gratuit, local) sur un corpus
|
| 22 |
+
- `/reports/<nom>` — un rapport rendu en HTML
|
| 23 |
+
- `/health` — sonde de vivacité
|
| 24 |
+
|
| 25 |
+
**Par défaut, l'instance exécute ses moteurs avec la clé de son opérateur** —
|
| 26 |
+
aucun blocage. Sans clé posée, seul le **socle gratuit** tourne (Tesseract, aucun
|
| 27 |
+
secret, aucun appel facturé) ; dès qu'une clé est présente (`MISTRAL_API_KEY`,
|
| 28 |
+
`OPENAI_API_KEY`…), les moteurs **cloud** (OCR→LLM, VLM) s'exécutent normalement.
|
| 29 |
+
|
| 30 |
+
## Utiliser un moteur cloud (Mistral, OpenAI…)
|
| 31 |
+
|
| 32 |
+
1. **Secret** : `MISTRAL_API_KEY` = votre clé (Settings → Variables and secrets).
|
| 33 |
+
Le SDK `mistralai` est déjà dans `requirements.txt`. Redéployez.
|
| 34 |
+
2. C'est tout : le menu déroulant « Modèle » du Banc d'essai se remplit directement
|
| 35 |
+
depuis l'API Mistral (`models.list`) et les prompts sont éditables par concurrent.
|
| 36 |
+
|
| 37 |
+
> 🔴 **Sécurité** : sur un Space **public**, la clé que vous posez est dépensable par
|
| 38 |
+
> n'importe quel visiteur. Rendez le Space **Private** (Settings → Visibility →
|
| 39 |
+
> Private) si vous y posez une clé facturée, **ou** activez le verrou ci-dessous.
|
| 40 |
+
|
| 41 |
+
## Verrou « mode public » — opt-in, pour protéger une clé sur un Space public
|
| 42 |
+
|
| 43 |
+
Si vous tenez à exposer un Space **public** *tout en* gardant une clé posée sans
|
| 44 |
+
qu'un visiteur puisse la dépenser, activez le verrou **fail-closed** : variable
|
| 45 |
+
`CINOC_PUBLIC_MODE` = `true` (Settings → Variables and secrets). Alors seul le
|
| 46 |
+
**socle gratuit** (Tesseract) s'exécute ; les moteurs **cloud**, les **imports
|
| 47 |
+
distants** (IIIF/Gallica/…) et les **plugins tiers** sont **refusés** (`403`). Le
|
| 48 |
+
verrou est **désactivé par défaut** : ne le posez que si ce scénario est le vôtre.
|
| 49 |
+
|
| 50 |
+
> Ce fichier est l'en-tête de configuration du Space Hugging Face (`sdk: docker`).
|
| 51 |
+
> Au déploiement, il devient le `README.md` racine du dépôt du Space ; le
|
| 52 |
+
> `Dockerfile` et `requirements.txt` vivent dans `deploy/`.
|
cinoc/_version.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# file generated by vcs-versioning
|
| 2 |
+
# don't change, don't track in version control
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
__all__ = [
|
| 6 |
+
"__version__",
|
| 7 |
+
"__version_tuple__",
|
| 8 |
+
"version",
|
| 9 |
+
"version_tuple",
|
| 10 |
+
"__commit_id__",
|
| 11 |
+
"commit_id",
|
| 12 |
+
]
|
| 13 |
+
|
| 14 |
+
version: str
|
| 15 |
+
__version__: str
|
| 16 |
+
__version_tuple__: tuple[int | str, ...]
|
| 17 |
+
version_tuple: tuple[int | str, ...]
|
| 18 |
+
commit_id: str | None
|
| 19 |
+
__commit_id__: str | None
|
| 20 |
+
|
| 21 |
+
__version__ = version = '0.1.1.dev1+g7ca8e2eab'
|
| 22 |
+
__version_tuple__ = version_tuple = (0, 1, 1, 'dev1', 'g7ca8e2eab')
|
| 23 |
+
|
| 24 |
+
__commit_id__ = commit_id = 'g7ca8e2eab'
|
cinoc/adapters/ANALYSE_COUCHE_5.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ANALYSE_COUCHE_5.md — `adapters/` (Picarones → Cinoc)
|
| 2 |
+
|
| 3 |
+
> **Type** : session d'ANALYSE (ne code rien). Guide de portage **durable**.
|
| 4 |
+
> **Couche** : 5 = `adapters` (cf. `CLAUDE.md` §3 : `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`).
|
| 5 |
+
> **Source gelée** : `../Picarones/picarones/adapters/` — **41 fichiers, 9 682 LOC** (mesuré).
|
| 6 |
+
> **Cible** : `cinoc/adapters/`.
|
| 7 |
+
> **Couches amont mergées dont on dépend** : `domain` (mergée), `formats` (mergée), `evaluation` (plan acté `MIGRATION_COUCHE_3.md`), `pipeline` (à venir — porte le **contrat de module**).
|
| 8 |
+
> **Partie 1 = durable** (source figée). **Partie 2 = périssable** (« à confirmer à la tranche »).
|
| 9 |
+
|
| 10 |
+
---
|
| 11 |
+
|
| 12 |
+
## 0. Drapeaux à lever AVANT toute construction (lire en premier)
|
| 13 |
+
|
| 14 |
+
| # | Drapeau | Statut | Action |
|
| 15 |
+
|---|---|---|---|
|
| 16 |
+
| **F-0** | La consigne d'analyse disait « Couche4 », le titre/ livrable disent « couche 5 ». | Coquille. `CLAUDE.md` §3 + `PROMPT_ANALYSE_COUCHE.md` confirment **couche 5 = `adapters`**. | Aucun conflit. Analysé = `adapters`. |
|
| 17 |
+
| **F-1** | `CLAUDE.md` D1 décrit le legacy comme « double contrat **`BaseOCRAdapter`→`BaseModule`** ». **Le code gelé ne contient PAS cet héritage.** | `base.py:58` = `class BaseOCRAdapter(ABC)` ; `llm/base.py:202` = `class BaseLLMAdapter(ABC)` ; **aucun n'hérite de `BaseModule`**. Le vrai contrat runtime est **`StepExecutor`** (`pipeline/protocols.py:42`, `execute(...)`). `BaseModule` (`domain/module_protocol.py`, `process(...)`) est **vestigial** : sa docstring `:24-27` *prétend* (à tort) que les adapters en sont des cas particuliers ; ses seuls usages sont un ré-export dans `__init__` + `evaluation/metrics/module_policy.py` (lui-même classé « mort » par `MIGRATION_COUCHE_3.md` §11). | **Ne PAS s'arrêter** : la *décision* D1 (un seul `Protocol` en couche 4, `BaseModule` supprimé) reste **correcte et même renforcée** — Picarones a déjà ce `Protocol` unique (`StepExecutor`). Seule la *caractérisation factuelle* de la source est à corriger : il s'agit d'un contrat **vestigial parallèle** (`BaseModule.process`), pas d'un emballage. Documenté §1.2. |
|
| 18 |
+
| **F-2** | `CLAUDE.md` §8.8 dit « 8+8+8 adapters LLM/VLM ». | Réalité : **8 OCR + 4 LLM + 4 VLM**. | Direction (« → minimal/starter ») valide ; chiffres rectifiés §1.1. |
|
| 19 |
+
| **F-3** | `_fallback_log.py` n'a qu'un consommateur final : le **détecteur narratif** `IMPORTER_FALLBACK_TRIGGERED`. | Or le **moteur narratif est SUPPRIMÉ** (`CLAUDE.md` §6, D2). | ⇒ « pas de consommateur = supprimé ». Verdict **SUPPRIMER** (remplacé par `logger.warning`). §1.3-corpus. |
|
| 20 |
+
|
| 21 |
+
Aucun de ces points ne contredit une couche **mergée** ni une décision actée → analyse poursuivie.
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
# PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable)
|
| 26 |
+
|
| 27 |
+
## 1.1 Inventaire exact (41 fichiers / 9 682 LOC)
|
| 28 |
+
|
| 29 |
+
| Sous-paquet | Fichiers | LOC | Rôle vérifié |
|
| 30 |
+
|---|---|---|---|
|
| 31 |
+
| **`/` (utils racine)** | 6 | **866** | I/O atomique, retry, http(x), downscale image, résolution de chemins. Mutualisés OCR+LLM. |
|
| 32 |
+
| **`ocr/`** | 12 | **3 206** | Contrat `BaseOCRAdapter` + factory + 8 moteurs + sidecar confidences. |
|
| 33 |
+
| **`corpus/`** | 8 | **3 045** | Importeurs de corpus patrimoniaux (IIIF, Gallica, eScriptorium, HTR-United, HuggingFace) + http urllib anti-SSRF + journal fallback. |
|
| 34 |
+
| **`llm/`** | 6 | **1 208** | Post-correction texte (`RAW_TEXT → CORRECTED_TEXT`). Base + 4 providers. |
|
| 35 |
+
| **`storage/`** | 3 | **964** | `ArtifactStore` (cache fichier) + `JobStore` (SQLite jobs web). |
|
| 36 |
+
| **`vlm/`** | 6 | **393** | Transcription `IMAGE → RAW_TEXT` zéro-OCR. Base + 4 manifestes de composition. |
|
| 37 |
+
|
| 38 |
+
Libs externes (toutes confinées à `adapters/`, conforme `CLAUDE.md` §3) : `pytesseract`+`PIL` (tesseract), `kraken`, `calamari-ocr`+TF, `pero-ocr`+torch, `google-cloud-vision`, `azure-ai-documentintelligence`, `mistralai`, `openai`, `anthropic`, `httpx` (ollama — **dép. cœur**, pas un extra), `datasets` (hf), `yaml`/`tqdm` (corpus). Le **noyau de contrat** (`ocr/base.py`, `factory.py`, `confidences.py`, `llm/base.py`, `vlm/base.py`, utils racine) n'importe **aucune** lib tierce sauf `httpx`/`PIL`.
|
| 39 |
+
|
| 40 |
+
## 1.2 Le contrat réel (la couture décisive `domain↔pipeline↔adapters`)
|
| 41 |
+
|
| 42 |
+
**Trois contrats coexistent ; un seul est vivant côté runtime.**
|
| 43 |
+
|
| 44 |
+
| Contrat | Lieu | Forme | Statut réel |
|
| 45 |
+
|---|---|---|---|
|
| 46 |
+
| `BaseModule` | `domain/module_protocol.py:39` | `process(inputs)→outputs`, types `tuple`, **aucun** concern d'exécution | **Vestigial.** Aucun adapter ne l'hérite ni ne l'implémente. Consommateurs : ré-export `__init__` + `module_policy.py` (mort). |
|
| 47 |
+
| **`StepExecutor`** | `pipeline/protocols.py:42` | `name`,`input_types`/`output_types` (`frozenset`), **`execute(inputs, params, context: RunContext, control: RunControl) → dict[ArtifactType, Artifact]`** | **Contrat vivant.** Porte deadline (`context.deadline`) + annulation (`control`). Satisfait par **duck-typing** (jamais hérité). |
|
| 48 |
+
| `BaseOCRAdapter` / `BaseLLMAdapter` / `BaseVLMAdapter` | `adapters/{ocr,llm,vlm}/base.py` | ABC d'**implémentation** (défauts `input_types`/`output_types`, helpers, `name`/`execute` abstraits) | Ce sont des **mixins d'implémentation**, **pas** un second contrat. `BaseVLMAdapter(BaseLLMAdapter)` ; les deux autres `(ABC)`. |
|
| 49 |
+
|
| 50 |
+
**Résolution `adapter_name → executor`** : pas de registre. `build_adapter_resolver(engines)` (`app/services/_benchmark_adapter_resolver.py:215`) construit un `dict[str, StepExecutor]` à partir d'**instances** d'engines fabriquées en amont, et lève `PicaronesError` sur collision de `name` à configs distinctes. La factory `ocr_adapter_from_name` (`ocr/factory.py:64`) est une **cascade `if/elif` codée en dur** (anti-OCP). **Aucune découverte de plugins entry-points** (`registry_service.py:39` le dit explicitement). Seule extensibilité « tierce » : `adapter_class: dotted.path` dans le YAML `run_spec` (`app/schemas/run_spec.py:32-62`), classe importable dans le venv.
|
| 51 |
+
|
| 52 |
+
> ⚠️ **Conséquence pour Cinoc** : le `Protocol` de module que `CLAUDE.md` D1 veut construire en **couche 4** ≈ **`StepExecutor` déjà existant**. Les adapters l'implémentent **directement**. `BaseModule` est **supprimé sans perte**. *(`version` manque dans `StepExecutor`/`BaseOCRAdapter` → à **ajouter** pour la repro `RunManifest`, cf. §2.1.)*
|
| 53 |
+
|
| 54 |
+
## 1.3 Verdicts fichier par fichier — **PROVISOIRE — à confirmer au build**
|
| 55 |
+
|
| 56 |
+
Légende verdict : **G**=garder · **M**=modifier · **C**=changer de couche · **S**=supprimer · **I**=incrémental (porter à la tranche qui le consomme, pas avant).
|
| 57 |
+
|
| 58 |
+
### Utils racine (`adapters/*.py`)
|
| 59 |
+
|
| 60 |
+
| Fichier | LOC | Rôle vérifié | Consommateurs réels (hors tests) | Bugs / dette | Verdict |
|
| 61 |
+
|---|---|---|---|---|---|
|
| 62 |
+
| `__init__.py` | 28 | Docstring de paquet, `__all__=[]`. | 0 (imports par chemin complet). | — | **G** (mince) |
|
| 63 |
+
| `_atomic_io.py` | 151 | `atomic_write_text` (tmp+`fsync`+`os.replace`). | `atomic_write_text` : **11 adapters**. | `atomic_write_bytes` = **0 conso** (mort). Corps dupliqué text/bytes. | **M** : garder `atomic_write_text`, **S** `atomic_write_bytes`. **`storage/` doit l'utiliser** (cf. infra). |
|
| 64 |
+
| `_httpx_helpers.py` | 213 | Client httpx deadline-aware + cancel cross-thread (`make_httpx_client`, `translate_or_reraise`). | **1** (`llm/ollama_adapter`). | Détection « close » par **heuristique de message** (`:179-190`) fragile. Couple `adapters→pipeline.run_control`. | **G+M** (revoir l'heuristique ; unifier la trad. d'erreur). |
|
| 65 |
+
| `_image.py` | 87 | Downscale image base64 (`PIL` lazy, dégradable). | **3** (ollama/openai/mistral LLM). | Bloc d'appel `if max_edge>0: downscale` **copié 3×** chez les callers. | **G** (factoriser l'appel dans la base LLM). |
|
| 66 |
+
| `_retry.py` | 274 | Retry exponentiel + `Retry-After` + jitter, deadline-aware (`call_with_retry`, `is_retryable`, `compute_retry_wait`). | `call_with_retry` : **3 OCR cloud** ; primitives : `llm/base.py`. | **Bug** : jitter `×[1.0,1.5)` appliqué **après** le plafond → `DEFAULT_MAX_WAIT` (120 s) dépassé jusqu'à **180 s** (`:138-139`). `retry_after_seconds`/`DEFAULT_MAX_WAIT` exportés sans conso externe. **Boucle ré-implémentée** dans `llm/base.py:384-429` ET `corpus/_http.download_url`. | **M** : corriger le jitter ; **unifier les 3 implémentations** ; resserrer l'API publique. |
|
| 67 |
+
| `output_paths.py` | 113 | `resolve_output_path` (segment par pipeline anti-collision). | **9 adapters**. | `context: Any` (duck-typing `RunContext`) → dégrade en silence si attribut absent. | **G**. |
|
| 68 |
+
|
| 69 |
+
### `ocr/`
|
| 70 |
+
|
| 71 |
+
| Fichier | LOC | Rôle / sortie | Consommateurs réels | Bugs / dette | Verdict |
|
| 72 |
+
|---|---|---|---|---|---|
|
| 73 |
+
| `__init__.py` | 48 | Façade ; ré-exporte les 8 adapters + base + factory. | traversé par `llm_pipeline_config`. | **Import eager** des 8 classes (sûr car les modules n'importent pas la lib lourde au top-level) mais court-circuite la robustesse lazy de la factory. | **M** (mince ; aligner sur le registre). |
|
| 74 |
+
| `base.py` | 189 | `BaseOCRAdapter(ABC)` + `OCRAdapterError`. **`effective_output_types`** = sous-ensemble **garanti** (vs maximal). | 8 adapters + resolver + factory + cli + robustness. | **Pas d'attribut `version`** (repro). `control` jamais honoré (cf. dette §1.4). | **M** : = mixin d'impl ; **ajouter `version`** ; garder `effective_output_types`. Contrat = `Protocol` couche 4. |
|
| 75 |
+
| `factory.py` | 194 | `ocr_adapter_from_name(name) → BaseOCRAdapter`. | cli + web + `__init__`. | **Cascade `if/elif`** (anti-OCP) ; alias incohérents (`calamari_ocr`→`calamari`, pas de court `kraken`) ; `_SUPPORTED`/`_ALIASES` divergent à la main ; message Azure cite `azure-ai-formrecognizer` ≠ SDK réel. | **C→app** : remplacer par **registre + entry-points** (`CLAUDE.md` §3 : registre = app/adapters, découverte = app). |
|
| 76 |
+
| `confidences.py` | 164 | Sidecar JSON de confidences. `ConfidenceToken`=TypedDict `{text, confidence∈[0,1]}`, **niveau mot**, normalisation Tesseract 0-100→0-1. | **1** (tesseract). Artefact `CONFIDENCES` lu par `evaluation/calibration`. | `model_version:null` toujours sérialisé ; entrée `list[dict]` non typée. | **G+M** : logique en adapters ; **promouvoir `ConfidenceToken`→`domain`** quand la calibration est consommée (backlog domain `CLAUDE.md`). |
|
| 77 |
+
| `precomputed.py` | 221 | **Starter.** Lit `<stem>.<label>.txt`. `IMAGE→RAW_TEXT`. 0 dép externe. | factory + builders. | Nommage `name` = `precomputed_<label>` (`_`) ≠ doc factory (`:`). `control` inutilisé ; double I/O policy `empty`. | **G** (starter) ; corriger nommage. |
|
| 78 |
+
| `tesseract.py` | 548 | **Starter.** `pytesseract` (binaire). `RAW_TEXT` (+`CONFIDENCES`/`ALTO_XML` **opt-in** via `effective_output_types`). | factory + cli + web. | >400 LOC. `control` inutilisé (timeout subprocess compense). | **G** (starter) ; **trim/split <400**. |
|
| 79 |
+
| `kraken.py` | 237 | HTR ML local (torch). `RAW_TEXT`. Extra `[kraken]`. | factory + web. | Deadline **ignorée** (inférence non interruptible) ; API Kraken 4.x fragile vs 5.x. | **G+I** (incrémental). |
|
| 80 |
+
| `calamari.py` | 253 | OCR ligne (TF). `RAW_TEXT`. Extra `[calamari]`. | factory + web. | Parsing résultat fragile (`getattr(...,"sentence","")`) ; deadline ignorée. | **G+I**. |
|
| 81 |
+
| `pero_ocr.py` | 226 | HTR ML local (torch, PAGE aplati). `RAW_TEXT`. | factory + web. | Docstring tronquée (`:1-3`) ; deadline ignorée ; ordre `page_size=(h,w)` à vérifier. | **G+I**. |
|
| 82 |
+
| `google_vision.py` | 330 | Cloud (SDK **ou** REST/urllib). `RAW_TEXT`. | factory + web. | SDK path ignore la deadline ; motif HTTP dupliqué (cf. azure/mistral). | **G+I**. |
|
| 83 |
+
| `azure_doc_intel.py` | 425 | Cloud async + polling (SDK ou REST). `RAW_TEXT`. | factory + web. | >400. **Meilleure gestion deadline** (REST) ; SDK path l'ignore. Backoff maison. | **G+I** ; trim<400. |
|
| 84 |
+
| `mistral_ocr.py` | 371 | `/v1/ocr` (urllib) **ou** chat/vision (SDK). `RAW_TEXT`. | factory + web. | SDK chat ignore deadline. **Importe `adapters.llm.base.normalize_llm_content`** (couplage cross-famille). | **G+I** ; sortir `normalize_*` en helper partagé. |
|
| 85 |
+
|
| 86 |
+
### `llm/` + `vlm/`
|
| 87 |
+
|
| 88 |
+
| Fichier | LOC | Rôle / contrat | Consommateurs réels | Bugs / dette | Verdict |
|
| 89 |
+
|---|---|---|---|---|---|
|
| 90 |
+
| `llm/__init__.py` | 16 | Docstring cadrage. | 0. | — | **G**. |
|
| 91 |
+
| `llm/base.py` | 582 | **Cœur.** `BaseLLMAdapter(ABC)`. **Double API** : `complete(prompt, image_b64, *, deadline, control)→LLMResult` (appel + retry) **et** `execute(...)` (orchestration fichier→complete→fichier). `RAW_TEXT→CORRECTED_TEXT`. | pipeline (llm_config/builder) + VLM + `mistral_ocr` (helper). | >400. `LLMResult.tokens_used` **jamais peuplé** (`:194` vs `:396`) → coût non remonté. `except Exception`(`:401`) intentionnel+tracé. | **M** : **split <400** (helpers / `LLMResult` / base / orchestration) ; **peupler ou supprimer `tokens_used`**. |
|
| 92 |
+
| `llm/openai_adapter.py` | 123 | **Starter.** SDK `openai`, extra `[llm]`, `temperature=0`. | web factory. | `del control` (SDK non câblé, documenté). | **G** (starter). |
|
| 93 |
+
| `llm/ollama_adapter.py` | 160 | **Starter.** httpx brut (**dép. cœur**), **seul à câbler `control`** (cancel cross-thread). | web factory. | `except (TimeoutException, HTTPError): pass` (`:131-132`) — re-raise délégué, à mieux commenter. | **G** (starter). |
|
| 94 |
+
| `llm/anthropic_adapter.py` | 129 | Idem motif, extra `[llm]`. | web factory. | `del control`. | **G+I**. |
|
| 95 |
+
| `llm/mistral_adapter.py` | 198 | Idem + table `_TEXT_ONLY_MODELS` (dégradé multimodal). | web factory. | Logs verbeux ; instance client par appel. | **G+I**. |
|
| 96 |
+
| `vlm/__init__.py` | 42 | Façade ré-export. | 0 (hors tests). | — | **G+I**. |
|
| 97 |
+
| `vlm/base.py` | 245 | `BaseVLMAdapter(BaseLLMAdapter)` : `IMAGE→RAW_TEXT`, override `execute` + prompts ; **garde-fou MRO** (`__init_subclass__`). | 0 instanciateur (2 docs). | — | **G+I** (porter à la tranche `zero_shot`). Conserver le garde MRO. |
|
| 98 |
+
| `vlm/openai_vlm.py` | 22 | `(BaseVLMAdapter, OpenAIAdapter)` + `name`. | **0**. | — | **I/S** : ~90 % hérité, **0 conso** → ne **pas** porter spéculativement (porter à la tranche zero_shot). |
|
| 99 |
+
| `vlm/anthropic_vlm.py` | 32 | idem. | **0**. | — | **I/S** idem. |
|
| 100 |
+
| `vlm/mistral_vlm.py` | 26 | idem + `default_model=pixtral`. | **0**. | — | **I/S** idem. |
|
| 101 |
+
| `vlm/ollama_vlm.py` | 26 | idem + `default_model=llava`. | **0**. | — | **I/S** idem. |
|
| 102 |
+
|
| 103 |
+
### `corpus/`
|
| 104 |
+
|
| 105 |
+
| Fichier | LOC | Source / sortie | Consommateurs réels | Bugs / dette | Verdict |
|
| 106 |
+
|---|---|---|---|---|---|
|
| 107 |
+
| `__init__.py` | 24 | Docstring « nommer/localiser (image, GT) » ; `__all__=[]`. | 0. | — | **G** (mince). |
|
| 108 |
+
| `_http.py` | 270 | urllib + **anti-SSRF** (`validate_http_url`, blocage IP réservées, revalidation redirects) + retry deadline-aware. | iiif, gallica, escriptorium. | `install_opener` = **effet de bord global au process** (`:159`). Boucle retry ≈ `_retry`. | **G+M** : brique sécurité réutilisable (invariant `CLAUDE.md`) ; retirer l'`install_opener` global ; unifier le retry. |
|
| 109 |
+
| `_fallback_log.py` | 98 | Journal mémoire des dégradations d'importeur. | `consume`←app `_benchmark_converter` → **détecteur narratif**. `record`←htr_united/hf. | Seul débouché = narratif. | **S** (narratif supprimé `CLAUDE.md` §6 → plus de consommateur ; remplacer par `logger.warning`). |
|
| 110 |
+
| `iiif.py` | 567 | Manifeste IIIF v2/v3 → **`Corpus`**. | CLI (`import iiif`) + web + gallica. | >400. **Fuite de fichiers temp** si `output_dir=None` (`NamedTemporaryFile(delete=False)`, `:465`). `tqdm` lazy. | **G+I+M** : trim<400 ; corriger la fuite temp. Référence du contrat « → Corpus ». |
|
| 111 |
+
| `gallica.py` | 563 | SRU+IIIF+OCR brut BnF → **`Corpus`** (délègue à `IIIFImporter`). | web seulement. | >400. **Bug latent** `selected_indices[i]+1` (additionne `IIIFCanvas`+int, `:415`). `_fetch_url` force `retries=1`. Beaucoup de surface morte (`search_gallica`, `get_metadata`…). | **M+I** : corriger le bug ; **S** des fonctions sans conso ; trim<400. |
|
| 112 |
+
| `escriptorium.py` | 571 | API REST eScriptorium → **`Corpus`** + écriture distante (export couche OCR). | web (`import_document` seul). | >400. **BUG dur** `Corpus(source=...)` (champ = `source_path`, `:450`) → `TypeError` au 1ᵉʳ appel réel, **masqué par les mocks**. `image_path:str` ≠ domain `Path`. ~40 % surface API morte. | **M+I** : corriger le bug ; **S** `export_benchmark_as_layer`/`list_*`/`connect_*` (0 conso) ; trim<400. |
|
| 113 |
+
| `htr_united.py` | 488 | Catalogue HTR-United (GitHub) → **dict-manifeste** (pas `Corpus`) + GT extraits (sans images). | web. | >400. **Pas de `_http`** (ni anti-SSRF ni retry). `except (URLError, Exception)`. ZIP **chargé entier en RAM**. `_DEMO_CATALOGUE`=**113 LOC de données fabriquées** dans le code. | **M+I** : sortie unifiée `Corpus` ; **données démo → `data/` (S du code)** ; passer par `_http` ; borner le download. |
|
| 114 |
+
| `huggingface.py` | 464 | HF Hub (lib `datasets`) → **dict-manifeste** + paires `.jpg`/`.gt.txt`. | web. | >400. **Monkey-patch au load** (`_patch_dataset_replace_source`, `:456`). `except (ImportError, Exception): return 0` **muet** (lib absente indistinguable de dataset cassé). GT orphelin si `image.save` échoue. `_REFERENCE_DATASETS`=**109 LOC** en dur. Docstring annonce des fonctions inexistantes. | **M+I** : sortie unifiée `Corpus` ; extra `[hf]` + message clair ; supprimer le monkey-patch ; données → `data/`. |
|
| 115 |
+
|
| 116 |
+
### `storage/`
|
| 117 |
+
|
| 118 |
+
| Fichier | LOC | Backend / type | Consommateurs réels | Bugs / dette | Verdict |
|
| 119 |
+
|---|---|---|---|---|---|
|
| 120 |
+
| `__init__.py` | 58 | Façade ré-export. Docstring annonce 2 cibles **non livrées** (migration `web/jobs`, rapatriement history SQLite). | — | — | **G**. |
|
| 121 |
+
| `artifact_store.py` | 417 | **Filesystem** (`index.jsonl` + `artifacts/<key>.json` + `payloads/<key>.bin`) indexé par `ArtifactKey.hash_hex()`. Expose `StoredArtifact{key, artifact, payload}`. | **0 direct** : consommé via le **Protocol `ArtifactCachePort`** (`pipeline/cache_protocol.py`) injecté dans l'executor (inversion de dépendance). | >400. **`put` non atomique** (3 écritures sous simple `Lock`) ; **pas de `fsync`** (réimplémente un écriture inférieure au lieu d'utiliser `_atomic_io`) ; **path-traversal non gardé** (`f"{key}.json"`) ; index non borné ; pas de concurrence multi-process. | **G+M** : **utiliser `_atomic_io`** ; **valider la clé / `validated_path`** (invariant `CLAUDE.md`) ; implémente un **port défini en couche 4**. Tranche *cache*. |
|
| 122 |
+
| `job_store.py` | 489 | **SQLite** (table `jobs`, WAL, `schema_version`). `JobRecord`, `JobStore`. États `pending→running→{complete\|error\|cancelled\|interrupted}`. | **1** : `app/services/job_runner.py`. | >400. **Doublon** : un **2ᵉ `JobStore` legacy** vit en `interfaces/web/jobs.py:103` et c'est **lui** que l'app web tournante câble (`state.py:138`). `LIMIT` interpolé (neutralisé par `int()`). Pas de validation des transitions. | **G+M** : **UN seul** job store (celui-ci) ; **S** le doublon legacy (couche 8, hors périmètre mais à acter) — ⚠️ mais le canonical **n'a ni `job_events` ni reprise SSE `Last-Event-ID`** (assumé `job_store.py:48-54`), capacités que porte le legacy `web/jobs.py` **et dont dépend le stream SSE du web** (cf. `ANALYSE_COUCHE_8` §0/§1.3 D-γ) → **réabsorber l'événementiel AVANT** de supprimer le legacy ; valider les transitions. Tranche *web/async*. |
|
| 123 |
+
|
| 124 |
+
`StoredArtifact` : **non-candidat domain confirmé** (`domain/artifact_key.py:22-24` : « infrastructures, pas des types purs » ; le pipeline le touche via Protocol, pas en direct). Reste en `adapters/storage`.
|
| 125 |
+
|
| 126 |
+
## 1.4 Dette transverse vérifiée (durable)
|
| 127 |
+
|
| 128 |
+
| # | Dette | Preuve | Portée |
|
| 129 |
+
|---|---|---|---|
|
| 130 |
+
| **D-A** | **`control: RunControl` jamais honoré** : tous les `execute()` OCR + LLM/VLM cloud le reçoivent mais **n'appellent jamais** `register_cancel_handle`. **Seul `ollama_adapter` câble l'annulation** (via `_httpx_helpers`). | `base.py:82-86` documente le contrat ; aucun adapter ne l'honore sauf ollama. | Gap d'annulation cross-thread généralisé. |
|
| 131 |
+
| **D-B** | **Deadline asymétrique** : Tesseract (timeout subprocess) + Azure-REST (polling clampé) la respectent ; **tous les paths SDK** (Azure SDK, Google SDK, Mistral chat) l'ignorent (limite SDK) ; kraken/calamari/pero l'ignorent totalement. | `azure_doc_intel.py:366-379` (bon) vs `:261` (SDK ignore) ; `mistral_ocr.py:310-316`. | Un run ML local/SDK peut dépasser le budget en silence. |
|
| 132 |
+
| **D-C** | **Retry implémenté 3×** : `_retry.call_with_retry`, `llm/base.py:384-429` (inline), `corpus/_http.download_url`. | grep. | Triple maintenance du même algorithme. |
|
| 133 |
+
| **D-D** | **Deux piles HTTP** dans la même couche : `httpx` (`_httpx_helpers`+`_retry`, pour OCR/LLM cloud) vs `urllib` (`corpus/_http`, anti-SSRF, pour importeurs). Aucune ne réutilise l'autre. | grep : `corpus/*` n'importe jamais `_retry`/`_httpx_helpers`. | Scission lib/menace **assumée** mais coûteuse. |
|
| 134 |
+
| **D-E** | **`__init__` à effet de bord** : `corpus/_http.py:159` fait `install_opener` **global** au process à l'import. | `:153-161`. | Viole l'esprit « `__init__` minces, aucun effet de bord » (`CLAUDE.md` §7). |
|
| 135 |
+
| **D-F** | **Fracture du contrat de sortie corpus** : iiif/gallica/escriptorium → `Corpus` ; **htr_united/huggingface → `dict`-manifeste** + effets disque. 3/5 écrivent des fichiers. | tableaux §1.3-corpus. | Pas de contrat unique d'ingestion. |
|
| 136 |
+
| **D-G** | **Code mort / surface non câblée** : `atomic_write_bytes` ; exports `_retry` sans conso ; ~40 % d'`escriptorium.py` ; commodités `gallica`/`hf` ; `JobRecord.is_terminal/is_live` ; `_MIGRATIONS` vide. | greps agents. | « pas de consommateur = supprimé ». |
|
| 137 |
+
| **D-H** | **Données fabriquées dans le code** : `_DEMO_CATALOGUE` (113 LOC) + `_REFERENCE_DATASETS` (109 LOC) = ~220 LOC de fausses données servies en repli. | `htr_united.py:44`, `huggingface.py`. | Risque d'afficher du faux contenu ; doit être de la **donnée** (`data/`) ou supprimé. |
|
| 138 |
+
| **D-I** | **Bugs latents masqués par les mocks** : `escriptorium Corpus(source=...)` (`:450`) ; `gallica selected_indices[i]+1` (`:415`). | lecture code + tests mockés. | Régression garantie au 1ᵉʳ appel réel → à corriger au portage. |
|
| 139 |
+
| **D-J** | **`execution_mode` : déjà retiré** (« mensonge structurel », commit `5e13c0d`, mai 2026 — attribut décoratif jamais lu par le runner thread-only). 0 occurrence prod ; garde-fou de test anti-résurrection. | git show. | **Leçon** : ne **jamais** réintroduire d'attribut « au cas où » non câblé. |
|
| 140 |
+
|
| 141 |
+
---
|
| 142 |
+
|
| 143 |
+
# PARTIE 2 — RÉORGANISATION CIBLE Cinoc (périssable — « à confirmer à la tranche »)
|
| 144 |
+
|
| 145 |
+
> Conforme `CLAUDE.md` : **deux axes**, **tranches verticales** (jamais « finir la couche de haut en bas »), **budgets <400 LOC**, **rupture nette zéro shim**, **narrative supprimé**, **« pas de consommateur = supprimé »**. La couche 5 se **remplit par tranches** (chaque adapter naît avec le pipeline qui le consomme), pas en bloc.
|
| 146 |
+
|
| 147 |
+
## 2.1 Enveloppe (axe 1 — dimensionnée plein-scope, mais **définie en couche 4**)
|
| 148 |
+
|
| 149 |
+
Le **contrat de module exécutable** n'est **pas** un livrable de la couche 5 : il vit en **couche 4** (`pipeline`, cf. `CLAUDE.md` D1). La couche 5 ne fait que **l'implémenter**. À acter quand on conçoit la couche 4 (rappelé ici car il façonne tous les adapters) :
|
| 150 |
+
|
| 151 |
+
- **`Protocol` unique** ≈ `StepExecutor` de Picarones : `name`, **`version`** (➕ vs Picarones, repro `RunManifest`), `input_types`/`output_types` (`frozenset[ArtifactType]`), `execute(inputs, params, context, control) → dict`.
|
| 152 |
+
- **`BaseModule` supprimé** (vestigial). Les ABC `Base*Adapter` deviennent de **simples mixins d'implémentation** (défauts + helpers + `_write_text_artifact()`), **pas** un contrat.
|
| 153 |
+
- **`effective_output_types`** (garanti ⊂ maximal) **conservé** — sinon `missing_output` casse les steps quand un extra opt-in (Tesseract `CONFIDENCES`/`ALTO_XML`) n'est pas produit.
|
| 154 |
+
- **Trancher la dette d'annulation `control`** (D-A/D-B) : soit honorer `control` partout où l'opération est annulable, soit assumer explicitement « best-effort deadline » (documenté, testé). Ne pas traîner un paramètre non câblé.
|
| 155 |
+
|
| 156 |
+
**Registre + découverte (couche `app`, cf. `CLAUDE.md` §3)** : remplacer la cascade `if/elif` + les factories CLI/web dispersées par **un registre type-driven `adapter_name → factory`** + **découverte entry-points `cinoc.modules`** + `register()` local. **Seul point d'extension tiers = les briques de pipeline** (OCR/HTR/VLM/post-correction/segmentation/ordre de lecture/NER). **Corpus, métriques, sections, stores = first-party, non pluggables.**
|
| 157 |
+
|
| 158 |
+
## 2.2 Surface (axe 2 — incrémentale, par tranches)
|
| 159 |
+
|
| 160 |
+
**Starter pack** (le strict nécessaire au squelette ambulant) :
|
| 161 |
+
|
| 162 |
+
| Brique | Fichiers source | Extra | Tranche |
|
| 163 |
+
|---|---|---|---|
|
| 164 |
+
| `precomputed` (OCR 0 dép) | `ocr/precomputed.py` | — | **squelette texte** (corpus pré-calculé → CER) |
|
| 165 |
+
| `tesseract` (OCR binaire) | `ocr/tesseract.py` + `confidences.py` | binaire système | tôt (1ᵉʳ vrai moteur) |
|
| 166 |
+
| `openai` + `ollama` (LLM) | `llm/base.py` + `openai_adapter.py` + `ollama_adapter.py` | `[llm]` (openai) ; `httpx`=cœur (ollama) | tranche OCR+LLM `text_only` |
|
| 167 |
+
| utils racine | `_atomic_io`(text), `_retry`(corrigé+unifié), `output_paths`, `_image`, `_httpx_helpers` | — | portés **en bloc** avec le starter |
|
| 168 |
+
|
| 169 |
+
**Incrémental (porter à la tranche qui le consomme)** : anthropic/mistral (LLM) ; kraken/calamari/pero/google/azure/mistral_ocr (OCR — extras dédiés) ; **VLM** (tranche `zero_shot` — ~90 % gratuit une fois le LLM porté, **0 conso aujourd'hui → ne pas porter avant**) ; importeurs corpus (un par un) ; `artifact_store` (tranche *cache*) ; `job_store` (tranche *web/async*) ; **store longitudinal *tidy*** (tranche *longitudinal*, **rapatrié** de `evaluation/metrics/history.py` → `adapters/storage`, cf. `MIGRATION_COUCHE_3.md` §8).
|
| 170 |
+
|
| 171 |
+
## 2.3 Arborescence cible proposée (à confirmer)
|
| 172 |
+
|
| 173 |
+
```
|
| 174 |
+
cinoc/adapters/
|
| 175 |
+
├── _atomic_io.py atomic_write_text (+fsync) [G, -atomic_write_bytes]
|
| 176 |
+
├── _retry.py retry unifié, jitter corrigé [M, unifie les 3]
|
| 177 |
+
├── _http.py transport + anti-SSRF + retry [fusion _httpx_helpers + corpus/_http ?]
|
| 178 |
+
├── _image.py downscale (PIL lazy) [G]
|
| 179 |
+
├── output_paths.py resolve_output_path [G]
|
| 180 |
+
├── ocr/ base(mixin)+precomputed+tesseract [starter] ; reste incrémental
|
| 181 |
+
│ └── confidences.py (ConfidenceToken→domain à terme)
|
| 182 |
+
├── llm/ base+openai+ollama [starter] ; anthropic/mistral incrémental
|
| 183 |
+
├── vlm/ base+manifestes — tranche zero_shot uniquement
|
| 184 |
+
├── corpus/ importeurs first-party, sortie UNIQUE = Corpus, 1 par tranche
|
| 185 |
+
│ [_fallback_log SUPPRIMÉ ; données démo → data/]
|
| 186 |
+
└── storage/ artifact_store + job_store (UNIQUE) + history tidy (rapatrié)
|
| 187 |
+
```
|
| 188 |
+
*Note* : la **factory/registre** ne vit **pas** ici mais en `app/` (résolution `adapter_name`) ; la couche 5 ne fait qu'exposer des classes implémentant le `Protocol` couche 4.
|
| 189 |
+
|
| 190 |
+
## 2.4 Application des 5 garde-fous
|
| 191 |
+
|
| 192 |
+
- **Rupture nette / zéro shim** : un seul `JobStore` (S le legacy `web/jobs.py`) ; un seul contrat de sortie corpus (`Corpus`, S les dict-manifestes) ; pas de `BaseModule` ; une seule pile HTTP si possible (D-D).
|
| 193 |
+
- **Budgets <400** : `llm/base.py`(582), `escriptorium`(571), `iiif`(567), `gallica`(563), `tesseract`(548), `job_store`(489), `htr_united`(488), `huggingface`(464), `azure`(425), `artifact_store`(417) **dépassent**. La plupart **rétrécissent** après retrait du code mort (D-G), des données démo (D-H) et dédup du boilerplate (`_write_text_artifact`, retry, HTTP). Sinon split ou entrée justifiée `test_file_budgets`.
|
| 194 |
+
- **Pas de consommateur = supprimé** : `atomic_write_bytes`, `_fallback_log`, surface morte d'escriptorium/gallica/hf, exports `_retry` inutiles, VLM concrets tant que `zero_shot` n'existe pas.
|
| 195 |
+
- **Tests d'archi jour 1** : whitelist `adapters/` = `domain` + `pipeline` + `formats` (+ `evaluation` pour le type `Corpus` chargé, **à expliciter** — Picarones le tolérait par wildcard) ; `no-side-effect-import` (tue le `install_opener` global D-E) ; `no-broad-except` (htr/hf `except Exception`) ; `file-budgets`.
|
| 196 |
+
- **Feature entière + élaguée** : chaque adapter porté **avec** sa correction de bug (D-I), son extra optionnel, son message clair d'absence de dép, sans annotation de sprint.
|
| 197 |
+
|
| 198 |
+
## 2.5 Libs cibles & arbitrages (périssable — « à confirmer à la tranche »)
|
| 199 |
+
|
| 200 |
+
> Rappel : la minceur de la couche vient surtout de l'**élagage** (code mort, données démo, dédup), **pas** de l'ajout de libs. Une lib ne paie que si elle **retire plus de code que le wrapper qu'elle impose**. Contreparties transverses à toute dép : **supply-chain** (cf. `fastapi==0.136.3` / `MAL-2026-4750` exclue dans le `pyproject` Picarones ; `pip-audit` à tenir), **reproductibilité** (`dependencies_lock` du `RunManifest` → **épingler serré**), **code tiers in-process** (entry-points).
|
| 201 |
+
|
| 202 |
+
| Lib / appel | Verdict | Raison / condition |
|
| 203 |
+
|---|---|---|
|
| 204 |
+
| `httpx` (transport REST unique) | ✅ **conditionnel** | Pour le REST à **auth simple** (ollama, mistral `/v1/ocr`, HF, eScriptorium, htr_united, openai-compatible). Unifie les 2 piles HTTP (D-D), corrige la deadline (D-B). **Garder le SDK** pour Google/Azure (auth JWT service-account / AD lourde). Anti-SSRF toujours à coder (transport dédié, **sans `install_opener` global** — D-E). |
|
| 205 |
+
| Drop dual SDK/REST cloud | ✅ **conditionnel** | Seulement où le SDK n'offre ni timeout-par-appel ni annulation **et** l'auth est simple. Pas systématique. |
|
| 206 |
+
| `huggingface_hub` | ✅ **extra `[hf]`** | Remplace le REST urllib maison de `huggingface.py` (pagination tronquée, `except` muet, monkey-patch — D-G). Lib lourde/mouvante → confinée à l'extra, épinglée. |
|
| 207 |
+
| `importlib.metadata.entry_points` (stdlib) | ✅ **gaté** | Outil du point d'extension `cinoc.modules`. **Désactiver la découverte en mode public** (in-process = sécurité, `CLAUDE.md` §3) ; capter la **version** du plugin pour le `RunManifest`. |
|
| 208 |
+
| `pydantic` v2 | ✅ **configs** / ❌ **par-token** | OK pour les **configs d'adapter** (petites, 1×, aligne le repo). **Pas** pour `ConfidenceToken` (milliers/page → coût de validation, cf. `MIGRATION_COUCHE_2.md` G-D) : `TypedDict`/`dataclass(slots=True)`, valider une fois à la frontière. |
|
| 209 |
+
| `tenacity` (retry) | ❌ **garder maison** | Le retry doit être deadline/cancel-aware (`clamp_to_remaining`, `RunControl`), honorer `Retry-After`, et **testable déterministe** (horloge injectable). Tenacity impose un wrapper custom qui annule le gain. `_retry.py` maison (bug jitter corrigé) reste plus aligné. |
|
| 210 |
+
| anti-SSRF · parseur IIIF · subprocess Tesseract | ❌ **garder maison** | Pas de lib mainstream fiable (SSRF, IIIF) ; le subprocess `pytesseract` est **ce qui honore la deadline** pour Tesseract — un binding C-API (`tesserocr`) la perdrait. |
|
| 211 |
+
| ORM (SQLAlchemy) · async (`anyio`) | ❌ | `sqlite3` brut suffit (2 tables) ; runner **thread-only** acté (retrait `execution_mode`, D-J) → async = rupture injustifiée. |
|
| 212 |
+
|
| 213 |
+
**Cible LOC** (rappel, périssable) : enveloppe + starter pack utile ≈ **3 700 LOC** ; parité complète nettoyée ≈ **6 500–7 000 LOC** (vs **9 682** Picarones, ≈ **−30 %**). La couche se remplit par tranches — elle n'a jamais besoin de revoir les 9,7k. *(Détail des leviers de coupe : §1.4 + §2.4.)*
|
| 214 |
+
|
| 215 |
+
---
|
| 216 |
+
|
| 217 |
+
# PARTIE 3 — RISQUES DE TRANSFERT & DETTES (avec détection)
|
| 218 |
+
|
| 219 |
+
| Risque | Détail | Comment détecter / désamorcer |
|
| 220 |
+
|---|---|---|
|
| 221 |
+
| **R-1 Contrat couche 4 manquant** | La couche 5 ne peut être portée **qu'après** le `Protocol` de module (couche 4) + `RunContext`/`RunControl`/`Deadline`. | Construire d'abord la tranche pipeline minimale ; un adapter starter (`precomputed`) sert de **premier client** du `Protocol`. |
|
| 222 |
+
| **R-2 `version` absent** | Picarones n'a pas d'attribut `version` sur les adapters → repro `RunManifest` incomplète. | Test : `RunManifest` doit capter `name+version` de chaque step ; refuser un module sans `version`. |
|
| 223 |
+
| **R-3 Annulation/deadline (D-A/D-B)** | `control` non câblé partout sauf ollama ; deadline ignorée par SDK/ML local. | Test d'annulation : lancer un step long, déclencher `control.cancel()`, vérifier l'arrêt < N s. Décider par adapter : annulable ou « best-effort » documenté. |
|
| 224 |
+
| **R-4 `ConfidenceToken`** | TypedDict local mot-niveau, échelle [0,1], normalisation Tesseract. À promouvoir en `domain` **quand** la métrique calibration le consomme. | Ne pas figer la forme avant le consommateur (backlog domain). Test de normalisation 0-100→0-1 + rejet <0/>100. |
|
| 225 |
+
| **R-5 Sortie corpus non uniforme (D-F)** | Choisir **`Corpus`** comme sortie unique ; pousser l'**orchestration disque** (download→write→manifest) en **service `app`**, garder en couche 5 le **transport + parsing schéma**. | Test : tout importeur retourne un `Corpus` valide (pas de `dict`). Séparer « localiser » (adapter) de « matérialiser » (app). |
|
| 226 |
+
| **R-6 Sécurité (anti-SSRF + chemins)** | `corpus/_http` porte l'anti-SSRF ; `artifact_store`/eScriptorium ne valident pas les chemins/URL. Invariant `CLAUDE.md` : `validated_path()` + URL contrôlée. | Test : URL loopback/IP réservée rejetée ; clé d'artefact `../` rejetée ; pas d'`install_opener` global (no-side-effect-import). |
|
| 227 |
+
| **R-7 Bugs latents (D-I)** | ✅ **entièrement clos**. `Corpus(source=)` eScriptorium (sortie `CorpusSpec`, `source` ∈ `metadata` → `TypeError` impossible). `selected_indices[i]+1` Gallica : mapping vue↔`f{n}` direct (position 1-based = numéro de vue), **aucune indirection** portée. Les deux prouvés non-mockés (`test_escriptorium_local_server.py`, `test_gallica_local_server.py`). | **Test d'intégration non-mocké** : loopback réel (`http.server`) par source — préféré au `live` quand la source exige auth/instance privée. |
|
| 228 |
+
| **R-8 Dérive de budget** | Fichiers riches >400. | `test_file_budgets` actif dès le 1ᵉʳ commit d'adapters ; budgets honnêtes, pas de gonflement silencieux. |
|
| 229 |
+
| **R-9 Couplage cross-famille** | `mistral_ocr` importe `llm.base.normalize_llm_content`. | Extraire la normalisation de contenu LLM en helper neutre partagé ; test d'archi sur les imports inter-sous-paquets. |
|
| 230 |
+
| **R-10 Deux JobStores** | Le legacy `interfaces/web/jobs.py` est celui réellement câblé. | Au portage : un seul store (`adapters/storage`), l'app web le consomme ; supprimer le legacy ; test qu'aucun `JobStore` ne vit hors `adapters/storage`. **⚠️ Le canonical doit d'abord réabsorber la table d'événements + la reprise `Last-Event-ID`** (absentes aujourd'hui), sinon le **stream SSE régresse** (cf. `ANALYSE_COUCHE_8` §0). |
|
| 231 |
+
| **R-11 Données démo en dur (D-H)** | ~220 LOC de faux catalogues. | Les sortir en `data/*.yaml` **ou** supprimer ; un repli démo ne doit jamais se faire passer pour de la vraie donnée (champ `is_demo` explicite). |
|
| 232 |
+
| **R-12 Numérique non rétro-compatible** | Comme la couche 2 (`MIGRATION_COUCHE_2.md` MIG-2) : Cinoc change normalisation/contrats → **toute « validation » par égalité de chiffres avec Picarones est invalide**. | Goldens **refaits**, pas hérités. |
|
| 233 |
+
|
| 234 |
+
---
|
| 235 |
+
|
| 236 |
+
## Résumé pour la session de CONSTRUCTION (3-5 points)
|
| 237 |
+
|
| 238 |
+
1. **Le « double contrat » à tuer n'est pas un héritage** : Picarones a déjà **un** contrat runtime unique = `StepExecutor` (`pipeline/protocols.py`, `execute(inputs, params, context, control)`), que les adapters implémentent par duck-typing. `BaseModule` est **vestigial** (à supprimer sans perte). ⇒ Le `Protocol` couche 4 de Cinoc ≈ `StepExecutor` **+ un attribut `version`** ; les `Base*Adapter` deviennent de simples **mixins d'implémentation**.
|
| 239 |
+
2. **Construire par tranches, starter pack d'abord** : `precomputed` + `tesseract`(+`confidences`) + `openai`+`ollama`, avec les utils racine (`_atomic_io`/`_retry` corrigé/`output_paths`/`_image`/`_httpx_helpers`) portés **en bloc**. Tout le reste (kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM**, importeurs, stores) est **incrémental — porté à la tranche qui le consomme**, jamais d'avance. **VLM = 0 consommateur aujourd'hui → ne pas porter avant la tranche `zero_shot`.**
|
| 240 |
+
3. **Dettes à corriger AU portage (pas après)** : annulation `control` non câblée (D-A) + deadline asymétrique (D-B) à trancher ; **3 implémentations de retry à unifier** + bug jitter>plafond ; **bugs latents** `Corpus(source=)`/Gallica masqués par les mocks ; `artifact_store` doit utiliser `_atomic_io`+`fsync` et **valider les chemins** ; **un seul `JobStore`**.
|
| 241 |
+
4. **Élagage discipliné** : **SUPPRIMER** `_fallback_log` (son seul débouché, le narratif, est supprimé), `atomic_write_bytes`, la surface morte (escriptorium/gallica/hf), `execution_mode` (déjà parti) ; **sortir** les ~220 LOC de données démo en `data/` ; **sortie corpus unique = `Corpus`** (fin des dict-manifestes). Registre/découverte = **couche `app`** (entry-points `cinoc.modules`), **seul point d'extension tiers = les briques de pipeline**.
|
| 242 |
+
5. **Placement & dépendances** : les importeurs de corpus restent **first-party en couche 5** (transport + parsing → `Corpus`), l'**orchestration disque** remonte en service `app` ; le **store SQLite longitudinal/tidy** est **rapatrié** de `evaluation/` vers `adapters/storage` (cf. `MIGRATION_COUCHE_3.md`). Whitelist d'archi `adapters/` à **expliciter** (inclut `evaluation` pour le type `Corpus` chargé).
|
| 243 |
+
|
| 244 |
+
## DoD vivante (couche 5) — **autorité de détail** ; le `MIGRATION_PLAN.md` indexe
|
| 245 |
+
|
| 246 |
+
> **T8 (D-066)** : tous les modules du socle renvoient `StepOutput` ; les adapters LLM/VLM remontent `tokens_in/out` (OpenAI/Mistral `usage.prompt/completion_tokens`, Anthropic `usage.input/output_tokens`, Ollama `prompt_eval_count`/`eval_count`) via `LLMCompletion` — un seul point de logique (`_base.run_llm_step`).
|
| 247 |
+
|
| 248 |
+
> **T12 (D-070)** : tesseract publie l'artefact `CONFIDENCES` (sidecar JSON de `ConfidenceToken`, `image_to_data` conf÷100, best-effort → sidecar vide + warning). Preuves : `tests/adapters/test_tesseract.py`.
|
| 249 |
+
|
| 250 |
+
> **T14 (D-072)** : `+ ocr/kraken.py` (HTR, extra `[kraken]`, `model` requis, RAW_TEXT+CONFIDENCES) `+ ocr/mistral_ocr.py` (cloud à la page, kind `mistral_ocr`). Preuves : `tests/adapters/test_kraken.py` · `test_mistral_ocr.py`.
|
| 251 |
+
|
| 252 |
+
> Tri-état : `[x]` fait **+ preuve** · `[ ]` à faire · `[~]` différé/réserve + raison.
|
| 253 |
+
> Maj dans le **même commit** que le code. **Statut : ✅ T3 complet** — `precomputed` + `tesseract` + **`openai`** + **`ollama`** (LLM post-correction) verts, annulation câblée (D-A clos) **+ `mistral`** (post-correction cloud, extra `[mistral]`, secret `MISTRAL_API_KEY`) ; stores/VLM **par tranches**. **+ T5 `layout/`** : `precomputed` (source LAYOUT + recognizer région), `assembler` (`AltoAssembler`), **`crop`** (`crop_region` PIL — découpe réelle des blocs du pipeline hybride seg→OCR ; `PIL` ajouté à la whitelist archi `adapters`). — *preuves : `tests/adapters/layout/test_crop.py`, `…/test_alto_assembler.py`, `tests/pipeline/test_hybrid_real_bnl.py` (live)*. **+ T7 `corpus/`** : 1ᵉʳ importeur **IIIF** — `_http` (transport `httpx` durci anti-SSRF : http(s) seul, rejet userinfo + IP non publiques, redirections re-validées, taille plafonnée, **aucun `install_opener` global** ; **anti-DNS-rebinding fermé** D-051 : résolution unique → IP validées **épinglées** via `network_backend` `httpcore` custom (`_PinnedBackend`), URL/`Host`/SNI/cert TLS inchangés, ré-épinglage par redirection — `httpcore` whitelisté en archi adapters + extras ; **`download` en flux disque** D-052 : `.part` au fil de l'eau + `os.replace` atomique + cap `IMAGE_MAX_BYTES`, aucun fichier partiel ; `download` accepte `headers` — média eScriptorium **même-hôte** seul porte le jeton) + `iiif` (parser manifeste v2/v3 → `IIIFImage`, **images seules**, pur) ; matérialisation disque → **`CorpusSpec`** en `app/corpus_import.py` (R-5 : localiser ≠ matérialiser). Sortie **unique `CorpusSpec`** (fin des dict-manifestes). — *preuves : `tests/adapters/corpus/{test_iiif_parse,test_http_ssrf,test_iiif_local_server}.py` (transport réel loopback), `…/test_iiif_live.py` (distant, opt-in), `tests/app/test_corpus_import.py`*. **+ eScriptorium** (porteur de **GT** → corpus **scorable**) : `escriptorium.py` (auth token, pagination `next`, `extract_gt_text` couche transcription, pur ; `_http.fetch_json` gagne `headers`) + `import_escriptorium_corpus` (image + `.gt.txt` → `GroundTruthRef` RAW_TEXT). **R-7/D-I fermé** : pas de `Corpus(source=)` (la sortie est `CorpusSpec`, `source` ∈ `metadata`) — prouvé **non-mocké** (test loopback : auth header + pagination 2 pages + GT lignes/`content`). — *preuves : `tests/adapters/corpus/{test_escriptorium_parse,test_escriptorium_local_server}.py`, `tests/app/test_corpus_import_escriptorium.py`*. **+ Gallica** : `gallica.py` (`normalize_ark` + `manifest_url` **IIIF `/iiif/`** + `fetch_ocr_text` via **ALTO** `RequestDigitalElement` + `alto_to_text` ; `_http.fetch_bytes` ajouté pour parser l'ALTO en octets) — **images réutilisent `IIIFImporter`**, OCR = **référence étiquetée** : type domaine `REFERENCE_TEXT` (D-053, ≠ GT manuelle) + `gt_source=gallica_ocr` — non scoré par défaut, vue référence dédiée (opt-in). **2 bugs prod corrigés (D-058, révélés par la capture réelle)** : URL manifeste `/iiif/` manquant (→ 403, masqué par les mocks) ; `texteBrut` ALTCHA-gated → bascule ALTO (rupture nette, `texteBrut`/`_looks_like_html` supprimés). **R-7 clos** : mapping vue *i* ↔ `f{i}` lu dans l'URL d'image (`/f{n}/`), **sans indirection**, prouvé non-mocké sur **vraies pages Gallica** (`tests/fixtures/gallica_alto/`, *Pourceaugnac*). — *preuves : `tests/adapters/corpus/{test_gallica_parse,test_gallica_alto,test_gallica_local_server}.py`, `tests/app/test_corpus_import_gallica.py`*. **+ HTR-United & HuggingFace** (**découverte**, pas matérialisation) : `htr_united.py` (`fetch_catalogue` YAML via `_http.fetch_text` + `parse_catalogue` pur + recherche + **repli démo `is_demo`** ; `yaml` ajouté à la whitelist adapters) ; `huggingface.py` (`search_reference` socle intégré + `HuggingFaceCatalogue.search` API publique **best-effort** via `_http.fetch_json`, `source` ∈ {reference, api}). **+ import HF matérialisant** (D-054, Lot D) : `stream_pages` (convention Cinoc `image`+`ground_truth`, lib `datasets` en extra `[huggingface]`, `streaming=True`, `Image(decode=False)` → octets sans PIL, `loader` injectable) → `app.import_hf_corpus` (GT `RAW_TEXT` réelle) + endpoint `/api/corpus/import/huggingface` (CSRF, gate public 403, non-conforme 422, extra absent 409). **Importeurs cœur complets.** — *preuves : `tests/adapters/corpus/{test_htr_united,test_htr_united_local_server,test_huggingface,test_huggingface_local_server}.py`*. **+ T7 `storage/history_store.py`** : `HistoryStore` SQLite **longitudinal** — `add(HistoryRecord…)` (idempotent, PK `(run_id,pipeline,view,metric)`), `history(pipeline,view,metric)` (chronologique), `regressions(view,metric, threshold, higher_is_better)` (2 derniers runs, direction-aware). Persistance **pure de lignes primitives** (n'importe pas `RunResult`) ; 1 connexion/opération (thread-safe). — *preuves : `tests/adapters/storage/test_history_store.py` (vrai SQLite : round-trip, idempotence, persistance inter-instances, régression/seuil/sens)*
|
| 254 |
+
|
| 255 |
+
**Enveloppe :**
|
| 256 |
+
- [x] `precomputed` implémente le `Module` Protocol (couche 4) **directement**, avec `name`/`version`. — *preuve : `tests/adapters/test_precomputed.py` (`isinstance(.., Module)` ; lecture `<stem>.<label>.txt` → `RAW_TEXT` + `content_hash` ; UTF-8 strict ; annulation)*
|
| 257 |
+
- [x] **1ᵉʳ vrai moteur : `tesseract`** (implémente `Module` directement ; `lang` anti-injection, timeout borné par la deadline, écrit dans le **workspace** ; pytesseract = **extra**, invocation **mockable** → CI sans binaire). — *preuve : `test_tesseract` (mock + validations + workspace + annulation) ; `test_live` opt-in*
|
| 258 |
+
- [x] **2ᵉ famille de module : LLM post-correction** (`openai`, mode `text_only`) — `RAW_TEXT → CORRECTED_TEXT` ; SDK extra, invocation **mockable** (CI sans clé) ; **pipeline multi-étapes OCR→LLM** prouvé (`CORRECTED_TEXT` **non vide**, bug historique). — *preuve : `test_openai` + `test_llm_pipeline`*
|
| 259 |
+
- [x] **2ᵉ LLM (généralisation prouvée) : `ollama`** — *même* contrat que `openai`, sortie `CORRECTED_TEXT`, **aucun cas particulier** ; transport `httpx` = **extra**, `_invoke_ollama` **mockable** (CI sans serveur). **Référence d'annulation câblée** : `_invoke_ollama` enregistre `client.close` via `RunControl.register_cancel_handle` → `trigger_cancel` ferme la connexion en vol ; « annulation vs panne » tranchée en sondant `is_cancelled` (`_fail_or_cancel`, **fiable** — remplace l'heuristique de message fragile, **D-A clos**). — *preuve : `test_ollama` (conformité Module + mock + workspace + annulation + `_fail_or_cancel` cancel/panne) ; mécanisme `register_cancel_handle` : `test_run_control` (fire-on-cancel · immédiat-si-déjà-annulé · once · ordre)*
|
| 260 |
+
- [~] VLM/`zero_shot` (0 conso) · `Base*Adapter` mixins · confidences/ALTO · `unregister`/déduplication des handles (accumulation bénigne, runs bornés) : **différés**.
|
| 261 |
+
|
| 262 |
+
**Garde-fous :**
|
| 263 |
+
- [x] `layer_dependencies` (whitelist `adapters` explicitée : domain+pipeline+formats) · `no_side_effect_imports` · `no_broad_except` · `file_budgets`. — *preuve : `test_adapters_imports_are_allowed` + suite archi verte.* `[~]` `install_opener` global (D-E) : tué à la tranche HTTP.
|
| 264 |
+
|
| 265 |
+
**Validation par tranche :** `MIGRATION_PLAN.md` §3 — starter pack via `Module` (T1-T3) · **1 seul `JobStore` avec SSE/`job_events` réabsorbés** (T4, cf. §0/D-γ) · bugs latents `Corpus(source=)`/`selected_indices+1` corrigés + test `live` (T7).
|
| 266 |
+
|
| 267 |
+
- [~] **Différé** : kraken/calamari/pero/cloud OCR, anthropic/mistral, **VLM (0 conso → pas avant `zero_shot`)**, importeurs (1/tranche, sortie unique `Corpus`), stores. **Supprimer** `_fallback_log` + données démo en dur (→`data/`).
|
| 268 |
+
|
| 269 |
+
---
|
| 270 |
+
|
| 271 |
+
*Tous les verdicts de la Partie 1 sont marqués **PROVISOIRE — à confirmer au build** : le contact du code corrige souvent l'analyse.*
|
cinoc/adapters/__init__.py
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Couche 5 — ``adapters`` : modules concrets (starter pack).
|
| 2 |
+
|
| 3 |
+
Chaque adapter implémente directement le ``Module`` Protocol (couche 4). Les
|
| 4 |
+
dépendances lourdes (SDK LLM, moteurs OCR) sont des extras optionnels, importés
|
| 5 |
+
dans le module qui les utilise — jamais ici. ``__init__`` mince, sans effet de
|
| 6 |
+
bord.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
cinoc/adapters/_resilience.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Résilience des appels réseau (couche 5, adapters) : cap de concurrence + retry.
|
| 2 |
+
|
| 3 |
+
Deux mécanismes, **par défaut, sans configuration**, pour qu'un run depuis l'UI
|
| 4 |
+
web (n'importe qui) ne sature pas une API instable :
|
| 5 |
+
|
| 6 |
+
- **Cap de concurrence par fournisseur** (``network_slot``) : un sémaphore borné
|
| 7 |
+
*par fournisseur* (Mistral, OpenAI… chacun le sien) → au plus N appels réseau
|
| 8 |
+
simultanés, quel que soit le nombre de threads du runner. C'est le fix
|
| 9 |
+
structurel du pool global partagé (Pero local + LLM réseau dans un même run).
|
| 10 |
+
- **Retry borné** (``call_resilient``) : sur 429/5xx, attend ``Retry-After`` si le
|
| 11 |
+
serveur l'indique, sinon un backoff exponentiel borné + gigue, puis recommence.
|
| 12 |
+
|
| 13 |
+
Stdlib pure (aucune dépendance). **Déterminisme (invariant §12)** : ces mécanismes
|
| 14 |
+
ne changent que le *timing*, jamais la valeur renvoyée → le ``RunResult`` reste
|
| 15 |
+
byte-identique. ``N`` = ``CINOC_NETWORK_CONCURRENCY`` (défaut 3, plancher 1).
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import logging
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import threading
|
| 24 |
+
import time
|
| 25 |
+
from collections.abc import Callable
|
| 26 |
+
from contextlib import AbstractContextManager
|
| 27 |
+
from typing import TypeVar
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
T = TypeVar("T")
|
| 32 |
+
|
| 33 |
+
#: Plafond par défaut d'appels réseau concurrents **par fournisseur** (anti-429).
|
| 34 |
+
_DEFAULT_LIMIT = 3
|
| 35 |
+
_LIMIT_ENV = "CINOC_NETWORK_CONCURRENCY"
|
| 36 |
+
#: Politique de retry : tentatives + backoff exponentiel borné (secondes).
|
| 37 |
+
_DEFAULT_ATTEMPTS = 5
|
| 38 |
+
_BASE_BACKOFF = 1.0
|
| 39 |
+
_MAX_BACKOFF = 30.0
|
| 40 |
+
#: Statuts HTTP retentés : 429 (rate limit) + 5xx transitoires (instabilité).
|
| 41 |
+
_RETRYABLE_STATUS = frozenset({429, 500, 502, 503, 504})
|
| 42 |
+
|
| 43 |
+
#: Registre process-global des sémaphores, créés paresseusement (aucun effet de
|
| 44 |
+
#: bord à l'import). Protégé par un verrou : ``network_slot`` est thread-safe.
|
| 45 |
+
_SEMAPHORES: dict[str, threading.BoundedSemaphore] = {}
|
| 46 |
+
_REGISTRY_LOCK = threading.Lock()
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _network_limit() -> int:
|
| 50 |
+
"""Plafond effectif depuis l'environnement (défaut ``_DEFAULT_LIMIT``)."""
|
| 51 |
+
raw = os.environ.get(_LIMIT_ENV)
|
| 52 |
+
if raw is None:
|
| 53 |
+
return _DEFAULT_LIMIT
|
| 54 |
+
try:
|
| 55 |
+
value = int(raw)
|
| 56 |
+
except ValueError:
|
| 57 |
+
logger.warning(
|
| 58 |
+
"[resilience] %s=%r invalide (entier attendu) — défaut %d.",
|
| 59 |
+
_LIMIT_ENV,
|
| 60 |
+
raw,
|
| 61 |
+
_DEFAULT_LIMIT,
|
| 62 |
+
)
|
| 63 |
+
return _DEFAULT_LIMIT
|
| 64 |
+
return max(1, value)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def network_slot(provider: str) -> AbstractContextManager[bool]:
|
| 68 |
+
"""Sémaphore borné **du fournisseur** : ``with network_slot("mistral"): …``.
|
| 69 |
+
|
| 70 |
+
Au plus ``_network_limit()`` détenteurs simultanés par fournisseur. Chaque
|
| 71 |
+
fournisseur a son quota propre (un run OpenAI + Mistral ne les fait pas se
|
| 72 |
+
partager le budget). Le plafond est figé à la première création du fournisseur
|
| 73 |
+
(lecture d'environnement unique, déterministe pour la durée du process)."""
|
| 74 |
+
with _REGISTRY_LOCK:
|
| 75 |
+
sem = _SEMAPHORES.get(provider)
|
| 76 |
+
if sem is None:
|
| 77 |
+
sem = threading.BoundedSemaphore(_network_limit())
|
| 78 |
+
_SEMAPHORES[provider] = sem
|
| 79 |
+
return sem
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _default_jitter() -> float:
|
| 83 |
+
return random.uniform(0.0, 1.0)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _backoff(attempt: int, jitter: Callable[[], float]) -> float:
|
| 87 |
+
"""Backoff exponentiel borné + gigue (désynchronise les threads concurrents)."""
|
| 88 |
+
return min(_MAX_BACKOFF, _BASE_BACKOFF * (2.0**attempt)) + jitter()
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def http_is_retryable(exc: BaseException) -> bool:
|
| 92 |
+
"""Vrai si l'exception SDK porte un statut HTTP transitoire (429/5xx).
|
| 93 |
+
|
| 94 |
+
Duck-typing assumé : les SDK httpx (mistralai/openai/anthropic) exposent
|
| 95 |
+
``status_code``. Une erreur sans statut (réseau coupé, bug local) n'est **pas**
|
| 96 |
+
retentée — on ne masque pas un vrai défaut."""
|
| 97 |
+
return getattr(exc, "status_code", None) in _RETRYABLE_STATUS
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def http_retry_after(exc: BaseException) -> float | None:
|
| 101 |
+
"""Délai ``Retry-After`` (secondes, borné) si le serveur l'indique, sinon ``None``.
|
| 102 |
+
|
| 103 |
+
Sonde l'en-tête sur ``exc.response.headers`` puis ``exc.headers`` ; tolère
|
| 104 |
+
toute absence/forme invalide (→ ``None`` → l'appelant calcule un backoff)."""
|
| 105 |
+
for holder in (getattr(exc, "response", None), exc):
|
| 106 |
+
headers = getattr(holder, "headers", None)
|
| 107 |
+
if headers is None or not hasattr(headers, "get"):
|
| 108 |
+
continue
|
| 109 |
+
raw = headers.get("retry-after")
|
| 110 |
+
if raw is None:
|
| 111 |
+
continue
|
| 112 |
+
try:
|
| 113 |
+
seconds = float(raw)
|
| 114 |
+
except (TypeError, ValueError):
|
| 115 |
+
continue
|
| 116 |
+
if seconds >= 0:
|
| 117 |
+
return min(_MAX_BACKOFF, seconds)
|
| 118 |
+
return None
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def call_resilient(
|
| 122 |
+
provider: str,
|
| 123 |
+
fn: Callable[[], T],
|
| 124 |
+
*,
|
| 125 |
+
is_retryable: Callable[[BaseException], bool] = http_is_retryable,
|
| 126 |
+
retry_after: Callable[[BaseException], float | None] = http_retry_after,
|
| 127 |
+
attempts: int = _DEFAULT_ATTEMPTS,
|
| 128 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 129 |
+
jitter: Callable[[], float] = _default_jitter,
|
| 130 |
+
) -> T:
|
| 131 |
+
"""Exécute ``fn`` sous le quota du fournisseur, avec retry borné sur 429/5xx.
|
| 132 |
+
|
| 133 |
+
Acquiert ``network_slot(provider)`` **par tentative** (relâché pendant
|
| 134 |
+
l'attente → on ne gèle pas un quota en dormant). Sur erreur retentable et tant
|
| 135 |
+
qu'il reste des tentatives : attend ``Retry-After`` si fourni, sinon un backoff
|
| 136 |
+
exponentiel borné + gigue, puis recommence. Sinon l'exception remonte
|
| 137 |
+
(l'appelant la traduit en ``AdapterStepError`` → unité isolée par le runner).
|
| 138 |
+
``sleep``/``jitter`` injectables (tests déterministes). **Ne change que le
|
| 139 |
+
*timing*, jamais la valeur renvoyée.**"""
|
| 140 |
+
for attempt in range(attempts):
|
| 141 |
+
delay = 0.0
|
| 142 |
+
with network_slot(provider):
|
| 143 |
+
try:
|
| 144 |
+
return fn()
|
| 145 |
+
except Exception as exc:
|
| 146 |
+
if not is_retryable(exc) or attempt == attempts - 1:
|
| 147 |
+
raise
|
| 148 |
+
after = retry_after(exc)
|
| 149 |
+
delay = after if after is not None else _backoff(attempt, jitter)
|
| 150 |
+
logger.warning(
|
| 151 |
+
"[resilience] %s : tentative %d/%d échouée (%s) — "
|
| 152 |
+
"nouvelle tentative dans %.1f s.",
|
| 153 |
+
provider,
|
| 154 |
+
attempt + 1,
|
| 155 |
+
attempts,
|
| 156 |
+
type(exc).__name__,
|
| 157 |
+
delay,
|
| 158 |
+
)
|
| 159 |
+
sleep(delay) # hors du créneau : on ne retient pas le quota en dormant
|
| 160 |
+
raise AssertionError( # pragma: no cover — la boucle retourne ou relance avant
|
| 161 |
+
"call_resilient : boucle épuisée sans issue"
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
__all__ = [
|
| 166 |
+
"call_resilient",
|
| 167 |
+
"http_is_retryable",
|
| 168 |
+
"http_retry_after",
|
| 169 |
+
"network_slot",
|
| 170 |
+
]
|
cinoc/adapters/_workspace.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Chemins de sortie dans le workspace (couche 5, partagé par les écrivains).
|
| 2 |
+
|
| 3 |
+
Les adapters qui **produisent** un artefact (OCR, post-correction LLM) y déposent
|
| 4 |
+
un fichier. Le ``stem`` dérivé du ``document_id`` est **injectif** et sans
|
| 5 |
+
séparateur de chemin : encodage type URL (``quote``), **réversible** donc
|
| 6 |
+
injectif, qui neutralise ``/`` (→ ``%2F``) et l'octet nul (→ ``%00``). Deux ids
|
| 7 |
+
distincts ne peuvent jamais produire le même nom de fichier (et donc s'écraser) —
|
| 8 |
+
y compris les cas limites ``a/_b`` vs ``a_/b`` qu'un simple échappement maison
|
| 9 |
+
collisionnerait. Les ids courants (alphanumériques) restent lisibles.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from pathlib import Path
|
| 15 |
+
from urllib.parse import quote
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def safe_document_stem(document_id: str) -> str:
|
| 19 |
+
"""``document_id`` → composant de nom de fichier **injectif** et sans ``/``."""
|
| 20 |
+
return quote(document_id, safe="")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def workspace_artifact_path(
|
| 24 |
+
workspace_uri: str, document_id: str, label: str, suffix: str
|
| 25 |
+
) -> Path:
|
| 26 |
+
"""Chemin ``<workspace>/<stem>.<label>.<suffix>`` (``stem`` injectif)."""
|
| 27 |
+
stem = safe_document_stem(document_id)
|
| 28 |
+
return Path(workspace_uri) / f"{stem}.{label}.{suffix}"
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
__all__ = ["safe_document_stem", "workspace_artifact_path"]
|
cinoc/adapters/corpus/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeurs de corpus first-party (couche 5).
|
| 2 |
+
|
| 3 |
+
Chaque importeur **localise** des ressources (transport + parsing de schéma) et
|
| 4 |
+
retourne un type neutre ; la **matérialisation disque → ``CorpusSpec``** vit en
|
| 5 |
+
couche ``app``. Module mince, aucun effet de bord à l'import.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.corpus.escriptorium import (
|
| 11 |
+
EScriptoriumImporter,
|
| 12 |
+
EScriptoriumPage,
|
| 13 |
+
)
|
| 14 |
+
from cinoc.adapters.corpus.gallica import GallicaImporter, normalize_ark
|
| 15 |
+
from cinoc.adapters.corpus.htr_united import (
|
| 16 |
+
HTRUnitedCatalogue,
|
| 17 |
+
HTRUnitedEntry,
|
| 18 |
+
fetch_catalogue,
|
| 19 |
+
)
|
| 20 |
+
from cinoc.adapters.corpus.huggingface import (
|
| 21 |
+
HuggingFaceCatalogue,
|
| 22 |
+
HuggingFaceDataset,
|
| 23 |
+
)
|
| 24 |
+
from cinoc.adapters.corpus.iiif import IIIFImage, IIIFImporter, parse_manifest
|
| 25 |
+
|
| 26 |
+
__all__ = [
|
| 27 |
+
"EScriptoriumImporter",
|
| 28 |
+
"EScriptoriumPage",
|
| 29 |
+
"GallicaImporter",
|
| 30 |
+
"HTRUnitedCatalogue",
|
| 31 |
+
"HTRUnitedEntry",
|
| 32 |
+
"HuggingFaceCatalogue",
|
| 33 |
+
"HuggingFaceDataset",
|
| 34 |
+
"IIIFImage",
|
| 35 |
+
"IIIFImporter",
|
| 36 |
+
"fetch_catalogue",
|
| 37 |
+
"normalize_ark",
|
| 38 |
+
"parse_manifest",
|
| 39 |
+
]
|
cinoc/adapters/corpus/_http.py
ADDED
|
@@ -0,0 +1,360 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Transport HTTP **durci anti-SSRF** pour les importeurs de corpus (couche 5).
|
| 2 |
+
|
| 3 |
+
Un importeur va chercher des ressources à une URL **fournie par l'utilisateur** :
|
| 4 |
+
le risque est qu'on serve de proxy vers le réseau interne (SSRF) ou qu'on télécharge
|
| 5 |
+
sans borne (DoS mémoire/disque). Défenses, toutes testées :
|
| 6 |
+
|
| 7 |
+
- **schéma** ``http``/``https`` seuls (pas de ``file://``, ``gopher://``…) ;
|
| 8 |
+
- **pas de userinfo** dans l'URL ;
|
| 9 |
+
- **résolution DNS validée** : *toutes* les IP résolues doivent être **publiques**
|
| 10 |
+
(rejet loopback / privé / link-local / réservé / multicast / non-spécifié) ;
|
| 11 |
+
- **redirections re-validées** à chaque saut (une 30x ne peut pas pointer vers
|
| 12 |
+
l'interne) ; nombre de sauts borné ;
|
| 13 |
+
- **taille plafonnée** au fil de l'eau (on ne fait pas confiance à
|
| 14 |
+
``Content-Length``).
|
| 15 |
+
|
| 16 |
+
**DNS-rebinding fermé — épinglage d'IP.** ``assert_public_url`` résout l'hôte
|
| 17 |
+
**une seule fois**, valide **toutes** les IP, et **retourne** ces IP. La
|
| 18 |
+
connexion vise alors directement l'IP **épinglée** (transport ``httpx`` à
|
| 19 |
+
``network_backend`` custom, cf. :class:`_PinnedBackend`) : il n'y a **pas de
|
| 20 |
+
seconde résolution** que pourrait détourner un DNS hostile. Le nom d'hôte de
|
| 21 |
+
l'URL reste inchangé, donc le ``Host`` envoyé, le **SNI** et la vérification du
|
| 22 |
+
certificat TLS portent toujours sur le vrai hôte (aucun override fragile) —
|
| 23 |
+
seule la cible TCP est figée. L'épinglage est ré-appliqué **à chaque saut** de
|
| 24 |
+
redirection. Le **gate « mode public » (403)** côté interface reste la première
|
| 25 |
+
barrière (les imports distants y sont refusés) ; l'épinglage est la défense en
|
| 26 |
+
profondeur, désormais étanche.
|
| 27 |
+
|
| 28 |
+
Auth & redirections : les en-têtes (jeton) ne suivent **pas** un changement
|
| 29 |
+
d'hôte (cf. ``_stream_validated``). Une seule pile HTTP (``httpx`` + son moteur
|
| 30 |
+
``httpcore``) ; **aucun état global** (pas d'``install_opener``).
|
| 31 |
+
"""
|
| 32 |
+
|
| 33 |
+
from __future__ import annotations
|
| 34 |
+
|
| 35 |
+
import contextlib
|
| 36 |
+
import ipaddress
|
| 37 |
+
import json
|
| 38 |
+
import os
|
| 39 |
+
import socket
|
| 40 |
+
import typing
|
| 41 |
+
from collections.abc import Iterator
|
| 42 |
+
from pathlib import Path
|
| 43 |
+
from urllib.parse import urlsplit
|
| 44 |
+
|
| 45 |
+
import httpcore
|
| 46 |
+
import httpx
|
| 47 |
+
|
| 48 |
+
from cinoc.domain.errors import CinocError
|
| 49 |
+
|
| 50 |
+
#: Plafonds (généreux mais bornés — un Space gratuit n'est pas un entrepôt).
|
| 51 |
+
MANIFEST_MAX_BYTES = 16 * 1024 * 1024
|
| 52 |
+
IMAGE_MAX_BYTES = 50 * 1024 * 1024
|
| 53 |
+
DEFAULT_TIMEOUT = 30.0
|
| 54 |
+
_MAX_REDIRECTS = 5
|
| 55 |
+
_ALLOWED_SCHEMES = frozenset({"http", "https"})
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class CorpusHttpError(CinocError):
|
| 59 |
+
"""Échec de transport d'un importeur de corpus."""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class SsrfError(CorpusHttpError):
|
| 63 |
+
"""L'URL cible une ressource interdite (schéma, userinfo ou IP non publique)."""
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class HttpFetchError(CorpusHttpError):
|
| 67 |
+
"""La requête a échoué (statut, redirection cassée, dépassement de taille)."""
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _is_public(ip: ipaddress.IPv4Address | ipaddress.IPv6Address) -> bool:
|
| 71 |
+
return not (
|
| 72 |
+
ip.is_private
|
| 73 |
+
or ip.is_loopback
|
| 74 |
+
or ip.is_link_local
|
| 75 |
+
or ip.is_reserved
|
| 76 |
+
or ip.is_multicast
|
| 77 |
+
or ip.is_unspecified
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def assert_public_url(url: str) -> tuple[str, ...]:
|
| 82 |
+
"""Valide ``url`` et **retourne les IP publiques** vers lesquelles épingler.
|
| 83 |
+
|
| 84 |
+
Résout l'hôte **une seule fois** et exige que **toutes** les IP retournées
|
| 85 |
+
soient publiques : un nom qui résout (même partiellement) vers l'interne est
|
| 86 |
+
rejeté (``SsrfError``). Les IP validées sont retournées pour que l'appelant
|
| 87 |
+
**épingle** la connexion dessus (anti-DNS-rebinding) — pas de seconde
|
| 88 |
+
résolution. Pour une IP littérale, retourne cette IP (aucun DNS réel).
|
| 89 |
+
"""
|
| 90 |
+
parts = urlsplit(url)
|
| 91 |
+
if parts.scheme not in _ALLOWED_SCHEMES:
|
| 92 |
+
raise SsrfError(f"schéma non autorisé : {parts.scheme!r} (http/https seuls).")
|
| 93 |
+
if parts.username or parts.password:
|
| 94 |
+
raise SsrfError("userinfo interdit dans l'URL.")
|
| 95 |
+
host = parts.hostname
|
| 96 |
+
if not host:
|
| 97 |
+
raise SsrfError("URL sans hôte.")
|
| 98 |
+
port = parts.port or (443 if parts.scheme == "https" else 80)
|
| 99 |
+
try:
|
| 100 |
+
infos = socket.getaddrinfo(host, port, proto=socket.IPPROTO_TCP)
|
| 101 |
+
except OSError as exc:
|
| 102 |
+
raise SsrfError(f"résolution DNS impossible pour {host!r} : {exc}.") from exc
|
| 103 |
+
pins: list[str] = []
|
| 104 |
+
for info in infos:
|
| 105 |
+
addr = str(info[4][0]) # sockaddr[0] = adresse textuelle (IPv4/IPv6)
|
| 106 |
+
if not _is_public(ipaddress.ip_address(addr)):
|
| 107 |
+
raise SsrfError(
|
| 108 |
+
f"hôte {host!r} résout vers une IP non publique ({addr})."
|
| 109 |
+
)
|
| 110 |
+
if addr not in pins:
|
| 111 |
+
pins.append(addr)
|
| 112 |
+
return tuple(pins)
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
class _PinnedBackend(httpcore.SyncBackend):
|
| 116 |
+
"""Épingle la cible TCP sur des IP **pré-validées** (anti-DNS-rebinding).
|
| 117 |
+
|
| 118 |
+
``httpcore`` appelle ``connect_tcp(host, port)`` avec le **nom d'hôte** de
|
| 119 |
+
l'origine ; on substitue les IP déjà résolues+validées, sans repasser par le
|
| 120 |
+
DNS. Le nom d'hôte de l'URL restant inchangé, le SNI et la vérification du
|
| 121 |
+
certificat TLS (pilotés par ``httpcore`` depuis l'origin) portent toujours
|
| 122 |
+
sur le **vrai hôte** — donc aucun override fragile. On essaie les IP dans
|
| 123 |
+
l'ordre (comme ``socket.create_connection`` le ferait), préservant le
|
| 124 |
+
repli multi-adresses.
|
| 125 |
+
"""
|
| 126 |
+
|
| 127 |
+
def __init__(self, ips: tuple[str, ...]) -> None:
|
| 128 |
+
super().__init__()
|
| 129 |
+
self._ips = ips
|
| 130 |
+
|
| 131 |
+
def connect_tcp(
|
| 132 |
+
self,
|
| 133 |
+
host: str,
|
| 134 |
+
port: int,
|
| 135 |
+
timeout: float | None = None,
|
| 136 |
+
local_address: str | None = None,
|
| 137 |
+
socket_options: typing.Any = None,
|
| 138 |
+
) -> httpcore.NetworkStream:
|
| 139 |
+
last_exc: httpcore.ConnectError | None = None
|
| 140 |
+
for ip in self._ips:
|
| 141 |
+
try:
|
| 142 |
+
return super().connect_tcp(
|
| 143 |
+
ip,
|
| 144 |
+
port,
|
| 145 |
+
timeout=timeout,
|
| 146 |
+
local_address=local_address,
|
| 147 |
+
socket_options=socket_options,
|
| 148 |
+
)
|
| 149 |
+
except httpcore.ConnectError as exc:
|
| 150 |
+
last_exc = exc
|
| 151 |
+
assert last_exc is not None # _ips est non vide (cf. _make_client)
|
| 152 |
+
raise last_exc
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _pinned_transport(ips: tuple[str, ...]) -> httpx.HTTPTransport:
|
| 156 |
+
"""Transport ``httpx`` dont le backend réseau épingle ``ips``.
|
| 157 |
+
|
| 158 |
+
``httpx`` n'expose pas ``network_backend`` ; on le pose sur le pool interne
|
| 159 |
+
(seul point d'injection). Le ``isinstance`` fait échouer **bruyamment** si
|
| 160 |
+
une future version de ``httpx`` change la forme du pool, plutôt que de
|
| 161 |
+
contourner silencieusement l'épinglage.
|
| 162 |
+
"""
|
| 163 |
+
transport = httpx.HTTPTransport()
|
| 164 |
+
pool = transport._pool
|
| 165 |
+
if not isinstance(pool, httpcore.ConnectionPool): # pragma: no cover
|
| 166 |
+
transport.close()
|
| 167 |
+
raise CorpusHttpError("épinglage IP indisponible : transport httpx inattendu.")
|
| 168 |
+
pool._network_backend = _PinnedBackend(ips)
|
| 169 |
+
return transport
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _make_client(pins: tuple[str, ...] | None, timeout: float) -> httpx.Client:
|
| 173 |
+
"""Client mono-saut. ``pins`` vide/None → résolution httpx normale.
|
| 174 |
+
|
| 175 |
+
Le cas ``None`` n'arrive qu'en test (``assert_public_url`` neutralisé en
|
| 176 |
+
no-op pour viser le loopback) ; en production il est toujours non vide.
|
| 177 |
+
"""
|
| 178 |
+
if not pins:
|
| 179 |
+
return httpx.Client(timeout=timeout, follow_redirects=False)
|
| 180 |
+
return httpx.Client(
|
| 181 |
+
transport=_pinned_transport(pins), timeout=timeout, follow_redirects=False
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
@contextlib.contextmanager
|
| 186 |
+
def _stream_validated(
|
| 187 |
+
url: str, *, timeout: float, headers: dict[str, str] | None = None
|
| 188 |
+
) -> Iterator[httpx.Response]:
|
| 189 |
+
"""GET en streaming, **épinglé à l'IP validée** et re-validé à chaque saut.
|
| 190 |
+
|
| 191 |
+
Chaque saut (origine + redirections, bornées) : résout+valide une fois via
|
| 192 |
+
``assert_public_url``, épingle la connexion sur l'IP retournée, puis envoie.
|
| 193 |
+
Les en-têtes fournis (ex. ``Authorization: Token …``) ne sont **jamais**
|
| 194 |
+
propagés vers un **hôte différent** de l'origine (fuite de credentials sur
|
| 195 |
+
redirection). Un client ``httpx`` distinct est créé par saut (épinglage
|
| 196 |
+
propre à l'IP du saut) ; tous sont fermés à la sortie du contexte.
|
| 197 |
+
"""
|
| 198 |
+
origin_host = urlsplit(url).hostname
|
| 199 |
+
current = url
|
| 200 |
+
with contextlib.ExitStack() as stack:
|
| 201 |
+
for _ in range(_MAX_REDIRECTS + 1):
|
| 202 |
+
pins = assert_public_url(current)
|
| 203 |
+
# Auth conservée seulement tant qu'on reste sur l'hôte d'origine.
|
| 204 |
+
hop_headers = headers if urlsplit(current).hostname == origin_host else None
|
| 205 |
+
client = stack.enter_context(_make_client(pins, timeout))
|
| 206 |
+
request = client.build_request("GET", current, headers=hop_headers)
|
| 207 |
+
response = client.send(request, stream=True)
|
| 208 |
+
if response.is_redirect:
|
| 209 |
+
location = response.headers.get("location", "")
|
| 210 |
+
response.close()
|
| 211 |
+
if not location:
|
| 212 |
+
raise HttpFetchError(
|
| 213 |
+
f"redirection sans 'Location' depuis {current!r}."
|
| 214 |
+
)
|
| 215 |
+
current = str(request.url.join(location))
|
| 216 |
+
continue
|
| 217 |
+
try:
|
| 218 |
+
yield response
|
| 219 |
+
finally:
|
| 220 |
+
response.close()
|
| 221 |
+
return
|
| 222 |
+
raise HttpFetchError(f"trop de redirections (> {_MAX_REDIRECTS}) pour {url!r}.")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _read_capped(response: httpx.Response, max_bytes: int) -> bytes:
|
| 226 |
+
chunks: list[bytes] = []
|
| 227 |
+
total = 0
|
| 228 |
+
for chunk in response.iter_bytes():
|
| 229 |
+
total += len(chunk)
|
| 230 |
+
if total > max_bytes:
|
| 231 |
+
raise HttpFetchError(f"réponse dépasse le plafond de {max_bytes} octets.")
|
| 232 |
+
chunks.append(chunk)
|
| 233 |
+
return b"".join(chunks)
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
def fetch_json(
|
| 237 |
+
url: str,
|
| 238 |
+
*,
|
| 239 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 240 |
+
headers: dict[str, str] | None = None,
|
| 241 |
+
) -> object:
|
| 242 |
+
"""Récupère et parse un JSON ; anti-SSRF + plafond ``MANIFEST_MAX_BYTES``.
|
| 243 |
+
|
| 244 |
+
``headers`` permet l'authentification d'API (ex. ``Authorization: Token …``) ;
|
| 245 |
+
il n'est jamais journalisé (les messages d'erreur ne portent que l'URL).
|
| 246 |
+
"""
|
| 247 |
+
try:
|
| 248 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 249 |
+
response.raise_for_status()
|
| 250 |
+
raw = _read_capped(response, MANIFEST_MAX_BYTES)
|
| 251 |
+
except httpx.HTTPStatusError as exc:
|
| 252 |
+
raise HttpFetchError(
|
| 253 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 254 |
+
) from exc
|
| 255 |
+
except httpx.HTTPError as exc:
|
| 256 |
+
raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
|
| 257 |
+
try:
|
| 258 |
+
return json.loads(raw)
|
| 259 |
+
except json.JSONDecodeError as exc:
|
| 260 |
+
raise HttpFetchError(f"réponse non-JSON depuis {url!r} : {exc}.") from exc
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def download(
|
| 264 |
+
url: str,
|
| 265 |
+
dest: Path,
|
| 266 |
+
*,
|
| 267 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 268 |
+
headers: dict[str, str] | None = None,
|
| 269 |
+
) -> None:
|
| 270 |
+
"""Télécharge ``url`` vers ``dest`` en **flux disque** ; anti-SSRF + plafond.
|
| 271 |
+
|
| 272 |
+
Écrit au fil de l'eau dans un fichier ``.part`` voisin (jamais tout le corps
|
| 273 |
+
en RAM), plafonne à ``IMAGE_MAX_BYTES``, puis **renomme atomiquement**
|
| 274 |
+
(``os.replace``) vers ``dest``. À la moindre erreur (statut, dépassement de
|
| 275 |
+
plafond, I/O, annulation), le ``.part`` est supprimé : **aucun fichier
|
| 276 |
+
partiel** n'est laissé derrière, et ``dest`` n'apparaît qu'une fois complet.
|
| 277 |
+
|
| 278 |
+
``headers`` (ex. ``Authorization``) n'est transmis qu'à l'hôte d'origine —
|
| 279 |
+
``_stream_validated`` le retire sur une redirection cross-hôte (cf. D-050).
|
| 280 |
+
"""
|
| 281 |
+
part = dest.with_name(dest.name + ".part")
|
| 282 |
+
try:
|
| 283 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 284 |
+
try:
|
| 285 |
+
response.raise_for_status()
|
| 286 |
+
except httpx.HTTPStatusError as exc:
|
| 287 |
+
raise HttpFetchError(
|
| 288 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 289 |
+
) from exc
|
| 290 |
+
total = 0
|
| 291 |
+
with part.open("wb") as handle:
|
| 292 |
+
for chunk in response.iter_bytes():
|
| 293 |
+
total += len(chunk)
|
| 294 |
+
if total > IMAGE_MAX_BYTES:
|
| 295 |
+
raise HttpFetchError(
|
| 296 |
+
f"réponse dépasse le plafond de {IMAGE_MAX_BYTES} octets."
|
| 297 |
+
)
|
| 298 |
+
handle.write(chunk)
|
| 299 |
+
os.replace(part, dest)
|
| 300 |
+
except BaseException:
|
| 301 |
+
part.unlink(missing_ok=True)
|
| 302 |
+
raise
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def fetch_bytes(
|
| 306 |
+
url: str,
|
| 307 |
+
*,
|
| 308 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 309 |
+
headers: dict[str, str] | None = None,
|
| 310 |
+
) -> bytes:
|
| 311 |
+
"""Récupère un corps **binaire** en mémoire ; anti-SSRF + plafond manifeste.
|
| 312 |
+
|
| 313 |
+
Pour les ressources qui doivent être parsées depuis des **octets** (ex. ALTO
|
| 314 |
+
Gallica : lxml refuse un ``str`` portant une déclaration d'encodage XML).
|
| 315 |
+
"""
|
| 316 |
+
try:
|
| 317 |
+
with _stream_validated(url, timeout=timeout, headers=headers) as response:
|
| 318 |
+
response.raise_for_status()
|
| 319 |
+
return _read_capped(response, MANIFEST_MAX_BYTES)
|
| 320 |
+
except httpx.HTTPStatusError as exc:
|
| 321 |
+
raise HttpFetchError(
|
| 322 |
+
f"statut HTTP {exc.response.status_code} sur {url!r}."
|
| 323 |
+
) from exc
|
| 324 |
+
except httpx.HTTPError as exc:
|
| 325 |
+
# Échec de transport (connexion/timeout/protocole) : on l'enveloppe en
|
| 326 |
+
# HttpFetchError pour que les appelants n'aient qu'un type réseau à gérer
|
| 327 |
+
# (jamais une exception httpx brute qui échapperait au repli).
|
| 328 |
+
raise HttpFetchError(f"échec de transport sur {url!r} : {exc}.") from exc
|
| 329 |
+
|
| 330 |
+
|
| 331 |
+
def fetch_text(
|
| 332 |
+
url: str,
|
| 333 |
+
*,
|
| 334 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 335 |
+
headers: dict[str, str] | None = None,
|
| 336 |
+
) -> str:
|
| 337 |
+
"""Récupère un corps **texte** (UTF-8, erreurs remplacées) ; anti-SSRF + plafond.
|
| 338 |
+
|
| 339 |
+
Pour les endpoints qui ne servent pas du JSON (ex. catalogue HTR-United).
|
| 340 |
+
"""
|
| 341 |
+
# Accept #15 : un corps distant peut contenir des octets non-UTF-8 isolés ;
|
| 342 |
+
# `errors="replace"` insère U+FFFD plutôt que d'échouer sur une donnée mal
|
| 343 |
+
# encodée (le contenu reste exploitable, pas une GT manuelle).
|
| 344 |
+
return fetch_bytes(url, timeout=timeout, headers=headers).decode(
|
| 345 |
+
"utf-8", errors="replace"
|
| 346 |
+
)
|
| 347 |
+
|
| 348 |
+
|
| 349 |
+
__all__ = [
|
| 350 |
+
"CorpusHttpError",
|
| 351 |
+
"HttpFetchError",
|
| 352 |
+
"IMAGE_MAX_BYTES",
|
| 353 |
+
"MANIFEST_MAX_BYTES",
|
| 354 |
+
"SsrfError",
|
| 355 |
+
"assert_public_url",
|
| 356 |
+
"download",
|
| 357 |
+
"fetch_bytes",
|
| 358 |
+
"fetch_json",
|
| 359 |
+
"fetch_text",
|
| 360 |
+
]
|
cinoc/adapters/corpus/escriptorium.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **eScriptorium** (API REST) — couche 5.
|
| 2 |
+
|
| 3 |
+
Rôle : **localiser** les pages d'un document eScriptorium (auth par token →
|
| 4 |
+
liste paginée des *parts* + leur transcription) et retourner un type neutre. La
|
| 5 |
+
**matérialisation disque** (download image + écriture GT → ``CorpusSpec``) vit en
|
| 6 |
+
couche ``app`` (``app/corpus_import.py``).
|
| 7 |
+
|
| 8 |
+
Contrairement à IIIF, eScriptorium porte une **vérité-terrain** (la couche de
|
| 9 |
+
transcription choisie) → le corpus importé est **scorable** (CER/WER).
|
| 10 |
+
|
| 11 |
+
API consommée (lecture seule) :
|
| 12 |
+
|
| 13 |
+
- ``GET /api/documents/{pk}/parts/`` → pages (paginé) ;
|
| 14 |
+
- ``GET /api/documents/{pk}/parts/{pk}/transcriptions/`` → transcriptions.
|
| 15 |
+
|
| 16 |
+
Dette de la source **non reprise** : l'export distant de couche OCR, le listing
|
| 17 |
+
projets/connexion (surface morte ~40 %), et surtout le bug latent
|
| 18 |
+
``Corpus(source=…)`` — ici la sortie est un ``CorpusSpec`` (``source`` va dans
|
| 19 |
+
``metadata``), structurellement à l'abri.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import logging
|
| 25 |
+
from dataclasses import dataclass
|
| 26 |
+
from typing import Any
|
| 27 |
+
from urllib.parse import urljoin, urlsplit
|
| 28 |
+
|
| 29 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
#: Couche de transcription par défaut (libellé eScriptorium usuel pour la GT).
|
| 34 |
+
DEFAULT_LAYER = "manual"
|
| 35 |
+
#: Borne de pagination (un document = au plus quelques milliers de pages).
|
| 36 |
+
_MAX_PAGES = 10_000
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass(frozen=True)
|
| 40 |
+
class EScriptoriumPage:
|
| 41 |
+
"""Une page (*part*) : son URL d'image absolue + son texte GT (peut être vide)."""
|
| 42 |
+
|
| 43 |
+
pk: int
|
| 44 |
+
image_url: str
|
| 45 |
+
gt_text: str
|
| 46 |
+
title: str
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def _image_uri(item: dict[str, Any], base_url: str) -> str:
|
| 50 |
+
"""Extrait l'URL d'image d'une *part* (champ ``image`` : str ou objet)."""
|
| 51 |
+
img = item.get("image")
|
| 52 |
+
if isinstance(img, dict):
|
| 53 |
+
img = img.get("uri") or img.get("url") or ""
|
| 54 |
+
if not isinstance(img, str) or not img:
|
| 55 |
+
return ""
|
| 56 |
+
return urljoin(base_url, img)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _layer_name(transcription: dict[str, Any]) -> str:
|
| 60 |
+
nested = transcription.get("transcription")
|
| 61 |
+
if isinstance(nested, dict):
|
| 62 |
+
return str(nested.get("name", ""))
|
| 63 |
+
return str(transcription.get("name", ""))
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def extract_gt_text(transcriptions: list[dict[str, Any]], layer: str) -> str:
|
| 67 |
+
"""Texte GT de la couche ``layer`` (lignes jointes, ou ``content``). Pur.
|
| 68 |
+
|
| 69 |
+
``layer`` vide → première transcription rencontrée. Renvoie ``""`` si la
|
| 70 |
+
couche demandée est absente.
|
| 71 |
+
"""
|
| 72 |
+
for transcription in transcriptions:
|
| 73 |
+
if layer and _layer_name(transcription) != layer:
|
| 74 |
+
continue
|
| 75 |
+
lines = transcription.get("lines") or []
|
| 76 |
+
if lines:
|
| 77 |
+
return "\n".join(
|
| 78 |
+
str(line.get("content", ""))
|
| 79 |
+
for line in lines
|
| 80 |
+
if isinstance(line, dict) and line.get("content")
|
| 81 |
+
)
|
| 82 |
+
return str(transcription.get("content", "") or "")
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
class EScriptoriumImporter:
|
| 87 |
+
"""Localise les pages d'un document eScriptorium (auth + API, **sans disque**)."""
|
| 88 |
+
|
| 89 |
+
name = "escriptorium"
|
| 90 |
+
|
| 91 |
+
def __init__(
|
| 92 |
+
self,
|
| 93 |
+
base_url: str,
|
| 94 |
+
token: str,
|
| 95 |
+
*,
|
| 96 |
+
layer: str = DEFAULT_LAYER,
|
| 97 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 98 |
+
) -> None:
|
| 99 |
+
self._base_url = base_url.rstrip("/")
|
| 100 |
+
self._token = token
|
| 101 |
+
self._layer = layer
|
| 102 |
+
self._timeout = timeout
|
| 103 |
+
|
| 104 |
+
def _headers(self) -> dict[str, str]:
|
| 105 |
+
# Le token n'est jamais journalisé (cf. _http : erreurs sans en-têtes).
|
| 106 |
+
return {"Authorization": f"Token {self._token}", "Accept": "application/json"}
|
| 107 |
+
|
| 108 |
+
def _get(self, url: str) -> dict[str, Any]:
|
| 109 |
+
data = fetch_json(url, timeout=self._timeout, headers=self._headers())
|
| 110 |
+
if not isinstance(data, dict):
|
| 111 |
+
raise HttpFetchError(
|
| 112 |
+
f"réponse eScriptorium inattendue (objet JSON attendu) : {url!r}."
|
| 113 |
+
)
|
| 114 |
+
return data
|
| 115 |
+
|
| 116 |
+
def _paginate(self, path: str) -> list[dict[str, Any]]:
|
| 117 |
+
"""Suit les liens ``next`` jusqu'à épuisement, **sans quitter l'hôte**.
|
| 118 |
+
|
| 119 |
+
Le ``next`` est contrôlé par le serveur : on **n'envoie jamais le jeton**
|
| 120 |
+
vers un hôte différent de ``base_url`` (un ``next`` cross-hôte est ignoré).
|
| 121 |
+
"""
|
| 122 |
+
base_host = urlsplit(self._base_url).hostname
|
| 123 |
+
results: list[dict[str, Any]] = []
|
| 124 |
+
url: str | None = f"{self._base_url}/api/{path.lstrip('/')}"
|
| 125 |
+
for _ in range(_MAX_PAGES):
|
| 126 |
+
if not url:
|
| 127 |
+
break
|
| 128 |
+
data = self._get(url)
|
| 129 |
+
page = data.get("results")
|
| 130 |
+
results.extend(page if isinstance(page, list) else [])
|
| 131 |
+
nxt = data.get("next")
|
| 132 |
+
if not (isinstance(nxt, str) and nxt):
|
| 133 |
+
break
|
| 134 |
+
if urlsplit(nxt).hostname != base_host:
|
| 135 |
+
logger.warning(
|
| 136 |
+
"[escriptorium] lien 'next' hors hôte ignoré (pas de jeton "
|
| 137 |
+
"envoyé à un tiers) : %s",
|
| 138 |
+
nxt,
|
| 139 |
+
)
|
| 140 |
+
break
|
| 141 |
+
url = nxt
|
| 142 |
+
return results
|
| 143 |
+
|
| 144 |
+
def _transcriptions(self, doc_pk: int, part_pk: int) -> list[dict[str, Any]]:
|
| 145 |
+
url = f"{self._base_url}/api/documents/{doc_pk}/parts/{part_pk}/transcriptions/"
|
| 146 |
+
data = fetch_json(url, timeout=self._timeout, headers=self._headers())
|
| 147 |
+
if isinstance(data, list):
|
| 148 |
+
return [t for t in data if isinstance(t, dict)]
|
| 149 |
+
if isinstance(data, dict):
|
| 150 |
+
page = data.get("results")
|
| 151 |
+
if isinstance(page, list):
|
| 152 |
+
return [t for t in page if isinstance(t, dict)]
|
| 153 |
+
return []
|
| 154 |
+
|
| 155 |
+
def fetch_pages(self, doc_pk: int) -> tuple[EScriptoriumPage, ...]:
|
| 156 |
+
pages: list[EScriptoriumPage] = []
|
| 157 |
+
for item in self._paginate(f"documents/{doc_pk}/parts/"):
|
| 158 |
+
pk = item.get("pk")
|
| 159 |
+
if not isinstance(pk, int):
|
| 160 |
+
# Accept #14 : une *part* sans pk entier (schéma inattendu) est
|
| 161 |
+
# ignorée — on ne devine pas un identifiant. Avertir, pas planter.
|
| 162 |
+
logger.warning(
|
| 163 |
+
"[escriptorium] part sans 'pk' entier ignorée (doc %s) : %r",
|
| 164 |
+
doc_pk,
|
| 165 |
+
item.get("pk"),
|
| 166 |
+
)
|
| 167 |
+
continue
|
| 168 |
+
transcriptions = self._transcriptions(doc_pk, pk)
|
| 169 |
+
pages.append(
|
| 170 |
+
EScriptoriumPage(
|
| 171 |
+
pk=pk,
|
| 172 |
+
image_url=_image_uri(item, self._base_url),
|
| 173 |
+
gt_text=extract_gt_text(transcriptions, self._layer),
|
| 174 |
+
title=str(item.get("title", "") or f"part {pk}"),
|
| 175 |
+
)
|
| 176 |
+
)
|
| 177 |
+
return tuple(pages)
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
__all__ = [
|
| 181 |
+
"DEFAULT_LAYER",
|
| 182 |
+
"EScriptoriumImporter",
|
| 183 |
+
"EScriptoriumPage",
|
| 184 |
+
"extract_gt_text",
|
| 185 |
+
]
|
cinoc/adapters/corpus/gallica.py
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **Gallica** (BnF) — couche 5.
|
| 2 |
+
|
| 3 |
+
Gallica expose ses images via un **manifeste IIIF** (réutilisé tel quel par
|
| 4 |
+
``IIIFImporter``) et son OCR par page via l'endpoint **ALTO** officiel
|
| 5 |
+
``RequestDigitalElement``. Cet adapter ne porte que la logique propre à Gallica :
|
| 6 |
+
normalisation de l'**ARK**, URL du manifeste IIIF, et récupération de l'OCR d'une
|
| 7 |
+
page (ALTO → texte en ordre de lecture).
|
| 8 |
+
|
| 9 |
+
Choix : **ALTO** plutôt que ``texteBrut``. L'endpoint ``f{n}.texteBrut`` est
|
| 10 |
+
désormais protégé par un challenge anti-bot (ALTCHA) en accès automatisé, donc
|
| 11 |
+
inutilisable en import ; ``RequestDigitalElement?...&E=ALTO`` reste accessible et
|
| 12 |
+
plus riche (on en extrait le texte via le parseur ALTO de la couche 2).
|
| 13 |
+
|
| 14 |
+
Bug latent de la source **corrigé** : la numérotation page→OCR ne passe plus par
|
| 15 |
+
une indirection ``selected_indices[i]+1`` (source d'un décalage) — c'est la
|
| 16 |
+
**vue Gallica** lue dans l'URL d'image IIIF (``/f{n}/``) qui *est* le ``Deb`` de
|
| 17 |
+
la requête ALTO. Le mapping est fait par le service ``app`` et prouvé par test.
|
| 18 |
+
|
| 19 |
+
⚠️ L'OCR Gallica **n'est pas une vérité-terrain manuelle** : c'est la transcription
|
| 20 |
+
automatique de la BnF, utilisable comme *référence* (baseline) à condition de
|
| 21 |
+
l'étiqueter (``gt_source=gallica_ocr`` dans les métadonnées du corpus).
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
import logging
|
| 27 |
+
import re
|
| 28 |
+
from urllib.parse import urlencode
|
| 29 |
+
|
| 30 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_bytes
|
| 31 |
+
from cinoc.domain.errors import CinocError, FormatError
|
| 32 |
+
from cinoc.formats.alto import (
|
| 33 |
+
AltoBlock,
|
| 34 |
+
AltoComposedBlock,
|
| 35 |
+
AltoDocument,
|
| 36 |
+
AltoTextBlock,
|
| 37 |
+
parse_alto,
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
logger = logging.getLogger(__name__)
|
| 41 |
+
|
| 42 |
+
#: Hôte Gallica de production (surchargeable pour les tests transport).
|
| 43 |
+
GALLICA_BASE = "https://gallica.bnf.fr"
|
| 44 |
+
|
| 45 |
+
#: ARK normalisé : ``naID/identifiant`` (ex. ``12148/btv1b8453561w``).
|
| 46 |
+
_ARK_RE = re.compile(r"^[0-9]+/[A-Za-z0-9_.\-]+$")
|
| 47 |
+
|
| 48 |
+
#: Numéro de **vue** Gallica dans une URL d'image IIIF (``…/ark:/12148/X/f3/…``).
|
| 49 |
+
_VUE_RE = re.compile(r"/f(\d+)/")
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class GallicaArkError(CinocError):
|
| 53 |
+
"""ARK Gallica malformé."""
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def normalize_ark(ark: str) -> str:
|
| 57 |
+
"""Normalise un ARK (``ark:/12148/x`` | ``12148/x`` → ``12148/x``). Pur."""
|
| 58 |
+
value = ark.strip()
|
| 59 |
+
if value.startswith("ark:/"):
|
| 60 |
+
value = value[len("ark:/"):]
|
| 61 |
+
value = value.strip("/")
|
| 62 |
+
if not _ARK_RE.match(value):
|
| 63 |
+
raise GallicaArkError(
|
| 64 |
+
f"ARK invalide : {ark!r}. Forme attendue 'naID/identifiant' "
|
| 65 |
+
"(ex. '12148/btv1b8453561w')."
|
| 66 |
+
)
|
| 67 |
+
return value
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def vue_number(image_url: str) -> int | None:
|
| 71 |
+
"""Numéro de vue Gallica extrait d'une URL d'image (``/f{n}/``), sinon ``None``.
|
| 72 |
+
|
| 73 |
+
C'est la source **autoritaire** pour apparier une image à son OCR : se fier à
|
| 74 |
+
la position dans la liste d'images est faux dès qu'un canvas sans image est
|
| 75 |
+
sauté par le parseur IIIF (décalage de page).
|
| 76 |
+
"""
|
| 77 |
+
match = _VUE_RE.search(image_url)
|
| 78 |
+
return int(match.group(1)) if match else None
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _block_lines(blocks: tuple[AltoBlock, ...], out: list[str]) -> None:
|
| 82 |
+
"""Aplatit les ``<TextBlock>`` (récursif sur les ``<ComposedBlock>``) en lignes."""
|
| 83 |
+
for block in blocks:
|
| 84 |
+
if isinstance(block, AltoTextBlock):
|
| 85 |
+
for line in block.lines:
|
| 86 |
+
text = " ".join(s.content for s in line.strings if s.content)
|
| 87 |
+
if text:
|
| 88 |
+
out.append(text)
|
| 89 |
+
elif isinstance(block, AltoComposedBlock):
|
| 90 |
+
_block_lines(block.blocks, out)
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
def alto_to_text(document: AltoDocument) -> str:
|
| 94 |
+
"""Texte d'un ALTO en **ordre de lecture** : lignes (``String/@CONTENT``) jointes.
|
| 95 |
+
|
| 96 |
+
Pas de dé-césure (le ``texteBrut`` historique n'en faisait pas non plus) : on
|
| 97 |
+
rend le contenu brut, ligne par ligne.
|
| 98 |
+
"""
|
| 99 |
+
lines: list[str] = []
|
| 100 |
+
for page in document.pages:
|
| 101 |
+
_block_lines(page.blocks, lines)
|
| 102 |
+
return "\n".join(lines)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
class GallicaImporter:
|
| 106 |
+
"""Localise un document Gallica : URL de manifeste IIIF + OCR ALTO par page."""
|
| 107 |
+
|
| 108 |
+
name = "gallica"
|
| 109 |
+
|
| 110 |
+
def __init__(
|
| 111 |
+
self,
|
| 112 |
+
ark: str,
|
| 113 |
+
*,
|
| 114 |
+
base_url: str = GALLICA_BASE,
|
| 115 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 116 |
+
) -> None:
|
| 117 |
+
self.ark = normalize_ark(ark)
|
| 118 |
+
self._base_url = base_url.rstrip("/")
|
| 119 |
+
self._timeout = timeout
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def _identifier(self) -> str:
|
| 123 |
+
"""Identifiant de la numérisation (ARK sans le naID) — param ``O`` de l'ALTO."""
|
| 124 |
+
return self.ark.split("/", 1)[1]
|
| 125 |
+
|
| 126 |
+
@property
|
| 127 |
+
def manifest_url(self) -> str:
|
| 128 |
+
return f"{self._base_url}/iiif/ark:/{self.ark}/manifest.json"
|
| 129 |
+
|
| 130 |
+
def alto_url(self, page: int) -> str:
|
| 131 |
+
"""URL ALTO officielle d'une vue (``RequestDigitalElement``)."""
|
| 132 |
+
query = urlencode({"O": self._identifier, "E": "ALTO", "Deb": page})
|
| 133 |
+
return f"{self._base_url}/RequestDigitalElement?{query}"
|
| 134 |
+
|
| 135 |
+
def fetch_ocr_text(self, page: int) -> str:
|
| 136 |
+
"""OCR d'une vue (1-based) via ALTO → texte. ``""`` si absent ou illisible."""
|
| 137 |
+
try:
|
| 138 |
+
data = fetch_bytes(self.alto_url(page), timeout=self._timeout)
|
| 139 |
+
except HttpFetchError as exc:
|
| 140 |
+
logger.warning(
|
| 141 |
+
"[gallica] ALTO indisponible pour %s f%d : %s", self.ark, page, exc
|
| 142 |
+
)
|
| 143 |
+
return ""
|
| 144 |
+
try:
|
| 145 |
+
return alto_to_text(parse_alto(data))
|
| 146 |
+
except FormatError as exc: # ALTO malformé ou XML invalide
|
| 147 |
+
logger.warning(
|
| 148 |
+
"[gallica] ALTO illisible pour %s f%d : %s", self.ark, page, exc
|
| 149 |
+
)
|
| 150 |
+
return ""
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
__all__ = [
|
| 154 |
+
"GALLICA_BASE",
|
| 155 |
+
"GallicaArkError",
|
| 156 |
+
"GallicaImporter",
|
| 157 |
+
"alto_to_text",
|
| 158 |
+
"normalize_ark",
|
| 159 |
+
"vue_number",
|
| 160 |
+
]
|
cinoc/adapters/corpus/htr_united.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Catalogue **HTR-United** (découverte de corpus GT) — couche 5.
|
| 2 |
+
|
| 3 |
+
HTR-United publie un index communautaire de corpus de vérité-terrain HTR/OCR sous
|
| 4 |
+
forme d'un fichier YAML versionné sur GitHub. Cet adapter **découvre** (télécharge
|
| 5 |
+
l'index + recherche), il ne **matérialise pas** un corpus (les entrées pointent
|
| 6 |
+
vers des dépôts ALTO/PAGE — matérialisation = incrémentale, ultérieure).
|
| 7 |
+
|
| 8 |
+
Repli **hors-ligne** assumé : si l'index distant est injoignable ou illisible, on
|
| 9 |
+
sert un **socle de démonstration** intégré (petit, pas un dump) avec
|
| 10 |
+
``source="demo"`` / ``is_demo=True`` — l'appelant sait que ce n'est pas le
|
| 11 |
+
catalogue à jour (alimente la future UI de sélection de corpus).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
import yaml
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, CorpusHttpError, fetch_text
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
#: Index communautaire (YAML brut sur GitHub).
|
| 26 |
+
CATALOGUE_URL = (
|
| 27 |
+
"https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml"
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
@dataclass(frozen=True)
|
| 32 |
+
class HTRUnitedEntry:
|
| 33 |
+
"""Une entrée du catalogue (métadonnées de découverte, pas le corpus)."""
|
| 34 |
+
|
| 35 |
+
id: str
|
| 36 |
+
title: str
|
| 37 |
+
url: str
|
| 38 |
+
description: str
|
| 39 |
+
languages: tuple[str, ...]
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
#: Socle de démonstration minimal (corpus HTR-United réels) — repli hors-ligne.
|
| 43 |
+
_DEMO_ENTRIES: tuple[HTRUnitedEntry, ...] = (
|
| 44 |
+
HTRUnitedEntry(
|
| 45 |
+
id="cremma-medieval",
|
| 46 |
+
title="CREMMA Medieval",
|
| 47 |
+
url="https://github.com/HTR-United/cremma-medieval",
|
| 48 |
+
description="Manuscrits médiévaux français et latins.",
|
| 49 |
+
languages=("fr", "la"),
|
| 50 |
+
),
|
| 51 |
+
HTRUnitedEntry(
|
| 52 |
+
id="lectaurep-repertoires",
|
| 53 |
+
title="LECTAUREP Répertoires",
|
| 54 |
+
url="https://github.com/HTR-United/lectaurep-repertoires",
|
| 55 |
+
description="Répertoires de notaires manuscrits (XIXe-XXe).",
|
| 56 |
+
languages=("fr",),
|
| 57 |
+
),
|
| 58 |
+
HTRUnitedEntry(
|
| 59 |
+
id="caroline-minuscule",
|
| 60 |
+
title="Caroline Minuscule",
|
| 61 |
+
url="https://github.com/HTR-United/CemmaCarolineMinuscule",
|
| 62 |
+
description="Minuscule caroline latine.",
|
| 63 |
+
languages=("la",),
|
| 64 |
+
),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def _as_languages(raw: object) -> tuple[str, ...]:
|
| 69 |
+
"""Normalise un champ langue (str, liste de str, ou liste de dicts)."""
|
| 70 |
+
if isinstance(raw, str):
|
| 71 |
+
return (raw,)
|
| 72 |
+
if isinstance(raw, list):
|
| 73 |
+
out: list[str] = []
|
| 74 |
+
for item in raw:
|
| 75 |
+
if isinstance(item, str):
|
| 76 |
+
out.append(item)
|
| 77 |
+
elif isinstance(item, dict):
|
| 78 |
+
value = item.get("iso") or item.get("lang") or item.get("label")
|
| 79 |
+
if value:
|
| 80 |
+
out.append(str(value))
|
| 81 |
+
return tuple(out)
|
| 82 |
+
return ()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def _entry_from_dict(item: dict[str, object]) -> HTRUnitedEntry | None:
|
| 86 |
+
url = str(item.get("url", "") or "")
|
| 87 |
+
title = str(item.get("title", "") or "")
|
| 88 |
+
if not url and not title:
|
| 89 |
+
return None
|
| 90 |
+
entry_id = str(item.get("id") or "") or url.rstrip("/").rsplit("/", 1)[-1] or title
|
| 91 |
+
return HTRUnitedEntry(
|
| 92 |
+
id=entry_id,
|
| 93 |
+
title=title or entry_id,
|
| 94 |
+
url=url,
|
| 95 |
+
description=str(item.get("description", "") or ""),
|
| 96 |
+
languages=_as_languages(item.get("language")),
|
| 97 |
+
)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def parse_catalogue(raw_yaml: str) -> tuple[HTRUnitedEntry, ...]:
|
| 101 |
+
"""Parse l'index YAML HTR-United → entrées. Pur (tolérant au schéma)."""
|
| 102 |
+
data = yaml.safe_load(raw_yaml)
|
| 103 |
+
if isinstance(data, dict):
|
| 104 |
+
for key in ("data", "projects", "catalog", "catalogue"):
|
| 105 |
+
if isinstance(data.get(key), list):
|
| 106 |
+
data = data[key]
|
| 107 |
+
break
|
| 108 |
+
if not isinstance(data, list):
|
| 109 |
+
return ()
|
| 110 |
+
entries = [
|
| 111 |
+
_entry_from_dict(item) for item in data if isinstance(item, dict)
|
| 112 |
+
]
|
| 113 |
+
return tuple(e for e in entries if e is not None)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class HTRUnitedCatalogue:
|
| 118 |
+
"""Index HTR-United chargé, avec sa provenance (``remote`` | ``demo``)."""
|
| 119 |
+
|
| 120 |
+
entries: tuple[HTRUnitedEntry, ...]
|
| 121 |
+
source: str
|
| 122 |
+
|
| 123 |
+
@property
|
| 124 |
+
def is_demo(self) -> bool:
|
| 125 |
+
return self.source == "demo"
|
| 126 |
+
|
| 127 |
+
@classmethod
|
| 128 |
+
def from_demo(cls) -> HTRUnitedCatalogue:
|
| 129 |
+
return cls(entries=_DEMO_ENTRIES, source="demo")
|
| 130 |
+
|
| 131 |
+
def search(
|
| 132 |
+
self, query: str = "", language: str | None = None
|
| 133 |
+
) -> tuple[HTRUnitedEntry, ...]:
|
| 134 |
+
results = self.entries
|
| 135 |
+
if query:
|
| 136 |
+
q = query.lower()
|
| 137 |
+
results = tuple(
|
| 138 |
+
e
|
| 139 |
+
for e in results
|
| 140 |
+
if q in e.title.lower()
|
| 141 |
+
or q in e.description.lower()
|
| 142 |
+
or q in e.id.lower()
|
| 143 |
+
or any(q in lg.lower() for lg in e.languages)
|
| 144 |
+
)
|
| 145 |
+
if language:
|
| 146 |
+
lang = language.lower()
|
| 147 |
+
results = tuple(
|
| 148 |
+
e for e in results if any(lang in lg.lower() for lg in e.languages)
|
| 149 |
+
)
|
| 150 |
+
return results
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def fetch_catalogue(
|
| 154 |
+
*, catalogue_url: str = CATALOGUE_URL, timeout: float = DEFAULT_TIMEOUT
|
| 155 |
+
) -> HTRUnitedCatalogue:
|
| 156 |
+
"""Télécharge l'index HTR-United ; **repli démo** si indisponible/illisible."""
|
| 157 |
+
try:
|
| 158 |
+
raw = fetch_text(catalogue_url, timeout=timeout)
|
| 159 |
+
entries = parse_catalogue(raw)
|
| 160 |
+
except (CorpusHttpError, yaml.YAMLError) as exc:
|
| 161 |
+
logger.warning(
|
| 162 |
+
"[htr_united] index distant indisponible (%s) — repli démo : %s",
|
| 163 |
+
catalogue_url,
|
| 164 |
+
exc,
|
| 165 |
+
)
|
| 166 |
+
return HTRUnitedCatalogue.from_demo()
|
| 167 |
+
return HTRUnitedCatalogue(entries=entries, source="remote")
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
__all__ = [
|
| 171 |
+
"CATALOGUE_URL",
|
| 172 |
+
"HTRUnitedCatalogue",
|
| 173 |
+
"HTRUnitedEntry",
|
| 174 |
+
"fetch_catalogue",
|
| 175 |
+
"parse_catalogue",
|
| 176 |
+
]
|
cinoc/adapters/corpus/huggingface.py
ADDED
|
@@ -0,0 +1,487 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Découverte de datasets HTR/OCR sur **HuggingFace Hub** — couche 5.
|
| 2 |
+
|
| 3 |
+
Recherche (découverte), pas matérialisation : on liste des datasets candidats
|
| 4 |
+
(socle de **référence** intégré + API publique du Hub). Le téléchargement effectif
|
| 5 |
+
d'un dataset (lib ``datasets``, dépendance lourde) est un extra **ultérieur**
|
| 6 |
+
(`cinoc[huggingface]`), hors de cette tranche.
|
| 7 |
+
|
| 8 |
+
L'appel API est **best-effort** : s'il échoue, on renvoie le socle de référence
|
| 9 |
+
(``source="reference"``) en journalisant — l'utilisateur n'est jamais bloqué, et
|
| 10 |
+
sait d'où vient chaque résultat (``source`` : ``reference`` | ``api``).
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from collections.abc import Callable, Iterable, Iterator
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
from pathlib import Path, PurePosixPath
|
| 19 |
+
from urllib.parse import urlencode
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.corpus._http import (
|
| 22 |
+
DEFAULT_TIMEOUT,
|
| 23 |
+
IMAGE_MAX_BYTES,
|
| 24 |
+
CorpusHttpError,
|
| 25 |
+
fetch_json,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.errors import CinocError
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
HF_API_BASE = "https://huggingface.co/api"
|
| 32 |
+
|
| 33 |
+
#: Tag de convention apposé aux datasets **curés Cinoc** (carte HF) : il rend un
|
| 34 |
+
#: dataset de référence **découvrable** sur un compte sans coller son ``repo_id``.
|
| 35 |
+
#: Émis par ``app.dataset_standardize`` (front-matter), filtré par ``discover_curated``.
|
| 36 |
+
CURATED_DATASET_TAG = "cinoc-corpus"
|
| 37 |
+
|
| 38 |
+
#: Convention Cinoc (cf. ``docs/corpus_huggingface.md``) : un dataset importable
|
| 39 |
+
#: porte **au minimum** une colonne image et une colonne vérité-terrain. La
|
| 40 |
+
#: segmentation est une extension *future* (non requise ici).
|
| 41 |
+
CINOC_IMAGE_COLUMN = "image"
|
| 42 |
+
CINOC_GT_COLUMN = "ground_truth"
|
| 43 |
+
|
| 44 |
+
#: Extensions image reconnues (sinon ``.jpg`` par défaut) pour nommer le fichier.
|
| 45 |
+
_IMAGE_EXTS = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2", ".webp"})
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@dataclass(frozen=True)
|
| 49 |
+
class HuggingFaceDataset:
|
| 50 |
+
"""Un dataset candidat (métadonnées de découverte)."""
|
| 51 |
+
|
| 52 |
+
dataset_id: str
|
| 53 |
+
title: str
|
| 54 |
+
description: str
|
| 55 |
+
languages: tuple[str, ...]
|
| 56 |
+
downloads: int
|
| 57 |
+
source: str # "reference" | "api"
|
| 58 |
+
|
| 59 |
+
@property
|
| 60 |
+
def hf_url(self) -> str:
|
| 61 |
+
return f"https://huggingface.co/datasets/{self.dataset_id}"
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
@dataclass(frozen=True)
|
| 65 |
+
class CuratedDatasetRef:
|
| 66 |
+
"""Un dataset **curé Cinoc** découvert sur un compte HF (tag de convention).
|
| 67 |
+
|
| 68 |
+
Métadonnées d'affichage **et** d'import en un clic : ``repo_id`` (+ ``revision``
|
| 69 |
+
si l'API la fournit, pour épingler la repro) alimentent directement
|
| 70 |
+
``import_curated_hf_corpus``."""
|
| 71 |
+
|
| 72 |
+
repo_id: str
|
| 73 |
+
title: str
|
| 74 |
+
revision: str | None
|
| 75 |
+
last_modified: str
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _ref(
|
| 79 |
+
dataset_id: str, title: str, desc: str, langs: tuple[str, ...], dl: int
|
| 80 |
+
) -> HuggingFaceDataset:
|
| 81 |
+
return HuggingFaceDataset(
|
| 82 |
+
dataset_id=dataset_id,
|
| 83 |
+
title=title,
|
| 84 |
+
description=desc,
|
| 85 |
+
languages=langs,
|
| 86 |
+
downloads=dl,
|
| 87 |
+
source="reference",
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
#: Socle de référence minimal (datasets HTR/OCR patrimoniaux connus).
|
| 92 |
+
_REFERENCE_DATASETS: tuple[HuggingFaceDataset, ...] = (
|
| 93 |
+
_ref(
|
| 94 |
+
"Teklia/NorHand", "NorHand", "Écriture manuscrite norvégienne.", ("no",), 1200
|
| 95 |
+
),
|
| 96 |
+
_ref(
|
| 97 |
+
"Teklia/IAM-line",
|
| 98 |
+
"IAM (lignes)",
|
| 99 |
+
"Référence anglaise manuscrite.",
|
| 100 |
+
("en",),
|
| 101 |
+
8400,
|
| 102 |
+
),
|
| 103 |
+
_ref(
|
| 104 |
+
"bnf/gallica-presse-xix",
|
| 105 |
+
"Gallica Presse XIXe",
|
| 106 |
+
"Journaux numérisés XIXe (OCR + images).",
|
| 107 |
+
("fr",),
|
| 108 |
+
15200,
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def _matches(ds: HuggingFaceDataset, query: str, language: str | None) -> bool:
|
| 114 |
+
if query:
|
| 115 |
+
q = query.lower()
|
| 116 |
+
if not (
|
| 117 |
+
q in ds.title.lower()
|
| 118 |
+
or q in ds.description.lower()
|
| 119 |
+
or q in ds.dataset_id.lower()
|
| 120 |
+
or any(q in lg.lower() for lg in ds.languages)
|
| 121 |
+
):
|
| 122 |
+
return False
|
| 123 |
+
if language and not any(language.lower() in lg.lower() for lg in ds.languages):
|
| 124 |
+
return False
|
| 125 |
+
return True
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def search_reference(
|
| 129 |
+
query: str = "", language: str | None = None
|
| 130 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 131 |
+
"""Filtre le socle de référence intégré. Pur (sans réseau)."""
|
| 132 |
+
return tuple(ds for ds in _REFERENCE_DATASETS if _matches(ds, query, language))
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def _parse_api(payload: object) -> tuple[HuggingFaceDataset, ...]:
|
| 136 |
+
"""Mappe la réponse de l'API HF (liste de dicts) → datasets. Pur."""
|
| 137 |
+
if not isinstance(payload, list):
|
| 138 |
+
return ()
|
| 139 |
+
out: list[HuggingFaceDataset] = []
|
| 140 |
+
for item in payload:
|
| 141 |
+
if not isinstance(item, dict):
|
| 142 |
+
continue
|
| 143 |
+
dataset_id = str(item.get("id", "") or "")
|
| 144 |
+
if not dataset_id:
|
| 145 |
+
continue
|
| 146 |
+
downloads = item.get("downloads", 0)
|
| 147 |
+
out.append(
|
| 148 |
+
HuggingFaceDataset(
|
| 149 |
+
dataset_id=dataset_id,
|
| 150 |
+
title=dataset_id,
|
| 151 |
+
description="",
|
| 152 |
+
languages=(),
|
| 153 |
+
downloads=int(downloads) if isinstance(downloads, int) else 0,
|
| 154 |
+
source="api",
|
| 155 |
+
)
|
| 156 |
+
)
|
| 157 |
+
return tuple(out)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class HuggingFaceCatalogue:
|
| 161 |
+
"""Recherche de datasets : socle de référence + API publique (best-effort)."""
|
| 162 |
+
|
| 163 |
+
name = "huggingface"
|
| 164 |
+
|
| 165 |
+
def __init__(
|
| 166 |
+
self, *, api_base: str = HF_API_BASE, timeout: float = DEFAULT_TIMEOUT
|
| 167 |
+
) -> None:
|
| 168 |
+
self._api_base = api_base.rstrip("/")
|
| 169 |
+
self._timeout = timeout
|
| 170 |
+
|
| 171 |
+
def _fetch_api(
|
| 172 |
+
self, query: str, language: str | None, limit: int
|
| 173 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 174 |
+
params: dict[str, str] = {
|
| 175 |
+
"task_categories": "image-to-text",
|
| 176 |
+
"limit": str(min(limit, 50)),
|
| 177 |
+
}
|
| 178 |
+
if query:
|
| 179 |
+
params["search"] = query
|
| 180 |
+
if language:
|
| 181 |
+
params["language"] = language
|
| 182 |
+
url = f"{self._api_base}/datasets?{urlencode(params)}"
|
| 183 |
+
return _parse_api(fetch_json(url, timeout=self._timeout))
|
| 184 |
+
|
| 185 |
+
def search(
|
| 186 |
+
self,
|
| 187 |
+
query: str = "",
|
| 188 |
+
language: str | None = None,
|
| 189 |
+
*,
|
| 190 |
+
limit: int = 20,
|
| 191 |
+
use_reference: bool = True,
|
| 192 |
+
include_api: bool = True,
|
| 193 |
+
) -> tuple[HuggingFaceDataset, ...]:
|
| 194 |
+
results: list[HuggingFaceDataset] = []
|
| 195 |
+
if use_reference:
|
| 196 |
+
results.extend(search_reference(query, language))
|
| 197 |
+
if include_api:
|
| 198 |
+
seen = {ds.dataset_id for ds in results}
|
| 199 |
+
try:
|
| 200 |
+
for ds in self._fetch_api(query, language, limit):
|
| 201 |
+
if ds.dataset_id not in seen:
|
| 202 |
+
results.append(ds)
|
| 203 |
+
seen.add(ds.dataset_id)
|
| 204 |
+
except CorpusHttpError as exc:
|
| 205 |
+
logger.warning(
|
| 206 |
+
"[huggingface] recherche API indisponible — socle de référence "
|
| 207 |
+
"seul : %s",
|
| 208 |
+
exc,
|
| 209 |
+
)
|
| 210 |
+
# Accept #9 : socle de référence d'abord, API ensuite, puis troncature
|
| 211 |
+
# finale à `limit` — la référence (curée) prime sur l'API si l'union
|
| 212 |
+
# dépasse. Choix de découverte (display), pas de classement par score.
|
| 213 |
+
return tuple(results[:limit])
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
class HuggingFaceConventionError(CinocError):
|
| 217 |
+
"""Le dataset ne respecte pas la **convention Cinoc** (colonnes attendues)."""
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class HuggingFaceUnavailableError(CinocError):
|
| 221 |
+
"""La lib ``datasets`` (extra ``[huggingface]``) n'est pas installée."""
|
| 222 |
+
|
| 223 |
+
|
| 224 |
+
@dataclass(frozen=True)
|
| 225 |
+
class HFPage:
|
| 226 |
+
"""Une page **streamée** : octets image encodés + extension + texte GT.
|
| 227 |
+
|
| 228 |
+
Type neutre à la frontière adapter→app : l'image est livrée en **octets**
|
| 229 |
+
(jamais un objet PIL) pour que la matérialisation disque (couche 6) n'ait
|
| 230 |
+
aucune dépendance image.
|
| 231 |
+
"""
|
| 232 |
+
|
| 233 |
+
image_bytes: bytes
|
| 234 |
+
image_ext: str
|
| 235 |
+
gt_text: str
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
#: Charge un dataset en streaming → itérable de lignes. Injectable (test).
|
| 239 |
+
HFLoader = Callable[[str, str], Iterable[object]]
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def _default_loader(dataset_id: str, split: str) -> Iterable[object]:
|
| 243 |
+
"""Charge le dataset HF **en streaming** (jamais de snapshot local complet).
|
| 244 |
+
|
| 245 |
+
Import **paresseux** de ``datasets`` (extra ``[huggingface]``) : sans l'extra,
|
| 246 |
+
lève ``HuggingFaceUnavailableError`` (message actionnable). Les images sont
|
| 247 |
+
castées en ``Image(decode=False)`` → octets bruts (pas de décodage PIL).
|
| 248 |
+
"""
|
| 249 |
+
try:
|
| 250 |
+
from datasets import Image, load_dataset # type: ignore[import-not-found]
|
| 251 |
+
except ImportError as exc:
|
| 252 |
+
raise HuggingFaceUnavailableError(
|
| 253 |
+
"import HuggingFace indisponible : installer 'cinoc[huggingface]' "
|
| 254 |
+
"(lib 'datasets')."
|
| 255 |
+
) from exc
|
| 256 |
+
dataset = load_dataset(dataset_id, split=split, streaming=True)
|
| 257 |
+
features = getattr(dataset, "features", None) or {}
|
| 258 |
+
if CINOC_IMAGE_COLUMN in features:
|
| 259 |
+
dataset = dataset.cast_column(CINOC_IMAGE_COLUMN, Image(decode=False))
|
| 260 |
+
return dataset # type: ignore[no-any-return]
|
| 261 |
+
|
| 262 |
+
|
| 263 |
+
def _ext_from_path(path: object) -> str:
|
| 264 |
+
if isinstance(path, str) and path:
|
| 265 |
+
suffix = PurePosixPath(path).suffix.lower()
|
| 266 |
+
if suffix in _IMAGE_EXTS:
|
| 267 |
+
return suffix
|
| 268 |
+
return ".jpg"
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def _extract_image(value: object) -> tuple[bytes, str]:
|
| 272 |
+
"""Octets image + extension depuis une cellule ``image`` (``decode=False``)."""
|
| 273 |
+
if isinstance(value, dict):
|
| 274 |
+
raw = value.get("bytes")
|
| 275 |
+
if isinstance(raw, bytes) and raw:
|
| 276 |
+
return raw, _ext_from_path(value.get("path"))
|
| 277 |
+
raise HuggingFaceConventionError(
|
| 278 |
+
f"colonne {CINOC_IMAGE_COLUMN!r} : octets image absents (le dataset "
|
| 279 |
+
"doit stocker l'image en octets ; cf. convention Cinoc)."
|
| 280 |
+
)
|
| 281 |
+
|
| 282 |
+
|
| 283 |
+
def _validate_columns(row: dict[str, object]) -> None:
|
| 284 |
+
missing = [c for c in (CINOC_IMAGE_COLUMN, CINOC_GT_COLUMN) if c not in row]
|
| 285 |
+
if missing:
|
| 286 |
+
raise HuggingFaceConventionError(
|
| 287 |
+
f"dataset non conforme à la convention Cinoc : colonnes manquantes "
|
| 288 |
+
f"{missing} (attendu : {CINOC_IMAGE_COLUMN!r} + {CINOC_GT_COLUMN!r})."
|
| 289 |
+
)
|
| 290 |
+
|
| 291 |
+
|
| 292 |
+
def stream_pages(
|
| 293 |
+
dataset_id: str,
|
| 294 |
+
*,
|
| 295 |
+
split: str = "train",
|
| 296 |
+
limit: int | None = None,
|
| 297 |
+
loader: HFLoader | None = None,
|
| 298 |
+
) -> Iterator[HFPage]:
|
| 299 |
+
"""Streame les pages d'un dataset HF conforme à la **convention Cinoc**.
|
| 300 |
+
|
| 301 |
+
Page-par-page (``streaming=True`` côté ``datasets``) : on ne télécharge jamais
|
| 302 |
+
le snapshot complet ; ``limit`` borne le nombre de pages lues. La conformité
|
| 303 |
+
(colonnes ``image`` + ``ground_truth``) est validée sur la **1ʳᵉ ligne**. Une
|
| 304 |
+
image dépassant ``IMAGE_MAX_BYTES`` est **ignorée** (avertie), pas fatale.
|
| 305 |
+
|
| 306 |
+
``loader`` (``(dataset_id, split) → itérable de lignes``) est injectable pour
|
| 307 |
+
tester sans la lib ``datasets`` ; défaut = streaming réel.
|
| 308 |
+
"""
|
| 309 |
+
load = loader or _default_loader
|
| 310 |
+
validated = False
|
| 311 |
+
count = 0
|
| 312 |
+
for row in load(dataset_id, split):
|
| 313 |
+
if not isinstance(row, dict):
|
| 314 |
+
continue
|
| 315 |
+
if not validated:
|
| 316 |
+
_validate_columns(row)
|
| 317 |
+
validated = True
|
| 318 |
+
image_bytes, ext = _extract_image(row[CINOC_IMAGE_COLUMN])
|
| 319 |
+
if len(image_bytes) > IMAGE_MAX_BYTES:
|
| 320 |
+
logger.warning(
|
| 321 |
+
"[huggingface] image > %d octets ignorée (dataset %s).",
|
| 322 |
+
IMAGE_MAX_BYTES,
|
| 323 |
+
dataset_id,
|
| 324 |
+
)
|
| 325 |
+
continue
|
| 326 |
+
gt_value = row.get(CINOC_GT_COLUMN)
|
| 327 |
+
gt_text = gt_value if isinstance(gt_value, str) else ""
|
| 328 |
+
yield HFPage(image_bytes=image_bytes, image_ext=ext, gt_text=gt_text)
|
| 329 |
+
count += 1
|
| 330 |
+
if limit is not None and count >= limit:
|
| 331 |
+
return
|
| 332 |
+
|
| 333 |
+
|
| 334 |
+
#: Récupère un JSON depuis une URL. Injectable → ``discover_curated`` se teste
|
| 335 |
+
#: sans réseau (le défaut tape l'API HF via ``fetch_json``).
|
| 336 |
+
CuratedFetcher = Callable[[str], object]
|
| 337 |
+
|
| 338 |
+
|
| 339 |
+
def _parse_curated(payload: object, *, tag: str) -> tuple[CuratedDatasetRef, ...]:
|
| 340 |
+
"""Réponse ``/api/datasets`` (liste de dicts) → datasets curés. Pure.
|
| 341 |
+
|
| 342 |
+
Re-filtre **défensivement** sur le tag (si la liste porte ``tags``) : on ne
|
| 343 |
+
présente que de vrais layouts Cinoc, jamais un dataset quelconque du compte."""
|
| 344 |
+
if not isinstance(payload, list):
|
| 345 |
+
return ()
|
| 346 |
+
out: list[CuratedDatasetRef] = []
|
| 347 |
+
for item in payload:
|
| 348 |
+
if not isinstance(item, dict):
|
| 349 |
+
continue
|
| 350 |
+
repo_id = str(item.get("id", "") or "")
|
| 351 |
+
if not repo_id:
|
| 352 |
+
continue
|
| 353 |
+
tags = item.get("tags")
|
| 354 |
+
if isinstance(tags, list) and tag not in tags:
|
| 355 |
+
continue
|
| 356 |
+
sha = item.get("sha")
|
| 357 |
+
out.append(
|
| 358 |
+
CuratedDatasetRef(
|
| 359 |
+
repo_id=repo_id,
|
| 360 |
+
title=str(item.get("id", repo_id)),
|
| 361 |
+
revision=str(sha) if isinstance(sha, str) and sha else None,
|
| 362 |
+
last_modified=str(item.get("lastModified", "") or ""),
|
| 363 |
+
)
|
| 364 |
+
)
|
| 365 |
+
return tuple(out)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
def whoami(
|
| 369 |
+
token: str,
|
| 370 |
+
*,
|
| 371 |
+
api_base: str = HF_API_BASE,
|
| 372 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 373 |
+
fetcher: Callable[[str], object] | None = None,
|
| 374 |
+
) -> str | None:
|
| 375 |
+
"""Handle du compte HF derrière un ``token`` (``/api/whoami-v2``). Best-effort.
|
| 376 |
+
|
| 377 |
+
Sert à faire **apparaître automatiquement** les datasets curés de l'opérateur
|
| 378 |
+
sans qu'il colle son identifiant : on résout son handle depuis sa clé. Échec
|
| 379 |
+
réseau / token invalide → ``None`` (repli sur ``CINOC_HF_AUTHOR`` ou l'import
|
| 380 |
+
manuel). Le token n'est jamais journalisé (cf. ``_http``)."""
|
| 381 |
+
if not token:
|
| 382 |
+
return None
|
| 383 |
+
get = fetcher or (
|
| 384 |
+
lambda url: fetch_json(
|
| 385 |
+
url, timeout=timeout, headers={"Authorization": f"Bearer {token}"}
|
| 386 |
+
)
|
| 387 |
+
)
|
| 388 |
+
try:
|
| 389 |
+
payload = get(f"{api_base.rstrip('/')}/whoami-v2")
|
| 390 |
+
except CorpusHttpError as exc:
|
| 391 |
+
logger.warning("[huggingface] whoami indisponible : %s", exc)
|
| 392 |
+
return None
|
| 393 |
+
if isinstance(payload, dict):
|
| 394 |
+
name = payload.get("name")
|
| 395 |
+
if isinstance(name, str) and name.strip():
|
| 396 |
+
return name.strip()
|
| 397 |
+
return None
|
| 398 |
+
|
| 399 |
+
|
| 400 |
+
def discover_curated(
|
| 401 |
+
author: str,
|
| 402 |
+
*,
|
| 403 |
+
tag: str = CURATED_DATASET_TAG,
|
| 404 |
+
api_base: str = HF_API_BASE,
|
| 405 |
+
timeout: float = DEFAULT_TIMEOUT,
|
| 406 |
+
fetcher: CuratedFetcher | None = None,
|
| 407 |
+
) -> tuple[CuratedDatasetRef, ...]:
|
| 408 |
+
"""Liste les datasets **curés Cinoc** d'un compte HF (``author`` + ``tag``).
|
| 409 |
+
|
| 410 |
+
Best-effort : API injoignable → ``()`` en journalisant (l'UI retombe sur
|
| 411 |
+
l'import manuel par ``repo_id``, jamais un blocage). C'est ce qui fait
|
| 412 |
+
« apparaître » un dataset publié sans coller son identifiant : on filtre le
|
| 413 |
+
compte par le tag de convention."""
|
| 414 |
+
if not author:
|
| 415 |
+
return ()
|
| 416 |
+
get = fetcher or (lambda url: fetch_json(url, timeout=timeout))
|
| 417 |
+
params = {"author": author, "filter": tag, "full": "true", "limit": "100"}
|
| 418 |
+
url = f"{api_base.rstrip('/')}/datasets?{urlencode(params)}"
|
| 419 |
+
try:
|
| 420 |
+
payload = get(url)
|
| 421 |
+
except CorpusHttpError as exc:
|
| 422 |
+
logger.warning(
|
| 423 |
+
"[huggingface] découverte curée indisponible (compte %s) : %s",
|
| 424 |
+
author,
|
| 425 |
+
exc,
|
| 426 |
+
)
|
| 427 |
+
return ()
|
| 428 |
+
return _parse_curated(payload, tag=tag)
|
| 429 |
+
|
| 430 |
+
|
| 431 |
+
def snapshot_curated_layout(
|
| 432 |
+
repo_id: str, dest: Path, revision: str | None
|
| 433 |
+
) -> Path: # pragma: no cover -- réseau réel (test 'live')
|
| 434 |
+
"""Snapshot **partiel** d'un dataset curé Cinoc publié sur HF.
|
| 435 |
+
|
| 436 |
+
Ne rapatrie que ``corpus.json`` + ``ground_truth/`` (scorables en local) ; les
|
| 437 |
+
images restent des **références distantes** : le dataset embarque un layout
|
| 438 |
+
**IIIF statique** (Image API 3 level0, cf. ``app.dataset_standardize``) servi
|
| 439 |
+
par la racine ``resolve`` HF épinglée à la révision — l'URL est donc un lien
|
| 440 |
+
``huggingface.co`` dont le chemin suit la syntaxe IIIF. Le rapport les affiche
|
| 441 |
+
(saveur « liens », D-094) et l'orchestrateur les **matérialise au run** (fetch
|
| 442 |
+
durci) pour les moteurs. ``huggingface_hub`` (extra ``[huggingface]``) est
|
| 443 |
+
importé paresseusement → sans l'extra, ``HuggingFaceUnavailableError``
|
| 444 |
+
actionnable ; dépôt/révision injoignable → ``CorpusHttpError`` (→ 422 web).
|
| 445 |
+
"""
|
| 446 |
+
try:
|
| 447 |
+
from huggingface_hub import snapshot_download # type: ignore[import-not-found]
|
| 448 |
+
from huggingface_hub.utils import ( # type: ignore[import-not-found]
|
| 449 |
+
HfHubHTTPError,
|
| 450 |
+
)
|
| 451 |
+
except ImportError as exc:
|
| 452 |
+
raise HuggingFaceUnavailableError(
|
| 453 |
+
"import curé HF indisponible : installer 'cinoc[huggingface]' "
|
| 454 |
+
"(lib 'huggingface_hub')."
|
| 455 |
+
) from exc
|
| 456 |
+
try:
|
| 457 |
+
local = snapshot_download(
|
| 458 |
+
repo_id,
|
| 459 |
+
repo_type="dataset",
|
| 460 |
+
revision=revision,
|
| 461 |
+
allow_patterns=["corpus.json", "ground_truth/*"],
|
| 462 |
+
local_dir=str(dest),
|
| 463 |
+
)
|
| 464 |
+
except HfHubHTTPError as exc:
|
| 465 |
+
raise CorpusHttpError(
|
| 466 |
+
f"dataset curé HF {repo_id!r} injoignable : {exc}"
|
| 467 |
+
) from exc
|
| 468 |
+
return Path(local)
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
__all__ = [
|
| 472 |
+
"HF_API_BASE",
|
| 473 |
+
"CINOC_GT_COLUMN",
|
| 474 |
+
"CINOC_IMAGE_COLUMN",
|
| 475 |
+
"CURATED_DATASET_TAG",
|
| 476 |
+
"CuratedDatasetRef",
|
| 477 |
+
"HFPage",
|
| 478 |
+
"HuggingFaceCatalogue",
|
| 479 |
+
"HuggingFaceConventionError",
|
| 480 |
+
"HuggingFaceDataset",
|
| 481 |
+
"HuggingFaceUnavailableError",
|
| 482 |
+
"discover_curated",
|
| 483 |
+
"search_reference",
|
| 484 |
+
"snapshot_curated_layout",
|
| 485 |
+
"stream_pages",
|
| 486 |
+
"whoami",
|
| 487 |
+
]
|
cinoc/adapters/corpus/iiif.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Importeur de corpus **IIIF** (Presentation API v2 & v3) — couche 5.
|
| 2 |
+
|
| 3 |
+
Rôle : **localiser** les pages d'un manifeste (parsing du schéma → URLs d'image).
|
| 4 |
+
La **matérialisation disque** (téléchargement → ``CorpusSpec``) vit en couche
|
| 5 |
+
``app`` (``app/corpus_import.py``) : un adapter parle protocole, pas filesystem.
|
| 6 |
+
|
| 7 |
+
Périmètre : **images seules**. Un manifeste IIIF porte des pages numérisées, pas
|
| 8 |
+
de transcription ; le corpus importé est donc sans vérité-terrain (OCR exécutable,
|
| 9 |
+
métriques non applicables tant qu'une GT n'est pas appariée). C'est un choix
|
| 10 |
+
assumé de la tranche, pas un manque.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from dataclasses import dataclass
|
| 16 |
+
from typing import Any
|
| 17 |
+
|
| 18 |
+
from cinoc.adapters.corpus._http import DEFAULT_TIMEOUT, HttpFetchError, fetch_json
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass(frozen=True)
|
| 22 |
+
class IIIFImage:
|
| 23 |
+
"""Une page du manifeste : son URL d'image + un label lisible."""
|
| 24 |
+
|
| 25 |
+
image_url: str
|
| 26 |
+
label: str
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _extract_label(raw: object) -> str:
|
| 30 |
+
"""Aplati les formats de label IIIF (str, liste, map de langue v3)."""
|
| 31 |
+
if isinstance(raw, str):
|
| 32 |
+
return raw
|
| 33 |
+
if isinstance(raw, list) and raw:
|
| 34 |
+
return _extract_label(raw[0])
|
| 35 |
+
if isinstance(raw, dict):
|
| 36 |
+
for lang in ("fr", "en", "none", "@value"):
|
| 37 |
+
val = raw.get(lang)
|
| 38 |
+
if val:
|
| 39 |
+
return str(val[0]) if isinstance(val, list) and val else str(val)
|
| 40 |
+
for val in raw.values():
|
| 41 |
+
return _extract_label(val)
|
| 42 |
+
return str(raw) if raw else ""
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def _service_image_url(service: object) -> str:
|
| 46 |
+
"""Construit ``{service}/full/max/0/default.jpg`` depuis un service Image API."""
|
| 47 |
+
if isinstance(service, list):
|
| 48 |
+
service = service[0] if service else {}
|
| 49 |
+
if not isinstance(service, dict):
|
| 50 |
+
return ""
|
| 51 |
+
svc_id = service.get("@id") or service.get("id") or ""
|
| 52 |
+
if not isinstance(svc_id, str) or not svc_id:
|
| 53 |
+
return ""
|
| 54 |
+
return f"{svc_id.rstrip('/')}/full/max/0/default.jpg"
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _image_url_v2(canvas: dict[str, Any]) -> str:
|
| 58 |
+
images = canvas.get("images") or []
|
| 59 |
+
if not images or not isinstance(images[0], dict):
|
| 60 |
+
return ""
|
| 61 |
+
resource = images[0].get("resource", {})
|
| 62 |
+
if not isinstance(resource, dict):
|
| 63 |
+
return ""
|
| 64 |
+
direct = resource.get("@id", "")
|
| 65 |
+
if isinstance(direct, str) and direct and not direct.endswith("/info.json"):
|
| 66 |
+
return direct
|
| 67 |
+
return _service_image_url(resource.get("service", {}))
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _image_url_v3(canvas: dict[str, Any]) -> str:
|
| 71 |
+
for annotation_page in canvas.get("items") or []:
|
| 72 |
+
for annotation in annotation_page.get("items") or []:
|
| 73 |
+
body = annotation.get("body", {})
|
| 74 |
+
if isinstance(body, list):
|
| 75 |
+
body = body[0] if body else {}
|
| 76 |
+
if not isinstance(body, dict):
|
| 77 |
+
continue
|
| 78 |
+
url = body.get("id") or body.get("@id") or ""
|
| 79 |
+
if isinstance(url, str) and url and body.get("type") == "Image":
|
| 80 |
+
return url
|
| 81 |
+
service_url = _service_image_url(body.get("service", []))
|
| 82 |
+
if service_url:
|
| 83 |
+
return service_url
|
| 84 |
+
if isinstance(url, str) and url:
|
| 85 |
+
return url
|
| 86 |
+
return ""
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def _detect_version(manifest: dict[str, Any]) -> int:
|
| 90 |
+
context = manifest.get("@context", "")
|
| 91 |
+
contexts = context if isinstance(context, list) else [context]
|
| 92 |
+
if any(isinstance(c, str) and "presentation/3" in c for c in contexts):
|
| 93 |
+
return 3
|
| 94 |
+
if manifest.get("type") == "Manifest" and "items" in manifest:
|
| 95 |
+
return 3
|
| 96 |
+
return 2
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def parse_manifest(manifest: dict[str, Any]) -> tuple[IIIFImage, ...]:
|
| 100 |
+
"""Parse un manifeste IIIF (v2/v3 auto-détecté) → pages avec image. Pur."""
|
| 101 |
+
if _detect_version(manifest) == 3:
|
| 102 |
+
canvases = manifest.get("items") or []
|
| 103 |
+
extract = _image_url_v3
|
| 104 |
+
else:
|
| 105 |
+
sequences = manifest.get("sequences") or []
|
| 106 |
+
canvases = sequences[0].get("canvases", []) if sequences else []
|
| 107 |
+
extract = _image_url_v2
|
| 108 |
+
images: list[IIIFImage] = []
|
| 109 |
+
for i, canvas in enumerate(canvases, start=1):
|
| 110 |
+
if not isinstance(canvas, dict):
|
| 111 |
+
continue
|
| 112 |
+
url = extract(canvas)
|
| 113 |
+
if not url:
|
| 114 |
+
continue
|
| 115 |
+
label = _extract_label(canvas.get("label", f"canvas_{i}")) or f"canvas_{i}"
|
| 116 |
+
images.append(IIIFImage(image_url=url, label=label))
|
| 117 |
+
return tuple(images)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
class IIIFImporter:
|
| 121 |
+
"""Localise les pages d'un manifeste IIIF (fetch + parse, **sans disque**)."""
|
| 122 |
+
|
| 123 |
+
name = "iiif"
|
| 124 |
+
|
| 125 |
+
def __init__(self, *, timeout: float = DEFAULT_TIMEOUT) -> None:
|
| 126 |
+
self._timeout = timeout
|
| 127 |
+
|
| 128 |
+
def fetch_images(self, manifest_url: str) -> tuple[IIIFImage, ...]:
|
| 129 |
+
manifest = fetch_json(manifest_url, timeout=self._timeout)
|
| 130 |
+
if not isinstance(manifest, dict):
|
| 131 |
+
raise HttpFetchError(
|
| 132 |
+
f"manifeste IIIF {manifest_url!r} : objet JSON attendu."
|
| 133 |
+
)
|
| 134 |
+
return parse_manifest(manifest)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
__all__ = ["IIIFImage", "IIIFImporter", "parse_manifest"]
|
cinoc/adapters/images.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Vignettes du rapport : image → dérivé JPEG redimensionné (couche 5, adapter).
|
| 2 |
+
|
| 3 |
+
Le redimensionnement utilise **Pillow** (interdit en ``reports`` — couche 7) ; il
|
| 4 |
+
vit donc ici. Deux saveurs partagent le **même cœur** ``_render_jpeg`` :
|
| 5 |
+
|
| 6 |
+
- ``thumbnail_data_uri`` → **data-URI** embarqué dans le HTML (saveur fichier
|
| 7 |
+
unique) ; les octets ne touchent jamais le ``RunResult``.
|
| 8 |
+
- ``thumbnail_to_file`` → écrit le dérivé dans un dossier d'assets et renvoie un
|
| 9 |
+
**href relatif** (saveur dossier : HTML léger à liens relatifs).
|
| 10 |
+
|
| 11 |
+
**Dégradé gracieux** : si Pillow n'est pas installé (extra ``[images]``), si le
|
| 12 |
+
fichier manque ou est illisible → ``None`` (le rapport retombe sur l'aperçu
|
| 13 |
+
synthétique). Aucune exception large.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import base64
|
| 19 |
+
import io
|
| 20 |
+
import logging
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _render_jpeg(source: str | Path, max_px: int) -> bytes | None:
|
| 27 |
+
"""Octets JPEG d'une image locale redimensionnée ; ``None`` si indisponible.
|
| 28 |
+
|
| 29 |
+
Cœur partagé des deux saveurs (data-URI / fichier) : redimensionne pour que le
|
| 30 |
+
plus grand côté ≤ ``max_px`` (jamais agrandi), convertit en RGB, encode JPEG.
|
| 31 |
+
``None`` (avec ``logger.warning``) si Pillow absent, fichier introuvable, ou
|
| 32 |
+
image illisible — le rendu reste fonctionnel (aperçu synthétique)."""
|
| 33 |
+
path = Path(source)
|
| 34 |
+
if not path.is_file():
|
| 35 |
+
return None
|
| 36 |
+
try:
|
| 37 |
+
from PIL import Image, UnidentifiedImageError
|
| 38 |
+
except ImportError as exc:
|
| 39 |
+
logger.warning(
|
| 40 |
+
"[images] Pillow indisponible (%s) — vignettes omises, aperçu "
|
| 41 |
+
"synthétique. Installer l'extra ``[images]``.",
|
| 42 |
+
exc,
|
| 43 |
+
)
|
| 44 |
+
return None
|
| 45 |
+
try:
|
| 46 |
+
with Image.open(path) as opened:
|
| 47 |
+
rgb = opened.convert("RGB")
|
| 48 |
+
rgb.thumbnail((max_px, max_px))
|
| 49 |
+
buffer = io.BytesIO()
|
| 50 |
+
rgb.save(buffer, format="JPEG", quality=82, optimize=True)
|
| 51 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 52 |
+
logger.warning("[images] image illisible %s : %s — vignette omise.", path, exc)
|
| 53 |
+
return None
|
| 54 |
+
return buffer.getvalue()
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def thumbnail_data_uri(source: str | Path, *, max_px: int = 280) -> str | None:
|
| 58 |
+
"""Vignette JPEG **data-URI** d'une image locale ; ``None`` si indisponible.
|
| 59 |
+
|
| 60 |
+
Saveur fichier unique : les octets sont inlinés dans le HTML. ``None`` si le
|
| 61 |
+
dérivé n'a pu être produit (Pillow absent, fichier manquant/illisible)."""
|
| 62 |
+
raw = _render_jpeg(source, max_px)
|
| 63 |
+
if raw is None:
|
| 64 |
+
return None
|
| 65 |
+
payload = base64.b64encode(raw).decode("ascii")
|
| 66 |
+
return f"data:image/jpeg;base64,{payload}"
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
def thumbnail_to_file(
|
| 70 |
+
source: str | Path, dest_dir: str | Path, stem: str, *, max_px: int = 280
|
| 71 |
+
) -> str | None:
|
| 72 |
+
"""Écrit la vignette JPEG dans ``dest_dir/<stem>.jpg`` ; renvoie un href relatif.
|
| 73 |
+
|
| 74 |
+
Saveur dossier : le dérivé est **un fichier sur disque** (pas inliné), le HTML
|
| 75 |
+
n'en porte qu'une **référence relative**. L'href renvoyé est relatif au
|
| 76 |
+
**parent** de ``dest_dir`` (``f"{dest_dir.name}/{stem}.jpg"``, séparateur ``/``
|
| 77 |
+
portable) : l'appelant garantit que le HTML est un voisin de ``dest_dir`` (cf.
|
| 78 |
+
``app.report_images.write_report_bundle``). ``None`` si le dérivé n'a pu être
|
| 79 |
+
produit (dégradé gracieux, aucun fichier écrit, aucun dossier créé)."""
|
| 80 |
+
raw = _render_jpeg(source, max_px)
|
| 81 |
+
if raw is None:
|
| 82 |
+
return None
|
| 83 |
+
folder = Path(dest_dir)
|
| 84 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
(folder / f"{stem}.jpg").write_bytes(raw)
|
| 86 |
+
return f"{folder.name}/{stem}.jpg"
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def iiif_derivative(
|
| 90 |
+
source: str | Path, dest_path: str | Path, *, width: int
|
| 91 |
+
) -> tuple[int, int] | None:
|
| 92 |
+
"""Écrit un dérivé JPEG de **largeur exacte** ``width`` (jamais agrandi) à
|
| 93 |
+
``dest_path`` ; renvoie ``(largeur, hauteur)`` réels, ou ``None`` (dégradé).
|
| 94 |
+
|
| 95 |
+
Variante **largeur-fixe** de ``_render_jpeg`` (qui borne le plus grand côté) :
|
| 96 |
+
une taille IIIF Image API ``full/{w},/`` exige une largeur exacte, pas un
|
| 97 |
+
cadrage. Convertit en RGB, ré-échantillonne (Lanczos), crée les dossiers
|
| 98 |
+
parents. ``None`` (avec ``logger.warning``) si Pillow absent, fichier
|
| 99 |
+
introuvable ou image illisible — l'outil appelant décide d'échouer ou non."""
|
| 100 |
+
path = Path(source)
|
| 101 |
+
if not path.is_file():
|
| 102 |
+
logger.warning("[images] image introuvable %s — dérivé IIIF omis.", path)
|
| 103 |
+
return None
|
| 104 |
+
try:
|
| 105 |
+
from PIL import Image, UnidentifiedImageError
|
| 106 |
+
except ImportError as exc:
|
| 107 |
+
logger.warning(
|
| 108 |
+
"[images] Pillow indisponible (%s) — dérivé IIIF omis. Extra ``[images]``.",
|
| 109 |
+
exc,
|
| 110 |
+
)
|
| 111 |
+
return None
|
| 112 |
+
try:
|
| 113 |
+
with Image.open(path) as opened:
|
| 114 |
+
rgb = opened.convert("RGB")
|
| 115 |
+
orig_w, orig_h = rgb.size
|
| 116 |
+
target_w = min(width, orig_w) # jamais agrandi
|
| 117 |
+
target_h = max(1, round(orig_h * target_w / orig_w))
|
| 118 |
+
resized = rgb.resize((target_w, target_h), Image.Resampling.LANCZOS)
|
| 119 |
+
dest = Path(dest_path)
|
| 120 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 121 |
+
resized.save(dest, format="JPEG", quality=82, optimize=True)
|
| 122 |
+
except (UnidentifiedImageError, OSError) as exc:
|
| 123 |
+
logger.warning(
|
| 124 |
+
"[images] image illisible %s : %s — dérivé IIIF omis.", path, exc
|
| 125 |
+
)
|
| 126 |
+
return None
|
| 127 |
+
return (target_w, target_h)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
__all__ = ["thumbnail_data_uri", "thumbnail_to_file", "iiif_derivative"]
|
cinoc/adapters/layout/__init__.py
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters de mise en page (couche 5) — socle ``precomputed``."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
cinoc/adapters/layout/_base.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Contrat **partagé** des segmenteurs de mise en page (couche 5).
|
| 2 |
+
|
| 3 |
+
Tout segmenteur (PP-DocLayout maison, segmenteur distant HF, futurs) produit la
|
| 4 |
+
même forme : un détecteur rend des **régions brutes** (``LayoutDetection``), qu'on
|
| 5 |
+
convertit en ``CanonicalLayout`` neutre (``to_canonical_layout``) puis qu'on
|
| 6 |
+
sérialise en artefact ``LAYOUT`` (``layout_step_output``, la *queue* d'``execute``).
|
| 7 |
+
|
| 8 |
+
Ces pièces sont **agnostiques du moteur** : elles vivaient dans ``pp_doclayout``
|
| 9 |
+
(le segmenteur maison), ce qui forçait le segmenteur distant à importer un module
|
| 10 |
+
PaddleX-spécifique pour les réutiliser. Extraites ici, les deux adapters en
|
| 11 |
+
dépendent sans se coupler l'un à l'autre.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import Callable
|
| 17 |
+
from dataclasses import dataclass
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.domain.layout import BBox, CanonicalLayout, Geometry, LayoutPage, Region
|
| 23 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
@dataclass(frozen=True)
|
| 27 |
+
class DetectedRegion:
|
| 28 |
+
"""Une région détectée : étiquette, boîte (pixels) et score de confiance."""
|
| 29 |
+
|
| 30 |
+
label: str
|
| 31 |
+
x: int
|
| 32 |
+
y: int
|
| 33 |
+
width: int
|
| 34 |
+
height: int
|
| 35 |
+
score: float
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
@dataclass(frozen=True)
|
| 39 |
+
class LayoutDetection:
|
| 40 |
+
"""Sortie brute d'un détecteur : dimensions de page + régions détectées."""
|
| 41 |
+
|
| 42 |
+
page_width: int
|
| 43 |
+
page_height: int
|
| 44 |
+
regions: tuple[DetectedRegion, ...]
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
#: Détecteur **injectable** : chemin image → détection. Le défaut (PaddleX, appel
|
| 48 |
+
#: distant…) est résolu paresseusement à l'exécution ; les tests injectent un faux.
|
| 49 |
+
DetectorFn = Callable[[str], LayoutDetection]
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def to_canonical_layout(
|
| 53 |
+
detection: LayoutDetection, *, min_score: float
|
| 54 |
+
) -> CanonicalLayout:
|
| 55 |
+
"""Détections → ``CanonicalLayout`` (1 page, régions **sans lignes**).
|
| 56 |
+
|
| 57 |
+
Filtre au seuil de score, **trie** par position (haut→bas puis gauche→droite)
|
| 58 |
+
pour un ordre de lecture **déterministe** indépendant de l'ordre de détection,
|
| 59 |
+
et numérote les régions ``r1..rN``. Le ``label`` du modèle devient le
|
| 60 |
+
``region_type`` neutre (``None`` si vide).
|
| 61 |
+
"""
|
| 62 |
+
kept = [r for r in detection.regions if r.score >= min_score]
|
| 63 |
+
ordered = sorted(kept, key=lambda r: (r.y, r.x))
|
| 64 |
+
regions = tuple(
|
| 65 |
+
Region(
|
| 66 |
+
id=f"r{index + 1}",
|
| 67 |
+
region_type=r.label or None,
|
| 68 |
+
geometry=Geometry(
|
| 69 |
+
bbox=BBox(x=r.x, y=r.y, width=r.width, height=r.height)
|
| 70 |
+
),
|
| 71 |
+
)
|
| 72 |
+
for index, r in enumerate(ordered)
|
| 73 |
+
)
|
| 74 |
+
page = LayoutPage(
|
| 75 |
+
width=detection.page_width or None,
|
| 76 |
+
height=detection.page_height or None,
|
| 77 |
+
regions=regions,
|
| 78 |
+
reading_order=tuple(region.id for region in regions),
|
| 79 |
+
)
|
| 80 |
+
return CanonicalLayout(pages=(page,))
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def layout_step_output(
|
| 84 |
+
layout: CanonicalLayout, context: RunContext, name: str
|
| 85 |
+
) -> StepOutput:
|
| 86 |
+
"""Sérialise un ``CanonicalLayout`` → artefact ``LAYOUT`` (tail d'``execute``
|
| 87 |
+
partagé par tous les segmenteurs : PP-DocLayout, segmenteur distant…)."""
|
| 88 |
+
if context.workspace_uri is None:
|
| 89 |
+
raise AdapterStepError(f"{name} : workspace requis (RunContext.workspace_uri).")
|
| 90 |
+
payload = layout.model_dump_json().encode("utf-8")
|
| 91 |
+
output_path = workspace_artifact_path(
|
| 92 |
+
context.workspace_uri, context.document_id, name, "layout.json"
|
| 93 |
+
)
|
| 94 |
+
output_path.write_bytes(payload)
|
| 95 |
+
return StepOutput(
|
| 96 |
+
artifacts={
|
| 97 |
+
ArtifactType.LAYOUT: Artifact(
|
| 98 |
+
id=f"{context.document_id}:{name}:layout",
|
| 99 |
+
document_id=context.document_id,
|
| 100 |
+
type=ArtifactType.LAYOUT,
|
| 101 |
+
uri=str(output_path),
|
| 102 |
+
content_hash=compute_content_hash(payload),
|
| 103 |
+
)
|
| 104 |
+
}
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
__all__ = [
|
| 109 |
+
"DetectedRegion",
|
| 110 |
+
"DetectorFn",
|
| 111 |
+
"LayoutDetection",
|
| 112 |
+
"layout_step_output",
|
| 113 |
+
"to_canonical_layout",
|
| 114 |
+
]
|
cinoc/adapters/layout/assembler.py
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AltoAssembler`` — assemblage ``LAYOUT → ALTO_XML`` (couche 5).
|
| 2 |
+
|
| 3 |
+
Troisième étage du pipeline de segmentation (``CLAUDE.md`` §3 : *assemblage
|
| 4 |
+
LAYOUT → ALTO_XML*). C'est un **constructeur d'ALTO** au sens du même `Module`
|
| 5 |
+
Protocol que les autres briques : il prend un ``CanonicalLayout`` **rempli**
|
| 6 |
+
(sortie du fan-out, sérialisé JSON) et émet de l'ALTO 4 déterministe
|
| 7 |
+
(``layout_to_alto`` + ``write_alto`` octet-stable).
|
| 8 |
+
|
| 9 |
+
L'assembleur vit en couche 5 (et non `pipeline`) parce qu'il dépend de `formats`
|
| 10 |
+
(`write_alto`) — un import légal pour un adapter, interdit à `pipeline`.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
|
| 17 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 18 |
+
from cinoc.domain.errors import AdapterStepError
|
| 19 |
+
from cinoc.domain.layout import CanonicalLayout
|
| 20 |
+
from cinoc.formats.alto import write_alto
|
| 21 |
+
from cinoc.formats.alto.layout_map import layout_to_alto
|
| 22 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 23 |
+
from cinoc.pipeline.run_control import RunControl
|
| 24 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 25 |
+
|
| 26 |
+
_VERSION = "1.0"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class AltoAssembler:
|
| 30 |
+
"""Assemble un ``LAYOUT`` rempli en ``ALTO_XML`` (ALTO 4, déterministe)."""
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def name(self) -> str:
|
| 34 |
+
return "alto_assembler"
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def version(self) -> str:
|
| 38 |
+
return _VERSION
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 42 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 46 |
+
return frozenset({ArtifactType.ALTO_XML})
|
| 47 |
+
|
| 48 |
+
def execute(
|
| 49 |
+
self,
|
| 50 |
+
inputs: dict[ArtifactType, Artifact],
|
| 51 |
+
params: dict[str, ParamValue],
|
| 52 |
+
context: RunContext,
|
| 53 |
+
control: RunControl,
|
| 54 |
+
) -> StepOutput:
|
| 55 |
+
control.raise_if_cancelled()
|
| 56 |
+
layout_art = inputs.get(ArtifactType.LAYOUT)
|
| 57 |
+
if layout_art is None or layout_art.uri is None:
|
| 58 |
+
raise AdapterStepError(
|
| 59 |
+
f"{self.name} : artefact LAYOUT manquant ou sans URI."
|
| 60 |
+
)
|
| 61 |
+
layout_path = Path(layout_art.uri)
|
| 62 |
+
try:
|
| 63 |
+
layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
|
| 64 |
+
except (OSError, ValueError) as exc:
|
| 65 |
+
raise AdapterStepError(
|
| 66 |
+
f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
|
| 67 |
+
) from exc
|
| 68 |
+
payload = write_alto(layout_to_alto(layout))
|
| 69 |
+
out_dir = (
|
| 70 |
+
Path(context.workspace_uri)
|
| 71 |
+
if context.workspace_uri
|
| 72 |
+
else layout_path.parent
|
| 73 |
+
)
|
| 74 |
+
out_path = out_dir / f"{context.document_id.replace('/', '_')}.alto.xml"
|
| 75 |
+
out_path.write_bytes(payload)
|
| 76 |
+
return StepOutput(
|
| 77 |
+
artifacts={
|
| 78 |
+
ArtifactType.ALTO_XML: Artifact(
|
| 79 |
+
id=f"{context.document_id}:{self.name}:alto_xml",
|
| 80 |
+
document_id=context.document_id,
|
| 81 |
+
type=ArtifactType.ALTO_XML,
|
| 82 |
+
uri=str(out_path),
|
| 83 |
+
content_hash=compute_content_hash(payload),
|
| 84 |
+
)
|
| 85 |
+
}
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
__all__ = ["AltoAssembler"]
|
cinoc/adapters/layout/crop.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``crop_region`` — découpe l'image d'un bloc pour l'OCR par région (couche 5).
|
| 2 |
+
|
| 3 |
+
Brique du pipeline **hybride** « segmentation externe → OCR des blocs » : le
|
| 4 |
+
fan-out (couche 4) fournit une **boîte relative** ``(x0, y0, x1, y1) ∈ [0, 1]``
|
| 5 |
+
(la couche 4 a déjà neutralisé les unités, ALTO ``mm10`` vs pixels) ; ici on
|
| 6 |
+
ouvre l'image, on convertit en pixels et on découpe — le crop réel vit en
|
| 7 |
+
couche 5 car il dépend de ``PIL`` (interdit à ``pipeline``).
|
| 8 |
+
|
| 9 |
+
``Pillow`` est un extra optionnel, importé **paresseusement** : importer ce
|
| 10 |
+
module n'exige pas la lib, seule l'exécution.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from urllib.parse import quote
|
| 17 |
+
|
| 18 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 19 |
+
from cinoc.domain.errors import AdapterStepError
|
| 20 |
+
from cinoc.pipeline.types import RunContext
|
| 21 |
+
|
| 22 |
+
_RelBox = tuple[float, float, float, float]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def crop_region(
|
| 26 |
+
page_image: Artifact, rel_bbox: _RelBox, region_id: str, context: RunContext
|
| 27 |
+
) -> Artifact:
|
| 28 |
+
"""Découpe ``rel_bbox`` (relatif) de l'image page → artefact ``IMAGE`` du bloc."""
|
| 29 |
+
if page_image.uri is None:
|
| 30 |
+
raise AdapterStepError("crop_region : image page sans URI.")
|
| 31 |
+
if context.workspace_uri is None:
|
| 32 |
+
raise AdapterStepError("crop_region : workspace requis pour écrire le crop.")
|
| 33 |
+
try:
|
| 34 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 35 |
+
except ImportError as exc:
|
| 36 |
+
raise AdapterStepError(
|
| 37 |
+
"crop_region : Pillow non installé (pip install 'cinoc[tesseract]')."
|
| 38 |
+
) from exc
|
| 39 |
+
try:
|
| 40 |
+
with Image.open(page_image.uri) as image:
|
| 41 |
+
width, height = image.size
|
| 42 |
+
box = (
|
| 43 |
+
max(0, min(width, round(rel_bbox[0] * width))),
|
| 44 |
+
max(0, min(height, round(rel_bbox[1] * height))),
|
| 45 |
+
max(0, min(width, round(rel_bbox[2] * width))),
|
| 46 |
+
max(0, min(height, round(rel_bbox[3] * height))),
|
| 47 |
+
)
|
| 48 |
+
if box[2] <= box[0] or box[3] <= box[1]:
|
| 49 |
+
raise AdapterStepError(
|
| 50 |
+
f"crop_region : boîte vide pour la région {region_id!r}."
|
| 51 |
+
)
|
| 52 |
+
crop = image.crop(box)
|
| 53 |
+
stem = quote(region_id, safe="")
|
| 54 |
+
out_path = Path(context.workspace_uri) / (
|
| 55 |
+
f"{quote(context.document_id, safe='')}.{stem}.crop.png"
|
| 56 |
+
)
|
| 57 |
+
crop.save(out_path)
|
| 58 |
+
except OSError as exc:
|
| 59 |
+
raise AdapterStepError(
|
| 60 |
+
f"crop_region : image illisible ({page_image.uri!r}) : {exc}"
|
| 61 |
+
) from exc
|
| 62 |
+
return Artifact(
|
| 63 |
+
id=f"{context.document_id}:crop:{region_id}",
|
| 64 |
+
document_id=context.document_id,
|
| 65 |
+
type=ArtifactType.IMAGE,
|
| 66 |
+
uri=str(out_path),
|
| 67 |
+
content_hash=compute_content_hash(out_path.read_bytes()),
|
| 68 |
+
region_id=region_id,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
__all__ = ["crop_region"]
|
cinoc/adapters/layout/pp_doclayout.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PPDocLayoutSegmenter`` — segmenteur de mise en page réel (PP-DocLayout).
|
| 2 |
+
|
| 3 |
+
Détecte les **régions** d'une page (titre, paragraphe, figure, tableau…) et émet
|
| 4 |
+
un ``LAYOUT`` (``CanonicalLayout`` à régions **sans lignes** — la reconnaissance
|
| 5 |
+
les remplit ensuite, cf. fan-out couche 4). Implémente le ``Module`` Protocol
|
| 6 |
+
**directement**, comme les autres adapters.
|
| 7 |
+
|
| 8 |
+
Module **maison du socle** (≠ plugin tiers découvert) : enregistré par
|
| 9 |
+
``register_default_modules`` → disponible **même en mode public** sur le Space
|
| 10 |
+
(contrairement aux plugins via entry-points, fail-closed). Son SDK (PaddleX,
|
| 11 |
+
Apache-2.0) est un **extra optionnel** (``cinoc[segment]``), importé
|
| 12 |
+
**paresseusement** : importer ce module n'exige ni la lib ni les poids — seule
|
| 13 |
+
l'exécution les requiert.
|
| 14 |
+
|
| 15 |
+
Le détecteur est **injectable** (``detector=``) : la conversion détections →
|
| 16 |
+
``CanonicalLayout`` se teste sans SDK ni poids (CI), le vrai modèle reste un test
|
| 17 |
+
``live``/``slow`` opt-in. Déterminisme (§12) : à modèle + poids + params figés et
|
| 18 |
+
seuil de score fixe, la sortie est stable ; l'ordre de lecture est **trié**
|
| 19 |
+
(haut→bas puis gauche→droite), indépendant de l'ordre de détection.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters.layout._base import (
|
| 25 |
+
DetectedRegion,
|
| 26 |
+
DetectorFn,
|
| 27 |
+
LayoutDetection,
|
| 28 |
+
layout_step_output,
|
| 29 |
+
to_canonical_layout,
|
| 30 |
+
)
|
| 31 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 32 |
+
from cinoc.domain.errors import AdapterStepError
|
| 33 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 34 |
+
from cinoc.pipeline.run_control import RunControl
|
| 35 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 36 |
+
|
| 37 |
+
_VERSION = "1.0"
|
| 38 |
+
_DEFAULT_MIN_SCORE = 0.5
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
#: Modèle PP-DocLayout par défaut. La variante **légère** ``PP-DocLayout-S``
|
| 42 |
+
#: (poids ~10× plus petits) est bakable dans l'image Space pour **tester
|
| 43 |
+
#: l'option** sans peser sur le free-tier ; ``-L`` reste le défaut qualité.
|
| 44 |
+
#: Surchargé par ``CINOC_PPDOCLAYOUT_MODEL`` (lu par le builder) ou ``model=``.
|
| 45 |
+
_DEFAULT_MODEL = "PP-DocLayout-L"
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _detect_with_paddle( # pragma: no cover -- SDK + poids requis (test 'live')
|
| 49 |
+
image_path: str, model: str = _DEFAULT_MODEL
|
| 50 |
+
) -> LayoutDetection:
|
| 51 |
+
"""Détecte la mise en page via **PaddleX PP-DocLayout**. Isolé → mockable.
|
| 52 |
+
|
| 53 |
+
SDK absent → ``AdapterStepError`` explicite (l'extra ``[segment]`` manque) ;
|
| 54 |
+
le module reste listable, il ne plante pas à l'import. Le corps qui exécute le
|
| 55 |
+
modèle n'est exercé que par un test ``live`` (SDK + poids installés). ``model``
|
| 56 |
+
choisit la variante (``-L`` qualité / ``-S`` légère)."""
|
| 57 |
+
try:
|
| 58 |
+
from paddlex import create_model # type: ignore[import-not-found]
|
| 59 |
+
except ImportError as exc:
|
| 60 |
+
raise AdapterStepError(
|
| 61 |
+
"pp_doclayout : PaddleX non installé "
|
| 62 |
+
"(pip install 'cinoc[segment]' + poids PP-DocLayout)."
|
| 63 |
+
) from exc
|
| 64 |
+
results = list(create_model(model).predict(image_path, batch_size=1))
|
| 65 |
+
if not results:
|
| 66 |
+
return LayoutDetection(page_width=0, page_height=0, regions=())
|
| 67 |
+
boxes = results[0].get("boxes", [])
|
| 68 |
+
regions = tuple(
|
| 69 |
+
DetectedRegion(
|
| 70 |
+
label=str(box.get("label", "")),
|
| 71 |
+
x=int(box["coordinate"][0]),
|
| 72 |
+
y=int(box["coordinate"][1]),
|
| 73 |
+
width=int(box["coordinate"][2]) - int(box["coordinate"][0]),
|
| 74 |
+
height=int(box["coordinate"][3]) - int(box["coordinate"][1]),
|
| 75 |
+
score=float(box.get("score", 0.0)),
|
| 76 |
+
)
|
| 77 |
+
for box in boxes
|
| 78 |
+
)
|
| 79 |
+
shape = results[0].get("input_img_shape") or (0, 0)
|
| 80 |
+
return LayoutDetection(
|
| 81 |
+
page_width=int(shape[1]), page_height=int(shape[0]), regions=regions
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class PPDocLayoutSegmenter:
|
| 86 |
+
"""Segmenteur PP-DocLayout : ``IMAGE → LAYOUT`` (régions sans lignes)."""
|
| 87 |
+
|
| 88 |
+
def __init__(
|
| 89 |
+
self,
|
| 90 |
+
*,
|
| 91 |
+
model: str = _DEFAULT_MODEL,
|
| 92 |
+
min_score: float = _DEFAULT_MIN_SCORE,
|
| 93 |
+
detector: DetectorFn | None = None,
|
| 94 |
+
) -> None:
|
| 95 |
+
if not 0.0 <= min_score <= 1.0:
|
| 96 |
+
raise AdapterStepError(
|
| 97 |
+
f"PPDocLayoutSegmenter : min_score ∈ [0, 1], reçu {min_score}."
|
| 98 |
+
)
|
| 99 |
+
self._model = model
|
| 100 |
+
self._min_score = min_score
|
| 101 |
+
self._detector = detector
|
| 102 |
+
|
| 103 |
+
@property
|
| 104 |
+
def name(self) -> str:
|
| 105 |
+
return "pp_doclayout"
|
| 106 |
+
|
| 107 |
+
@property
|
| 108 |
+
def version(self) -> str:
|
| 109 |
+
return _VERSION
|
| 110 |
+
|
| 111 |
+
@property
|
| 112 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 113 |
+
return frozenset({ArtifactType.IMAGE})
|
| 114 |
+
|
| 115 |
+
@property
|
| 116 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 117 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 118 |
+
|
| 119 |
+
def _detect(self, image_path: str) -> LayoutDetection:
|
| 120 |
+
if self._detector is not None:
|
| 121 |
+
return self._detector(image_path)
|
| 122 |
+
return _detect_with_paddle(image_path, self._model)
|
| 123 |
+
|
| 124 |
+
def execute(
|
| 125 |
+
self,
|
| 126 |
+
inputs: dict[ArtifactType, Artifact],
|
| 127 |
+
params: dict[str, ParamValue],
|
| 128 |
+
context: RunContext,
|
| 129 |
+
control: RunControl,
|
| 130 |
+
) -> StepOutput:
|
| 131 |
+
control.raise_if_cancelled()
|
| 132 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 133 |
+
if image is None or image.uri is None:
|
| 134 |
+
raise AdapterStepError(
|
| 135 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 136 |
+
)
|
| 137 |
+
if context.workspace_uri is None:
|
| 138 |
+
raise AdapterStepError(
|
| 139 |
+
f"{self.name} : workspace requis (RunContext.workspace_uri)."
|
| 140 |
+
)
|
| 141 |
+
layout = to_canonical_layout(
|
| 142 |
+
self._detect(image.uri), min_score=self._min_score
|
| 143 |
+
)
|
| 144 |
+
return layout_step_output(layout, context, self.name)
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
__all__ = ["PPDocLayoutSegmenter"]
|
cinoc/adapters/layout/precomputed.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Socle ``precomputed`` de la tranche segmentation (couche 5, 0 dép, 0 réseau).
|
| 2 |
+
|
| 3 |
+
Deux briques, **même ``Module`` Protocol** que les autres adapters, qui font
|
| 4 |
+
tourner le squelette segmentation **sans segmenteur ni OCR réels** :
|
| 5 |
+
|
| 6 |
+
- ``PrecomputedLayoutSource`` : ``IMAGE → LAYOUT`` — rejoue une mise en page
|
| 7 |
+
pré-calculée (régions seules) depuis ``<stem>.layout.json``.
|
| 8 |
+
- ``PrecomputedRegionRecognizer`` : ``IMAGE → RAW_TEXT`` — rejoue le texte d'**une
|
| 9 |
+
région** (clé = ``Artifact.region_id`` de l'IMAGE d'entrée) depuis
|
| 10 |
+
``<stem>.<label>.regions.json`` (``{region_id: texte}``). Appelé **une fois par
|
| 11 |
+
région** par le fan-out (couche 4) ; aucun découpage d'image réel (la donnée
|
| 12 |
+
est figée).
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import json
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.domain.layout import CanonicalLayout
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
_VERSION = "1.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _require_image(inputs: dict[ArtifactType, Artifact], who: str) -> Path:
|
| 31 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 32 |
+
if image is None or image.uri is None:
|
| 33 |
+
raise AdapterStepError(f"{who} : artefact IMAGE manquant ou sans URI.")
|
| 34 |
+
return Path(image.uri)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class PrecomputedLayoutSource:
|
| 38 |
+
"""Rejoue une mise en page pré-calculée (``<stem>.layout.json``)."""
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def name(self) -> str:
|
| 42 |
+
return "precomputed_layout"
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def version(self) -> str:
|
| 46 |
+
return _VERSION
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 50 |
+
return frozenset({ArtifactType.IMAGE})
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 54 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 55 |
+
|
| 56 |
+
def execute(
|
| 57 |
+
self,
|
| 58 |
+
inputs: dict[ArtifactType, Artifact],
|
| 59 |
+
params: dict[str, ParamValue],
|
| 60 |
+
context: RunContext,
|
| 61 |
+
control: RunControl,
|
| 62 |
+
) -> StepOutput:
|
| 63 |
+
control.raise_if_cancelled()
|
| 64 |
+
image_path = _require_image(inputs, self.name)
|
| 65 |
+
layout_path = image_path.parent / f"{image_path.stem}.layout.json"
|
| 66 |
+
if not layout_path.is_file():
|
| 67 |
+
raise AdapterStepError(
|
| 68 |
+
f"{self.name} : mise en page pré-calculée introuvable — "
|
| 69 |
+
f"{layout_path.name!r} attendu près de {image_path.name!r}."
|
| 70 |
+
)
|
| 71 |
+
data = layout_path.read_bytes()
|
| 72 |
+
try:
|
| 73 |
+
CanonicalLayout.model_validate_json(data)
|
| 74 |
+
except ValueError as exc:
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"{self.name} : {layout_path.name!r} n'est pas un "
|
| 77 |
+
f"CanonicalLayout valide : {exc}"
|
| 78 |
+
) from exc
|
| 79 |
+
return StepOutput(
|
| 80 |
+
artifacts={
|
| 81 |
+
ArtifactType.LAYOUT: Artifact(
|
| 82 |
+
id=f"{context.document_id}:{self.name}:layout",
|
| 83 |
+
document_id=context.document_id,
|
| 84 |
+
type=ArtifactType.LAYOUT,
|
| 85 |
+
uri=str(layout_path),
|
| 86 |
+
content_hash=compute_content_hash(data),
|
| 87 |
+
)
|
| 88 |
+
}
|
| 89 |
+
)
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class PrecomputedRegionRecognizer:
|
| 93 |
+
"""Rejoue le texte d'une région (``<stem>.<label>.regions.json``)."""
|
| 94 |
+
|
| 95 |
+
def __init__(self, *, source_label: str) -> None:
|
| 96 |
+
if not source_label or not all(
|
| 97 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 98 |
+
):
|
| 99 |
+
raise AdapterStepError(
|
| 100 |
+
f"PrecomputedRegionRecognizer : source_label invalide "
|
| 101 |
+
f"{source_label!r} (alphanumérique + _ - uniquement)."
|
| 102 |
+
)
|
| 103 |
+
self._label = source_label
|
| 104 |
+
|
| 105 |
+
@property
|
| 106 |
+
def name(self) -> str:
|
| 107 |
+
return f"precomputed_region:{self._label}"
|
| 108 |
+
|
| 109 |
+
@property
|
| 110 |
+
def version(self) -> str:
|
| 111 |
+
return _VERSION
|
| 112 |
+
|
| 113 |
+
@property
|
| 114 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 115 |
+
return frozenset({ArtifactType.IMAGE})
|
| 116 |
+
|
| 117 |
+
@property
|
| 118 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 119 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 120 |
+
|
| 121 |
+
def execute(
|
| 122 |
+
self,
|
| 123 |
+
inputs: dict[ArtifactType, Artifact],
|
| 124 |
+
params: dict[str, ParamValue],
|
| 125 |
+
context: RunContext,
|
| 126 |
+
control: RunControl,
|
| 127 |
+
) -> StepOutput:
|
| 128 |
+
control.raise_if_cancelled()
|
| 129 |
+
image = inputs[ArtifactType.IMAGE]
|
| 130 |
+
if image.region_id is None:
|
| 131 |
+
raise AdapterStepError(
|
| 132 |
+
f"{self.name} : IMAGE sans region_id — la reconnaissance par "
|
| 133 |
+
"région attend une entrée déjà cadrée par le fan-out."
|
| 134 |
+
)
|
| 135 |
+
image_path = _require_image(inputs, self.name)
|
| 136 |
+
regions_path = (
|
| 137 |
+
image_path.parent / f"{image_path.stem}.{self._label}.regions.json"
|
| 138 |
+
)
|
| 139 |
+
if not regions_path.is_file():
|
| 140 |
+
raise AdapterStepError(
|
| 141 |
+
f"{self.name} : textes par région introuvables — "
|
| 142 |
+
f"{regions_path.name!r} attendu près de {image_path.name!r}."
|
| 143 |
+
)
|
| 144 |
+
try:
|
| 145 |
+
mapping = json.loads(regions_path.read_bytes())
|
| 146 |
+
except ValueError as exc:
|
| 147 |
+
raise AdapterStepError(
|
| 148 |
+
f"{self.name} : {regions_path.name!r} JSON invalide : {exc}"
|
| 149 |
+
) from exc
|
| 150 |
+
if not isinstance(mapping, dict) or image.region_id not in mapping:
|
| 151 |
+
raise AdapterStepError(
|
| 152 |
+
f"{self.name} : région {image.region_id!r} absente de "
|
| 153 |
+
f"{regions_path.name!r}."
|
| 154 |
+
)
|
| 155 |
+
text = str(mapping[image.region_id])
|
| 156 |
+
out_dir = (
|
| 157 |
+
Path(context.workspace_uri)
|
| 158 |
+
if context.workspace_uri
|
| 159 |
+
else image_path.parent
|
| 160 |
+
)
|
| 161 |
+
out_path = out_dir / f"{image_path.stem}.{self._label}.{image.region_id}.txt"
|
| 162 |
+
payload = text.encode("utf-8")
|
| 163 |
+
out_path.write_bytes(payload)
|
| 164 |
+
return StepOutput(
|
| 165 |
+
artifacts={
|
| 166 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 167 |
+
id=f"{context.document_id}:{self.name}:{image.region_id}:raw_text",
|
| 168 |
+
document_id=context.document_id,
|
| 169 |
+
type=ArtifactType.RAW_TEXT,
|
| 170 |
+
uri=str(out_path),
|
| 171 |
+
content_hash=compute_content_hash(payload),
|
| 172 |
+
region_id=image.region_id,
|
| 173 |
+
)
|
| 174 |
+
}
|
| 175 |
+
)
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
__all__ = ["PrecomputedLayoutSource", "PrecomputedRegionRecognizer"]
|
cinoc/adapters/layout/remote.py
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``RemoteSegmenter`` — segmenteur de mise en page **par appel distant**.
|
| 2 |
+
|
| 3 |
+
Au lieu d'embarquer un modèle lourd (poids dans l'image), ce module **délègue** la
|
| 4 |
+
détection de régions à un modèle hébergé ailleurs : un *Inference Endpoint* HF, un
|
| 5 |
+
Space-modèle, ou tout service exposant l'API **object-detection** de Hugging Face.
|
| 6 |
+
Cinoc **orchestre**, le modèle **tourne à distance** — le Space reste léger (pas de
|
| 7 |
+
poids, pas d'inférence CPU locale).
|
| 8 |
+
|
| 9 |
+
C'est l'incarnation « distante » du point d'extension unique de Cinoc : il
|
| 10 |
+
implémente le **même ``Module`` Protocol** que ``PPDocLayoutSegmenter`` (et que les
|
| 11 |
+
plugins tiers), donc il apparaît comme **segmenteur sélectionnable** avant un run.
|
| 12 |
+
On change de modèle en changeant l'``endpoint`` — sans rien réinstaller.
|
| 13 |
+
|
| 14 |
+
Schéma de réponse attendu = sortie **object-detection** HF :
|
| 15 |
+
``[{"label": str, "score": float, "box": {"xmin","ymin","xmax","ymax"}}, …]``.
|
| 16 |
+
|
| 17 |
+
Le détecteur est **injectable** (``detector=``) : la conversion détections →
|
| 18 |
+
``CanonicalLayout`` se teste sans réseau ; le vrai appel HTTP (``httpx`` + garde
|
| 19 |
+
**anti-SSRF** ``assert_public_url``) n'est exercé qu'en test ``live``. ``httpx`` est
|
| 20 |
+
un extra optionnel (``cinoc[serve]``/``[google]``…), importé paresseusement.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
|
| 27 |
+
from cinoc.adapters.corpus._http import SsrfError, assert_public_url
|
| 28 |
+
from cinoc.adapters.layout._base import (
|
| 29 |
+
DetectedRegion,
|
| 30 |
+
DetectorFn,
|
| 31 |
+
LayoutDetection,
|
| 32 |
+
layout_step_output,
|
| 33 |
+
to_canonical_layout,
|
| 34 |
+
)
|
| 35 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 36 |
+
from cinoc.domain.errors import AdapterStepError
|
| 37 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 38 |
+
from cinoc.pipeline.run_control import RunControl
|
| 39 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 40 |
+
|
| 41 |
+
_VERSION = "1.0"
|
| 42 |
+
_DEFAULT_MIN_SCORE = 0.5
|
| 43 |
+
_DEFAULT_TIMEOUT = 60.0
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def parse_hf_detections(
|
| 47 |
+
payload: object, *, page_width: int, page_height: int
|
| 48 |
+
) -> LayoutDetection:
|
| 49 |
+
"""Réponse object-detection HF → ``LayoutDetection`` (tolérante aux entrées
|
| 50 |
+
mal formées : les boîtes invalides sont ignorées, jamais une exception)."""
|
| 51 |
+
if not isinstance(payload, list):
|
| 52 |
+
raise AdapterStepError(
|
| 53 |
+
"remote_segmenter : réponse inattendue (liste de détections attendue)."
|
| 54 |
+
)
|
| 55 |
+
regions: list[DetectedRegion] = []
|
| 56 |
+
for item in payload:
|
| 57 |
+
if not isinstance(item, dict):
|
| 58 |
+
continue
|
| 59 |
+
box = item.get("box")
|
| 60 |
+
if not isinstance(box, dict):
|
| 61 |
+
continue
|
| 62 |
+
try:
|
| 63 |
+
x0, y0 = int(box["xmin"]), int(box["ymin"])
|
| 64 |
+
x1, y1 = int(box["xmax"]), int(box["ymax"])
|
| 65 |
+
except (KeyError, TypeError, ValueError):
|
| 66 |
+
continue
|
| 67 |
+
regions.append(
|
| 68 |
+
DetectedRegion(
|
| 69 |
+
label=str(item.get("label", "")),
|
| 70 |
+
x=x0,
|
| 71 |
+
y=y0,
|
| 72 |
+
width=max(0, x1 - x0),
|
| 73 |
+
height=max(0, y1 - y0),
|
| 74 |
+
score=float(item.get("score", 0.0)),
|
| 75 |
+
)
|
| 76 |
+
)
|
| 77 |
+
return LayoutDetection(
|
| 78 |
+
page_width=page_width, page_height=page_height, regions=tuple(regions)
|
| 79 |
+
)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _image_dims(image_path: str) -> tuple[int, int]:
|
| 83 |
+
"""Dimensions ``(largeur, hauteur)`` de l'image ; ``(0, 0)`` si indisponible
|
| 84 |
+
(Pillow absent / illisible) — la conversion gère les dimensions nulles."""
|
| 85 |
+
try:
|
| 86 |
+
from PIL import Image, UnidentifiedImageError
|
| 87 |
+
except ImportError:
|
| 88 |
+
return (0, 0)
|
| 89 |
+
try:
|
| 90 |
+
with Image.open(image_path) as opened:
|
| 91 |
+
return (int(opened.width), int(opened.height))
|
| 92 |
+
except (UnidentifiedImageError, OSError):
|
| 93 |
+
return (0, 0)
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class RemoteSegmenter:
|
| 97 |
+
"""Segmenteur ``IMAGE → LAYOUT`` délégué à un endpoint object-detection."""
|
| 98 |
+
|
| 99 |
+
def __init__(
|
| 100 |
+
self,
|
| 101 |
+
*,
|
| 102 |
+
endpoint: str,
|
| 103 |
+
token: str | None = None,
|
| 104 |
+
min_score: float = _DEFAULT_MIN_SCORE,
|
| 105 |
+
timeout: float = _DEFAULT_TIMEOUT,
|
| 106 |
+
detector: DetectorFn | None = None,
|
| 107 |
+
) -> None:
|
| 108 |
+
if not endpoint:
|
| 109 |
+
raise AdapterStepError("remote_segmenter : 'endpoint' requis.")
|
| 110 |
+
if not 0.0 <= min_score <= 1.0:
|
| 111 |
+
raise AdapterStepError(
|
| 112 |
+
f"remote_segmenter : min_score ∈ [0, 1], reçu {min_score}."
|
| 113 |
+
)
|
| 114 |
+
self._endpoint = endpoint
|
| 115 |
+
self._token = token
|
| 116 |
+
self._min_score = min_score
|
| 117 |
+
self._timeout = timeout
|
| 118 |
+
self._detector = detector
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def name(self) -> str:
|
| 122 |
+
return "remote_segmenter"
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def version(self) -> str:
|
| 126 |
+
return _VERSION
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 130 |
+
return frozenset({ArtifactType.IMAGE})
|
| 131 |
+
|
| 132 |
+
@property
|
| 133 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 134 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 135 |
+
|
| 136 |
+
def _detect(self, image_path: str) -> LayoutDetection:
|
| 137 |
+
if self._detector is not None:
|
| 138 |
+
return self._detector(image_path)
|
| 139 |
+
return self._remote_detect(image_path)
|
| 140 |
+
|
| 141 |
+
def _remote_detect( # pragma: no cover -- réseau réel (test 'live')
|
| 142 |
+
self, image_path: str
|
| 143 |
+
) -> LayoutDetection:
|
| 144 |
+
try:
|
| 145 |
+
assert_public_url(self._endpoint)
|
| 146 |
+
except SsrfError as exc:
|
| 147 |
+
raise AdapterStepError(
|
| 148 |
+
f"remote_segmenter : endpoint refusé (anti-SSRF) : {exc}"
|
| 149 |
+
) from exc
|
| 150 |
+
try:
|
| 151 |
+
import httpx
|
| 152 |
+
except ImportError as exc:
|
| 153 |
+
raise AdapterStepError(
|
| 154 |
+
"remote_segmenter : httpx absent (extra réseau requis)."
|
| 155 |
+
) from exc
|
| 156 |
+
headers = {"Content-Type": "application/octet-stream"}
|
| 157 |
+
if self._token:
|
| 158 |
+
headers["Authorization"] = f"Bearer {self._token}"
|
| 159 |
+
try:
|
| 160 |
+
with httpx.Client(timeout=self._timeout) as client:
|
| 161 |
+
response = client.post(
|
| 162 |
+
self._endpoint,
|
| 163 |
+
content=Path(image_path).read_bytes(),
|
| 164 |
+
headers=headers,
|
| 165 |
+
)
|
| 166 |
+
response.raise_for_status()
|
| 167 |
+
payload = response.json()
|
| 168 |
+
except httpx.HTTPError as exc:
|
| 169 |
+
raise AdapterStepError(
|
| 170 |
+
f"remote_segmenter : appel à l'endpoint échoué : {exc}"
|
| 171 |
+
) from exc
|
| 172 |
+
width, height = _image_dims(image_path)
|
| 173 |
+
return parse_hf_detections(payload, page_width=width, page_height=height)
|
| 174 |
+
|
| 175 |
+
def execute(
|
| 176 |
+
self,
|
| 177 |
+
inputs: dict[ArtifactType, Artifact],
|
| 178 |
+
params: dict[str, ParamValue],
|
| 179 |
+
context: RunContext,
|
| 180 |
+
control: RunControl,
|
| 181 |
+
) -> StepOutput:
|
| 182 |
+
control.raise_if_cancelled()
|
| 183 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 184 |
+
if image is None or image.uri is None:
|
| 185 |
+
raise AdapterStepError(
|
| 186 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 187 |
+
)
|
| 188 |
+
layout = to_canonical_layout(
|
| 189 |
+
self._detect(image.uri), min_score=self._min_score
|
| 190 |
+
)
|
| 191 |
+
return layout_step_output(layout, context, self.name)
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
__all__ = ["RemoteSegmenter", "parse_hf_detections"]
|
cinoc/adapters/layout/to_text.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``LayoutToTextExtractor`` — ``LAYOUT rempli → RAW_TEXT`` (couche 5).
|
| 2 |
+
|
| 3 |
+
Quatrième étage **optionnel** d'un pipeline hybride : une fois les régions
|
| 4 |
+
reconnues (fan-out → ``CanonicalLayout`` rempli), on aplatit le texte dans
|
| 5 |
+
**l'ordre de lecture** pour le rendre **scorable** comme n'importe quelle sortie
|
| 6 |
+
``RAW_TEXT`` (CER/WER à la page). C'est ce qui permet à un run « segmenteur →
|
| 7 |
+
reconnaissance par bloc » d'être un **concurrent du Banc d'essai** comparé à un
|
| 8 |
+
OCR à plat, et plus seulement un ALTO produit dans son coin.
|
| 9 |
+
|
| 10 |
+
Déterministe : ordre = ``reading_order`` de la page (repli sur l'ordre déclaré),
|
| 11 |
+
lignes d'une région jointes par ``\\n``, régions par ``\\n``, pages par ``\\n``.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 19 |
+
from cinoc.domain.errors import AdapterStepError
|
| 20 |
+
from cinoc.domain.layout import CanonicalLayout, LayoutPage
|
| 21 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 22 |
+
from cinoc.pipeline.run_control import RunControl
|
| 23 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 24 |
+
|
| 25 |
+
_VERSION = "1.0"
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
def _page_text(page: LayoutPage) -> str:
|
| 29 |
+
"""Texte d'une page, régions dans l'ordre de lecture (repli ordre déclaré)."""
|
| 30 |
+
by_id = {region.id: region for region in page.regions}
|
| 31 |
+
ordered = [by_id[rid] for rid in page.reading_order if rid in by_id]
|
| 32 |
+
seen = {region.id for region in ordered}
|
| 33 |
+
ordered.extend(region for region in page.regions if region.id not in seen)
|
| 34 |
+
blocks: list[str] = []
|
| 35 |
+
for region in ordered:
|
| 36 |
+
lines = "\n".join(line.text for line in region.lines if line.text)
|
| 37 |
+
if lines:
|
| 38 |
+
blocks.append(lines)
|
| 39 |
+
return "\n".join(blocks)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class LayoutToTextExtractor:
|
| 43 |
+
"""Aplatit un ``LAYOUT`` rempli en ``RAW_TEXT`` (ordre de lecture, déterministe)."""
|
| 44 |
+
|
| 45 |
+
def __init__(self, label: str) -> None:
|
| 46 |
+
self._label = label
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def name(self) -> str:
|
| 50 |
+
return f"layout_to_text:{self._label}"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def version(self) -> str:
|
| 54 |
+
return _VERSION
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 58 |
+
return frozenset({ArtifactType.LAYOUT})
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 62 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 63 |
+
|
| 64 |
+
def execute(
|
| 65 |
+
self,
|
| 66 |
+
inputs: dict[ArtifactType, Artifact],
|
| 67 |
+
params: dict[str, ParamValue],
|
| 68 |
+
context: RunContext,
|
| 69 |
+
control: RunControl,
|
| 70 |
+
) -> StepOutput:
|
| 71 |
+
control.raise_if_cancelled()
|
| 72 |
+
layout_art = inputs.get(ArtifactType.LAYOUT)
|
| 73 |
+
if layout_art is None or layout_art.uri is None:
|
| 74 |
+
raise AdapterStepError(
|
| 75 |
+
f"{self.name} : artefact LAYOUT manquant ou sans URI."
|
| 76 |
+
)
|
| 77 |
+
layout_path = Path(layout_art.uri)
|
| 78 |
+
try:
|
| 79 |
+
layout = CanonicalLayout.model_validate_json(layout_path.read_bytes())
|
| 80 |
+
except (OSError, ValueError) as exc:
|
| 81 |
+
raise AdapterStepError(
|
| 82 |
+
f"{self.name} : LAYOUT illisible ({layout_path.name!r}) : {exc}"
|
| 83 |
+
) from exc
|
| 84 |
+
text = "\n".join(_page_text(page) for page in layout.pages)
|
| 85 |
+
payload = text.encode("utf-8")
|
| 86 |
+
out_dir = (
|
| 87 |
+
Path(context.workspace_uri)
|
| 88 |
+
if context.workspace_uri
|
| 89 |
+
else layout_path.parent
|
| 90 |
+
)
|
| 91 |
+
out_path = out_dir / f"{context.document_id.replace('/', '_')}.layout.txt"
|
| 92 |
+
out_path.write_bytes(payload)
|
| 93 |
+
return StepOutput(
|
| 94 |
+
artifacts={
|
| 95 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 96 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 97 |
+
document_id=context.document_id,
|
| 98 |
+
type=ArtifactType.RAW_TEXT,
|
| 99 |
+
uri=str(out_path),
|
| 100 |
+
content_hash=compute_content_hash(payload),
|
| 101 |
+
)
|
| 102 |
+
}
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["LayoutToTextExtractor"]
|
cinoc/adapters/llm/__init__.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters LLM/VLM (couche 5) — post-correction texte/image + transcription VLM.
|
| 2 |
+
|
| 3 |
+
Modes par adapter (``PipelineMode``) : ``text_only`` (tous), ``text_and_image``
|
| 4 |
+
et ``zero_shot`` (openai, mistral, anthropic — vision). ``ollama`` reste
|
| 5 |
+
``text_only`` (socle local minimal).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.llm.anthropic import AnthropicAdapter
|
| 11 |
+
from cinoc.adapters.llm.mistral import MistralAdapter
|
| 12 |
+
from cinoc.adapters.llm.ollama import OllamaAdapter
|
| 13 |
+
from cinoc.adapters.llm.openai import OpenAIAdapter
|
| 14 |
+
|
| 15 |
+
__all__ = ["AnthropicAdapter", "MistralAdapter", "OllamaAdapter", "OpenAIAdapter"]
|
cinoc/adapters/llm/_base.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Helpers partagés des adapters LLM/VLM (post-correction + transcription).
|
| 2 |
+
|
| 3 |
+
Les adapters concrets (openai, anthropic, mistral, ollama) implémentent le
|
| 4 |
+
``Module`` Protocol et ne fournissent que leurs **appels réseau** (isolés,
|
| 5 |
+
mockables). Toute la logique commune — choix des entrées/sorties selon le
|
| 6 |
+
**mode**, chargement du texte OCR et/ou de l'image, écriture de l'artefact —
|
| 7 |
+
vit **ici**, dans ``run_llm_step`` : un seul endroit, aucun mode dupliqué par
|
| 8 |
+
fournisseur (anti « 8+8+8 », cf. CLAUDE.md §8.8).
|
| 9 |
+
|
| 10 |
+
Modes (``PipelineMode``, source de vérité unique en ``domain``) :
|
| 11 |
+
|
| 12 |
+
- ``text_only`` — ``RAW_TEXT`` → ``CORRECTED_TEXT`` (le LLM ne voit pas l'image).
|
| 13 |
+
- ``text_and_image`` — ``{RAW_TEXT, IMAGE}`` → ``CORRECTED_TEXT`` (le VLM voit l'image).
|
| 14 |
+
- ``zero_shot`` — ``IMAGE`` → ``RAW_TEXT`` (transcription directe, sans OCR amont).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from base64 import b64encode
|
| 20 |
+
from collections.abc import Callable
|
| 21 |
+
from pathlib import Path
|
| 22 |
+
from typing import Any, NamedTuple, cast
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 25 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 26 |
+
from cinoc.domain.errors import AdapterStepError
|
| 27 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 28 |
+
from cinoc.domain.usage import ResourceUsage
|
| 29 |
+
from cinoc.formats.text import read_plaintext
|
| 30 |
+
from cinoc.pipeline.run_control import RunControl
|
| 31 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 32 |
+
|
| 33 |
+
#: Prompt de post-correction (modes ``text_only`` / ``text_and_image``) ;
|
| 34 |
+
#: ``{ocr_text}`` est substitué par le texte OCR amont.
|
| 35 |
+
DEFAULT_CORRECTION_PROMPT = (
|
| 36 |
+
"Tu es un correcteur de transcriptions OCR de documents patrimoniaux. "
|
| 37 |
+
"Corrige uniquement les erreurs manifestes de reconnaissance, sans "
|
| 38 |
+
"reformuler ni moderniser l'orthographe historique. Réponds par le seul "
|
| 39 |
+
"texte corrigé.\n\n{ocr_text}"
|
| 40 |
+
)
|
| 41 |
+
|
| 42 |
+
#: Prompt de transcription directe (mode ``zero_shot`` : le VLM lit l'image).
|
| 43 |
+
DEFAULT_TRANSCRIPTION_PROMPT = (
|
| 44 |
+
"Transcris fidèlement le texte de cette image de document patrimonial. "
|
| 45 |
+
"Conserve l'orthographe historique et la ponctuation d'origine. N'ajoute "
|
| 46 |
+
"aucun commentaire : réponds par le seul texte transcrit."
|
| 47 |
+
)
|
| 48 |
+
|
| 49 |
+
#: Extension de fichier → media type (sous-ensemble image courant).
|
| 50 |
+
_MEDIA_TYPES = {
|
| 51 |
+
".png": "image/png",
|
| 52 |
+
".jpg": "image/jpeg",
|
| 53 |
+
".jpeg": "image/jpeg",
|
| 54 |
+
".webp": "image/webp",
|
| 55 |
+
".gif": "image/gif",
|
| 56 |
+
".tif": "image/tiff",
|
| 57 |
+
".tiff": "image/tiff",
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
class LLMCompletion(NamedTuple):
|
| 61 |
+
"""Réponse d'un fournisseur : texte + jetons consommés (si exposés)."""
|
| 62 |
+
|
| 63 |
+
text: str
|
| 64 |
+
tokens_in: int | None = None
|
| 65 |
+
tokens_out: int | None = None
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
#: Appel d'un LLM texte : ``prompt → réponse`` (fournisseur + modèle capturés).
|
| 69 |
+
TextInvoke = Callable[[str], LLMCompletion]
|
| 70 |
+
#: Appel d'un VLM : ``prompt, media_type, image_b64 → réponse``.
|
| 71 |
+
VisionInvoke = Callable[[str, str, str], LLMCompletion]
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def usage_tokens(value: Any) -> int | None:
|
| 75 |
+
"""Coercition prudente d'un compteur de jetons SDK (``None`` si absent)."""
|
| 76 |
+
if isinstance(value, bool) or not isinstance(value, int) or value < 0:
|
| 77 |
+
return None
|
| 78 |
+
return value
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def normalize_llm_content(raw: Any) -> str:
|
| 82 |
+
"""Aplati une réponse LLM (``str`` ou liste de blocs) en chaîne."""
|
| 83 |
+
if raw is None:
|
| 84 |
+
return ""
|
| 85 |
+
if isinstance(raw, str):
|
| 86 |
+
return raw
|
| 87 |
+
if isinstance(raw, list):
|
| 88 |
+
parts: list[str] = []
|
| 89 |
+
for chunk in raw:
|
| 90 |
+
if isinstance(chunk, str):
|
| 91 |
+
parts.append(chunk)
|
| 92 |
+
elif isinstance(chunk, dict) and isinstance(chunk.get("text"), str):
|
| 93 |
+
parts.append(chunk["text"])
|
| 94 |
+
else:
|
| 95 |
+
text = getattr(chunk, "text", None)
|
| 96 |
+
if isinstance(text, str):
|
| 97 |
+
parts.append(text)
|
| 98 |
+
return "".join(parts)
|
| 99 |
+
return str(raw)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def validate_llm_label(label: str, adapter: str) -> str:
|
| 103 |
+
"""Valide un label d'adapter LLM (alphanum + ``_ -``) et le renvoie.
|
| 104 |
+
|
| 105 |
+
Le label compose le ``name`` du module (``<kind>:<label>``) ; le restreindre
|
| 106 |
+
garde les noms de modules sûrs et stables (provenance ``RunManifest``).
|
| 107 |
+
"""
|
| 108 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 109 |
+
raise AdapterStepError(
|
| 110 |
+
f"{adapter} : label invalide {label!r} (alphanum + _ -)."
|
| 111 |
+
)
|
| 112 |
+
return label
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def validate_role(role: str, adapter: str, supported: frozenset[str]) -> PipelineMode:
|
| 116 |
+
"""Valide un mode d'adapter contre ceux que le fournisseur supporte."""
|
| 117 |
+
if role not in supported:
|
| 118 |
+
raise AdapterStepError(
|
| 119 |
+
f"{adapter} : mode {role!r} non supporté "
|
| 120 |
+
f"(modes : {', '.join(sorted(supported))})."
|
| 121 |
+
)
|
| 122 |
+
return cast(PipelineMode, role)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def llm_input_types(role: PipelineMode) -> frozenset[ArtifactType]:
|
| 126 |
+
"""Types d'entrée requis par le ``role`` (mode du pipeline)."""
|
| 127 |
+
if role == "text_only":
|
| 128 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 129 |
+
if role == "text_and_image":
|
| 130 |
+
return frozenset({ArtifactType.RAW_TEXT, ArtifactType.IMAGE})
|
| 131 |
+
return frozenset({ArtifactType.IMAGE}) # zero_shot
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def llm_output_type(role: PipelineMode) -> ArtifactType:
|
| 135 |
+
"""Type produit : ``RAW_TEXT`` en transcription, sinon ``CORRECTED_TEXT``."""
|
| 136 |
+
return (
|
| 137 |
+
ArtifactType.RAW_TEXT
|
| 138 |
+
if role == "zero_shot"
|
| 139 |
+
else ArtifactType.CORRECTED_TEXT
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def default_prompt_for_role(role: PipelineMode) -> str:
|
| 144 |
+
"""Prompt par défaut adapté au mode (transcription vs correction)."""
|
| 145 |
+
return (
|
| 146 |
+
DEFAULT_TRANSCRIPTION_PROMPT
|
| 147 |
+
if role == "zero_shot"
|
| 148 |
+
else DEFAULT_CORRECTION_PROMPT
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
def build_prompt(template: str, ocr_text: str) -> str:
|
| 153 |
+
return template.replace("{ocr_text}", ocr_text)
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
def load_ocr_text(inputs: dict[ArtifactType, Artifact], adapter_name: str) -> str:
|
| 157 |
+
artifact = inputs.get(ArtifactType.RAW_TEXT)
|
| 158 |
+
if artifact is None or artifact.uri is None:
|
| 159 |
+
raise AdapterStepError(
|
| 160 |
+
f"{adapter_name} : input RAW_TEXT manquant ou sans URI."
|
| 161 |
+
)
|
| 162 |
+
return read_plaintext(Path(artifact.uri).read_bytes())
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def load_image_b64(
|
| 166 |
+
inputs: dict[ArtifactType, Artifact], adapter_name: str
|
| 167 |
+
) -> tuple[str, str]:
|
| 168 |
+
"""Charge l'``IMAGE`` d'entrée → ``(media_type, base64)`` pour un VLM."""
|
| 169 |
+
artifact = inputs.get(ArtifactType.IMAGE)
|
| 170 |
+
if artifact is None or artifact.uri is None:
|
| 171 |
+
raise AdapterStepError(
|
| 172 |
+
f"{adapter_name} : input IMAGE manquant ou sans URI."
|
| 173 |
+
)
|
| 174 |
+
path = Path(artifact.uri)
|
| 175 |
+
media_type = _MEDIA_TYPES.get(path.suffix.lower(), "image/png")
|
| 176 |
+
return media_type, b64encode(path.read_bytes()).decode("ascii")
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def write_text_artifact(
|
| 180 |
+
workspace_uri: str,
|
| 181 |
+
document_id: str,
|
| 182 |
+
label: str,
|
| 183 |
+
adapter_name: str,
|
| 184 |
+
text: str,
|
| 185 |
+
*,
|
| 186 |
+
output_type: ArtifactType,
|
| 187 |
+
) -> dict[ArtifactType, Artifact]:
|
| 188 |
+
"""Écrit ``text`` dans le workspace → artefact du type produit par le mode."""
|
| 189 |
+
is_corrected = output_type == ArtifactType.CORRECTED_TEXT
|
| 190 |
+
suffix = "corrected.txt" if is_corrected else "raw.txt"
|
| 191 |
+
output_path = workspace_artifact_path(
|
| 192 |
+
workspace_uri, document_id, label, suffix
|
| 193 |
+
)
|
| 194 |
+
output_path.write_text(text, encoding="utf-8")
|
| 195 |
+
return {
|
| 196 |
+
output_type: Artifact(
|
| 197 |
+
id=f"{document_id}:{adapter_name}:{output_type.value}",
|
| 198 |
+
document_id=document_id,
|
| 199 |
+
type=output_type,
|
| 200 |
+
uri=str(output_path),
|
| 201 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 202 |
+
)
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def run_llm_step(
|
| 207 |
+
*,
|
| 208 |
+
role: PipelineMode,
|
| 209 |
+
label: str,
|
| 210 |
+
name: str,
|
| 211 |
+
prompt: str,
|
| 212 |
+
inputs: dict[ArtifactType, Artifact],
|
| 213 |
+
context: RunContext,
|
| 214 |
+
control: RunControl,
|
| 215 |
+
text_invoke: TextInvoke,
|
| 216 |
+
vision_invoke: VisionInvoke | None,
|
| 217 |
+
) -> StepOutput:
|
| 218 |
+
"""Exécute une étape LLM/VLM selon le ``role`` — logique de mode unique.
|
| 219 |
+
|
| 220 |
+
Aiguille sur le mode : charge les bonnes entrées (texte et/ou image), appelle
|
| 221 |
+
l'invocation fournisseur idoine, écrit l'artefact du bon type. ``zero_shot``
|
| 222 |
+
et ``text_and_image`` exigent ``vision_invoke`` (un fournisseur texte-seul,
|
| 223 |
+
ex. ollama, passe ``None`` → ces modes sont refusés proprement). Les jetons
|
| 224 |
+
exposés par le fournisseur remontent dans ``StepOutput.usage``.
|
| 225 |
+
"""
|
| 226 |
+
control.raise_if_cancelled()
|
| 227 |
+
if context.workspace_uri is None:
|
| 228 |
+
raise AdapterStepError(
|
| 229 |
+
f"{name} : workspace requis (RunContext.workspace_uri)."
|
| 230 |
+
)
|
| 231 |
+
if role == "zero_shot":
|
| 232 |
+
if vision_invoke is None:
|
| 233 |
+
raise AdapterStepError(f"{name} : mode zero_shot requiert un VLM (vision).")
|
| 234 |
+
media_type, image_b64 = load_image_b64(inputs, name)
|
| 235 |
+
completion = vision_invoke(prompt, media_type, image_b64)
|
| 236 |
+
elif role == "text_and_image":
|
| 237 |
+
if vision_invoke is None:
|
| 238 |
+
raise AdapterStepError(
|
| 239 |
+
f"{name} : mode text_and_image requiert un VLM (vision)."
|
| 240 |
+
)
|
| 241 |
+
ocr_text = load_ocr_text(inputs, name)
|
| 242 |
+
media_type, image_b64 = load_image_b64(inputs, name)
|
| 243 |
+
completion = vision_invoke(
|
| 244 |
+
build_prompt(prompt, ocr_text), media_type, image_b64
|
| 245 |
+
)
|
| 246 |
+
else: # text_only
|
| 247 |
+
ocr_text = load_ocr_text(inputs, name)
|
| 248 |
+
completion = text_invoke(build_prompt(prompt, ocr_text))
|
| 249 |
+
artifacts = write_text_artifact(
|
| 250 |
+
context.workspace_uri,
|
| 251 |
+
context.document_id,
|
| 252 |
+
label,
|
| 253 |
+
name,
|
| 254 |
+
completion.text,
|
| 255 |
+
output_type=llm_output_type(role),
|
| 256 |
+
)
|
| 257 |
+
usage = (
|
| 258 |
+
ResourceUsage(
|
| 259 |
+
tokens_in=completion.tokens_in, tokens_out=completion.tokens_out
|
| 260 |
+
)
|
| 261 |
+
if completion.tokens_in is not None or completion.tokens_out is not None
|
| 262 |
+
else None
|
| 263 |
+
)
|
| 264 |
+
return StepOutput(artifacts=artifacts, usage=usage)
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
__all__ = [
|
| 268 |
+
"DEFAULT_CORRECTION_PROMPT",
|
| 269 |
+
"DEFAULT_TRANSCRIPTION_PROMPT",
|
| 270 |
+
"LLMCompletion",
|
| 271 |
+
"TextInvoke",
|
| 272 |
+
"VisionInvoke",
|
| 273 |
+
"build_prompt",
|
| 274 |
+
"default_prompt_for_role",
|
| 275 |
+
"llm_input_types",
|
| 276 |
+
"llm_output_type",
|
| 277 |
+
"load_image_b64",
|
| 278 |
+
"load_ocr_text",
|
| 279 |
+
"normalize_llm_content",
|
| 280 |
+
"run_llm_step",
|
| 281 |
+
"usage_tokens",
|
| 282 |
+
"validate_llm_label",
|
| 283 |
+
"validate_role",
|
| 284 |
+
"write_text_artifact",
|
| 285 |
+
]
|
cinoc/adapters/llm/anthropic.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AnthropicAdapter`` — post-correction LLM **et** transcription VLM (Claude).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
|
| 4 |
+
``text_only`` (texte → corrigé), ``text_and_image`` (Claude vision : image +
|
| 5 |
+
texte → corrigé), ``zero_shot`` (Claude vision : image → texte). Clé
|
| 6 |
+
``ANTHROPIC_API_KEY`` ; SDK ``anthropic`` = **extra** importé paresseusement dans
|
| 7 |
+
``_invoke_anthropic`` / ``_invoke_anthropic_vision`` (isolés → **mockables**, CI
|
| 8 |
+
sans clé ni réseau). La ``Deadline`` borne l'appel.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
_DEFAULT_MODEL = "claude-haiku-4-5-20251001"
|
| 37 |
+
_MAX_TOKENS = 4096
|
| 38 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def _completion_from_message(response: Any) -> LLMCompletion:
|
| 42 |
+
"""Réponse SDK ``messages.create`` → ``LLMCompletion`` (texte + jetons).
|
| 43 |
+
|
| 44 |
+
**Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
|
| 45 |
+
(``input_tokens``/``output_tokens``, **noms propres à Claude**) est **testable
|
| 46 |
+
sur cassette** (étape 2d) ; le client live (``# pragma: no cover``) ne le
|
| 47 |
+
permettrait pas. Jetons → économie (coût cloud réel).
|
| 48 |
+
"""
|
| 49 |
+
usage = getattr(response, "usage", None)
|
| 50 |
+
return LLMCompletion(
|
| 51 |
+
normalize_llm_content(getattr(response, "content", None)),
|
| 52 |
+
usage_tokens(getattr(usage, "input_tokens", None)),
|
| 53 |
+
usage_tokens(getattr(usage, "output_tokens", None)),
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _anthropic_client( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 58 |
+
model: str, content: object, deadline: Deadline
|
| 59 |
+
) -> LLMCompletion:
|
| 60 |
+
"""Appel ``messages.create`` partagé (texte ou multimodal)."""
|
| 61 |
+
import os
|
| 62 |
+
|
| 63 |
+
api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 64 |
+
if not api_key:
|
| 65 |
+
raise AdapterStepError("anthropic : ANTHROPIC_API_KEY manquante.")
|
| 66 |
+
try:
|
| 67 |
+
from anthropic import ( # type: ignore[import-not-found]
|
| 68 |
+
Anthropic,
|
| 69 |
+
AnthropicError,
|
| 70 |
+
)
|
| 71 |
+
except ImportError as exc:
|
| 72 |
+
raise AdapterStepError(
|
| 73 |
+
"anthropic : SDK non installé (pip install 'cinoc[anthropic]')."
|
| 74 |
+
) from exc
|
| 75 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 76 |
+
timeout = deadline.as_sdk_timeout()
|
| 77 |
+
if timeout is not None:
|
| 78 |
+
client_kwargs["timeout"] = timeout
|
| 79 |
+
try:
|
| 80 |
+
# SDK anthropic typé strictement ; dicts valides à l'exécution (tests live).
|
| 81 |
+
client = Anthropic(**client_kwargs) # type: ignore[arg-type]
|
| 82 |
+
|
| 83 |
+
def _create() -> Any:
|
| 84 |
+
return client.messages.create(
|
| 85 |
+
model=model,
|
| 86 |
+
max_tokens=_MAX_TOKENS,
|
| 87 |
+
messages=[{"role": "user", "content": content}], # type: ignore[typeddict-item]
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# Cap de concurrence (≤ N appels Anthropic simultanés) + retry borné sur
|
| 91 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 92 |
+
response = call_resilient("anthropic", _create)
|
| 93 |
+
except AnthropicError as exc:
|
| 94 |
+
raise AdapterStepError(
|
| 95 |
+
f"anthropic a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 96 |
+
) from exc
|
| 97 |
+
return _completion_from_message(response)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _invoke_anthropic( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 101 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 102 |
+
) -> LLMCompletion:
|
| 103 |
+
return _anthropic_client(model, prompt, deadline)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def _invoke_anthropic_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 107 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 108 |
+
) -> LLMCompletion:
|
| 109 |
+
content = [
|
| 110 |
+
{"type": "text", "text": prompt},
|
| 111 |
+
{
|
| 112 |
+
"type": "image",
|
| 113 |
+
"source": {
|
| 114 |
+
"type": "base64",
|
| 115 |
+
"media_type": media_type,
|
| 116 |
+
"data": image_b64,
|
| 117 |
+
},
|
| 118 |
+
},
|
| 119 |
+
]
|
| 120 |
+
return _anthropic_client(model, content, deadline)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
class AnthropicAdapter:
|
| 124 |
+
"""Adapter Anthropic multi-mode (post-correction texte/image, transcription)."""
|
| 125 |
+
|
| 126 |
+
def __init__(
|
| 127 |
+
self,
|
| 128 |
+
*,
|
| 129 |
+
label: str,
|
| 130 |
+
model: str = _DEFAULT_MODEL,
|
| 131 |
+
role: str = "text_only",
|
| 132 |
+
prompt: str | None = None,
|
| 133 |
+
) -> None:
|
| 134 |
+
self._label = validate_llm_label(label, "AnthropicAdapter")
|
| 135 |
+
self._model = model
|
| 136 |
+
self._role: PipelineMode = validate_role(role, "AnthropicAdapter", _SUPPORTED)
|
| 137 |
+
self._prompt = (
|
| 138 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 139 |
+
)
|
| 140 |
+
|
| 141 |
+
@property
|
| 142 |
+
def name(self) -> str:
|
| 143 |
+
return f"anthropic:{self._label}"
|
| 144 |
+
|
| 145 |
+
@property
|
| 146 |
+
def version(self) -> str:
|
| 147 |
+
return _VERSION
|
| 148 |
+
|
| 149 |
+
@property
|
| 150 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 151 |
+
return llm_input_types(self._role)
|
| 152 |
+
|
| 153 |
+
@property
|
| 154 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 155 |
+
return frozenset({llm_output_type(self._role)})
|
| 156 |
+
|
| 157 |
+
def execute(
|
| 158 |
+
self,
|
| 159 |
+
inputs: dict[ArtifactType, Artifact],
|
| 160 |
+
params: dict[str, ParamValue],
|
| 161 |
+
context: RunContext,
|
| 162 |
+
control: RunControl,
|
| 163 |
+
) -> StepOutput:
|
| 164 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 165 |
+
return _invoke_anthropic(
|
| 166 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
def vision_invoke(
|
| 170 |
+
prompt: str, media_type: str, image_b64: str
|
| 171 |
+
) -> LLMCompletion:
|
| 172 |
+
return _invoke_anthropic_vision(
|
| 173 |
+
model=self._model,
|
| 174 |
+
prompt=prompt,
|
| 175 |
+
media_type=media_type,
|
| 176 |
+
image_b64=image_b64,
|
| 177 |
+
deadline=context.deadline,
|
| 178 |
+
)
|
| 179 |
+
|
| 180 |
+
return run_llm_step(
|
| 181 |
+
role=self._role,
|
| 182 |
+
label=self._label,
|
| 183 |
+
name=self.name,
|
| 184 |
+
prompt=self._prompt,
|
| 185 |
+
inputs=inputs,
|
| 186 |
+
context=context,
|
| 187 |
+
control=control,
|
| 188 |
+
text_invoke=text_invoke,
|
| 189 |
+
vision_invoke=vision_invoke,
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
__all__ = ["AnthropicAdapter"]
|
cinoc/adapters/llm/mistral.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralAdapter`` — post-correction LLM **et** transcription VLM (API Mistral).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol, trois modes (``PipelineMode``) selon le rôle :
|
| 4 |
+
``text_only`` (texte → corrigé), ``text_and_image`` (Pixtral : image + texte →
|
| 5 |
+
corrigé), ``zero_shot`` (Pixtral : image → texte). Clé ``MISTRAL_API_KEY`` ; SDK
|
| 6 |
+
``mistralai`` = **extra** importé paresseusement dans ``_invoke_mistral`` /
|
| 7 |
+
``_invoke_mistral_vision`` (isolés → **mockables**, CI sans clé ni réseau).
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import logging
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
_VERSION = "1.0"
|
| 38 |
+
_DEFAULT_MODEL = "mistral-small-latest"
|
| 39 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _completion_from_chat(response: Any) -> LLMCompletion:
|
| 43 |
+
"""Réponse SDK ``chat.complete`` → ``LLMCompletion`` (texte + jetons).
|
| 44 |
+
|
| 45 |
+
**Pur** (aucun réseau, aucun SDK) → la cartographie usage→jetons
|
| 46 |
+
(``prompt_tokens``/``completion_tokens``) est **testable sur cassette** (étape
|
| 47 |
+
2d) ; le client live (``# pragma: no cover``) ne le permettrait pas. Jetons →
|
| 48 |
+
économie (coût cloud réel).
|
| 49 |
+
"""
|
| 50 |
+
usage = getattr(response, "usage", None)
|
| 51 |
+
tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
|
| 52 |
+
tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
|
| 53 |
+
choices = getattr(response, "choices", None)
|
| 54 |
+
if not choices:
|
| 55 |
+
return LLMCompletion("", tokens_in, tokens_out)
|
| 56 |
+
return LLMCompletion(
|
| 57 |
+
normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _mistral_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 62 |
+
model: str, content: object, deadline: Deadline
|
| 63 |
+
) -> LLMCompletion:
|
| 64 |
+
"""Appel ``chat.complete`` partagé (texte ou multimodal)."""
|
| 65 |
+
import os
|
| 66 |
+
|
| 67 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 68 |
+
if not api_key:
|
| 69 |
+
raise AdapterStepError("mistral : MISTRAL_API_KEY manquante.")
|
| 70 |
+
try:
|
| 71 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 72 |
+
except ImportError as exc:
|
| 73 |
+
raise AdapterStepError(
|
| 74 |
+
"mistral : SDK non installé (pip install 'cinoc[mistral]')."
|
| 75 |
+
) from exc
|
| 76 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 77 |
+
timeout = deadline.as_sdk_timeout()
|
| 78 |
+
if timeout is not None:
|
| 79 |
+
client_kwargs["timeout_ms"] = int(timeout * 1000)
|
| 80 |
+
try:
|
| 81 |
+
# Le SDK mistralai 1.x type strictement ses kwargs/messages ; on lui
|
| 82 |
+
# passe des dicts valides à l'exécution (cf. tests live). ``ignore`` ciblé.
|
| 83 |
+
client = Mistral(**client_kwargs) # type: ignore[arg-type]
|
| 84 |
+
|
| 85 |
+
def _complete() -> Any:
|
| 86 |
+
return client.chat.complete(
|
| 87 |
+
model=model,
|
| 88 |
+
messages=[{"role": "user", "content": content}], # type: ignore[arg-type]
|
| 89 |
+
temperature=0.0,
|
| 90 |
+
)
|
| 91 |
+
|
| 92 |
+
# Cap de concurrence (≤ N appels Mistral simultanés) + retry borné sur
|
| 93 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 94 |
+
response = call_resilient("mistral", _complete)
|
| 95 |
+
except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
|
| 96 |
+
raise AdapterStepError(
|
| 97 |
+
f"mistral a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 98 |
+
) from exc
|
| 99 |
+
return _completion_from_chat(response)
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def _invoke_mistral( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 103 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 104 |
+
) -> LLMCompletion:
|
| 105 |
+
return _mistral_client(model, prompt, deadline)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def _invoke_mistral_vision( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 109 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 110 |
+
) -> LLMCompletion:
|
| 111 |
+
content = [
|
| 112 |
+
{"type": "text", "text": prompt},
|
| 113 |
+
{"type": "image_url", "image_url": f"data:{media_type};base64,{image_b64}"},
|
| 114 |
+
]
|
| 115 |
+
return _mistral_client(model, content, deadline)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class MistralAdapter:
|
| 119 |
+
"""Adapter Mistral multi-mode (post-correction texte/image, transcription VLM)."""
|
| 120 |
+
|
| 121 |
+
def __init__(
|
| 122 |
+
self,
|
| 123 |
+
*,
|
| 124 |
+
label: str,
|
| 125 |
+
model: str = _DEFAULT_MODEL,
|
| 126 |
+
role: str = "text_only",
|
| 127 |
+
prompt: str | None = None,
|
| 128 |
+
) -> None:
|
| 129 |
+
self._label = validate_llm_label(label, "MistralAdapter")
|
| 130 |
+
self._model = model
|
| 131 |
+
self._role: PipelineMode = validate_role(role, "MistralAdapter", _SUPPORTED)
|
| 132 |
+
self._prompt = (
|
| 133 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
@property
|
| 137 |
+
def name(self) -> str:
|
| 138 |
+
return f"mistral:{self._label}"
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def version(self) -> str:
|
| 142 |
+
return _VERSION
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 146 |
+
return llm_input_types(self._role)
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 150 |
+
return frozenset({llm_output_type(self._role)})
|
| 151 |
+
|
| 152 |
+
def execute(
|
| 153 |
+
self,
|
| 154 |
+
inputs: dict[ArtifactType, Artifact],
|
| 155 |
+
params: dict[str, ParamValue],
|
| 156 |
+
context: RunContext,
|
| 157 |
+
control: RunControl,
|
| 158 |
+
) -> StepOutput:
|
| 159 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 160 |
+
return _invoke_mistral(
|
| 161 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
def vision_invoke(
|
| 165 |
+
prompt: str, media_type: str, image_b64: str
|
| 166 |
+
) -> LLMCompletion:
|
| 167 |
+
return _invoke_mistral_vision(
|
| 168 |
+
model=self._model,
|
| 169 |
+
prompt=prompt,
|
| 170 |
+
media_type=media_type,
|
| 171 |
+
image_b64=image_b64,
|
| 172 |
+
deadline=context.deadline,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
return run_llm_step(
|
| 176 |
+
role=self._role,
|
| 177 |
+
label=self._label,
|
| 178 |
+
name=self.name,
|
| 179 |
+
prompt=self._prompt,
|
| 180 |
+
inputs=inputs,
|
| 181 |
+
context=context,
|
| 182 |
+
control=control,
|
| 183 |
+
text_invoke=text_invoke,
|
| 184 |
+
vision_invoke=vision_invoke,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
def list_mistral_models(*, timeout: float = 5.0) -> tuple[str, ...]:
|
| 189 |
+
"""IDs des modèles disponibles pour la clé Mistral courante (``models.list``).
|
| 190 |
+
|
| 191 |
+
**Best-effort, commodité d'UI** : ``MISTRAL_API_KEY`` absente, SDK absent ou
|
| 192 |
+
erreur réseau → ``()`` (l'interface retombe sur la saisie libre). Rien de
|
| 193 |
+
hardcodé : la liste vient **directement de l'API Mistral**.
|
| 194 |
+
"""
|
| 195 |
+
import os
|
| 196 |
+
|
| 197 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 198 |
+
if not api_key:
|
| 199 |
+
return ()
|
| 200 |
+
try:
|
| 201 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 202 |
+
except ImportError:
|
| 203 |
+
return ()
|
| 204 |
+
try:
|
| 205 |
+
client = Mistral(api_key=api_key, timeout_ms=int(timeout * 1000))
|
| 206 |
+
response = client.models.list()
|
| 207 |
+
except Exception as exc: # noqa: BLE001 — voir rationale ci-dessous
|
| 208 |
+
# Catch-all **assumé** : commodité d'UI qui ne doit jamais casser la
|
| 209 |
+
# page du composeur. Le SDK mistralai **enveloppe** ses erreurs HTTP
|
| 210 |
+
# (auth/429/5xx) dans ses propres types + propage des erreurs httpx pour
|
| 211 |
+
# le transport ; narrower au seul ``httpx.HTTPError`` (comme ollama, qui
|
| 212 |
+
# appelle httpx en direct) laisserait fuir une erreur SDK et casserait la
|
| 213 |
+
# page. On trace (§7) et on retombe sur la saisie libre.
|
| 214 |
+
logger.warning("[mistral] liste des modèles indisponible : %s", exc)
|
| 215 |
+
return ()
|
| 216 |
+
data = getattr(response, "data", None) or ()
|
| 217 |
+
ids = [m.id for m in data if isinstance(getattr(m, "id", None), str)]
|
| 218 |
+
return tuple(sorted(set(ids)))
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
__all__ = ["MistralAdapter", "list_mistral_models"]
|
cinoc/adapters/llm/ollama.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OllamaAdapter`` — post-correction LLM via un serveur ollama local.
|
| 2 |
+
|
| 3 |
+
Deuxième famille LLM : prouve que le ``Module`` Protocol **généralise au-delà
|
| 4 |
+
d'openai** — même contrat ``RAW_TEXT`` → ``CORRECTED_TEXT``, sortie identique,
|
| 5 |
+
aucun cas particulier. Transport ``httpx`` (extra), importé paresseusement et
|
| 6 |
+
**isolé** dans ``_invoke_ollama`` (→ mockable, CI sans serveur).
|
| 7 |
+
|
| 8 |
+
C'est l'**implémentation de référence de l'annulation câblée** : ``_invoke_ollama``
|
| 9 |
+
enregistre ``client.close`` via ``RunControl.register_cancel_handle`` ; un
|
| 10 |
+
``trigger_cancel`` ferme la connexion et **interrompt la requête en vol**. La
|
| 11 |
+
distinction « annulation vs vraie panne réseau » se fait en sondant
|
| 12 |
+
``is_cancelled`` (``_fail_or_cancel``) — pas par une heuristique de message
|
| 13 |
+
fragile (dette de l'implémentation source, corrigée au portage).
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import NoReturn
|
| 19 |
+
|
| 20 |
+
from cinoc.adapters.llm._base import (
|
| 21 |
+
DEFAULT_CORRECTION_PROMPT,
|
| 22 |
+
LLMCompletion,
|
| 23 |
+
normalize_llm_content,
|
| 24 |
+
run_llm_step,
|
| 25 |
+
usage_tokens,
|
| 26 |
+
validate_llm_label,
|
| 27 |
+
)
|
| 28 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 29 |
+
from cinoc.domain.deadline import Deadline
|
| 30 |
+
from cinoc.domain.errors import AdapterStepError
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
_DEFAULT_MODEL = "llama3"
|
| 37 |
+
_DEFAULT_HOST = "http://localhost:11434"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _fail_or_cancel(control: RunControl, model: str, exc: Exception) -> NoReturn:
|
| 41 |
+
"""Traduit un échec d'appel ollama.
|
| 42 |
+
|
| 43 |
+
Si l'annulation a été déclenchée, le ``client.close`` enregistré a fait
|
| 44 |
+
échouer la requête en vol : on lève alors ``RunCancelledError`` (via
|
| 45 |
+
``raise_if_cancelled``). Sinon c'est une vraie panne → ``AdapterStepError``.
|
| 46 |
+
Sonder ``is_cancelled`` est **fiable**, là où l'implémentation source
|
| 47 |
+
devinait par le message d'exception (heuristique fragile, D-A).
|
| 48 |
+
"""
|
| 49 |
+
control.raise_if_cancelled()
|
| 50 |
+
raise AdapterStepError(f"ollama a échoué ({model}) : {exc}") from exc
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def _invoke_ollama( # pragma: no cover -- réseau (serveur ollama ; cf. marqueur 'live')
|
| 54 |
+
*,
|
| 55 |
+
model: str,
|
| 56 |
+
prompt: str,
|
| 57 |
+
host: str,
|
| 58 |
+
deadline: Deadline,
|
| 59 |
+
control: RunControl,
|
| 60 |
+
) -> LLMCompletion:
|
| 61 |
+
try:
|
| 62 |
+
import httpx # type: ignore[import-not-found]
|
| 63 |
+
except ImportError as exc:
|
| 64 |
+
raise AdapterStepError(
|
| 65 |
+
"ollama : httpx non installé (pip install 'cinoc[ollama]')."
|
| 66 |
+
) from exc
|
| 67 |
+
payload = {
|
| 68 |
+
"model": model,
|
| 69 |
+
"messages": [{"role": "user", "content": prompt}],
|
| 70 |
+
"stream": False,
|
| 71 |
+
}
|
| 72 |
+
client = httpx.Client(timeout=deadline.as_sdk_timeout())
|
| 73 |
+
# Annulation câblée : fermer le client interrompt la requête en vol.
|
| 74 |
+
control.register_cancel_handle(client.close)
|
| 75 |
+
try:
|
| 76 |
+
# **/api/chat** (et non /api/generate) : applique le gabarit de chat du
|
| 77 |
+
# modèle. Indispensable pour les modèles *instruct* (ex. churro), qui
|
| 78 |
+
# échouent sur /api/generate (complétion brute). C'est l'endpoint
|
| 79 |
+
# qu'utilise ``ollama run``.
|
| 80 |
+
response = client.post(f"{host}/api/chat", json=payload)
|
| 81 |
+
response.raise_for_status()
|
| 82 |
+
data = response.json()
|
| 83 |
+
except httpx.HTTPError as exc:
|
| 84 |
+
_fail_or_cancel(control, model, exc)
|
| 85 |
+
finally:
|
| 86 |
+
client.close()
|
| 87 |
+
# Ollama peut renvoyer une **erreur en HTTP 200** (modèle introuvable sous ce
|
| 88 |
+
# nom, mémoire insuffisante…) : ``raise_for_status`` ne la voit pas. On la
|
| 89 |
+
# remonte explicitement, sinon la « correction » serait un texte vide silencieux.
|
| 90 |
+
if isinstance(data, dict) and data.get("error"):
|
| 91 |
+
raise AdapterStepError(f"ollama a échoué ({model}) : {data['error']}")
|
| 92 |
+
message = data.get("message") if isinstance(data, dict) else None
|
| 93 |
+
content = message.get("content") if isinstance(message, dict) else None
|
| 94 |
+
# /api/chat expose la consommation : prompt_eval_count / eval_count.
|
| 95 |
+
counts = data if isinstance(data, dict) else {}
|
| 96 |
+
return LLMCompletion(
|
| 97 |
+
normalize_llm_content(content),
|
| 98 |
+
usage_tokens(counts.get("prompt_eval_count")),
|
| 99 |
+
usage_tokens(counts.get("eval_count")),
|
| 100 |
+
)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def list_installed_models(
|
| 104 |
+
host: str = _DEFAULT_HOST, *, timeout: float = 3.0
|
| 105 |
+
) -> tuple[str, ...]:
|
| 106 |
+
"""Noms des modèles installés sur le serveur ollama (``GET /api/tags``).
|
| 107 |
+
|
| 108 |
+
**Best-effort, commodité d'UI** : serveur injoignable, ``httpx`` absent ou
|
| 109 |
+
réponse inattendue → ``()`` (l'interface retombe alors sur la saisie libre).
|
| 110 |
+
Aucune exception propagée. Le ``name`` renvoyé est le **tag exact** servable à
|
| 111 |
+
``/api/generate`` (ex. ``gemma3:1b`` ou ``hf.co/…/churro-3B-GGUF:Q4_K_M``).
|
| 112 |
+
"""
|
| 113 |
+
try:
|
| 114 |
+
import httpx # type: ignore[import-not-found]
|
| 115 |
+
except ImportError:
|
| 116 |
+
return ()
|
| 117 |
+
try:
|
| 118 |
+
with httpx.Client(timeout=timeout) as client:
|
| 119 |
+
response = client.get(f"{host}/api/tags")
|
| 120 |
+
response.raise_for_status()
|
| 121 |
+
data = response.json()
|
| 122 |
+
except (httpx.HTTPError, ValueError):
|
| 123 |
+
return ()
|
| 124 |
+
models = data.get("models") if isinstance(data, dict) else None
|
| 125 |
+
if not isinstance(models, list):
|
| 126 |
+
return ()
|
| 127 |
+
return tuple(
|
| 128 |
+
m["name"]
|
| 129 |
+
for m in models
|
| 130 |
+
if isinstance(m, dict) and isinstance(m.get("name"), str)
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class OllamaAdapter:
|
| 135 |
+
"""Post-correction LLM, serveur ollama local : ``RAW_TEXT`` → ``CORRECTED_TEXT``."""
|
| 136 |
+
|
| 137 |
+
def __init__(
|
| 138 |
+
self,
|
| 139 |
+
*,
|
| 140 |
+
label: str,
|
| 141 |
+
model: str = _DEFAULT_MODEL,
|
| 142 |
+
host: str = _DEFAULT_HOST,
|
| 143 |
+
prompt: str = DEFAULT_CORRECTION_PROMPT,
|
| 144 |
+
) -> None:
|
| 145 |
+
self._label = validate_llm_label(label, "OllamaAdapter")
|
| 146 |
+
self._model = model
|
| 147 |
+
self._host = host
|
| 148 |
+
self._prompt = prompt
|
| 149 |
+
|
| 150 |
+
@property
|
| 151 |
+
def name(self) -> str:
|
| 152 |
+
return f"ollama:{self._label}"
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def version(self) -> str:
|
| 156 |
+
return _VERSION
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 160 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 164 |
+
return frozenset({ArtifactType.CORRECTED_TEXT})
|
| 165 |
+
|
| 166 |
+
def execute(
|
| 167 |
+
self,
|
| 168 |
+
inputs: dict[ArtifactType, Artifact],
|
| 169 |
+
params: dict[str, ParamValue],
|
| 170 |
+
context: RunContext,
|
| 171 |
+
control: RunControl,
|
| 172 |
+
) -> StepOutput:
|
| 173 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 174 |
+
return _invoke_ollama(
|
| 175 |
+
model=self._model,
|
| 176 |
+
prompt=prompt,
|
| 177 |
+
host=self._host,
|
| 178 |
+
deadline=context.deadline,
|
| 179 |
+
control=control,
|
| 180 |
+
)
|
| 181 |
+
|
| 182 |
+
return run_llm_step(
|
| 183 |
+
role="text_only",
|
| 184 |
+
label=self._label,
|
| 185 |
+
name=self.name,
|
| 186 |
+
prompt=self._prompt,
|
| 187 |
+
inputs=inputs,
|
| 188 |
+
context=context,
|
| 189 |
+
control=control,
|
| 190 |
+
text_invoke=text_invoke,
|
| 191 |
+
vision_invoke=None,
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
__all__ = ["OllamaAdapter"]
|
cinoc/adapters/llm/openai.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OpenAIAdapter`` — post-correction LLM **et** transcription VLM (API OpenAI).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol. Trois modes (``PipelineMode``) selon le rôle
|
| 4 |
+
passé à la construction : ``text_only`` (``RAW_TEXT`` → ``CORRECTED_TEXT``),
|
| 5 |
+
``text_and_image`` (image + texte → corrigé), ``zero_shot`` (image → texte). La
|
| 6 |
+
clé vient de ``OPENAI_API_KEY`` ; le SDK ``openai`` est un **extra** importé
|
| 7 |
+
paresseusement dans ``_invoke_openai`` / ``_invoke_openai_vision`` (isolés →
|
| 8 |
+
**mockables**, CI sans clé ni réseau). La ``Deadline`` borne l'appel.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
from typing import Any
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters._resilience import call_resilient
|
| 16 |
+
from cinoc.adapters.llm._base import (
|
| 17 |
+
LLMCompletion,
|
| 18 |
+
default_prompt_for_role,
|
| 19 |
+
llm_input_types,
|
| 20 |
+
llm_output_type,
|
| 21 |
+
normalize_llm_content,
|
| 22 |
+
run_llm_step,
|
| 23 |
+
usage_tokens,
|
| 24 |
+
validate_llm_label,
|
| 25 |
+
validate_role,
|
| 26 |
+
)
|
| 27 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 28 |
+
from cinoc.domain.deadline import Deadline
|
| 29 |
+
from cinoc.domain.errors import AdapterStepError
|
| 30 |
+
from cinoc.domain.pipeline import PipelineMode
|
| 31 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 32 |
+
from cinoc.pipeline.run_control import RunControl
|
| 33 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 34 |
+
|
| 35 |
+
_VERSION = "1.0"
|
| 36 |
+
_DEFAULT_MODEL = "gpt-4o-mini"
|
| 37 |
+
_SUPPORTED: frozenset[str] = frozenset({"text_only", "text_and_image", "zero_shot"})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def _completion_from_chat(response: Any) -> LLMCompletion:
|
| 41 |
+
"""Réponse SDK ``chat.completions`` → ``LLMCompletion`` (texte + jetons).
|
| 42 |
+
|
| 43 |
+
**Pur** (aucun réseau, aucun import SDK) → la cartographie usage→jetons
|
| 44 |
+
(``prompt_tokens``/``completion_tokens``) est **testable sur une réponse de
|
| 45 |
+
cassette**, ce que le client live (``# pragma: no cover``) ne permet pas. Les
|
| 46 |
+
jetons alimentent l'économie (coût réel par modèle).
|
| 47 |
+
"""
|
| 48 |
+
usage = getattr(response, "usage", None)
|
| 49 |
+
tokens_in = usage_tokens(getattr(usage, "prompt_tokens", None))
|
| 50 |
+
tokens_out = usage_tokens(getattr(usage, "completion_tokens", None))
|
| 51 |
+
choices = getattr(response, "choices", None)
|
| 52 |
+
if not choices:
|
| 53 |
+
return LLMCompletion("", tokens_in, tokens_out)
|
| 54 |
+
return LLMCompletion(
|
| 55 |
+
normalize_llm_content(choices[0].message.content), tokens_in, tokens_out
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _openai_client( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 60 |
+
model: str, messages: list[dict[str, object]], deadline: Deadline
|
| 61 |
+
) -> LLMCompletion:
|
| 62 |
+
"""Appel ``chat.completions`` partagé (texte ou multimodal)."""
|
| 63 |
+
import os
|
| 64 |
+
|
| 65 |
+
api_key = os.environ.get("OPENAI_API_KEY")
|
| 66 |
+
if not api_key:
|
| 67 |
+
raise AdapterStepError("openai : OPENAI_API_KEY manquante.")
|
| 68 |
+
try:
|
| 69 |
+
from openai import OpenAI, OpenAIError # type: ignore[import-not-found]
|
| 70 |
+
except ImportError as exc:
|
| 71 |
+
raise AdapterStepError(
|
| 72 |
+
"openai : SDK non installé (pip install 'cinoc[openai]')."
|
| 73 |
+
) from exc
|
| 74 |
+
kwargs: dict[str, object] = {
|
| 75 |
+
"model": model,
|
| 76 |
+
"messages": messages,
|
| 77 |
+
"temperature": 0.0,
|
| 78 |
+
}
|
| 79 |
+
timeout = deadline.as_sdk_timeout()
|
| 80 |
+
if timeout is not None:
|
| 81 |
+
kwargs["timeout"] = timeout
|
| 82 |
+
try:
|
| 83 |
+
# SDK openai typé strictement ; kwargs valides à l'exécution (tests live).
|
| 84 |
+
client = OpenAI(api_key=api_key)
|
| 85 |
+
|
| 86 |
+
def _create() -> Any:
|
| 87 |
+
return client.chat.completions.create(**kwargs) # type: ignore[call-overload]
|
| 88 |
+
|
| 89 |
+
# Cap de concurrence (≤ N appels OpenAI simultanés) + retry borné sur
|
| 90 |
+
# 429/5xx (Retry-After respecté) — par défaut, sans configuration UI.
|
| 91 |
+
response = call_resilient("openai", _create)
|
| 92 |
+
except OpenAIError as exc:
|
| 93 |
+
raise AdapterStepError(
|
| 94 |
+
f"openai a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 95 |
+
) from exc
|
| 96 |
+
return _completion_from_chat(response)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _invoke_openai( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 100 |
+
*, model: str, prompt: str, deadline: Deadline
|
| 101 |
+
) -> LLMCompletion:
|
| 102 |
+
return _openai_client(model, [{"role": "user", "content": prompt}], deadline)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _invoke_openai_vision( # pragma: no cover -- réseau + clé API (cf. marqueur 'live')
|
| 106 |
+
*, model: str, prompt: str, media_type: str, image_b64: str, deadline: Deadline
|
| 107 |
+
) -> LLMCompletion:
|
| 108 |
+
content = [
|
| 109 |
+
{"type": "text", "text": prompt},
|
| 110 |
+
{
|
| 111 |
+
"type": "image_url",
|
| 112 |
+
"image_url": {"url": f"data:{media_type};base64,{image_b64}"},
|
| 113 |
+
},
|
| 114 |
+
]
|
| 115 |
+
return _openai_client(model, [{"role": "user", "content": content}], deadline)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class OpenAIAdapter:
|
| 119 |
+
"""Adapter OpenAI multi-mode (post-correction texte/image, transcription VLM)."""
|
| 120 |
+
|
| 121 |
+
def __init__(
|
| 122 |
+
self,
|
| 123 |
+
*,
|
| 124 |
+
label: str,
|
| 125 |
+
model: str = _DEFAULT_MODEL,
|
| 126 |
+
role: str = "text_only",
|
| 127 |
+
prompt: str | None = None,
|
| 128 |
+
) -> None:
|
| 129 |
+
self._label = validate_llm_label(label, "OpenAIAdapter")
|
| 130 |
+
self._model = model
|
| 131 |
+
self._role: PipelineMode = validate_role(role, "OpenAIAdapter", _SUPPORTED)
|
| 132 |
+
self._prompt = (
|
| 133 |
+
prompt if prompt is not None else default_prompt_for_role(self._role)
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
@property
|
| 137 |
+
def name(self) -> str:
|
| 138 |
+
return f"openai:{self._label}"
|
| 139 |
+
|
| 140 |
+
@property
|
| 141 |
+
def version(self) -> str:
|
| 142 |
+
return _VERSION
|
| 143 |
+
|
| 144 |
+
@property
|
| 145 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 146 |
+
return llm_input_types(self._role)
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 150 |
+
return frozenset({llm_output_type(self._role)})
|
| 151 |
+
|
| 152 |
+
def execute(
|
| 153 |
+
self,
|
| 154 |
+
inputs: dict[ArtifactType, Artifact],
|
| 155 |
+
params: dict[str, ParamValue],
|
| 156 |
+
context: RunContext,
|
| 157 |
+
control: RunControl,
|
| 158 |
+
) -> StepOutput:
|
| 159 |
+
def text_invoke(prompt: str) -> LLMCompletion:
|
| 160 |
+
return _invoke_openai(
|
| 161 |
+
model=self._model, prompt=prompt, deadline=context.deadline
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
def vision_invoke(
|
| 165 |
+
prompt: str, media_type: str, image_b64: str
|
| 166 |
+
) -> LLMCompletion:
|
| 167 |
+
return _invoke_openai_vision(
|
| 168 |
+
model=self._model,
|
| 169 |
+
prompt=prompt,
|
| 170 |
+
media_type=media_type,
|
| 171 |
+
image_b64=image_b64,
|
| 172 |
+
deadline=context.deadline,
|
| 173 |
+
)
|
| 174 |
+
|
| 175 |
+
return run_llm_step(
|
| 176 |
+
role=self._role,
|
| 177 |
+
label=self._label,
|
| 178 |
+
name=self.name,
|
| 179 |
+
prompt=self._prompt,
|
| 180 |
+
inputs=inputs,
|
| 181 |
+
context=context,
|
| 182 |
+
control=control,
|
| 183 |
+
text_invoke=text_invoke,
|
| 184 |
+
vision_invoke=vision_invoke,
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
|
| 188 |
+
__all__ = ["OpenAIAdapter"]
|
cinoc/adapters/ner/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Adapters NER : extraction d'entités nommées (couche 5)."""
|
cinoc/adapters/ner/spacy_extractor.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``SpacyNerExtractor`` — extraction d'entités nommées via spaCy (couche 5).
|
| 2 |
+
|
| 3 |
+
Brique de pipeline ``RAW_TEXT|CORRECTED_TEXT → ENTITIES`` (la NER est une étape
|
| 4 |
+
explicite, CLAUDE.md §3). Implémente le ``Module`` Protocol **directement**. Le
|
| 5 |
+
SDK ``spacy`` + le **modèle** sont un extra optionnel (``cinoc[ner]`` + ``python
|
| 6 |
+
-m spacy download <modèle>``), importés paresseusement.
|
| 7 |
+
|
| 8 |
+
**Anti-silence (réparation R14 amont, bug du modèle source).** Si spaCy ou le
|
| 9 |
+
modèle manque, l'adapter **lève** ``AdapterStepError`` avec le message
|
| 10 |
+
d'installation — il ne renvoie **jamais** ``[]`` (le défaut de la source : un
|
| 11 |
+
benchmark qui « réussit » avec un rappel 0 sans message). L'orchestrateur isole
|
| 12 |
+
alors ce (concurrent × document) ; le document reste simplement non scoré.
|
| 13 |
+
|
| 14 |
+
L'artefact ``ENTITIES`` embarque **le texte d'entrée** (``{"text", "entities"}``)
|
| 15 |
+
— c'est lui qui rend la reprojection R14 auto-suffisante côté évaluation.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import json
|
| 21 |
+
import logging
|
| 22 |
+
from typing import Any
|
| 23 |
+
|
| 24 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 25 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 26 |
+
from cinoc.domain.errors import AdapterStepError
|
| 27 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 28 |
+
from cinoc.pipeline.run_control import RunControl
|
| 29 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
_VERSION = "1.0"
|
| 34 |
+
|
| 35 |
+
#: Normalisation des labels spaCy vers les conventions courtes HIPE/CoNLL.
|
| 36 |
+
_DEFAULT_LABEL_MAPPING: dict[str, str] = {
|
| 37 |
+
"PERSON": "PER",
|
| 38 |
+
"PER": "PER",
|
| 39 |
+
"LOC": "LOC",
|
| 40 |
+
"GPE": "LOC",
|
| 41 |
+
"ORG": "ORG",
|
| 42 |
+
"DATE": "DATE",
|
| 43 |
+
"TIME": "DATE",
|
| 44 |
+
"MISC": "MISC",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
#: Texte d'entrée accepté : sortie OCR brute **ou** corrigée par un LLM.
|
| 48 |
+
_INPUT_TYPES = frozenset({ArtifactType.RAW_TEXT, ArtifactType.CORRECTED_TEXT})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class SpacyNerExtractor:
|
| 52 |
+
"""NER spaCy : ``RAW_TEXT|CORRECTED_TEXT`` → ``ENTITIES`` (fail-closed)."""
|
| 53 |
+
|
| 54 |
+
def __init__(
|
| 55 |
+
self,
|
| 56 |
+
*,
|
| 57 |
+
label: str,
|
| 58 |
+
model: str = "fr_core_news_sm",
|
| 59 |
+
loader: Any | None = None,
|
| 60 |
+
) -> None:
|
| 61 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 62 |
+
raise AdapterStepError(f"ner : label invalide {label!r}.")
|
| 63 |
+
self._label = label
|
| 64 |
+
self._model = model
|
| 65 |
+
#: Injectable en test : ``(model_name) -> nlp`` ; défaut = ``spacy.load``.
|
| 66 |
+
self._loader = loader
|
| 67 |
+
self._nlp: Any | None = None
|
| 68 |
+
self._model_version: str | None = None
|
| 69 |
+
|
| 70 |
+
@property
|
| 71 |
+
def name(self) -> str:
|
| 72 |
+
return f"ner:{self._label}"
|
| 73 |
+
|
| 74 |
+
@property
|
| 75 |
+
def version(self) -> str:
|
| 76 |
+
return _VERSION
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 80 |
+
return _INPUT_TYPES
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 84 |
+
return frozenset({ArtifactType.ENTITIES})
|
| 85 |
+
|
| 86 |
+
def system_binaries(self) -> dict[str, str]:
|
| 87 |
+
"""Versions spaCy + modèle **si déjà chargés** (reproductibilité).
|
| 88 |
+
|
| 89 |
+
Best-effort sans effet de bord : ne déclenche aucun chargement — renvoie
|
| 90 |
+
``{}`` tant que le modèle n'a pas tourné. Fusionné au ``RunManifest`` par
|
| 91 |
+
l'orchestrateur (duck-typing, comme le binaire tesseract).
|
| 92 |
+
"""
|
| 93 |
+
if self._nlp is None:
|
| 94 |
+
return {}
|
| 95 |
+
out: dict[str, str] = {}
|
| 96 |
+
if self._model_version is not None:
|
| 97 |
+
out[f"spacy_model:{self._model}"] = self._model_version
|
| 98 |
+
try:
|
| 99 |
+
import spacy # type: ignore[import-not-found]
|
| 100 |
+
|
| 101 |
+
out["spacy"] = str(spacy.__version__)
|
| 102 |
+
except ImportError:
|
| 103 |
+
pass
|
| 104 |
+
return out
|
| 105 |
+
|
| 106 |
+
def _load(self) -> Any:
|
| 107 |
+
"""Charge le modèle (idempotent). Fail-closed : lève si SDK/modèle absent."""
|
| 108 |
+
if self._nlp is not None:
|
| 109 |
+
return self._nlp
|
| 110 |
+
if self._loader is not None:
|
| 111 |
+
self._nlp = self._loader(self._model)
|
| 112 |
+
self._model_version = self._probe_model_version(self._nlp)
|
| 113 |
+
return self._nlp
|
| 114 |
+
try:
|
| 115 |
+
import spacy # type: ignore[import-not-found]
|
| 116 |
+
except ImportError as exc:
|
| 117 |
+
raise AdapterStepError(
|
| 118 |
+
"ner : spaCy non installé — `pip install 'cinoc[ner]'`."
|
| 119 |
+
) from exc
|
| 120 |
+
try:
|
| 121 |
+
self._nlp = spacy.load(self._model)
|
| 122 |
+
except OSError as exc:
|
| 123 |
+
raise AdapterStepError(
|
| 124 |
+
f"ner : modèle spaCy {self._model!r} introuvable — "
|
| 125 |
+
f"`python -m spacy download {self._model}`."
|
| 126 |
+
) from exc
|
| 127 |
+
self._model_version = self._probe_model_version(self._nlp)
|
| 128 |
+
return self._nlp
|
| 129 |
+
|
| 130 |
+
@staticmethod
|
| 131 |
+
def _probe_model_version(nlp: Any) -> str | None:
|
| 132 |
+
meta = getattr(nlp, "meta", None)
|
| 133 |
+
if isinstance(meta, dict):
|
| 134 |
+
version = meta.get("version")
|
| 135 |
+
return str(version) if version is not None else None
|
| 136 |
+
return None
|
| 137 |
+
|
| 138 |
+
def _extract(self, text: str) -> list[dict[str, object]]:
|
| 139 |
+
nlp = self._load()
|
| 140 |
+
doc = nlp(text)
|
| 141 |
+
entities: list[dict[str, object]] = []
|
| 142 |
+
for ent in doc.ents:
|
| 143 |
+
label = _DEFAULT_LABEL_MAPPING.get(ent.label_, ent.label_)
|
| 144 |
+
entities.append(
|
| 145 |
+
{
|
| 146 |
+
"label": label,
|
| 147 |
+
"start": int(ent.start_char),
|
| 148 |
+
"end": int(ent.end_char),
|
| 149 |
+
"text": ent.text,
|
| 150 |
+
}
|
| 151 |
+
)
|
| 152 |
+
return entities
|
| 153 |
+
|
| 154 |
+
def execute(
|
| 155 |
+
self,
|
| 156 |
+
inputs: dict[ArtifactType, Artifact],
|
| 157 |
+
params: dict[str, ParamValue],
|
| 158 |
+
context: RunContext,
|
| 159 |
+
control: RunControl,
|
| 160 |
+
) -> StepOutput:
|
| 161 |
+
control.raise_if_cancelled()
|
| 162 |
+
source = inputs.get(ArtifactType.CORRECTED_TEXT) or inputs.get(
|
| 163 |
+
ArtifactType.RAW_TEXT
|
| 164 |
+
)
|
| 165 |
+
if source is None or source.uri is None:
|
| 166 |
+
raise AdapterStepError(
|
| 167 |
+
f"{self.name} : artefact texte (RAW_TEXT/CORRECTED_TEXT) manquant."
|
| 168 |
+
)
|
| 169 |
+
if context.workspace_uri is None:
|
| 170 |
+
raise AdapterStepError(f"{self.name} : workspace requis.")
|
| 171 |
+
text = _read_text(source.uri, self.name)
|
| 172 |
+
entities = self._extract(text)
|
| 173 |
+
payload = json.dumps(
|
| 174 |
+
{"text": text, "entities": entities}, ensure_ascii=False
|
| 175 |
+
).encode("utf-8")
|
| 176 |
+
out_path = workspace_artifact_path(
|
| 177 |
+
context.workspace_uri, context.document_id, self._label, "entities.json"
|
| 178 |
+
)
|
| 179 |
+
out_path.write_bytes(payload)
|
| 180 |
+
return StepOutput(
|
| 181 |
+
artifacts={
|
| 182 |
+
ArtifactType.ENTITIES: Artifact(
|
| 183 |
+
id=f"{context.document_id}:{self.name}:entities",
|
| 184 |
+
document_id=context.document_id,
|
| 185 |
+
type=ArtifactType.ENTITIES,
|
| 186 |
+
uri=str(out_path),
|
| 187 |
+
content_hash=compute_content_hash(payload),
|
| 188 |
+
)
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def _read_text(uri: str, name: str) -> str:
|
| 194 |
+
from pathlib import Path
|
| 195 |
+
|
| 196 |
+
try:
|
| 197 |
+
return Path(uri).read_text(encoding="utf-8")
|
| 198 |
+
except (OSError, UnicodeDecodeError) as exc:
|
| 199 |
+
raise AdapterStepError(
|
| 200 |
+
f"{name} : texte d'entrée illisible {uri!r} : {exc}"
|
| 201 |
+
) from exc
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
__all__ = ["SpacyNerExtractor"]
|
cinoc/adapters/ocr/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Modules OCR/HTR (couche 5)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from cinoc.adapters.ocr.precomputed import PrecomputedTextAdapter
|
| 6 |
+
from cinoc.adapters.ocr.tesseract import TesseractAdapter
|
| 7 |
+
|
| 8 |
+
__all__ = ["PrecomputedTextAdapter", "TesseractAdapter"]
|
cinoc/adapters/ocr/_base.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Squelette commun des adapters OCR « image → un seul RAW_TEXT » (couche 5).
|
| 2 |
+
|
| 3 |
+
Le même ``execute()`` était recopié dans chaque adapter OCR mono-sortie : sonde
|
| 4 |
+
d'annulation, garde IMAGE, garde workspace, écriture du ``.txt`` dans le workspace,
|
| 5 |
+
construction de l'artefact ``RAW_TEXT`` (id ``{doc}:{name}:raw_text`` + hash du
|
| 6 |
+
texte). On le mutualise ici (miroir de ``llm/_base``) ; le **détail moteur** (params,
|
| 7 |
+
clé API, deadline) reste dans chaque adapter, porté par la closure ``recognize``.
|
| 8 |
+
|
| 9 |
+
N'est PAS pour les adapters multi-sorties (``tesseract`` : RAW_TEXT + CONFIDENCES
|
| 10 |
+
+ ALTO optionnel) — eux gardent leur ``execute()`` propre. Contrat de l'artefact
|
| 11 |
+
``RAW_TEXT`` figé par ``tests/adapters/test_ocr_artifact_contract_anchor``.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
from collections.abc import Callable, Mapping
|
| 17 |
+
|
| 18 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 19 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 20 |
+
from cinoc.domain.errors import AdapterStepError
|
| 21 |
+
from cinoc.pipeline.run_control import RunControl
|
| 22 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def run_image_to_text(
|
| 26 |
+
*,
|
| 27 |
+
name: str,
|
| 28 |
+
label: str,
|
| 29 |
+
inputs: Mapping[ArtifactType, Artifact],
|
| 30 |
+
context: RunContext,
|
| 31 |
+
control: RunControl,
|
| 32 |
+
recognize: Callable[[str], str],
|
| 33 |
+
) -> StepOutput:
|
| 34 |
+
"""Exécute un OCR image → ``RAW_TEXT`` unique, gardes + persistance mutualisées.
|
| 35 |
+
|
| 36 |
+
``recognize(image_uri) -> texte`` porte la reconnaissance réelle (et ses gardes
|
| 37 |
+
propres : clé API, modèle…). Ordre identique à l'ancien code par adapter :
|
| 38 |
+
annulation → IMAGE → workspace → ``recognize`` → écriture ``.txt`` → artefact.
|
| 39 |
+
"""
|
| 40 |
+
control.raise_if_cancelled()
|
| 41 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 42 |
+
if image is None or image.uri is None:
|
| 43 |
+
raise AdapterStepError(f"{name} : artefact IMAGE manquant ou sans URI.")
|
| 44 |
+
if context.workspace_uri is None:
|
| 45 |
+
raise AdapterStepError(f"{name} : workspace requis.")
|
| 46 |
+
text = recognize(image.uri)
|
| 47 |
+
output_path = workspace_artifact_path(
|
| 48 |
+
context.workspace_uri, context.document_id, label, "txt"
|
| 49 |
+
)
|
| 50 |
+
output_path.write_text(text, encoding="utf-8")
|
| 51 |
+
return StepOutput(
|
| 52 |
+
artifacts={
|
| 53 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 54 |
+
id=f"{context.document_id}:{name}:raw_text",
|
| 55 |
+
document_id=context.document_id,
|
| 56 |
+
type=ArtifactType.RAW_TEXT,
|
| 57 |
+
uri=str(output_path),
|
| 58 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 59 |
+
)
|
| 60 |
+
}
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
__all__ = ["run_image_to_text"]
|
cinoc/adapters/ocr/azure_di.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AzureDocIntelAdapter`` — OCR cloud (Azure AI Document Intelligence, modèle Read).
|
| 2 |
+
|
| 3 |
+
Moteur cloud **first-party**. API **REST** (``httpx``) plutôt que le SDK
|
| 4 |
+
``azure-ai-documentintelligence`` (lourd, auth credential) : transport léger,
|
| 5 |
+
**testable par cassette HTTP**. Auth par **clé d'abonnement**
|
| 6 |
+
(``AZURE_DOC_INTEL_KEY``) + endpoint de la ressource (``AZURE_DOC_INTEL_ENDPOINT``).
|
| 7 |
+
Implémente le ``Module`` Protocol **directement** ; ``httpx`` est l'extra
|
| 8 |
+
``cinoc[azure]``, importé **paresseusement** (mockable, CI sans clé ni réseau).
|
| 9 |
+
|
| 10 |
+
Flux **asynchrone** propre à Azure DI (fidèlement modélisé) : ``POST …:analyze``
|
| 11 |
+
renvoie ``202`` + un en-tête ``Operation-Location`` ; on **sonde** cette URL
|
| 12 |
+
(GET) jusqu'à ``status == "succeeded"`` (ou ``failed``/délai). Sortie :
|
| 13 |
+
``analyzeResult.content`` du modèle ``prebuilt-read`` — le texte complet d'une page.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
import os
|
| 19 |
+
import time
|
| 20 |
+
from base64 import b64encode
|
| 21 |
+
from collections.abc import Callable
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
from typing import TYPE_CHECKING
|
| 24 |
+
|
| 25 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 26 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 27 |
+
from cinoc.domain.deadline import Deadline
|
| 28 |
+
from cinoc.domain.errors import AdapterStepError
|
| 29 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 30 |
+
from cinoc.pipeline.run_control import RunControl
|
| 31 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 32 |
+
|
| 33 |
+
if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
|
| 34 |
+
import httpx
|
| 35 |
+
|
| 36 |
+
_VERSION = "1.0"
|
| 37 |
+
_MODEL = "prebuilt-read"
|
| 38 |
+
_API_VERSION = "2024-11-30"
|
| 39 |
+
_ANALYZE_PATH = f"/documentintelligence/documentModels/{_MODEL}:analyze"
|
| 40 |
+
_KEY_HEADER = "Ocp-Apim-Subscription-Key"
|
| 41 |
+
_ENDPOINT_ENV = "AZURE_DOC_INTEL_ENDPOINT"
|
| 42 |
+
_KEY_ENV = "AZURE_DOC_INTEL_KEY"
|
| 43 |
+
#: Borne dure du *polling* (en plus de la ``Deadline``) — évite la boucle infinie
|
| 44 |
+
#: si le service reste indéfiniment ``running`` sous une deadline infinie.
|
| 45 |
+
_MAX_POLLS = 60
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def _extract_text(data: object) -> str:
|
| 49 |
+
"""Texte complet de ``analyzeResult.content`` ; page vide → ``""``."""
|
| 50 |
+
if not isinstance(data, dict):
|
| 51 |
+
raise AdapterStepError("azure_di : réponse JSON inattendue (objet).")
|
| 52 |
+
result = data.get("analyzeResult")
|
| 53 |
+
if not isinstance(result, dict):
|
| 54 |
+
raise AdapterStepError("azure_di : 'analyzeResult' absent de la réponse.")
|
| 55 |
+
content = result.get("content")
|
| 56 |
+
return str(content).strip() if content else ""
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def _invoke_azure_di(
|
| 60 |
+
*,
|
| 61 |
+
endpoint: str,
|
| 62 |
+
image_path: str,
|
| 63 |
+
deadline: Deadline,
|
| 64 |
+
key: str,
|
| 65 |
+
transport: httpx.BaseTransport | None = None,
|
| 66 |
+
poll_interval: float = 1.0,
|
| 67 |
+
sleep: Callable[[float], None] = time.sleep,
|
| 68 |
+
) -> str:
|
| 69 |
+
"""Analyse l'image (POST → sonde l'``Operation-Location``) et renvoie le texte.
|
| 70 |
+
|
| 71 |
+
``endpoint`` vient de la **configuration opérateur** (env), pas de
|
| 72 |
+
l'utilisateur → endpoint de confiance, pas de durcissement anti-SSRF (≠
|
| 73 |
+
importeurs de corpus). La clé est en **en-tête** (jamais journalisée : les
|
| 74 |
+
messages d'erreur ne portent que le statut). ``transport``/``sleep`` injectés
|
| 75 |
+
→ cassette **et** sonde déterministes en test (aucune attente réelle).
|
| 76 |
+
"""
|
| 77 |
+
try:
|
| 78 |
+
import httpx
|
| 79 |
+
except ImportError as exc:
|
| 80 |
+
raise AdapterStepError(
|
| 81 |
+
"azure_di : httpx non installé (pip install 'cinoc[azure]')."
|
| 82 |
+
) from exc
|
| 83 |
+
content = b64encode(Path(image_path).read_bytes()).decode("ascii")
|
| 84 |
+
analyze_url = endpoint.rstrip("/") + _ANALYZE_PATH
|
| 85 |
+
with httpx.Client(
|
| 86 |
+
timeout=deadline.as_sdk_timeout(), transport=transport
|
| 87 |
+
) as client:
|
| 88 |
+
operation_url = _submit_analysis(
|
| 89 |
+
client, analyze_url=analyze_url, key=key, content=content
|
| 90 |
+
)
|
| 91 |
+
return _poll_result(
|
| 92 |
+
client,
|
| 93 |
+
operation_url=operation_url,
|
| 94 |
+
key=key,
|
| 95 |
+
deadline=deadline,
|
| 96 |
+
poll_interval=poll_interval,
|
| 97 |
+
sleep=sleep,
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _submit_analysis(
|
| 102 |
+
client: httpx.Client, *, analyze_url: str, key: str, content: str
|
| 103 |
+
) -> str:
|
| 104 |
+
"""POST l'analyse ; renvoie l'URL ``Operation-Location`` à sonder."""
|
| 105 |
+
import httpx
|
| 106 |
+
|
| 107 |
+
headers = {_KEY_HEADER: key, "Content-Type": "application/json"}
|
| 108 |
+
try:
|
| 109 |
+
response = client.post(
|
| 110 |
+
analyze_url,
|
| 111 |
+
params={"api-version": _API_VERSION},
|
| 112 |
+
headers=headers,
|
| 113 |
+
json={"base64Source": content},
|
| 114 |
+
)
|
| 115 |
+
response.raise_for_status()
|
| 116 |
+
except httpx.HTTPStatusError as exc:
|
| 117 |
+
raise AdapterStepError(
|
| 118 |
+
f"azure_di : statut HTTP {exc.response.status_code} (soumission)."
|
| 119 |
+
) from exc
|
| 120 |
+
except httpx.HTTPError as exc:
|
| 121 |
+
raise AdapterStepError(
|
| 122 |
+
f"azure_di : échec de transport (soumission) : {type(exc).__name__}."
|
| 123 |
+
) from exc
|
| 124 |
+
operation_url = response.headers.get("operation-location")
|
| 125 |
+
if not operation_url:
|
| 126 |
+
raise AdapterStepError("azure_di : réponse sans en-tête Operation-Location.")
|
| 127 |
+
return str(operation_url)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _poll_result(
|
| 131 |
+
client: httpx.Client,
|
| 132 |
+
*,
|
| 133 |
+
operation_url: str,
|
| 134 |
+
key: str,
|
| 135 |
+
deadline: Deadline,
|
| 136 |
+
poll_interval: float,
|
| 137 |
+
sleep: Callable[[float], None],
|
| 138 |
+
) -> str:
|
| 139 |
+
"""Sonde l'``Operation-Location`` jusqu'à un état terminal (ou délai)."""
|
| 140 |
+
import httpx
|
| 141 |
+
|
| 142 |
+
for _ in range(_MAX_POLLS):
|
| 143 |
+
try:
|
| 144 |
+
response = client.get(operation_url, headers={_KEY_HEADER: key})
|
| 145 |
+
response.raise_for_status()
|
| 146 |
+
data = response.json()
|
| 147 |
+
except httpx.HTTPStatusError as exc:
|
| 148 |
+
raise AdapterStepError(
|
| 149 |
+
f"azure_di : statut HTTP {exc.response.status_code} (sonde)."
|
| 150 |
+
) from exc
|
| 151 |
+
except httpx.HTTPError as exc:
|
| 152 |
+
raise AdapterStepError(
|
| 153 |
+
f"azure_di : échec de transport (sonde) : {type(exc).__name__}."
|
| 154 |
+
) from exc
|
| 155 |
+
status = (
|
| 156 |
+
str(data.get("status", "")).lower() if isinstance(data, dict) else ""
|
| 157 |
+
)
|
| 158 |
+
if status == "succeeded":
|
| 159 |
+
return _extract_text(data)
|
| 160 |
+
if status == "failed":
|
| 161 |
+
raise AdapterStepError("azure_di : analyse échouée (status 'failed').")
|
| 162 |
+
if deadline.is_expired():
|
| 163 |
+
break
|
| 164 |
+
sleep(poll_interval)
|
| 165 |
+
raise AdapterStepError("azure_di : analyse non terminée (délai de sonde dépassé).")
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class AzureDocIntelAdapter:
|
| 169 |
+
"""OCR cloud Azure DI : ``IMAGE`` → ``RAW_TEXT`` (texte ``prebuilt-read``)."""
|
| 170 |
+
|
| 171 |
+
def __init__(self, *, label: str) -> None:
|
| 172 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 173 |
+
raise AdapterStepError(f"azure_di : label invalide {label!r}.")
|
| 174 |
+
self._label = label
|
| 175 |
+
|
| 176 |
+
@property
|
| 177 |
+
def name(self) -> str:
|
| 178 |
+
return f"azure_di:{self._label}"
|
| 179 |
+
|
| 180 |
+
@property
|
| 181 |
+
def version(self) -> str:
|
| 182 |
+
return _VERSION
|
| 183 |
+
|
| 184 |
+
@property
|
| 185 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 186 |
+
return frozenset({ArtifactType.IMAGE})
|
| 187 |
+
|
| 188 |
+
@property
|
| 189 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 190 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 191 |
+
|
| 192 |
+
def execute(
|
| 193 |
+
self,
|
| 194 |
+
inputs: dict[ArtifactType, Artifact],
|
| 195 |
+
params: dict[str, ParamValue],
|
| 196 |
+
context: RunContext,
|
| 197 |
+
control: RunControl,
|
| 198 |
+
) -> StepOutput:
|
| 199 |
+
def recognize(image_uri: str) -> str:
|
| 200 |
+
endpoint = os.environ.get(_ENDPOINT_ENV)
|
| 201 |
+
key = os.environ.get(_KEY_ENV)
|
| 202 |
+
if not endpoint:
|
| 203 |
+
raise AdapterStepError(f"{self.name} : {_ENDPOINT_ENV} manquant.")
|
| 204 |
+
if not key:
|
| 205 |
+
raise AdapterStepError(f"{self.name} : {_KEY_ENV} manquante.")
|
| 206 |
+
return _invoke_azure_di(
|
| 207 |
+
endpoint=endpoint,
|
| 208 |
+
image_path=image_uri,
|
| 209 |
+
deadline=context.deadline,
|
| 210 |
+
key=key,
|
| 211 |
+
)
|
| 212 |
+
|
| 213 |
+
return run_image_to_text(
|
| 214 |
+
name=self.name,
|
| 215 |
+
label=self._label,
|
| 216 |
+
inputs=inputs,
|
| 217 |
+
context=context,
|
| 218 |
+
control=control,
|
| 219 |
+
recognize=recognize,
|
| 220 |
+
)
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
__all__ = ["AzureDocIntelAdapter"]
|
cinoc/adapters/ocr/calamari.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``CalamariAdapter`` — moteur OCR réel (calamari-ocr, reconnaissance de lignes).
|
| 2 |
+
|
| 3 |
+
Local et sans clé. Implémente le ``Module`` Protocol **directement** — même
|
| 4 |
+
contrat que le socle, livré in-tree (pas de discrimination socle/plugin). La lib
|
| 5 |
+
``calamari_ocr`` est un **extra optionnel** (``cinoc[calamari]``), importée
|
| 6 |
+
**paresseusement** dans ``_invoke_calamari`` : importer ce module ne requiert rien ;
|
| 7 |
+
seule l'exécution exige la lib + un **modèle** (``model`` = checkpoint, obligatoire).
|
| 8 |
+
|
| 9 |
+
Calamari reconnaît une **ligne** par image : utilisé seul sur une page, il traite
|
| 10 |
+
l'image comme une ligne unique ; son usage naturel est en **recognizer par
|
| 11 |
+
région** dans le pipeline de segmentation (fan-out par bloc). Sortie : ``RAW_TEXT``.
|
| 12 |
+
|
| 13 |
+
**Non déployé au Space** (dép lourde TensorFlow, free-tier) : listé « indisponible »
|
| 14 |
+
sans l'extra, comme Kraken/PERO.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 21 |
+
from cinoc.domain.errors import AdapterStepError
|
| 22 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 23 |
+
from cinoc.pipeline.run_control import RunControl
|
| 24 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 25 |
+
|
| 26 |
+
_VERSION = "1.0"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _invoke_calamari( # pragma: no cover -- lib + modèle requis (cf. 'live')
|
| 30 |
+
*, image_path: str, model_path: str
|
| 31 |
+
) -> str:
|
| 32 |
+
"""Reconnaissance Calamari de l'image (ligne) → texte prédit."""
|
| 33 |
+
try:
|
| 34 |
+
import numpy as np # type: ignore[import-not-found]
|
| 35 |
+
from calamari_ocr.ocr.predict.predictor import ( # type: ignore[import-not-found]
|
| 36 |
+
Predictor,
|
| 37 |
+
PredictorParams,
|
| 38 |
+
)
|
| 39 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 40 |
+
except ImportError as exc:
|
| 41 |
+
raise AdapterStepError(
|
| 42 |
+
"calamari : lib non installée (pip install 'cinoc[calamari]')."
|
| 43 |
+
) from exc
|
| 44 |
+
try:
|
| 45 |
+
predictor = Predictor.from_checkpoint(
|
| 46 |
+
params=PredictorParams(), checkpoint=model_path
|
| 47 |
+
)
|
| 48 |
+
except (OSError, ValueError, RuntimeError) as exc: # loader calamari
|
| 49 |
+
raise AdapterStepError(
|
| 50 |
+
f"calamari : modèle illisible {model_path!r} : {exc}"
|
| 51 |
+
) from exc
|
| 52 |
+
image = np.array(Image.open(image_path).convert("L"))
|
| 53 |
+
predictions = predictor.predict_raw([image])
|
| 54 |
+
texts = [str(prediction.sentence) for prediction in predictions]
|
| 55 |
+
return " ".join(texts).strip()
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class CalamariAdapter:
|
| 59 |
+
"""OCR Calamari : ``IMAGE`` → ``RAW_TEXT`` (texte de la ligne reconnue)."""
|
| 60 |
+
|
| 61 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 62 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 63 |
+
raise AdapterStepError(f"calamari : label invalide {label!r}.")
|
| 64 |
+
if not model:
|
| 65 |
+
raise AdapterStepError(
|
| 66 |
+
"calamari : paramètre 'model' (checkpoint) requis."
|
| 67 |
+
)
|
| 68 |
+
self._label = label
|
| 69 |
+
self._model = model
|
| 70 |
+
|
| 71 |
+
@property
|
| 72 |
+
def name(self) -> str:
|
| 73 |
+
return f"calamari:{self._label}"
|
| 74 |
+
|
| 75 |
+
@property
|
| 76 |
+
def version(self) -> str:
|
| 77 |
+
return _VERSION
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 81 |
+
return frozenset({ArtifactType.IMAGE})
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 85 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 86 |
+
|
| 87 |
+
def execute(
|
| 88 |
+
self,
|
| 89 |
+
inputs: dict[ArtifactType, Artifact],
|
| 90 |
+
params: dict[str, ParamValue],
|
| 91 |
+
context: RunContext,
|
| 92 |
+
control: RunControl,
|
| 93 |
+
) -> StepOutput:
|
| 94 |
+
return run_image_to_text(
|
| 95 |
+
name=self.name,
|
| 96 |
+
label=self._label,
|
| 97 |
+
inputs=inputs,
|
| 98 |
+
context=context,
|
| 99 |
+
control=control,
|
| 100 |
+
recognize=lambda image_uri: _invoke_calamari(
|
| 101 |
+
image_path=image_uri, model_path=self._model
|
| 102 |
+
),
|
| 103 |
+
)
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
__all__ = ["CalamariAdapter"]
|
cinoc/adapters/ocr/google_vision.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``GoogleVisionAdapter`` — OCR cloud (Google Cloud Vision REST), facturé à l'image.
|
| 2 |
+
|
| 3 |
+
Moteur cloud **first-party**. On vise l'**API REST** (``httpx``) plutôt que le SDK
|
| 4 |
+
``google-cloud-vision`` (gRPC + auth ADC service-account, lourd et difficile à
|
| 5 |
+
rejouer) : transport léger, **testable par cassette HTTP**, auth par **clé d'API**
|
| 6 |
+
(``GOOGLE_VISION_API_KEY``). Implémente le ``Module`` Protocol **directement** ;
|
| 7 |
+
``httpx`` est l'extra ``cinoc[google]``, importé **paresseusement** dans
|
| 8 |
+
``_invoke_google_vision`` (mockable, CI sans clé ni réseau).
|
| 9 |
+
|
| 10 |
+
Sortie : ``fullTextAnnotation.text`` de ``DOCUMENT_TEXT_DETECTION`` — le texte
|
| 11 |
+
dense d'une page (le bon mode pour des documents, ≠ ``TEXT_DETECTION`` épars).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import os
|
| 17 |
+
from base64 import b64encode
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
from typing import TYPE_CHECKING
|
| 20 |
+
|
| 21 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 22 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 23 |
+
from cinoc.domain.deadline import Deadline
|
| 24 |
+
from cinoc.domain.errors import AdapterStepError
|
| 25 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 26 |
+
from cinoc.pipeline.run_control import RunControl
|
| 27 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 28 |
+
|
| 29 |
+
if TYPE_CHECKING: # annotations seules — pas d'import httpx au chargement du module
|
| 30 |
+
import httpx
|
| 31 |
+
|
| 32 |
+
_VERSION = "1.0"
|
| 33 |
+
_ENDPOINT = "https://vision.googleapis.com/v1/images:annotate"
|
| 34 |
+
_API_KEY_ENV = "GOOGLE_VISION_API_KEY"
|
| 35 |
+
_FEATURE = "DOCUMENT_TEXT_DETECTION"
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def _extract_text(payload: object) -> str:
|
| 39 |
+
"""Texte de la 1ʳᵉ réponse Vision ; erreur par image → ``AdapterStepError``.
|
| 40 |
+
|
| 41 |
+
Forme : ``{"responses": [{"fullTextAnnotation": {"text": …}}]}`` (ou
|
| 42 |
+
``{"error": {"message": …}}`` pour un échec par image). Aucun texte détecté
|
| 43 |
+
(page blanche) → ``""`` — un résultat valide, pas une erreur.
|
| 44 |
+
"""
|
| 45 |
+
if not isinstance(payload, dict):
|
| 46 |
+
raise AdapterStepError("google_vision : réponse JSON inattendue (objet).")
|
| 47 |
+
responses = payload.get("responses")
|
| 48 |
+
if not isinstance(responses, list) or not responses:
|
| 49 |
+
raise AdapterStepError("google_vision : réponse sans 'responses'.")
|
| 50 |
+
first = responses[0]
|
| 51 |
+
if not isinstance(first, dict):
|
| 52 |
+
raise AdapterStepError("google_vision : 'responses[0]' inattendu.")
|
| 53 |
+
error = first.get("error")
|
| 54 |
+
if isinstance(error, dict) and error:
|
| 55 |
+
message = error.get("message", "erreur sans message")
|
| 56 |
+
raise AdapterStepError(f"google_vision : erreur API : {message}")
|
| 57 |
+
annotation = first.get("fullTextAnnotation")
|
| 58 |
+
if isinstance(annotation, dict):
|
| 59 |
+
text = annotation.get("text")
|
| 60 |
+
return str(text).strip() if text else ""
|
| 61 |
+
return ""
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
def _invoke_google_vision(
|
| 65 |
+
*,
|
| 66 |
+
image_path: str,
|
| 67 |
+
deadline: Deadline,
|
| 68 |
+
api_key: str,
|
| 69 |
+
transport: httpx.BaseTransport | None = None,
|
| 70 |
+
) -> str:
|
| 71 |
+
"""POST l'image à Vision et renvoie le texte. ``transport`` injecté → cassette.
|
| 72 |
+
|
| 73 |
+
Endpoint **fixe et de confiance** (Google) : pas de durcissement anti-SSRF (≠
|
| 74 |
+
importeurs de corpus, dont l'URL vient de l'utilisateur). La clé d'API est en
|
| 75 |
+
*query string* (jamais journalisée : les messages d'erreur ne portent que le
|
| 76 |
+
statut). ``httpx`` paresseux : importer ce module n'exige pas l'extra.
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
import httpx
|
| 80 |
+
except ImportError as exc:
|
| 81 |
+
raise AdapterStepError(
|
| 82 |
+
"google_vision : httpx non installé (pip install 'cinoc[google]')."
|
| 83 |
+
) from exc
|
| 84 |
+
content = b64encode(Path(image_path).read_bytes()).decode("ascii")
|
| 85 |
+
body = {
|
| 86 |
+
"requests": [
|
| 87 |
+
{"image": {"content": content}, "features": [{"type": _FEATURE}]}
|
| 88 |
+
]
|
| 89 |
+
}
|
| 90 |
+
try:
|
| 91 |
+
with httpx.Client(
|
| 92 |
+
timeout=deadline.as_sdk_timeout(), transport=transport
|
| 93 |
+
) as client:
|
| 94 |
+
response = client.post(_ENDPOINT, params={"key": api_key}, json=body)
|
| 95 |
+
response.raise_for_status()
|
| 96 |
+
data = response.json()
|
| 97 |
+
except httpx.HTTPStatusError as exc:
|
| 98 |
+
raise AdapterStepError(
|
| 99 |
+
f"google_vision : statut HTTP {exc.response.status_code}."
|
| 100 |
+
) from exc
|
| 101 |
+
except httpx.HTTPError as exc:
|
| 102 |
+
raise AdapterStepError(
|
| 103 |
+
f"google_vision : échec de transport : {type(exc).__name__}."
|
| 104 |
+
) from exc
|
| 105 |
+
return _extract_text(data)
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
class GoogleVisionAdapter:
|
| 109 |
+
"""OCR cloud Google Vision : ``IMAGE`` → ``RAW_TEXT`` (texte dense de la page)."""
|
| 110 |
+
|
| 111 |
+
def __init__(self, *, label: str) -> None:
|
| 112 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 113 |
+
raise AdapterStepError(f"google_vision : label invalide {label!r}.")
|
| 114 |
+
self._label = label
|
| 115 |
+
|
| 116 |
+
@property
|
| 117 |
+
def name(self) -> str:
|
| 118 |
+
return f"google_vision:{self._label}"
|
| 119 |
+
|
| 120 |
+
@property
|
| 121 |
+
def version(self) -> str:
|
| 122 |
+
return _VERSION
|
| 123 |
+
|
| 124 |
+
@property
|
| 125 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 126 |
+
return frozenset({ArtifactType.IMAGE})
|
| 127 |
+
|
| 128 |
+
@property
|
| 129 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 130 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 131 |
+
|
| 132 |
+
def execute(
|
| 133 |
+
self,
|
| 134 |
+
inputs: dict[ArtifactType, Artifact],
|
| 135 |
+
params: dict[str, ParamValue],
|
| 136 |
+
context: RunContext,
|
| 137 |
+
control: RunControl,
|
| 138 |
+
) -> StepOutput:
|
| 139 |
+
def recognize(image_uri: str) -> str:
|
| 140 |
+
api_key = os.environ.get(_API_KEY_ENV)
|
| 141 |
+
if not api_key:
|
| 142 |
+
raise AdapterStepError(f"{self.name} : {_API_KEY_ENV} manquante.")
|
| 143 |
+
return _invoke_google_vision(
|
| 144 |
+
image_path=image_uri, deadline=context.deadline, api_key=api_key
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
return run_image_to_text(
|
| 148 |
+
name=self.name,
|
| 149 |
+
label=self._label,
|
| 150 |
+
inputs=inputs,
|
| 151 |
+
context=context,
|
| 152 |
+
control=control,
|
| 153 |
+
recognize=recognize,
|
| 154 |
+
)
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
__all__ = ["GoogleVisionAdapter"]
|
cinoc/adapters/ocr/kraken.py
ADDED
|
@@ -0,0 +1,166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``KrakenAdapter`` — moteur HTR réel (kraken, le moteur d'eScriptorium).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol directement. Couvre le **manuscrit** (HTR),
|
| 4 |
+
là où tesseract couvre l'imprimé. Le SDK ``kraken`` est un **extra optionnel**
|
| 5 |
+
(``cinoc[kraken]``), importé paresseusement dans ``_invoke_kraken`` — importer
|
| 6 |
+
ce module ne requiert rien ; seule l'exécution exige SDK + **modèle** (.mlmodel,
|
| 7 |
+
paramètre ``model`` obligatoire : pas de modèle par défaut embarqué).
|
| 8 |
+
|
| 9 |
+
Sorties : ``RAW_TEXT`` (lignes reconnues, ordre de lecture kraken) +
|
| 10 |
+
``CONFIDENCES`` (un jeton par mot, confiance = moyenne des confiances de
|
| 11 |
+
caractères du mot — best-effort, sidecar vide en dégradé).
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 20 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 21 |
+
from cinoc.domain.confidence import ConfidenceToken
|
| 22 |
+
from cinoc.domain.errors import AdapterStepError
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_VERSION = "1.0"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def tokens_from_lines(
|
| 33 |
+
lines: list[tuple[str, list[float]]],
|
| 34 |
+
) -> list[ConfidenceToken]:
|
| 35 |
+
"""Jetons mot+confiance depuis (texte de ligne, confiances par caractère).
|
| 36 |
+
|
| 37 |
+
La confiance d'un mot = moyenne des confiances de ses caractères (bornée
|
| 38 |
+
[0,1]) ; un mot sans couverture de confiance est ignoré.
|
| 39 |
+
"""
|
| 40 |
+
tokens: list[ConfidenceToken] = []
|
| 41 |
+
for text, confidences in lines:
|
| 42 |
+
cursor = 0
|
| 43 |
+
for word in text.split():
|
| 44 |
+
start = text.find(word, cursor)
|
| 45 |
+
if start < 0:
|
| 46 |
+
continue
|
| 47 |
+
cursor = start + len(word)
|
| 48 |
+
window = confidences[start : start + len(word)]
|
| 49 |
+
if not window:
|
| 50 |
+
continue
|
| 51 |
+
value = max(0.0, min(sum(window) / len(window), 1.0))
|
| 52 |
+
tokens.append(ConfidenceToken(text=word, confidence=value))
|
| 53 |
+
return tokens
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _invoke_kraken( # pragma: no cover -- SDK + modèle requis (cf. 'live')
|
| 57 |
+
*, image_path: str, model_path: str
|
| 58 |
+
) -> list[tuple[str, list[float]]]:
|
| 59 |
+
"""Segmentation + reconnaissance kraken → (texte, confiances/char) par ligne."""
|
| 60 |
+
try:
|
| 61 |
+
from kraken import ( # type: ignore[import-not-found]
|
| 62 |
+
binarization,
|
| 63 |
+
pageseg,
|
| 64 |
+
rpred,
|
| 65 |
+
)
|
| 66 |
+
from kraken.lib import models # type: ignore[import-not-found]
|
| 67 |
+
from PIL import Image # type: ignore[import-not-found]
|
| 68 |
+
except ImportError as exc:
|
| 69 |
+
raise AdapterStepError(
|
| 70 |
+
"kraken : SDK non installé (pip install 'cinoc[kraken]')."
|
| 71 |
+
) from exc
|
| 72 |
+
try:
|
| 73 |
+
model = models.load_any(model_path)
|
| 74 |
+
except (OSError, ValueError, RuntimeError) as exc: # loader kraken
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"kraken : modèle illisible {model_path!r} : {exc}"
|
| 77 |
+
) from exc
|
| 78 |
+
image = Image.open(image_path)
|
| 79 |
+
binarized = binarization.nlbin(image)
|
| 80 |
+
segmentation = pageseg.segment(binarized)
|
| 81 |
+
out: list[tuple[str, list[float]]] = []
|
| 82 |
+
for record in rpred.rpred(model, binarized, segmentation):
|
| 83 |
+
out.append((str(record), [float(c) for c in record.confidences]))
|
| 84 |
+
return out
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
class KrakenAdapter:
|
| 88 |
+
"""HTR kraken : ``IMAGE`` → ``RAW_TEXT`` + ``CONFIDENCES``."""
|
| 89 |
+
|
| 90 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 91 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 92 |
+
raise AdapterStepError(f"kraken : label invalide {label!r}.")
|
| 93 |
+
if not model:
|
| 94 |
+
raise AdapterStepError(
|
| 95 |
+
"kraken : paramètre 'model' (chemin .mlmodel) requis."
|
| 96 |
+
)
|
| 97 |
+
self._label = label
|
| 98 |
+
self._model = model
|
| 99 |
+
|
| 100 |
+
@property
|
| 101 |
+
def name(self) -> str:
|
| 102 |
+
return f"kraken:{self._label}"
|
| 103 |
+
|
| 104 |
+
@property
|
| 105 |
+
def version(self) -> str:
|
| 106 |
+
return _VERSION
|
| 107 |
+
|
| 108 |
+
@property
|
| 109 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 110 |
+
return frozenset({ArtifactType.IMAGE})
|
| 111 |
+
|
| 112 |
+
@property
|
| 113 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 114 |
+
return frozenset({ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES})
|
| 115 |
+
|
| 116 |
+
def execute(
|
| 117 |
+
self,
|
| 118 |
+
inputs: dict[ArtifactType, Artifact],
|
| 119 |
+
params: dict[str, ParamValue],
|
| 120 |
+
context: RunContext,
|
| 121 |
+
control: RunControl,
|
| 122 |
+
) -> StepOutput:
|
| 123 |
+
control.raise_if_cancelled()
|
| 124 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 125 |
+
if image is None or image.uri is None:
|
| 126 |
+
raise AdapterStepError(
|
| 127 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 128 |
+
)
|
| 129 |
+
if context.workspace_uri is None:
|
| 130 |
+
raise AdapterStepError(f"{self.name} : workspace requis.")
|
| 131 |
+
lines = _invoke_kraken(image_path=image.uri, model_path=self._model)
|
| 132 |
+
text = "\n".join(line for line, _ in lines)
|
| 133 |
+
tokens = tokens_from_lines(lines)
|
| 134 |
+
text_path = workspace_artifact_path(
|
| 135 |
+
context.workspace_uri, context.document_id, self._label, "txt"
|
| 136 |
+
)
|
| 137 |
+
text_path.write_text(text, encoding="utf-8")
|
| 138 |
+
sidecar = json.dumps(
|
| 139 |
+
[token.model_dump() for token in tokens], ensure_ascii=False
|
| 140 |
+
).encode("utf-8")
|
| 141 |
+
sidecar_path = workspace_artifact_path(
|
| 142 |
+
context.workspace_uri, context.document_id, self._label,
|
| 143 |
+
"confidences.json",
|
| 144 |
+
)
|
| 145 |
+
sidecar_path.write_bytes(sidecar)
|
| 146 |
+
return StepOutput(
|
| 147 |
+
artifacts={
|
| 148 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 149 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 150 |
+
document_id=context.document_id,
|
| 151 |
+
type=ArtifactType.RAW_TEXT,
|
| 152 |
+
uri=str(text_path),
|
| 153 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 154 |
+
),
|
| 155 |
+
ArtifactType.CONFIDENCES: Artifact(
|
| 156 |
+
id=f"{context.document_id}:{self.name}:confidences",
|
| 157 |
+
document_id=context.document_id,
|
| 158 |
+
type=ArtifactType.CONFIDENCES,
|
| 159 |
+
uri=str(sidecar_path),
|
| 160 |
+
content_hash=compute_content_hash(sidecar),
|
| 161 |
+
),
|
| 162 |
+
}
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
__all__ = ["KrakenAdapter", "tokens_from_lines"]
|
cinoc/adapters/ocr/mistral_ocr.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralOCRAdapter`` — OCR cloud (API Mistral OCR), facturé à la page.
|
| 2 |
+
|
| 3 |
+
Premier moteur **payant à la page** du socle : donne un sens réel à l'axe
|
| 4 |
+
coût du Pareto (économie T10, tarif ``cloud_page_kinds``). Implémente le
|
| 5 |
+
``Module`` Protocol ; SDK ``mistralai`` = extra (``cinoc[mistral]``), importé
|
| 6 |
+
paresseusement dans ``_invoke_mistral_ocr`` (mockable, CI sans clé ni réseau).
|
| 7 |
+
Clé ``MISTRAL_API_KEY``. Sortie : le texte (markdown) des pages, concaténé.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
from base64 import b64encode
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
|
| 15 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 16 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 17 |
+
from cinoc.domain.deadline import Deadline
|
| 18 |
+
from cinoc.domain.errors import AdapterStepError
|
| 19 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 20 |
+
from cinoc.pipeline.run_control import RunControl
|
| 21 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 22 |
+
|
| 23 |
+
_VERSION = "1.0"
|
| 24 |
+
_DEFAULT_MODEL = "mistral-ocr-latest"
|
| 25 |
+
_MEDIA = {".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg",
|
| 26 |
+
".webp": "image/webp", ".tif": "image/tiff", ".tiff": "image/tiff"}
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def _invoke_mistral_ocr( # pragma: no cover -- réseau + clé API (cf. 'live')
|
| 30 |
+
*, model: str, image_path: str, deadline: Deadline
|
| 31 |
+
) -> str:
|
| 32 |
+
import os
|
| 33 |
+
|
| 34 |
+
api_key = os.environ.get("MISTRAL_API_KEY")
|
| 35 |
+
if not api_key:
|
| 36 |
+
raise AdapterStepError("mistral_ocr : MISTRAL_API_KEY manquante.")
|
| 37 |
+
try:
|
| 38 |
+
from mistralai import Mistral # type: ignore[import-not-found]
|
| 39 |
+
except ImportError as exc:
|
| 40 |
+
raise AdapterStepError(
|
| 41 |
+
"mistral_ocr : SDK non installé (pip install 'cinoc[mistral]')."
|
| 42 |
+
) from exc
|
| 43 |
+
path = Path(image_path)
|
| 44 |
+
media = _MEDIA.get(path.suffix.lower(), "image/png")
|
| 45 |
+
image_b64 = b64encode(path.read_bytes()).decode("ascii")
|
| 46 |
+
client_kwargs: dict[str, object] = {"api_key": api_key}
|
| 47 |
+
timeout = deadline.as_sdk_timeout()
|
| 48 |
+
if timeout is not None:
|
| 49 |
+
client_kwargs["timeout_ms"] = int(timeout * 1000)
|
| 50 |
+
try:
|
| 51 |
+
client = Mistral(**client_kwargs) # type: ignore[arg-type]
|
| 52 |
+
response = client.ocr.process(
|
| 53 |
+
model=model,
|
| 54 |
+
document={
|
| 55 |
+
"type": "image_url",
|
| 56 |
+
"image_url": f"data:{media};base64,{image_b64}",
|
| 57 |
+
},
|
| 58 |
+
)
|
| 59 |
+
except Exception as exc: # le SDK lève des erreurs HTTP/SDK variées
|
| 60 |
+
raise AdapterStepError(
|
| 61 |
+
f"mistral_ocr a échoué ({model}) : {type(exc).__name__}: {exc}"
|
| 62 |
+
) from exc
|
| 63 |
+
pages = getattr(response, "pages", None) or []
|
| 64 |
+
return "\n\n".join(
|
| 65 |
+
str(getattr(page, "markdown", "") or "") for page in pages
|
| 66 |
+
).strip()
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
class MistralOCRAdapter:
|
| 70 |
+
"""OCR cloud Mistral : ``IMAGE`` → ``RAW_TEXT`` (markdown des pages)."""
|
| 71 |
+
|
| 72 |
+
def __init__(self, *, label: str, model: str = _DEFAULT_MODEL) -> None:
|
| 73 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 74 |
+
raise AdapterStepError(f"mistral_ocr : label invalide {label!r}.")
|
| 75 |
+
self._label = label
|
| 76 |
+
self._model = model
|
| 77 |
+
|
| 78 |
+
@property
|
| 79 |
+
def name(self) -> str:
|
| 80 |
+
return f"mistral_ocr:{self._label}"
|
| 81 |
+
|
| 82 |
+
@property
|
| 83 |
+
def version(self) -> str:
|
| 84 |
+
return _VERSION
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 88 |
+
return frozenset({ArtifactType.IMAGE})
|
| 89 |
+
|
| 90 |
+
@property
|
| 91 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 92 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 93 |
+
|
| 94 |
+
def execute(
|
| 95 |
+
self,
|
| 96 |
+
inputs: dict[ArtifactType, Artifact],
|
| 97 |
+
params: dict[str, ParamValue],
|
| 98 |
+
context: RunContext,
|
| 99 |
+
control: RunControl,
|
| 100 |
+
) -> StepOutput:
|
| 101 |
+
return run_image_to_text(
|
| 102 |
+
name=self.name,
|
| 103 |
+
label=self._label,
|
| 104 |
+
inputs=inputs,
|
| 105 |
+
context=context,
|
| 106 |
+
control=control,
|
| 107 |
+
recognize=lambda image_uri: _invoke_mistral_ocr(
|
| 108 |
+
model=self._model, image_path=image_uri, deadline=context.deadline
|
| 109 |
+
),
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
__all__ = ["MistralOCRAdapter"]
|
cinoc/adapters/ocr/pero.py
ADDED
|
@@ -0,0 +1,114 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PeroAdapter`` — moteur OCR/HTR réel (pero-ocr).
|
| 2 |
+
|
| 3 |
+
Local et sans clé : couvre l'imprimé ancien et le manuscrit via les modèles PERO,
|
| 4 |
+
là où tesseract couvre l'imprimé moderne. Implémente le ``Module`` Protocol
|
| 5 |
+
**directement** — même contrat que le socle, livré in-tree (décision : pas de
|
| 6 |
+
discrimination socle/plugin ; seule la livraison diffère). La lib ``pero_ocr`` est
|
| 7 |
+
un **extra optionnel** (``cinoc[pero]``), importée **paresseusement** dans
|
| 8 |
+
``_invoke_pero`` : importer ce module ne requiert rien ; seule l'exécution exige la
|
| 9 |
+
lib + un **modèle** (``model`` = chemin du fichier de config PERO, obligatoire —
|
| 10 |
+
pas de modèle embarqué). Sortie : ``RAW_TEXT`` (lignes reconnues, ordre PERO).
|
| 11 |
+
|
| 12 |
+
**Non déployé au Space** (dép lourde, free-tier) : listé « indisponible » sans
|
| 13 |
+
l'extra, comme Kraken — pas d'exécution cloud, pas de clé.
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from cinoc.adapters.ocr._base import run_image_to_text
|
| 21 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType
|
| 22 |
+
from cinoc.domain.errors import AdapterStepError
|
| 23 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 24 |
+
from cinoc.pipeline.run_control import RunControl
|
| 25 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 26 |
+
|
| 27 |
+
_VERSION = "1.0"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _invoke_pero( # pragma: no cover -- lib + modèle requis (cf. 'live')
|
| 31 |
+
*, image_path: str, config_path: str
|
| 32 |
+
) -> str:
|
| 33 |
+
"""Mise en page + reconnaissance PERO → texte (lignes jointes, ordre PERO)."""
|
| 34 |
+
try:
|
| 35 |
+
import configparser
|
| 36 |
+
|
| 37 |
+
import cv2 # type: ignore[import-not-found]
|
| 38 |
+
from pero_ocr.document_ocr.layout import ( # type: ignore[import-not-found]
|
| 39 |
+
PageLayout,
|
| 40 |
+
)
|
| 41 |
+
from pero_ocr.document_ocr.page_parser import ( # type: ignore[import-not-found]
|
| 42 |
+
PageParser,
|
| 43 |
+
)
|
| 44 |
+
except ImportError as exc:
|
| 45 |
+
raise AdapterStepError(
|
| 46 |
+
"pero : lib non installée (pip install 'cinoc[pero]')."
|
| 47 |
+
) from exc
|
| 48 |
+
config = configparser.ConfigParser()
|
| 49 |
+
if not config.read(config_path):
|
| 50 |
+
raise AdapterStepError(f"pero : config illisible {config_path!r}.")
|
| 51 |
+
image = cv2.imread(image_path)
|
| 52 |
+
if image is None:
|
| 53 |
+
raise AdapterStepError(f"pero : image illisible {image_path!r}.")
|
| 54 |
+
parser = PageParser(config, config_path=str(Path(config_path).parent))
|
| 55 |
+
layout = PageLayout(id="page", page_size=(image.shape[0], image.shape[1]))
|
| 56 |
+
layout = parser.process_page(image, layout)
|
| 57 |
+
lines = [
|
| 58 |
+
str(line.transcription)
|
| 59 |
+
for region in layout.regions
|
| 60 |
+
for line in region.lines
|
| 61 |
+
if line.transcription
|
| 62 |
+
]
|
| 63 |
+
return "\n".join(lines).strip()
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class PeroAdapter:
|
| 67 |
+
"""OCR/HTR PERO : ``IMAGE`` → ``RAW_TEXT`` (lignes reconnues)."""
|
| 68 |
+
|
| 69 |
+
def __init__(self, *, label: str, model: str) -> None:
|
| 70 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 71 |
+
raise AdapterStepError(f"pero : label invalide {label!r}.")
|
| 72 |
+
if not model:
|
| 73 |
+
raise AdapterStepError(
|
| 74 |
+
"pero : paramètre 'model' (chemin de config PERO) requis."
|
| 75 |
+
)
|
| 76 |
+
self._label = label
|
| 77 |
+
self._model = model
|
| 78 |
+
|
| 79 |
+
@property
|
| 80 |
+
def name(self) -> str:
|
| 81 |
+
return f"pero:{self._label}"
|
| 82 |
+
|
| 83 |
+
@property
|
| 84 |
+
def version(self) -> str:
|
| 85 |
+
return _VERSION
|
| 86 |
+
|
| 87 |
+
@property
|
| 88 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 89 |
+
return frozenset({ArtifactType.IMAGE})
|
| 90 |
+
|
| 91 |
+
@property
|
| 92 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 93 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 94 |
+
|
| 95 |
+
def execute(
|
| 96 |
+
self,
|
| 97 |
+
inputs: dict[ArtifactType, Artifact],
|
| 98 |
+
params: dict[str, ParamValue],
|
| 99 |
+
context: RunContext,
|
| 100 |
+
control: RunControl,
|
| 101 |
+
) -> StepOutput:
|
| 102 |
+
return run_image_to_text(
|
| 103 |
+
name=self.name,
|
| 104 |
+
label=self._label,
|
| 105 |
+
inputs=inputs,
|
| 106 |
+
context=context,
|
| 107 |
+
control=control,
|
| 108 |
+
recognize=lambda image_uri: _invoke_pero(
|
| 109 |
+
image_path=image_uri, config_path=self._model
|
| 110 |
+
),
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
__all__ = ["PeroAdapter"]
|
cinoc/adapters/ocr/precomputed.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PrecomputedTextAdapter`` — rejoue un texte OCR pré-calculé depuis le disque.
|
| 2 |
+
|
| 3 |
+
Module à **zéro dépendance et zéro réseau** : il lit ``<stem>.<source_label>.txt``
|
| 4 |
+
à côté de l'image et renvoie un ``RAW_TEXT``. Brique du *starter pack* — il fait
|
| 5 |
+
tourner le squelette ambulant (``cinoc demo``) et les tests déterministes sans
|
| 6 |
+
moteur réel. Implémente le ``Module`` Protocol (couche 4) **directement**.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 14 |
+
from cinoc.domain.errors import AdapterStepError
|
| 15 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 16 |
+
from cinoc.pipeline.run_control import RunControl
|
| 17 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 18 |
+
|
| 19 |
+
_VERSION = "1.0"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class PrecomputedTextAdapter:
|
| 23 |
+
"""Lit un texte OCR pré-calculé (``<stem>.<label>.txt`` près de l'image)."""
|
| 24 |
+
|
| 25 |
+
def __init__(self, *, source_label: str) -> None:
|
| 26 |
+
if not source_label or not all(
|
| 27 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 28 |
+
):
|
| 29 |
+
raise AdapterStepError(
|
| 30 |
+
f"PrecomputedTextAdapter : source_label invalide "
|
| 31 |
+
f"{source_label!r} (alphanumérique + _ - uniquement)."
|
| 32 |
+
)
|
| 33 |
+
self._label = source_label
|
| 34 |
+
|
| 35 |
+
@property
|
| 36 |
+
def name(self) -> str:
|
| 37 |
+
return f"precomputed:{self._label}"
|
| 38 |
+
|
| 39 |
+
@property
|
| 40 |
+
def version(self) -> str:
|
| 41 |
+
return _VERSION
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 45 |
+
return frozenset({ArtifactType.IMAGE})
|
| 46 |
+
|
| 47 |
+
@property
|
| 48 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 49 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 50 |
+
|
| 51 |
+
def execute(
|
| 52 |
+
self,
|
| 53 |
+
inputs: dict[ArtifactType, Artifact],
|
| 54 |
+
params: dict[str, ParamValue],
|
| 55 |
+
context: RunContext,
|
| 56 |
+
control: RunControl,
|
| 57 |
+
) -> StepOutput:
|
| 58 |
+
control.raise_if_cancelled()
|
| 59 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 60 |
+
if image is None or image.uri is None:
|
| 61 |
+
raise AdapterStepError(
|
| 62 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 63 |
+
)
|
| 64 |
+
image_path = Path(image.uri)
|
| 65 |
+
text_path = image_path.parent / f"{image_path.stem}.{self._label}.txt"
|
| 66 |
+
if not text_path.is_file():
|
| 67 |
+
raise AdapterStepError(
|
| 68 |
+
f"{self.name} : texte pré-calculé introuvable — "
|
| 69 |
+
f"{text_path.name!r} attendu près de {image_path.name!r}."
|
| 70 |
+
)
|
| 71 |
+
data = text_path.read_bytes()
|
| 72 |
+
try:
|
| 73 |
+
data.decode("utf-8")
|
| 74 |
+
except UnicodeDecodeError as exc:
|
| 75 |
+
raise AdapterStepError(
|
| 76 |
+
f"{self.name} : {text_path.name!r} n'est pas en UTF-8 : {exc}"
|
| 77 |
+
) from exc
|
| 78 |
+
return StepOutput(
|
| 79 |
+
artifacts={
|
| 80 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 81 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 82 |
+
document_id=context.document_id,
|
| 83 |
+
type=ArtifactType.RAW_TEXT,
|
| 84 |
+
uri=str(text_path),
|
| 85 |
+
content_hash=compute_content_hash(data),
|
| 86 |
+
)
|
| 87 |
+
}
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
__all__ = ["PrecomputedTextAdapter"]
|
cinoc/adapters/ocr/tesseract.py
ADDED
|
@@ -0,0 +1,332 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``TesseractAdapter`` — moteur OCR réel (Tesseract 5 via ``pytesseract``).
|
| 2 |
+
|
| 3 |
+
Implémente le ``Module`` Protocol (couche 4) **directement**. L'OCR a lieu dans
|
| 4 |
+
le binaire C ``tesseract`` lancé en sous-processus (le thread relâche le GIL).
|
| 5 |
+
``pytesseract`` est un **extra optionnel** (``cinoc[tesseract]``), importé
|
| 6 |
+
**paresseusement** dans ``_invoke_tesseract`` : importer ce module ne requiert ni
|
| 7 |
+
la lib ni le binaire — seule l'exécution les exige.
|
| 8 |
+
|
| 9 |
+
Sécurité : ``lang`` est validé (anti-injection ligne de commande) ; ``timeout``
|
| 10 |
+
est borné par la ``Deadline`` (un sous-processus figé ne doit pas geler le run).
|
| 11 |
+
Les **confidences** par mot (TSV natif, 0-100 → [0,1]) sont écrites en
|
| 12 |
+
sidecar JSON (``ConfidenceToken``) et publiées comme artefact ``CONFIDENCES``
|
| 13 |
+
— best-effort : un échec d'extraction dégrade en sidecar vide, jamais en
|
| 14 |
+
panne de l'OCR.
|
| 15 |
+
|
| 16 |
+
**ALTO natif** (``alto=True``) : Tesseract émet en plus un artefact
|
| 17 |
+
``ALTO_XML`` (géométrie + texte par mot, ``image_to_alto_xml``) — la forme
|
| 18 |
+
ré-importable dans un outil de relecture (eScriptorium, Transkribus). Contrairement
|
| 19 |
+
aux confidences, l'ALTO est ici le **livrable demandé** : un échec d'émission lève
|
| 20 |
+
(``AdapterStepError``), il ne dégrade pas en silence.
|
| 21 |
+
"""
|
| 22 |
+
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import re
|
| 28 |
+
import subprocess
|
| 29 |
+
from collections.abc import Callable
|
| 30 |
+
|
| 31 |
+
from cinoc.adapters._workspace import workspace_artifact_path
|
| 32 |
+
from cinoc.domain.artifacts import Artifact, ArtifactType, compute_content_hash
|
| 33 |
+
from cinoc.domain.confidence import ConfidenceToken
|
| 34 |
+
from cinoc.domain.errors import AdapterStepError
|
| 35 |
+
from cinoc.pipeline.protocols import ParamValue
|
| 36 |
+
from cinoc.pipeline.run_control import RunControl
|
| 37 |
+
from cinoc.pipeline.types import RunContext, StepOutput
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
_VERSION = "1.0"
|
| 42 |
+
_DEFAULT_TIMEOUT = 120.0
|
| 43 |
+
|
| 44 |
+
#: Codes langue Tesseract : ISO 639-3 (≥3 lettres ASCII), combinables par ``+``
|
| 45 |
+
#: (``fra+lat``). ``lang`` finit sur la ligne de commande tesseract → on refuse
|
| 46 |
+
#: tout caractère interprétable comme flag/séparateur (anti-injection).
|
| 47 |
+
_LANG_RE = re.compile(r"^[a-zA-Z]{3,}(?:\+[a-zA-Z]{3,})*$")
|
| 48 |
+
|
| 49 |
+
#: Lanceur de sous-processus, injectable → version sondable en test sans binaire.
|
| 50 |
+
BinaryRunner = Callable[..., "subprocess.CompletedProcess[str]"]
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def tesseract_binary_version(*, run: BinaryRunner = subprocess.run) -> str | None:
|
| 54 |
+
"""Version du binaire ``tesseract`` (1ʳᵉ ligne de ``tesseract --version``).
|
| 55 |
+
|
| 56 |
+
**Best-effort, jamais bloquant** : binaire absent, timeout, ou sortie illisible
|
| 57 |
+
→ ``None`` (jamais une panne). Sert la **reproductibilité** (``RunManifest`` §12) :
|
| 58 |
+
deux runs ne sont comparables qu'à version de binaire égale — la version
|
| 59 |
+
d'*adapter* (``_VERSION``) ne dit rien du Tesseract réellement installé.
|
| 60 |
+
``run`` est injecté → la sonde est déterministe en test, sans le binaire.
|
| 61 |
+
|
| 62 |
+
Tesseract émet sa bannière tantôt sur ``stdout`` tantôt sur ``stderr`` selon le
|
| 63 |
+
build → on lit les deux et garde la 1ʳᵉ ligne non vide (ex. ``tesseract 5.3.0``).
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
completed = run(
|
| 67 |
+
["tesseract", "--version"],
|
| 68 |
+
capture_output=True,
|
| 69 |
+
text=True,
|
| 70 |
+
timeout=10,
|
| 71 |
+
check=False,
|
| 72 |
+
)
|
| 73 |
+
except (OSError, subprocess.SubprocessError):
|
| 74 |
+
return None
|
| 75 |
+
lines = ((completed.stdout or "") + "\n" + (completed.stderr or "")).splitlines()
|
| 76 |
+
for line in lines:
|
| 77 |
+
if line.strip():
|
| 78 |
+
return line.strip()
|
| 79 |
+
return None
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def _invoke_tesseract( # pragma: no cover -- binaire requis (cf. marqueur 'live')
|
| 83 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 84 |
+
) -> str:
|
| 85 |
+
"""Lance tesseract et renvoie le texte. **Isolé → mockable** (CI sans binaire)."""
|
| 86 |
+
try:
|
| 87 |
+
import pytesseract # type: ignore[import-not-found, import-untyped]
|
| 88 |
+
except ImportError as exc:
|
| 89 |
+
raise AdapterStepError(
|
| 90 |
+
"tesseract : pytesseract non installé "
|
| 91 |
+
"(pip install 'cinoc[tesseract]' + binaire tesseract)."
|
| 92 |
+
) from exc
|
| 93 |
+
config = f"--oem {oem} --psm {psm}"
|
| 94 |
+
try:
|
| 95 |
+
text = pytesseract.image_to_string(
|
| 96 |
+
image_path, lang=lang, config=config, timeout=timeout
|
| 97 |
+
)
|
| 98 |
+
except (
|
| 99 |
+
pytesseract.TesseractNotFoundError,
|
| 100 |
+
pytesseract.TesseractError,
|
| 101 |
+
RuntimeError,
|
| 102 |
+
) as exc:
|
| 103 |
+
raise AdapterStepError(
|
| 104 |
+
f"tesseract a échoué sur {image_path!r} : {type(exc).__name__}: {exc}"
|
| 105 |
+
) from exc
|
| 106 |
+
return str(text).strip()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _invoke_tesseract_alto( # pragma: no cover -- binaire requis ('live')
|
| 110 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 111 |
+
) -> bytes:
|
| 112 |
+
"""ALTO XML natif via ``image_to_alto_xml`` (géométrie + texte par mot).
|
| 113 |
+
|
| 114 |
+
Renvoie les octets XML bruts (déjà conformes ALTO) — destinés à la
|
| 115 |
+
ré-importation dans un outil de relecture. Une panne du binaire lève
|
| 116 |
+
``AdapterStepError`` : l'ALTO est le livrable demandé, pas un extra dégradable.
|
| 117 |
+
"""
|
| 118 |
+
try:
|
| 119 |
+
import pytesseract # type: ignore[import-not-found, import-untyped]
|
| 120 |
+
except ImportError as exc:
|
| 121 |
+
raise AdapterStepError(
|
| 122 |
+
"tesseract : pytesseract non installé "
|
| 123 |
+
"(pip install 'cinoc[tesseract]' + binaire tesseract)."
|
| 124 |
+
) from exc
|
| 125 |
+
try:
|
| 126 |
+
xml = pytesseract.image_to_alto_xml(
|
| 127 |
+
image_path, lang=lang, config=f"--oem {oem} --psm {psm}", timeout=timeout
|
| 128 |
+
)
|
| 129 |
+
except (
|
| 130 |
+
pytesseract.TesseractNotFoundError,
|
| 131 |
+
pytesseract.TesseractError,
|
| 132 |
+
RuntimeError,
|
| 133 |
+
) as exc:
|
| 134 |
+
raise AdapterStepError(
|
| 135 |
+
f"tesseract ALTO a échoué sur {image_path!r} : "
|
| 136 |
+
f"{type(exc).__name__}: {exc}"
|
| 137 |
+
) from exc
|
| 138 |
+
return bytes(xml)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _invoke_tesseract_confidences( # pragma: no cover -- binaire requis ('live')
|
| 142 |
+
*, image_path: str, lang: str, psm: int, oem: int, timeout: float
|
| 143 |
+
) -> list[ConfidenceToken]:
|
| 144 |
+
"""Confidences par mot via ``image_to_data`` (TSV natif, conf 0-100)."""
|
| 145 |
+
import pytesseract # type: ignore[import-not-found]
|
| 146 |
+
|
| 147 |
+
data = pytesseract.image_to_data(
|
| 148 |
+
image_path,
|
| 149 |
+
lang=lang,
|
| 150 |
+
config=f"--psm {psm} --oem {oem}",
|
| 151 |
+
timeout=timeout,
|
| 152 |
+
output_type=pytesseract.Output.DICT,
|
| 153 |
+
)
|
| 154 |
+
tokens: list[ConfidenceToken] = []
|
| 155 |
+
for word, conf in zip(data["text"], data["conf"], strict=False):
|
| 156 |
+
if not isinstance(word, str) or not word.strip():
|
| 157 |
+
continue
|
| 158 |
+
value = float(conf)
|
| 159 |
+
if value < 0: # -1 : entrée non textuelle du TSV
|
| 160 |
+
continue
|
| 161 |
+
tokens.append(
|
| 162 |
+
ConfidenceToken(text=word.strip(), confidence=min(value / 100.0, 1.0))
|
| 163 |
+
)
|
| 164 |
+
return tokens
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
class TesseractAdapter:
|
| 168 |
+
"""OCR Tesseract 5 ; écrit le texte dans le workspace, renvoie un ``RAW_TEXT``."""
|
| 169 |
+
|
| 170 |
+
def __init__(
|
| 171 |
+
self,
|
| 172 |
+
*,
|
| 173 |
+
label: str,
|
| 174 |
+
lang: str = "fra",
|
| 175 |
+
psm: int = 6,
|
| 176 |
+
oem: int = 3,
|
| 177 |
+
alto: bool = False,
|
| 178 |
+
) -> None:
|
| 179 |
+
if not label or not all(c.isalnum() or c in "_-" for c in label):
|
| 180 |
+
raise AdapterStepError(
|
| 181 |
+
f"TesseractAdapter : label invalide {label!r} "
|
| 182 |
+
"(alphanumérique + _ - uniquement)."
|
| 183 |
+
)
|
| 184 |
+
if not _LANG_RE.fullmatch(lang):
|
| 185 |
+
raise AdapterStepError(
|
| 186 |
+
f"TesseractAdapter : lang invalide {lang!r} "
|
| 187 |
+
"(ISO 639-3, combinable par '+' : ex. 'fra+lat')."
|
| 188 |
+
)
|
| 189 |
+
if not 0 <= psm <= 13:
|
| 190 |
+
raise AdapterStepError(f"TesseractAdapter : psm ∈ [0, 13], reçu {psm}.")
|
| 191 |
+
if not 0 <= oem <= 3:
|
| 192 |
+
raise AdapterStepError(f"TesseractAdapter : oem ∈ [0, 3], reçu {oem}.")
|
| 193 |
+
self._label = label
|
| 194 |
+
self._lang = lang
|
| 195 |
+
self._psm = psm
|
| 196 |
+
self._oem = oem
|
| 197 |
+
#: Émet en plus un artefact ``ALTO_XML`` (ré-import eScriptorium/Transkribus).
|
| 198 |
+
self._alto = alto
|
| 199 |
+
|
| 200 |
+
@property
|
| 201 |
+
def name(self) -> str:
|
| 202 |
+
return f"tesseract:{self._label}"
|
| 203 |
+
|
| 204 |
+
@property
|
| 205 |
+
def version(self) -> str:
|
| 206 |
+
return _VERSION
|
| 207 |
+
|
| 208 |
+
def system_binaries(self) -> dict[str, str]:
|
| 209 |
+
"""Version du **binaire externe** pour le ``RunManifest`` (reproductibilité).
|
| 210 |
+
|
| 211 |
+
Hook de **provenance optionnel** (hors ``Module`` Protocol, qui reste le
|
| 212 |
+
contrat d'exécution unique) : l'orchestrateur l'appelle par *duck-typing*
|
| 213 |
+
sur les modules qui l'exposent et fusionne le résultat dans
|
| 214 |
+
``system_binaries_lock``. Best-effort : binaire absent → ``{}``.
|
| 215 |
+
"""
|
| 216 |
+
version = tesseract_binary_version()
|
| 217 |
+
return {"tesseract": version} if version is not None else {}
|
| 218 |
+
|
| 219 |
+
@property
|
| 220 |
+
def input_types(self) -> frozenset[ArtifactType]:
|
| 221 |
+
return frozenset({ArtifactType.IMAGE})
|
| 222 |
+
|
| 223 |
+
@property
|
| 224 |
+
def output_types(self) -> frozenset[ArtifactType]:
|
| 225 |
+
types = {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES}
|
| 226 |
+
if self._alto:
|
| 227 |
+
types.add(ArtifactType.ALTO_XML)
|
| 228 |
+
return frozenset(types)
|
| 229 |
+
|
| 230 |
+
def execute(
|
| 231 |
+
self,
|
| 232 |
+
inputs: dict[ArtifactType, Artifact],
|
| 233 |
+
params: dict[str, ParamValue],
|
| 234 |
+
context: RunContext,
|
| 235 |
+
control: RunControl,
|
| 236 |
+
) -> StepOutput:
|
| 237 |
+
control.raise_if_cancelled()
|
| 238 |
+
image = inputs.get(ArtifactType.IMAGE)
|
| 239 |
+
if image is None or image.uri is None:
|
| 240 |
+
raise AdapterStepError(
|
| 241 |
+
f"{self.name} : artefact IMAGE manquant ou sans URI."
|
| 242 |
+
)
|
| 243 |
+
if context.workspace_uri is None:
|
| 244 |
+
raise AdapterStepError(
|
| 245 |
+
f"{self.name} : workspace requis (RunContext.workspace_uri)."
|
| 246 |
+
)
|
| 247 |
+
timeout = max(0.001, context.deadline.clamp_to_remaining(_DEFAULT_TIMEOUT))
|
| 248 |
+
text = _invoke_tesseract(
|
| 249 |
+
image_path=image.uri,
|
| 250 |
+
lang=self._lang,
|
| 251 |
+
psm=self._psm,
|
| 252 |
+
oem=self._oem,
|
| 253 |
+
timeout=timeout,
|
| 254 |
+
)
|
| 255 |
+
output_path = workspace_artifact_path(
|
| 256 |
+
context.workspace_uri, context.document_id, self._label, "txt"
|
| 257 |
+
)
|
| 258 |
+
output_path.write_text(text, encoding="utf-8")
|
| 259 |
+
# Confidences best-effort : un échec d'extraction ne doit pas faire
|
| 260 |
+
# échouer un OCR réussi — sidecar vide + avertissement.
|
| 261 |
+
try:
|
| 262 |
+
tokens = _invoke_tesseract_confidences(
|
| 263 |
+
image_path=image.uri,
|
| 264 |
+
lang=self._lang,
|
| 265 |
+
psm=self._psm,
|
| 266 |
+
oem=self._oem,
|
| 267 |
+
timeout=timeout,
|
| 268 |
+
)
|
| 269 |
+
except (
|
| 270 |
+
AdapterStepError,
|
| 271 |
+
ImportError,
|
| 272 |
+
RuntimeError,
|
| 273 |
+
ValueError,
|
| 274 |
+
OSError,
|
| 275 |
+
) as exc:
|
| 276 |
+
logger.warning(
|
| 277 |
+
"[tesseract] confidences dégradées (sidecar vide) : %s", exc
|
| 278 |
+
)
|
| 279 |
+
tokens = []
|
| 280 |
+
sidecar = json.dumps(
|
| 281 |
+
[token.model_dump() for token in tokens], ensure_ascii=False
|
| 282 |
+
).encode("utf-8")
|
| 283 |
+
sidecar_path = workspace_artifact_path(
|
| 284 |
+
context.workspace_uri,
|
| 285 |
+
context.document_id,
|
| 286 |
+
self._label,
|
| 287 |
+
"confidences.json",
|
| 288 |
+
)
|
| 289 |
+
sidecar_path.write_bytes(sidecar)
|
| 290 |
+
artifacts: dict[ArtifactType, Artifact] = {
|
| 291 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 292 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 293 |
+
document_id=context.document_id,
|
| 294 |
+
type=ArtifactType.RAW_TEXT,
|
| 295 |
+
uri=str(output_path),
|
| 296 |
+
content_hash=compute_content_hash(text.encode("utf-8")),
|
| 297 |
+
),
|
| 298 |
+
ArtifactType.CONFIDENCES: Artifact(
|
| 299 |
+
id=f"{context.document_id}:{self.name}:confidences",
|
| 300 |
+
document_id=context.document_id,
|
| 301 |
+
type=ArtifactType.CONFIDENCES,
|
| 302 |
+
uri=str(sidecar_path),
|
| 303 |
+
content_hash=compute_content_hash(sidecar),
|
| 304 |
+
),
|
| 305 |
+
}
|
| 306 |
+
if self._alto:
|
| 307 |
+
# ALTO demandé → livrable : un échec lève (≠ confidences best-effort).
|
| 308 |
+
alto_bytes = _invoke_tesseract_alto(
|
| 309 |
+
image_path=image.uri,
|
| 310 |
+
lang=self._lang,
|
| 311 |
+
psm=self._psm,
|
| 312 |
+
oem=self._oem,
|
| 313 |
+
timeout=timeout,
|
| 314 |
+
)
|
| 315 |
+
alto_path = workspace_artifact_path(
|
| 316 |
+
context.workspace_uri,
|
| 317 |
+
context.document_id,
|
| 318 |
+
self._label,
|
| 319 |
+
"alto.xml",
|
| 320 |
+
)
|
| 321 |
+
alto_path.write_bytes(alto_bytes)
|
| 322 |
+
artifacts[ArtifactType.ALTO_XML] = Artifact(
|
| 323 |
+
id=f"{context.document_id}:{self.name}:alto_xml",
|
| 324 |
+
document_id=context.document_id,
|
| 325 |
+
type=ArtifactType.ALTO_XML,
|
| 326 |
+
uri=str(alto_path),
|
| 327 |
+
content_hash=compute_content_hash(alto_bytes),
|
| 328 |
+
)
|
| 329 |
+
return StepOutput(artifacts=artifacts)
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
__all__ = ["TesseractAdapter", "tesseract_binary_version"]
|
cinoc/adapters/storage/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Stockage (couche 5) : état des jobs de run lancés via le web/CLI long.
|
| 2 |
+
|
| 3 |
+
En mémoire par choix : suffit au lanceur mono-worker du Space. Le journal
|
| 4 |
+
d'événements du ``JobStore`` porte la reprise SSE ``Last-Event-ID`` (réserve
|
| 5 |
+
R-10 d'ANALYSE_COUCHE_5, levée).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from cinoc.adapters.storage.history_store import (
|
| 11 |
+
HistoryRecord,
|
| 12 |
+
HistoryStore,
|
| 13 |
+
Regression,
|
| 14 |
+
)
|
| 15 |
+
from cinoc.adapters.storage.job_store import Job, JobState, JobStore
|
| 16 |
+
|
| 17 |
+
__all__ = [
|
| 18 |
+
"HistoryRecord",
|
| 19 |
+
"HistoryStore",
|
| 20 |
+
"Job",
|
| 21 |
+
"JobState",
|
| 22 |
+
"JobStore",
|
| 23 |
+
"Regression",
|
| 24 |
+
]
|
cinoc/adapters/storage/history_store.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``HistoryStore`` — historique **longitudinal** des runs, en SQLite (couche 5).
|
| 2 |
+
|
| 3 |
+
Persiste, run après run, la **valeur agrégée de chaque métrique** par pipeline et
|
| 4 |
+
par vue, pour suivre l'évolution dans le temps (tendance) et **détecter les
|
| 5 |
+
régressions** (un moteur dont le CER se dégrade d'un run au suivant).
|
| 6 |
+
|
| 7 |
+
Le store ne connaît **que des enregistrements primitifs** (``HistoryRecord``) : il
|
| 8 |
+
n'importe pas ``RunResult`` (couche 3) — c'est la couche ``app`` qui aplatit un
|
| 9 |
+
``RunResult`` en enregistrements (``app/history.py``). Persistance pure, réutilisable
|
| 10 |
+
et testable avec de la donnée simple.
|
| 11 |
+
|
| 12 |
+
Concurrence : **une connexion par opération** (``sqlite3.connect`` est bon marché),
|
| 13 |
+
donc aucun partage de connexion entre threads. Ré-enregistrer un même run est
|
| 14 |
+
**idempotent** (clé primaire ``(run_id, pipeline, view, metric)``).
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import sqlite3
|
| 20 |
+
from collections.abc import Iterable
|
| 21 |
+
from contextlib import closing
|
| 22 |
+
from dataclasses import dataclass
|
| 23 |
+
from pathlib import Path
|
| 24 |
+
|
| 25 |
+
_SCHEMA = """
|
| 26 |
+
CREATE TABLE IF NOT EXISTS run_metrics (
|
| 27 |
+
run_id TEXT NOT NULL,
|
| 28 |
+
completed_at TEXT NOT NULL,
|
| 29 |
+
corpus_name TEXT NOT NULL,
|
| 30 |
+
code_version TEXT NOT NULL,
|
| 31 |
+
pipeline TEXT NOT NULL,
|
| 32 |
+
view TEXT NOT NULL,
|
| 33 |
+
metric TEXT NOT NULL,
|
| 34 |
+
value REAL NOT NULL,
|
| 35 |
+
PRIMARY KEY (run_id, pipeline, view, metric)
|
| 36 |
+
);
|
| 37 |
+
CREATE INDEX IF NOT EXISTS idx_run_metrics_lookup
|
| 38 |
+
ON run_metrics (view, metric, pipeline, completed_at);
|
| 39 |
+
"""
|
| 40 |
+
|
| 41 |
+
_COLUMNS = (
|
| 42 |
+
"run_id, completed_at, corpus_name, code_version, pipeline, view, metric, value"
|
| 43 |
+
)
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@dataclass(frozen=True)
|
| 47 |
+
class HistoryRecord:
|
| 48 |
+
"""Une valeur de métrique agrégée pour un run (ligne d'historique)."""
|
| 49 |
+
|
| 50 |
+
run_id: str
|
| 51 |
+
completed_at: str # ISO 8601 (UTC) — triable lexicographiquement
|
| 52 |
+
corpus_name: str
|
| 53 |
+
code_version: str
|
| 54 |
+
pipeline: str
|
| 55 |
+
view: str
|
| 56 |
+
metric: str
|
| 57 |
+
value: float
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
@dataclass(frozen=True)
|
| 61 |
+
class Regression:
|
| 62 |
+
"""Dégradation d'un pipeline entre ses deux runs les plus récents."""
|
| 63 |
+
|
| 64 |
+
pipeline: str
|
| 65 |
+
view: str
|
| 66 |
+
metric: str
|
| 67 |
+
previous_run_id: str
|
| 68 |
+
latest_run_id: str
|
| 69 |
+
previous: float
|
| 70 |
+
latest: float
|
| 71 |
+
delta: float # latest - previous (signé)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class HistoryStore:
|
| 75 |
+
"""Historique longitudinal sur un fichier SQLite."""
|
| 76 |
+
|
| 77 |
+
def __init__(self, db_path: str | Path) -> None:
|
| 78 |
+
# **Initialisation paresseuse** : on ne touche pas au filesystem ici.
|
| 79 |
+
# ``create_app`` peut instancier le store avec un chemin par défaut
|
| 80 |
+
# (``/data`` sur un Space) non writable au moment de la construction ;
|
| 81 |
+
# le dossier + le schéma sont créés à la **première** opération réelle.
|
| 82 |
+
self._path = Path(db_path)
|
| 83 |
+
|
| 84 |
+
def _connect(self) -> sqlite3.Connection:
|
| 85 |
+
"""Ouvre une connexion, créant dossier + schéma au besoin (idempotent)."""
|
| 86 |
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 87 |
+
conn = sqlite3.connect(self._path)
|
| 88 |
+
conn.executescript(_SCHEMA)
|
| 89 |
+
return conn
|
| 90 |
+
|
| 91 |
+
def add(self, records: Iterable[HistoryRecord]) -> int:
|
| 92 |
+
"""Enregistre des lignes (``INSERT OR REPLACE``) ; renvoie le compte."""
|
| 93 |
+
rows = [
|
| 94 |
+
(
|
| 95 |
+
r.run_id,
|
| 96 |
+
r.completed_at,
|
| 97 |
+
r.corpus_name,
|
| 98 |
+
r.code_version,
|
| 99 |
+
r.pipeline,
|
| 100 |
+
r.view,
|
| 101 |
+
r.metric,
|
| 102 |
+
r.value,
|
| 103 |
+
)
|
| 104 |
+
for r in records
|
| 105 |
+
]
|
| 106 |
+
if not rows:
|
| 107 |
+
return 0
|
| 108 |
+
with closing(self._connect()) as conn:
|
| 109 |
+
conn.executemany(
|
| 110 |
+
f"INSERT OR REPLACE INTO run_metrics ({_COLUMNS}) "
|
| 111 |
+
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
|
| 112 |
+
rows,
|
| 113 |
+
)
|
| 114 |
+
conn.commit()
|
| 115 |
+
return len(rows)
|
| 116 |
+
|
| 117 |
+
def history(
|
| 118 |
+
self, pipeline: str, view: str, metric: str
|
| 119 |
+
) -> tuple[HistoryRecord, ...]:
|
| 120 |
+
"""Suite chronologique (croissante) d'une métrique pour un pipeline/vue."""
|
| 121 |
+
with closing(self._connect()) as conn:
|
| 122 |
+
cur = conn.execute(
|
| 123 |
+
f"SELECT {_COLUMNS} FROM run_metrics "
|
| 124 |
+
"WHERE pipeline = ? AND view = ? AND metric = ? "
|
| 125 |
+
"ORDER BY completed_at, run_id", # run_id = bris d'égalité déterministe
|
| 126 |
+
(pipeline, view, metric),
|
| 127 |
+
)
|
| 128 |
+
return tuple(HistoryRecord(*row) for row in cur.fetchall())
|
| 129 |
+
|
| 130 |
+
def all_records(self, *, limit: int = 1000) -> tuple[HistoryRecord, ...]:
|
| 131 |
+
"""Lignes les plus récentes d'abord, **bornées** (pour la vue Historique)."""
|
| 132 |
+
with closing(self._connect()) as conn:
|
| 133 |
+
cur = conn.execute(
|
| 134 |
+
f"SELECT {_COLUMNS} FROM run_metrics "
|
| 135 |
+
"ORDER BY completed_at DESC, run_id, pipeline, view, metric "
|
| 136 |
+
"LIMIT ?",
|
| 137 |
+
(limit,),
|
| 138 |
+
)
|
| 139 |
+
return tuple(HistoryRecord(*row) for row in cur.fetchall())
|
| 140 |
+
|
| 141 |
+
def regressions(
|
| 142 |
+
self,
|
| 143 |
+
view: str,
|
| 144 |
+
metric: str,
|
| 145 |
+
*,
|
| 146 |
+
threshold: float = 0.0,
|
| 147 |
+
higher_is_better: bool = False,
|
| 148 |
+
) -> tuple[Regression, ...]:
|
| 149 |
+
"""Pipelines dont la métrique s'est dégradée entre les 2 derniers runs.
|
| 150 |
+
|
| 151 |
+
``higher_is_better=False`` (défaut, CER/WER) : régression = la valeur a
|
| 152 |
+
**augmenté** de plus de ``threshold``. ``True`` inverse le sens.
|
| 153 |
+
"""
|
| 154 |
+
with closing(self._connect()) as conn:
|
| 155 |
+
cur = conn.execute(
|
| 156 |
+
"SELECT pipeline, completed_at, value, run_id FROM run_metrics "
|
| 157 |
+
"WHERE view = ? AND metric = ? "
|
| 158 |
+
"ORDER BY pipeline, completed_at, run_id", # bris d'égalité stable
|
| 159 |
+
(view, metric),
|
| 160 |
+
)
|
| 161 |
+
rows = cur.fetchall()
|
| 162 |
+
|
| 163 |
+
by_pipeline: dict[str, list[tuple[str, float, str]]] = {}
|
| 164 |
+
for pipeline, completed_at, value, run_id in rows:
|
| 165 |
+
by_pipeline.setdefault(pipeline, []).append((completed_at, value, run_id))
|
| 166 |
+
|
| 167 |
+
out: list[Regression] = []
|
| 168 |
+
for pipeline, series in by_pipeline.items():
|
| 169 |
+
if len(series) < 2:
|
| 170 |
+
continue
|
| 171 |
+
_, prev_value, prev_run = series[-2]
|
| 172 |
+
_, last_value, last_run = series[-1]
|
| 173 |
+
delta = last_value - prev_value
|
| 174 |
+
worse = -delta if higher_is_better else delta
|
| 175 |
+
if worse > threshold:
|
| 176 |
+
out.append(
|
| 177 |
+
Regression(
|
| 178 |
+
pipeline=pipeline,
|
| 179 |
+
view=view,
|
| 180 |
+
metric=metric,
|
| 181 |
+
previous_run_id=prev_run,
|
| 182 |
+
latest_run_id=last_run,
|
| 183 |
+
previous=prev_value,
|
| 184 |
+
latest=last_value,
|
| 185 |
+
delta=delta,
|
| 186 |
+
)
|
| 187 |
+
)
|
| 188 |
+
return tuple(out)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
__all__ = ["HistoryRecord", "HistoryStore", "Regression"]
|
cinoc/adapters/storage/job_store.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``JobStore`` — état des jobs de run, **en mémoire et thread-safe** (couche 5).
|
| 2 |
+
|
| 3 |
+
Un job = une exécution asynchrone d'un ``RunSpec`` (lancée par le ``JobRunner``,
|
| 4 |
+
couche 6). Le store ne *fait* rien : il garde l'état observable (la machine à
|
| 5 |
+
états ci-dessous) pour que le web le restitue et qu'un ``cancel`` soit traçable.
|
| 6 |
+
|
| 7 |
+
Concurrence : un seul verrou protège le dict ; ``Job`` est **immuable** (pydantic
|
| 8 |
+
``frozen``), donc un lecteur (requête HTTP) reçoit un instantané cohérent sans
|
| 9 |
+
risque de course avec le worker qui le remplace sous verrou.
|
| 10 |
+
|
| 11 |
+
Chaque transition est aussi **journalisée** (``_history``) : un instantané ``Job``
|
| 12 |
+
par changement d'état, indexé par un id monotone (1-based). C'est la matière
|
| 13 |
+
première du **flux SSE** + de la reprise ``Last-Event-ID`` (couche 8) — rejouable
|
| 14 |
+
même après la fin du job.
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import threading
|
| 20 |
+
import uuid
|
| 21 |
+
from enum import StrEnum
|
| 22 |
+
|
| 23 |
+
from pydantic import BaseModel, ConfigDict
|
| 24 |
+
|
| 25 |
+
from cinoc.domain.errors import CinocError
|
| 26 |
+
from cinoc.domain.run import utcnow
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class JobState(StrEnum):
|
| 30 |
+
"""États d'un job. Terminaux : ``DONE``, ``FAILED``, ``CANCELLED``."""
|
| 31 |
+
|
| 32 |
+
PENDING = "pending"
|
| 33 |
+
RUNNING = "running"
|
| 34 |
+
DONE = "done"
|
| 35 |
+
FAILED = "failed"
|
| 36 |
+
CANCELLED = "cancelled"
|
| 37 |
+
|
| 38 |
+
@property
|
| 39 |
+
def is_terminal(self) -> bool:
|
| 40 |
+
return self in _TERMINAL
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
_TERMINAL = frozenset({JobState.DONE, JobState.FAILED, JobState.CANCELLED})
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
class JobError(CinocError):
|
| 47 |
+
"""Job introuvable."""
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class Job(BaseModel):
|
| 51 |
+
"""Instantané immuable de l'état d'un job."""
|
| 52 |
+
|
| 53 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 54 |
+
|
| 55 |
+
id: str
|
| 56 |
+
state: JobState
|
| 57 |
+
created_at: str
|
| 58 |
+
updated_at: str
|
| 59 |
+
#: Stem du ``RunResult`` JSON écrit quand ``state == DONE`` (sinon ``None``).
|
| 60 |
+
report_name: str | None = None
|
| 61 |
+
#: Message d'erreur quand ``state == FAILED`` (sinon ``None``).
|
| 62 |
+
error: str | None = None
|
| 63 |
+
#: URL distante du rapport publié si la persistance est active, sinon ``None``.
|
| 64 |
+
published_url: str | None = None
|
| 65 |
+
#: Progression : unités (document × concurrent) traitées / total. ``0/0`` =
|
| 66 |
+
#: inconnu (job pas encore démarré). Alimente la barre de progression (SSE).
|
| 67 |
+
done: int = 0
|
| 68 |
+
total: int = 0
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class JobStore:
|
| 72 |
+
"""Dictionnaire thread-safe ``job_id → Job`` (instantanés immuables)."""
|
| 73 |
+
|
| 74 |
+
def __init__(self) -> None:
|
| 75 |
+
self._jobs: dict[str, Job] = {}
|
| 76 |
+
self._history: dict[str, list[Job]] = {}
|
| 77 |
+
self._lock = threading.Lock()
|
| 78 |
+
|
| 79 |
+
def create(self) -> Job:
|
| 80 |
+
"""Crée un job ``PENDING`` (id généré) et le renvoie."""
|
| 81 |
+
now = utcnow().isoformat()
|
| 82 |
+
job = Job(id=uuid.uuid4().hex, state=JobState.PENDING, created_at=now,
|
| 83 |
+
updated_at=now)
|
| 84 |
+
with self._lock:
|
| 85 |
+
self._jobs[job.id] = job
|
| 86 |
+
self._history[job.id] = [job]
|
| 87 |
+
return job
|
| 88 |
+
|
| 89 |
+
def get(self, job_id: str) -> Job | None:
|
| 90 |
+
with self._lock:
|
| 91 |
+
return self._jobs.get(job_id)
|
| 92 |
+
|
| 93 |
+
def all_jobs(self) -> tuple[Job, ...]:
|
| 94 |
+
"""Tous les jobs, du plus récent au plus ancien."""
|
| 95 |
+
with self._lock:
|
| 96 |
+
jobs = tuple(self._jobs.values())
|
| 97 |
+
return tuple(sorted(jobs, key=lambda j: j.created_at, reverse=True))
|
| 98 |
+
|
| 99 |
+
def update(
|
| 100 |
+
self,
|
| 101 |
+
job_id: str,
|
| 102 |
+
*,
|
| 103 |
+
state: JobState,
|
| 104 |
+
report_name: str | None = None,
|
| 105 |
+
error: str | None = None,
|
| 106 |
+
published_url: str | None = None,
|
| 107 |
+
) -> Job:
|
| 108 |
+
"""Remplace l'instantané du job (sous verrou). Lève si inconnu."""
|
| 109 |
+
with self._lock:
|
| 110 |
+
current = self._jobs.get(job_id)
|
| 111 |
+
if current is None:
|
| 112 |
+
raise JobError(f"job {job_id!r} introuvable.")
|
| 113 |
+
updated = current.model_copy(
|
| 114 |
+
update={
|
| 115 |
+
"state": state,
|
| 116 |
+
"report_name": report_name,
|
| 117 |
+
"error": error,
|
| 118 |
+
"published_url": published_url,
|
| 119 |
+
"updated_at": utcnow().isoformat(),
|
| 120 |
+
}
|
| 121 |
+
)
|
| 122 |
+
self._jobs[job_id] = updated
|
| 123 |
+
self._history[job_id].append(updated)
|
| 124 |
+
return updated
|
| 125 |
+
|
| 126 |
+
def progress(self, job_id: str, done: int, total: int) -> Job:
|
| 127 |
+
"""Met à jour la progression (``done``/``total``) **sans changer l'état**.
|
| 128 |
+
|
| 129 |
+
Ajoute un instantané au journal → un **événement SSE** (de type ``running``)
|
| 130 |
+
par unité traitée, que l'UI consomme pour la barre de progression. Les
|
| 131 |
+
autres champs (état, rapport…) sont préservés (``model_copy`` ciblé).
|
| 132 |
+
"""
|
| 133 |
+
with self._lock:
|
| 134 |
+
current = self._jobs.get(job_id)
|
| 135 |
+
if current is None:
|
| 136 |
+
raise JobError(f"job {job_id!r} introuvable.")
|
| 137 |
+
updated = current.model_copy(
|
| 138 |
+
update={
|
| 139 |
+
"done": done,
|
| 140 |
+
"total": total,
|
| 141 |
+
"updated_at": utcnow().isoformat(),
|
| 142 |
+
}
|
| 143 |
+
)
|
| 144 |
+
self._jobs[job_id] = updated
|
| 145 |
+
self._history[job_id].append(updated)
|
| 146 |
+
return updated
|
| 147 |
+
|
| 148 |
+
def history_since(self, job_id: str, after_id: int) -> list[tuple[int, Job]]:
|
| 149 |
+
"""Instantanés (``event_id``, ``Job``) du journal **après** ``after_id``.
|
| 150 |
+
|
| 151 |
+
``event_id`` est la position 1-based dans le journal du job → c'est l'id
|
| 152 |
+
d'événement SSE (``Last-Event-ID``). Job inconnu → liste vide.
|
| 153 |
+
"""
|
| 154 |
+
with self._lock:
|
| 155 |
+
history = list(self._history.get(job_id, ()))
|
| 156 |
+
return [(i, job) for i, job in enumerate(history, start=1) if i > after_id]
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
__all__ = ["Job", "JobError", "JobState", "JobStore"]
|
cinoc/adapters/storage/publisher.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publication d'un ``RunResult`` vers un dépôt distant (persistance).
|
| 2 |
+
|
| 3 |
+
Le disque d'un HF Space est **éphémère** : un run calculé est perdu au
|
| 4 |
+
redémarrage. Ce module **pousse** le ``RunResult`` JSON vers un dépôt GitHub
|
| 5 |
+
(API *contents*) après chaque run réussi, ce qui le rend **durable** et alimente
|
| 6 |
+
la vitrine publique.
|
| 7 |
+
|
| 8 |
+
Sécurité & opt-in :
|
| 9 |
+
|
| 10 |
+
- **Désactivé par défaut.** Sans dépôt **et** jeton (secrets), on renvoie un
|
| 11 |
+
``NoopPublisher`` : aucune sortie réseau. La vitrine read-only (sans secret)
|
| 12 |
+
reste donc inerte.
|
| 13 |
+
- Le **jeton vient d'un secret** (``CINOC_PUBLISH_TOKEN``) et n'est **jamais
|
| 14 |
+
journalisé** (il ne vit que dans l'en-tête ``Authorization``).
|
| 15 |
+
- Aucune dépendance ajoutée : l'appel HTTP réel utilise ``urllib`` (stdlib),
|
| 16 |
+
importé et exécuté seulement à la publication (chemin *live*, hors CI).
|
| 17 |
+
|
| 18 |
+
La construction de la requête (:func:`build_put_request`) est **pure** et
|
| 19 |
+
testée ; seul l'envoi réseau est non couvert.
|
| 20 |
+
"""
|
| 21 |
+
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import base64
|
| 25 |
+
import json
|
| 26 |
+
import logging
|
| 27 |
+
import os
|
| 28 |
+
from collections.abc import Callable
|
| 29 |
+
from pathlib import Path
|
| 30 |
+
from typing import NamedTuple, Protocol
|
| 31 |
+
from urllib.parse import quote
|
| 32 |
+
|
| 33 |
+
from pydantic import BaseModel, ConfigDict
|
| 34 |
+
|
| 35 |
+
logger = logging.getLogger(__name__)
|
| 36 |
+
|
| 37 |
+
_API = "https://api.github.com"
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
class PublishTarget(BaseModel):
|
| 41 |
+
"""Cible de publication (dépôt GitHub) — résolue depuis les secrets."""
|
| 42 |
+
|
| 43 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 44 |
+
|
| 45 |
+
repo: str # "owner/name"
|
| 46 |
+
token: str
|
| 47 |
+
branch: str = "main"
|
| 48 |
+
subdir: str = "reports"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class PutRequest(NamedTuple):
|
| 52 |
+
"""Requête HTTP prête à envoyer (pure, testable sans réseau)."""
|
| 53 |
+
|
| 54 |
+
url: str
|
| 55 |
+
headers: dict[str, str]
|
| 56 |
+
body: bytes
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ResultPublisher(Protocol):
|
| 60 |
+
"""Publie un ``RunResult`` ; renvoie l'URL distante, ou ``None`` si inactif."""
|
| 61 |
+
|
| 62 |
+
def publish(self, name: str, run_result_path: Path) -> str | None: ...
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
class NoopPublisher:
|
| 66 |
+
"""Publisher inactif (aucun secret configuré) : ne fait rien."""
|
| 67 |
+
|
| 68 |
+
def publish(self, name: str, run_result_path: Path) -> str | None:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def build_put_request(target: PublishTarget, name: str, content: bytes) -> PutRequest:
|
| 73 |
+
"""Construit la requête *PUT contents* GitHub pour ``<subdir>/<name>.json``."""
|
| 74 |
+
path = quote(f"{target.subdir}/{name}.json", safe="/")
|
| 75 |
+
body = json.dumps(
|
| 76 |
+
{
|
| 77 |
+
"message": f"cinoc: publie le rapport {name}",
|
| 78 |
+
"content": base64.b64encode(content).decode("ascii"),
|
| 79 |
+
"branch": target.branch,
|
| 80 |
+
}
|
| 81 |
+
).encode("utf-8")
|
| 82 |
+
headers = {
|
| 83 |
+
"Authorization": f"Bearer {target.token}",
|
| 84 |
+
"Accept": "application/vnd.github+json",
|
| 85 |
+
"X-GitHub-Api-Version": "2022-11-28",
|
| 86 |
+
"Content-Type": "application/json",
|
| 87 |
+
}
|
| 88 |
+
return PutRequest(f"{_API}/repos/{target.repo}/contents/{path}", headers, body)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
class GitHubPublisher:
|
| 92 |
+
"""Pousse le ``RunResult`` JSON vers un dépôt GitHub (API *contents*)."""
|
| 93 |
+
|
| 94 |
+
def __init__(self, target: PublishTarget) -> None:
|
| 95 |
+
self._target = target
|
| 96 |
+
|
| 97 |
+
def publish(self, name: str, run_result_path: Path) -> str | None:
|
| 98 |
+
content = run_result_path.read_bytes()
|
| 99 |
+
request = build_put_request(self._target, name, content)
|
| 100 |
+
return self._send(request)
|
| 101 |
+
|
| 102 |
+
def _send(self, request: PutRequest) -> str | None: # pragma: no cover (réseau)
|
| 103 |
+
import urllib.request
|
| 104 |
+
|
| 105 |
+
req = urllib.request.Request(
|
| 106 |
+
request.url, data=request.body, headers=request.headers, method="PUT"
|
| 107 |
+
)
|
| 108 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 109 |
+
payload = json.loads(resp.read().decode("utf-8"))
|
| 110 |
+
content = payload.get("content") or {}
|
| 111 |
+
return content.get("html_url")
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def resolve_publish_target(
|
| 115 |
+
get_env: Callable[[str], str | None] = os.environ.get,
|
| 116 |
+
) -> PublishTarget | None:
|
| 117 |
+
"""Cible depuis les secrets, ou ``None`` si dépôt **ou** jeton manquant."""
|
| 118 |
+
repo = (get_env("CINOC_PUBLISH_REPO") or "").strip()
|
| 119 |
+
token = (get_env("CINOC_PUBLISH_TOKEN") or "").strip()
|
| 120 |
+
if not repo or "/" not in repo or not token:
|
| 121 |
+
return None
|
| 122 |
+
return PublishTarget(
|
| 123 |
+
repo=repo,
|
| 124 |
+
token=token,
|
| 125 |
+
branch=(get_env("CINOC_PUBLISH_BRANCH") or "main").strip(),
|
| 126 |
+
subdir=(get_env("CINOC_PUBLISH_DIR") or "reports").strip(),
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def resolve_publisher(
|
| 131 |
+
get_env: Callable[[str], str | None] = os.environ.get,
|
| 132 |
+
) -> ResultPublisher:
|
| 133 |
+
"""``GitHubPublisher`` si configuré (secrets), sinon ``NoopPublisher``."""
|
| 134 |
+
target = resolve_publish_target(get_env)
|
| 135 |
+
return GitHubPublisher(target) if target is not None else NoopPublisher()
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
__all__ = [
|
| 139 |
+
"GitHubPublisher",
|
| 140 |
+
"NoopPublisher",
|
| 141 |
+
"PublishTarget",
|
| 142 |
+
"PutRequest",
|
| 143 |
+
"ResultPublisher",
|
| 144 |
+
"build_put_request",
|
| 145 |
+
"resolve_publish_target",
|
| 146 |
+
"resolve_publisher",
|
| 147 |
+
]
|
cinoc/app/ANALYSE_COUCHE_6.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ANALYSE_COUCHE_6.md — `app` (Picarones → Cinoc)
|
| 2 |
+
|
| 3 |
+
> **Type** : session d'ANALYSE (guide de portage durable). **Aucun code Cinoc écrit.**
|
| 4 |
+
> **Couche 6 = `app`** (orchestration applicative) dans
|
| 5 |
+
> `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`.
|
| 6 |
+
> **Source figée** : `Picarones/picarones/app/` — **27 fichiers, 6 648 LOC**.
|
| 7 |
+
> **Méthode** : 4 sous-agents d'exploration en parallèle (un par cluster), synthèse + recoupement
|
| 8 |
+
> personnel des points décisifs dans le code (couplages inter-couches, sécurité, code mort, shim).
|
| 9 |
+
> **Deux natures de savoir** (CLAUDE.md §9) : **Partie 1** = analyse source (durable) ;
|
| 10 |
+
> **Partie 2** = réorg cible (périssable, « à confirmer à la tranche »). Verdicts **PROVISOIRES**.
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## PARTIE 0 — Vérification anti-contradiction (obligatoire)
|
| 15 |
+
|
| 16 |
+
Aucune conclusion ne contredit `CLAUDE.md` ni une couche mergée (domain ✅, formats ✅ ; evaluation = plan). Au contraire, l'analyse **confirme** trois décisions déjà actées ailleurs :
|
| 17 |
+
|
| 18 |
+
| Décision actée (où) | Confirmé dans le code Picarones |
|
| 19 |
+
|---|---|
|
| 20 |
+
| `RunSpec`/`StepSpec` → `domain`, **séparés du loader YAML** (backlog domain ; couche 1 §9) | `run_spec.py` mêle effectivement type déclaratif + loader YAML + résolution dotted-path → split nécessaire |
|
| 21 |
+
| `_benchmark_*` : double format `BenchmarkResult↔RunResult` supprimé + calcul rapatrié en `evaluation/runner` (CLAUDE.md §8.1 ; couche 3 §6,§11,§12-4) | `_benchmark_converter` calcule (compute_metrics, hooks, aggregate) ; `_benchmark_ner` calcule du NER ; les deux vivent à tort en `app/` |
|
| 22 |
+
| Persistance / store → `adapters/storage` (couche 3 §8) | `benchmark_service.persist()` écrit du JSONL ; `job_runner` parle à `adapters.storage.JobStore` |
|
| 23 |
+
| Registre + factory + **découverte entry-points en `app`** (CLAUDE.md §3) | `registry_service.bootstrap_default_registries()` + `resolve_adapter_class()` + `_resolve_entity_extractor()` = les **germes** (mais enregistrement codé en dur, **pas** d'entry-points) |
|
| 24 |
+
|
| 25 |
+
**Nuance importante à ne pas violer** (CLAUDE.md §3) : le **seul** point d'extension tiers = les **briques de pipeline**. Métriques, projecteurs, importeurs restent **internes**. Or `registry_service` mélange aujourd'hui registre de **métriques** (interne) et résolution d'**adapters** (extensible) — à scinder en cible.
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
# PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable)
|
| 30 |
+
|
| 31 |
+
## 1.1 Inventaire par cluster (27 fichiers / 6 648 LOC)
|
| 32 |
+
|
| 33 |
+
| Cluster | Fichiers | LOC | Rôle global vérifié |
|
| 34 |
+
|---|---|---|---|
|
| 35 |
+
| **A. Colonne vertébrale + API** | `__init__.py`(27), `results.py`(24), `services/__init__.py`(96), `schemas/__init__.py`(49), `schemas/run_spec.py`(559) | **755** | Headers de paquet, façades de re-export, DTO YAML + loader |
|
| 36 |
+
| **B. Shim double-format `_benchmark_*`** | `_benchmark_adapter_resolver.py`(313), `_benchmark_conversions.py`(255), `_benchmark_converter.py`(236), `_benchmark_helpers.py`(266), `_benchmark_ner.py`(189), `_benchmark_persistence.py`(55), `benchmark_service.py`(407) | **1 721** | Conversion `RunResult`↔`BenchmarkResult` (legacy) + calcul de métriques mal placé + runner v2 |
|
| 37 |
+
| **C. Orchestration / jobs / reprise** | `run_orchestrator.py`(500), `run_orchestrator_execution.py`(310), `run_orchestrator_helpers/{__init__(55),builders(241),factories(119),legacy(308),loaders(43)}`, `_orchestrator_partial.py`(256), `job_runner.py`(267), `partial_store.py`(360) | **2 459** | `RunOrchestrator`, exécution asynchrone, reprise partielle (fingerprint SHA-256) |
|
| 38 |
+
| **D. Services transverses** | `path_security.py`(454), `corpus_service.py`(541), `registry_service.py`(327), `python_helpers.py`(277), `dependencies.py`(114) | **1 713** | Sécurité chemins (invariant §12), import ZIP corpus, bootstrap registres, bridge Python, capture deps |
|
| 39 |
+
|
| 40 |
+
**5 fichiers > 400 LOC** (budget Cinoc) : `run_spec`(559), `corpus_service`(541), `run_orchestrator`(500), `path_security`(454), `benchmark_service`(407).
|
| 41 |
+
|
| 42 |
+
## 1.2 Couplages inter-couches & consommateurs réels
|
| 43 |
+
|
| 44 |
+
**`app/` importe** (orientation **respectée** : que des couches internes ; **jamais** `reports/` ni `interfaces/`) :
|
| 45 |
+
|
| 46 |
+
| Vers | Symboles principaux | Cluster qui couple |
|
| 47 |
+
|---|---|---|
|
| 48 |
+
| `domain` | errors, artifacts (ArtifactType/Artifact), corpus (CorpusSpec), documents (DocumentRef/GroundTruthRef), pipeline_spec, evaluation_spec, run_manifest | tous |
|
| 49 |
+
| `evaluation` | **benchmark_result, metric_hooks, metric_result, metrics.{text,ner,over_normalization,search,alto_structural}, views, projectors, registry, corpus** | **B** (calcul/shim) + **D** (registry_service) |
|
| 50 |
+
| `pipeline` | runner.CorpusRunner, types.{PipelineResult,RunContext}, run_result.RunResult, llm_pipeline_builder | B, C |
|
| 51 |
+
| `formats` | alto.parser.parse_alto, text.normalization.get_builtin_profile | C (loaders), legacy |
|
| 52 |
+
| `adapters` | storage.JobStore, ocr.base.BaseOCRAdapter, corpus._fallback_log | B, C |
|
| 53 |
+
|
| 54 |
+
**Qui consomme `app/`** (grep hors `tests/`) — seulement **2 frontières** (sain) :
|
| 55 |
+
|
| 56 |
+
| Consommateur | Importe | Usage |
|
| 57 |
+
|---|---|---|
|
| 58 |
+
| `picarones/__init__.py` (API top-level) | `RunSpec`, `RunSpecLoadError`, `load_run_spec_from_yaml`, `RunOrchestrator`, `OrchestrationResult`, `prepare_preset_args`, `run_result_to_benchmark_result` | API publique + ⚠️ auto-déclenche `register_default_metrics()` à l'import (effet de bord) |
|
| 59 |
+
| `interfaces/cli/_workflows.py` | `RunOrchestrator`, `prepare_preset_args`, `run_result_to_benchmark_result` | `prepare → execute_preset → convertir en BenchmarkResult` |
|
| 60 |
+
| `interfaces/web/{benchmark_utils,_path_helpers,security_paths}.py` | `path_security.*`, `RunOrchestrator`, `prepare_preset_args`, `run_result_to_benchmark_result` | endpoints `/api/benchmark/start`, sandbox chemins |
|
| 61 |
+
|
| 62 |
+
> **Pattern consommateur clé** : CLI et web font tous deux
|
| 63 |
+
> `RunResult → run_result_to_benchmark_result() → BenchmarkResult` **parce que les renderers
|
| 64 |
+
> (couche 7) consomment `BenchmarkResult`, pas `RunResult`**. C'est la racine du double format.
|
| 65 |
+
> En Cinoc (format unique `RunResult`, reports le lit direct) **cette conversion disparaît entièrement**.
|
| 66 |
+
|
| 67 |
+
## 1.3 La dette centrale : double format de sortie + god-module éclaté
|
| 68 |
+
|
| 69 |
+
- Picarones produit **deux** formats : `RunResult` (couche 4, `pipeline/run_result.py`) **et** `BenchmarkResult` (couche 3, `evaluation/benchmark_result.py`). Le cluster B est presque entièrement le **pont** entre les deux.
|
| 70 |
+
- Historique vérifié : un god-module **`benchmark_runner.py` (~1 700 LOC)** a été éclaté en `_benchmark_*` lors d'une « Phase 6 audit code-quality ». **`benchmark_runner.py` n'existe plus** — toutes les docstrings qui le citent (≈ 15 fichiers) sont **périmées**. Idem `report_service.py` (cité, inexistant).
|
| 71 |
+
- Réconciliation du « ~1 570 LOC de shim » (CLAUDE.md §8.1) : shim strict ≈ `_benchmark_converter`(236) + `_benchmark_helpers`(266) + `_benchmark_persistence`(55) + ~½ `_benchmark_conversions`(255) + ~½ `_benchmark_adapter_resolver`(313) ≈ **950–1 000 LOC**, + le pont `run_orchestrator_helpers/legacy.py`(308) + le mort `partial_store.py`(360, legacy) → **~1 600 LOC de code legacy/shim/double-format** au total. ✅ cohérent.
|
| 72 |
+
|
| 73 |
+
## 1.4 Code mort & doc morte confirmés (recoupés dans le code)
|
| 74 |
+
|
| 75 |
+
| Élément | Preuve | Note |
|
| 76 |
+
|---|---|---|
|
| 77 |
+
| **`partial_store.py` : `_load_partial`, `_save_partial_line`, `_delete_partial`, `partial_path_for_engine`** | **0 consommateur hors-tests** ; servaient `run_benchmark_via_service` | ⚠️ ~½ du fichier est **mort** depuis la suppression de cette fonction |
|
| 78 |
+
| **`run_benchmark_via_service`** | **aucun `def`** dans `picarones/` ; ne subsistent que docstrings/commentaires (dont l'avis de dépréciation de `__init__.py`) | fonction **supprimée** mais « fantôme » partout en prose |
|
| 79 |
+
| Réfs à **`benchmark_runner.py`** (~15 fichiers) | `find` → inexistant | doc morte (god-module éclaté) |
|
| 80 |
+
| Réfs à **`_benchmark_execution.py`** (factories/builders) | inexistant | pattern copié-commenté, pas importé |
|
| 81 |
+
| Réf à **`report_service.py`** (`services/__init__` docstring) | inexistant | doc morte |
|
| 82 |
+
| `run_orchestrator._build_pipelines`/`_load_corpus` (staticmethods l.472-494) | wrappers qui ne délèguent qu'aux fonctions globales | conservés **pour un test** uniquement |
|
| 83 |
+
|
| 84 |
+
## 1.5 Bugs / fragilités notables (vérifiés)
|
| 85 |
+
|
| 86 |
+
| Fichier | Problème | Gravité |
|
| 87 |
+
|---|---|---|
|
| 88 |
+
| `run_orchestrator.py` | `progress_callback`/`cancel_event` **acceptés mais ignorés** sur le chemin `bench.run()` (consommés seulement si `partial_dir`) — le caller croit son callback branché | moyen (silencieux) |
|
| 89 |
+
| `run_orchestrator_execution.py` | appelle `BenchmarkService._evaluate_document_in_views()` (**privée**) pour recalculer les vues des docs repris | fragile (couplage privé) |
|
| 90 |
+
| `factories.py` | `counter_state` (lock+dict) partagé par closure ; OK seulement si chaque run a une factory fraîche | risque multithread |
|
| 91 |
+
| `partial_store.py` / `_orchestrator_partial.py` | fingerprint = path+size+mtime (pas de hash de contenu) → ne détecte pas une modif d'image à id constant | acceptable (documenté) |
|
| 92 |
+
| `run_spec.py` | 9 champs « Phase B » (`char_exclude`, `normalization_profile`, `partial_dir`, `entity_extractor`, `profile`, `output_json`, `timeout_seconds_per_doc`, `max_in_flight`, `poll_interval_seconds`) **validés mais à demi-consommés** (migration B1→B2.x inachevée) | dette de migration |
|
| 93 |
+
| `python_helpers.py` | `_dummy_pipeline_yaml()` = pipeline **bidon** juste pour satisfaire `RunSpec.pipelines (min_len=1)` en mode preset ; `adapter_kwargs` vide → `RunManifest` non reproductible sans le code Python | hack |
|
| 94 |
+
| `picarones/__init__.py` | `register_default_metrics()` **auto-déclenché à l'import** | anti-pattern (interdit Cinoc §7) |
|
| 95 |
+
|
| 96 |
+
## 1.6 Verdicts par fichier — **PROVISOIRES — à confirmer au build**
|
| 97 |
+
|
| 98 |
+
> Légende : **GARDER** (concept à reporter ~tel quel) · **MODIFIER** (concept gardé, forme à revoir/dégonfler/splitter) · **DÉPLACER** (change de couche) · **SUPPRIMER**.
|
| 99 |
+
> Le verdict décrit l'intention **pour le port Cinoc** ; les faits (rôle/conso) sont, eux, durables.
|
| 100 |
+
|
| 101 |
+
### Cluster A — colonne vertébrale (755 LOC)
|
| 102 |
+
| Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
|
| 103 |
+
|---|---|---|---|
|
| 104 |
+
| `__init__.py` | 27 | header paquet, `__all__` vide, sans effet de bord | **GARDER** — réécrire docstring, purger « 6 P0 du S1 » |
|
| 105 |
+
| `results.py` | 24 | **shim** re-export `RunResult/RunDocumentResult/ReportRenderer` ← `pipeline/run_result` | **SUPPRIMER** — en Cinoc `RunResult` vit en `evaluation/result.py`, reports le lit direct (zéro shim §5.1) |
|
| 106 |
+
| `services/__init__.py` | 96 | façade re-export (dont le converter `run_result_to_benchmark_result`) | **MODIFIER** — `__init__` mince §7 ; ne pas ré-exporter de shim |
|
| 107 |
+
| `schemas/__init__.py` | 49 | façade re-export `run_spec` | **MODIFIER/fusionner** avec le split ci-dessous |
|
| 108 |
+
| `schemas/run_spec.py` | 559 | `RunSpec`/`StepSpec`/`PipelineSpecYaml` (DTO YAML, 7 validators) + `load_run_spec_from_yaml` + `resolve_adapter_class` (importlib dotted-path) + `CANONICAL_VIEW_NAMES` | **DÉPLACER (split)** — type déclaratif `RunSpec`/`StepSpec` → **`domain`** (backlog confirmé) ; **loader YAML + `resolve_adapter_class`** → restent **`app`** ; **purger les 9 champs Phase B** (implémenter pour de vrai ou retirer) ; budget <400 |
|
| 109 |
+
|
| 110 |
+
### Cluster B — shim double-format (1 721 LOC)
|
| 111 |
+
| Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
|
| 112 |
+
|---|---|---|---|
|
| 113 |
+
| `benchmark_service.py` | 407 | **runner v2 propre** : orchestre `CorpusRunner`(4) + `view_executor`(3), assemble `RunResult`, persiste JSONL ; 0 import `_benchmark_*` | **MODIFIER (split)** — *pas « garder tel quel »* : assemblage `RunResult` + éval vues → **`evaluation/runner`** (3) ; persistance JSONL → **`adapters/storage`** (5) ; il ne reste en `app` qu'une coquille d'orchestration mince |
|
| 114 |
+
| `_benchmark_converter.py` | 236 | cœur du shim `RunResult→BenchmarkResult` ; **calcule** CER/WER + hooks + agrégation | **SUPPRIMER** — double format éliminé ; le calcul est déjà en `evaluation/`, juste appelé d'ici |
|
| 115 |
+
| `_benchmark_helpers.py` | 266 | extracteurs de texte + fingerprint pour le converter | **SUPPRIMER** — récupérer au plus 1-2 extracteurs `Artifact→texte` utiles → `pipeline`/`evaluation` |
|
| 116 |
+
| `_benchmark_persistence.py` | 55 | `asdict(BenchmarkResult)→JSON` | **SUPPRIMER** — pas de `BenchmarkResult` ; persistance `RunResult` → `adapters/storage` |
|
| 117 |
+
| `_benchmark_ner.py` | 189 | **calcule** NER (`compute_ner_metrics`) puis l'attache au `BenchmarkResult` | **DÉPLACER → `evaluation`** — le NER est une métrique (couche 3) |
|
| 118 |
+
| `_benchmark_conversions.py` | 255 | `Corpus`(3, chargé) → `CorpusSpec`(1, déclaratif) + `Document→DocumentRef` ; `_safe_doc_id` (normalisation) | **MODIFIER** — pont qui dépend du modèle corpus Cinoc (à confirmer) ; **garder la normalisation `doc_id`** (utile, réutilisée par NER + corpus_service) |
|
| 119 |
+
| `_benchmark_adapter_resolver.py` | 313 | `engine(BaseOCRAdapter / OCRLLMPipelineConfig) → PipelineSpec` + `build_adapter_resolver` (`name→StepExecutor`) | **MODIFIER** — le **resolver `name→executor` = germe du registre/factory** (garder, → registre `app`) ; la conversion `engine→spec` legacy disparaît (en Cinoc on déclare `PipelineSpec` direct) |
|
| 120 |
+
|
| 121 |
+
### Cluster C — orchestration / jobs / reprise (2 459 LOC)
|
| 122 |
+
| Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
|
| 123 |
+
|---|---|---|---|
|
| 124 |
+
| `run_orchestrator.py` | 500 | `RunOrchestrator.execute()` **et** `execute_preset()` → `OrchestrationResult` ; orchestre vraiment (corpus→registres→pipelines→service) | **MODIFIER** — concept d'orchestrateur `app` à **garder** ; dégonfler : fusionner `execute`/`execute_preset` (le 2ᵉ existe pour les engines en mémoire legacy), retirer la persistance `output_json` legacy + les callbacks « acceptés-ignorés » ; budget <400 |
|
| 125 |
+
| `run_orchestrator_execution.py` | 310 | `execute_with_partial()` (reprise par pipeline) ; recalcule les vues via méthode **privée** du service | **MODIFIER/différer** — feature réelle mais couplage fragile ; à reconcevoir proprement **à la tranche reprise** (≠ MVP) |
|
| 126 |
+
| `run_orchestrator_helpers/__init__.py` | 55 | re-export pour préserver l'API historique | **SUPPRIMER** — pas d'API historique à préserver (zéro shim) |
|
| 127 |
+
| `…/builders.py` | 241 | `_load_corpus`/`_build_pipelines`/`_build_views`/`_build_benchmark_service` | **MODIFIER** — helpers d'assemblage à **garder** mais replier dans l'orchestrateur/registre |
|
| 128 |
+
| `…/factories.py` | 119 | `_default_gt_factory`/`_default_inputs_factory`/`_make_context_factory` (closure progress, thread-safe) | **GARDER (concept)** — frontière « doc → artefacts » + `RunContext` par doc |
|
| 129 |
+
| `…/legacy.py` | 308 | pont `BenchmarkResult` : `_PipelineEngineProxy`, `_resolve_entity_extractor` (dotted-path→callable NER), `_persist_legacy_benchmark_json` | **SUPPRIMER** — `BenchmarkResult` disparu ; **garder le concept** `_resolve_entity_extractor` = résolution de module tiers (→ entry-points `app`) |
|
| 130 |
+
| `…/loaders.py` | 43 | `_filesystem_payload_loader` (disque→payload ALTO/texte) + `_kwargs_signature` | **GARDER (concept)** — chargement payload = I/O (`app` ou `adapters/storage`) |
|
| 131 |
+
| `_orchestrator_partial.py` | 256 | reprise **par pipeline** (fingerprint, load/append/delete/filter) ; système **vivant** | **MODIFIER** — devenir le **seul** mécanisme de reprise (différer à sa tranche) |
|
| 132 |
+
| `job_runner.py` | 267 | `JobRunner` thread daemon + `JobStore` ; annulation **best-effort non branchée** | **GARDER (concept)** pour `serve` — mais brancher une vraie annulation via `Deadline`/`RunControl` (couche 4, invariant §12) ; couple à `adapters/storage` |
|
| 133 |
+
| `partial_store.py` | 360 | reprise **par engine** (legacy) ; **~½ mort** ; seuls `compute_run_fingerprint`+`_partial_path` vivants | **SUPPRIMER** — garder seulement le **concept fingerprint**, fusionné dans le mécanisme partial unique |
|
| 134 |
+
|
| 135 |
+
### Cluster D — services transverses (1 713 LOC)
|
| 136 |
+
| Fichier | LOC | Rôle réel vérifié | Verdict PROVISOIRE + raison |
|
| 137 |
+
|---|---|---|---|
|
| 138 |
+
| `path_security.py` | 454 | **invariant §12** : `validated_path` (resolve+`is_relative_to`), `WorkspaceManager` (sandbox/session, anti-symlink post-mkdir), `safe_report_name`, `validated_prompt_filename` | **GARDER** — porter **fidèlement** (→ package `security/` §8.5) ; dédupliquer `prompt_filename`/`report_name`, moderniser (`is_relative_to` natif 3.11), splitter <400 |
|
| 139 |
+
| `corpus_service.py` | 541 | import ZIP sandboxé (anti zip-bomb/traversal/symlink) + appariement image/GT (`.gt.alto.xml`…) → `CorpusSpec` | **GARDER/DÉPLACER ?** — défenses à garder ; place à confirmer (`app` vs `adapters/corpus` qui porte déjà IIIF/Gallica/…) ; splitter <400 (extraction · catalogage · appariement) |
|
| 140 |
+
| `registry_service.py` | 327 | `bootstrap_default_registries()` (boucle **codée en dur** sur métriques+projecteurs), `RegistriesBundle` ; **pas** d'entry-points | **MODIFIER (scinder)** — registre **métriques/projecteurs** = interne (couche 3) ; registre **modules de pipeline** + **découverte entry-points** (`cinoc.modules`) + `register()` = couche `app` (CLAUDE.md §3) |
|
| 141 |
+
| `python_helpers.py` | 277 | `prepare_preset_args`/`PresetArgs` : bridge engines-en-mémoire→`RunSpec` ; `_dummy_pipeline_yaml` (hack) | **SUPPRIMER/reconcevoir** — existe pour les objets engine legacy ; en Cinoc on déclare `PipelineSpec` direct ; si une API Python est voulue, la rebâtir proprement |
|
| 142 |
+
| `dependencies.py` | 114 | `capture_dependencies_lock` + `capture_system_binaries_lock` → `RunManifest` (reproductibilité §12) ; pur, stateless | **GARDER** — invariant repro ; étendre la table binaires au-delà de Tesseract (starter pack) ; place `app` vs `adapters` à confirmer |
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
# PARTIE 2 — RÉORGANISATION CIBLE Cinoc (périssable — à confirmer à la tranche)
|
| 147 |
+
|
| 148 |
+
> ⚠️ Périssable. Le contact du code amont (evaluation/pipeline/adapters non encore mergés) corrigera ces formes.
|
| 149 |
+
> **On ne construit PAS la couche `app` de haut en bas** : elle **apparaît par tranches verticales**, au fur et à mesure que `interfaces` en a besoin.
|
| 150 |
+
|
| 151 |
+
## 2.1 Rôle d'`app` en Cinoc = **coquille mince** (les deux axes, CLAUDE.md §2)
|
| 152 |
+
|
| 153 |
+
`app` orchestre, **ne calcule jamais** (« l'app appelle, ne calcule plus »). Elle :
|
| 154 |
+
1. charge/valide une spec (`RunSpec`) et résout les modules par nom ;
|
| 155 |
+
2. appelle `pipeline`(4) puis `evaluation`(3) ; l'assemblage de `RunResult` est fait **par `evaluation`** ;
|
| 156 |
+
3. délègue la persistance à `adapters/storage`(5) et le rendu à `reports`(7, via callable injecté) ;
|
| 157 |
+
4. applique les invariants transverses : **sécurité chemins** (§12), **reproductibilité** (deps/binaires → `RunManifest`), **annulation/timeout** (`Deadline`/`RunControl`).
|
| 158 |
+
|
| 159 |
+
| Axe | Contenu `app` | Quand |
|
| 160 |
+
|---|---|---|
|
| 161 |
+
| **Enveloppe (plein-scope, maintenant)** | contrat d'orchestration ; **registre de modules + factory + découverte entry-points** (`cinoc.modules`) + `register()` ; `RunSpec` (en `domain`) + loader YAML (en `app`) ; sécurité chemins | conçu d'office |
|
| 162 |
+
| **Surface (incrémentale, élaguée)** | quels workflows CLI (`run/report/compare/demo/serve`), quels importeurs branchés, le job-runner web, la reprise partielle | une tranche à la fois |
|
| 163 |
+
|
| 164 |
+
## 2.2 Où va chaque concept Picarones (table de transfert)
|
| 165 |
+
|
| 166 |
+
| Concept Picarones (app) | Destination Cinoc | Note |
|
| 167 |
+
|---|---|---|
|
| 168 |
+
| `RunSpec`/`StepSpec` (déclaratif) | **`domain`** (couche 1) | frère de `PipelineSpec`/`EvaluationSpec` ; backlog confirmé |
|
| 169 |
+
| `load_run_spec_from_yaml` + `resolve_adapter_class` | **`app`** (loader/résolution = I/O + import dynamique) | « séparer du loader YAML » |
|
| 170 |
+
| `RunOrchestrator` (mince) + builders/factories | **`app`** | un seul `execute`, pas de `execute_preset` |
|
| 171 |
+
| assemblage `RunResult` + éval vues (`benchmark_service`) | **`evaluation/runner`** (couche 3) | RunResult = sortie d'évaluation |
|
| 172 |
+
| persistance JSONL / store longitudinal | **`adapters/storage`** (couche 5) | tidy store (couche 3 §8) |
|
| 173 |
+
| `_benchmark_ner` (calcul NER) | **`evaluation/metrics`** (couche 3) | NER = métrique |
|
| 174 |
+
| `path_security` | **`app/security/`** (package, §8.5) | invariant §12, port fidèle |
|
| 175 |
+
| `corpus_service` (import ZIP + appariement) | **`app`** ou **`adapters/corpus`** | à confirmer (adapters/corpus porte déjà les importeurs) |
|
| 176 |
+
| `dependencies` (capture deps/binaires) | **`app`** (ou `adapters`) | reproductibilité |
|
| 177 |
+
| `registry_service` (modules de pipeline) | **`app`** (+ entry-points) | seul point d'extension tiers |
|
| 178 |
+
| `job_runner` | **`app`** | pour `serve` ; annulation réelle via `RunControl` |
|
| 179 |
+
| `_orchestrator_partial` (reprise) | **`app`** (1 seul système) | à la tranche reprise |
|
| 180 |
+
| `results.py`, `legacy.py`, `_benchmark_{converter,helpers,persistence}`, `partial_store` (mort), `python_helpers` (preset), `…helpers/__init__` re-export | **SUPPRIMÉ** | shim/double-format/legacy/mort |
|
| 181 |
+
|
| 182 |
+
## 2.3 Esquisse d'arborescence cible (à confirmer)
|
| 183 |
+
|
| 184 |
+
```
|
| 185 |
+
cinoc/app/
|
| 186 |
+
├── __init__.py mince, zéro effet de bord (pas de register_* auto)
|
| 187 |
+
├── orchestrator.py RunOrchestrator unique → appelle pipeline + evaluation, assemble via eval
|
| 188 |
+
├── spec_loader.py load_run_spec_from_yaml + resolve dotted-path/entry-point (RunSpec vit en domain)
|
| 189 |
+
├── modules/ registre + factory + découverte entry-points (cinoc.modules) + register()
|
| 190 |
+
├── security/ validated_path, WorkspaceManager, safe_* (invariant §12)
|
| 191 |
+
├── corpus_import.py (?) import ZIP + appariement image/GT → CorpusSpec [ou adapters/corpus]
|
| 192 |
+
├── reproducibility.py (?) capture deps + binaires → RunManifest [ou adapters]
|
| 193 |
+
└── jobs.py JobRunner (serve) + annulation coopérative
|
| 194 |
+
```
|
| 195 |
+
*(Tous < 400 LOC ; `corpus_import`/`reproducibility` peut-être en `adapters` — décidé à la tranche.)*
|
| 196 |
+
|
| 197 |
+
## 2.4 Apparition par tranches (≠ couche complète)
|
| 198 |
+
|
| 199 |
+
| Tranche (cf. couche 3 §15) | Ce que `app` matérialise alors |
|
| 200 |
+
|---|---|
|
| 201 |
+
| **1. Squelette `demo`** (corpus pré-calculé → CER → RunResult → HTML → CLI) | orchestrateur **minimal** + registre de modules (1 module `precomputed`) + `RunSpec` minimal. Prouve que la coquille tient. |
|
| 202 |
+
| **2. Épaississement texte** (`run`, profils, `compare`) | loader YAML complet, sécurité chemins, capture repro, plus de modules au registre |
|
| 203 |
+
| **3. `serve`** | `JobRunner` + sandbox session + annulation |
|
| 204 |
+
| **4. reprise partielle** | mécanisme partial unique (fingerprint), si justifié par un consommateur réel |
|
| 205 |
+
|
| 206 |
+
---
|
| 207 |
+
|
| 208 |
+
# PARTIE 3 — RISQUES DE TRANSFERT & DETTES (comment détecter / désamorcer)
|
| 209 |
+
|
| 210 |
+
| # | Risque / dette | Détection | Désamorçage |
|
| 211 |
+
|---|---|---|---|
|
| 212 |
+
| **R1** | **Réintroduire le double format** en gardant un `BenchmarkResult` ou un converter « le temps de migrer » | test `no-legacy-imports` (interdire `benchmark_result`, `run_result_to_benchmark_result`) ; 1 seul type de sortie | tout consommateur lit `RunResult` direct ; **reports + web + compare migrés en bloc** (couche 3 §12-4 : ~30 conso) |
|
| 213 |
+
| **R2** | **Calcul qui reste en `app`** (NER, hooks, agrégation) | `test_layer_dependencies` : `app` n'importe **pas** `evaluation/metrics*` pour calculer ; revue « app appelle, ne calcule pas » | NER/hooks/agrégation → `evaluation/runner` |
|
| 214 |
+
| **R3** | **Mélanger registre interne (métriques) et registre extensible (modules)** → ouvrir une prise de trop | revue : entry-points `cinoc.modules` **uniquement** pour briques de pipeline (CLAUDE.md §3) | deux registres distincts ; métriques/projecteurs non pluggables |
|
| 215 |
+
| **R4** | **Sécurité chemins affaiblie au portage** (path-traversal/symlink/zip-bomb) | tests obligatoires : `..`, chemin absolu, symlink, zip-bomb, octet nul **doivent échouer** ; `validated_path` partout | porter `path_security` + `corpus_service._extract_safely` **fidèlement**, tester d'abord |
|
| 216 |
+
| **R5** | **Champs de spec à demi-câblés** (les 9 « Phase B ») recopiés tels quels | revue : tout champ `RunSpec` a un consommateur réel en CI (« pas de conso = supprimé ») | n'ajouter un champ que quand sa tranche le consomme |
|
| 217 |
+
| **R6** | **Reproductibilité perdue** (mode preset Picarones perdait `adapter_kwargs`) | golden `RunManifest` byte-stable ; test : run reproductible **depuis le manifest seul** | pas de chemin « engines en mémoire » ; toute brique déclare `version` (§3) |
|
| 218 |
+
| **R7** | **Annulation/timeout fantômes** (Picarones : callbacks acceptés-ignorés, cancel best-effort) | test : un `Deadline` dépassé **interrompt** ; annulation observable | brancher `Deadline`/`RunControl` (couche 4) dès `job_runner` |
|
| 219 |
+
| **R8** | **Doc/commentaires périmés recopiés** (`benchmark_runner.py`, `report_service.py`, `run_benchmark_via_service`, sprints S/A/Phase) | grep anti-narration ; `test_no_legacy_imports` | « garder le pourquoi, jeter la datation » (couche 2 MIG-3) |
|
| 220 |
+
| **R9** | **Code mort transféré** (½ `partial_store`, staticmethods test-only, `_dummy_pipeline_yaml`) | couverture CI ; « pas de conso = supprimé » | ne pas porter ; reprise = 1 seul système |
|
| 221 |
+
| **R10** | **Budgets explosés** (5 fichiers >400) | `test_file_budgets` strict | splitter dès l'écriture (orchestrateur / sécurité / corpus) |
|
| 222 |
+
| **R11** | **Effet de bord à l'import** (`register_default_metrics()` auto) | `test_no_side_effect_imports` | enregistrement **explicite, idempotent, testable** (§7) |
|
| 223 |
+
|
| 224 |
+
---
|
| 225 |
+
|
| 226 |
+
## Résumé pour la future session de CONSTRUCTION (3–5 points)
|
| 227 |
+
|
| 228 |
+
1. **`app` = coquille d'orchestration mince qui NE CALCULE PAS.** Elle appelle `pipeline`(4) puis `evaluation`(3) ; l'assemblage de `RunResult` se fait **dans `evaluation/runner`**, la persistance dans **`adapters/storage`**. Le seul « gros » runner sain de Picarones (`benchmark_service.py`) se **scinde** entre ces couches — ce n'est pas un « garder tel quel ».
|
| 229 |
+
2. **Tout le cluster `_benchmark_*` + `results.py` + `legacy.py` + ½ `partial_store` + `python_helpers`(preset) = SUPPRIMÉ** : c'est le double format `BenchmarkResult↔RunResult` (+ un god-module éclaté, + une fonction `run_benchmark_via_service` déjà morte qui laisse du code orphelin). En Cinoc, `RunResult` est unique et **les reports le lisent direct** → la conversion n'existe plus. Le **NER** part en `evaluation` (c'est une métrique).
|
| 230 |
+
3. **Deux germes d'enveloppe à concevoir plein-scope, sans les remplir** : (a) `RunSpec`/`StepSpec` **en `domain`** + loader YAML/résolution **en `app`** (split « séparer du loader ») ; (b) **registre de modules de pipeline + factory + découverte entry-points** (`cinoc.modules`) — **uniquement** pour les briques de pipeline, jamais les métriques/importeurs.
|
| 231 |
+
4. **Invariants à porter fidèlement et à tester EN PREMIER** : sécurité chemins (`validated_path`/`WorkspaceManager`/anti zip-bomb-traversal-symlink, §12), reproductibilité (`dependencies` → `RunManifest`), annulation coopérative (`Deadline`/`RunControl` — Picarones ne la branche pas vraiment, à corriger).
|
| 232 |
+
5. **Ne pas bâtir `app` en bloc** : elle naît par tranches. Tranche 1 (`demo`) = orchestrateur minimal + registre (1 module `precomputed`) + `RunSpec` minimal — juste assez pour prouver que la coquille tient. Budgets <400, zéro shim, zéro effet de bord à l'import, zéro champ de spec sans consommateur.
|
| 233 |
+
|
| 234 |
+
## DoD vivante (couche 6) — **autorité de détail** ; le `MIGRATION_PLAN.md` indexe
|
| 235 |
+
|
| 236 |
+
> **T8 (D-066)** : l'orchestrateur collecte un `DocumentUsage` par (pipeline × document) réussi et le passe à `evaluate_run` → `RunResult.usage` trié (déterministe). Preuve : `tests/app/test_orchestrator.py::test_run_result_carries_sorted_usage`.
|
| 237 |
+
|
| 238 |
+
> **T14 (D-072)** : builders `kraken`/`mistral_ocr` + sondes `engine_statuses` (SDK/clé) + `_OCR_ENGINES` étendu — matrice CLI/web alignée. Preuves : `tests/app/test_module_registry.py` · `test_engines.py`.
|
| 239 |
+
|
| 240 |
+
> **T15 (D-073)** : `+ resume.py` (`ResumeStore` + `unit_key`) — cache d'exécution par empreinte, branché à l'orchestrateur (`resume_store=`) et à la CLI (`run --resume-dir`) ; évaluation toujours recalculée. Preuves : `tests/app/test_resume.py`.
|
| 241 |
+
|
| 242 |
+
> Tri-état : `[x]` fait **+ preuve** · `[ ]` à faire · `[~]` différé/réserve + raison.
|
| 243 |
+
> Maj dans le **même commit** que le code. **Statut : ✅ T2** — orchestrateur (+ **workspace** par run) + registre/factory + `RunSpec` + **loader YAML** + **sécurité chemins** verts. **+ T6 découverte entry-points** : `discover_plugins` (groupe `cinoc.modules`, builder tiers enregistré comme le socle ; **fail-closed mode public** ; câblé CLI + web). **Prouvé de bout en bout** : loader injecté + **vrai `EntryPoint`** (résolution dotted-path) + **paquet réellement pip-installé auto-découvert** (`importlib.metadata`, test `slow` isolé). — *preuves : `tests/app/test_plugin_discovery.py`, `tests/app/test_plugin_install.py` + plugins de réf. `tests/fixtures/sample_segmenter_plugin.py` & `…/sample_plugin_pkg/`*. **+ T7 `history.py`** : trait d'union `RunResult`→`HistoryStore` — `records_from_run` aplatit les agrégats par pipeline/vue en `HistoryRecord` (métriques `None` écartées), `record_run` persiste. La connaissance de la forme `RunResult` vit **ici** (couche 6), pas dans le store (couche 5 = persistance pure). — *preuves : `tests/app/test_history.py`*. **Câblage auto livré (S6, D-046)** : `JobRunner` prend un `history_store` optionnel et appelle `record_run` après chaque run terminé (best-effort, comme `publish`) → **différé D-042 levé** ; `create_app` instancie un `HistoryStore` sous `catalog_dir`. — *preuves : `tests/app/test_history_recording.py`*. Différé : reprise partielle, deps-lock (hors CI).
|
| 244 |
+
|
| 245 |
+
**Enveloppe :**
|
| 246 |
+
- [x] `app` = **coquille mince qui NE CALCULE PAS** (appelle pipeline puis `evaluate_run` ; l'assemblage métrique vit en `evaluation`). — *preuve : `test_app_imports_are_allowed` ; l'orchestrateur délègue, n'importe aucune `evaluation.metrics*`*
|
| 247 |
+
- [x] **Registre + factory** (`name→Module` via builder enregistré, convention `<kind>:<label>`) — socle en dur, **seul** point d'extension. — *preuve : `test_module_registry` (build `precomputed` ; kind inconnu ; kwargs incohérents)*
|
| 248 |
+
- [ ] Découverte **entry-points `cinoc.modules`** + `register()` tiers. — *T6*
|
| 249 |
+
- [x] `RunSpec` → `domain` (compose `PipelineSpec` **directement**, sans `StepSpec` — D-010) ; orchestrateur assemble `RunManifest` (`adapter_kwargs` capturés). — *preuve : `test_run_spec` + `test_orchestrator`*
|
| 250 |
+
- [x] **loader YAML** (`RunSpec.model_validate`, `extra="forbid"`) + **sécurité chemins** (`validated_path` : rejet `..`/absolu-hors-base/octet-nul/symlink, §12 ; **GT `must_exist=True`** → erreur typée au chargement, pas d'`OSError` opaque en run) — **pas** de `resolve_adapter_class` (le registre résout `name→Module`, D-010). — *preuve : `test_loader` (+ GT manquante rejetée) + `test_security`*
|
| 251 |
+
- [x] **Isolation des workspaces par pipeline** (sous-dossier dédié) : deux pipelines partageant un `adapter_name` **écrivain** (ex. `openai:gpt`) n'écrasent plus mutuellement leur sortie → zéro contamination inter-pipelines. — *preuve : `test_orchestrator::test_pipelines_sharing_a_writer_do_not_contaminate`* — **corrigé à l'audit T3.**
|
| 252 |
+
- [x] **`RunManifest.module_versions`** : version déclarée de chaque module exécuté capturée (R-2). — *preuve : `test_orchestrator::test_manifest_captures_module_versions`.* `[~]` deps/binaires lock (`dependencies_lock`/`system_binaries_lock`, dont version **binaire** tesseract) : capture live, **différée** (hors CI).
|
| 253 |
+
|
| 254 |
+
**Garde-fous :**
|
| 255 |
+
- [x] `no_side_effect_imports` (**pas de `register_default_*()` auto** ni singleton module-level ; `register_default_modules`/`metrics` explicites) · `file_budgets` · `layer_dependencies`. — *preuve : suite archi verte*
|
| 256 |
+
|
| 257 |
+
**Validation par tranche :** `MIGRATION_PLAN.md` §3 — orchestrateur minimal + registre 1 module + `RunSpec` minimal (T1) · sécurité chemins testée d'abord + repro (T2) · entry-points + plugin (T6).
|
| 258 |
+
|
| 259 |
+
- [~] **Supprimé** : tout `_benchmark_*` (double format) · `results.py`/`legacy.py` (shim) · ½ `partial_store` mort · `python_helpers` (preset). **Différé** : reprise partielle (1 seul mécanisme, à sa tranche).
|
| 260 |
+
|
| 261 |
+
---
|
| 262 |
+
|
| 263 |
+
*(Tous les verdicts ci-dessus sont **PROVISOIRES — à confirmer au build** ; le contact du code amont non encore mergé prévaut.)*
|
cinoc/app/__init__.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Couche 6 — ``app`` : orchestration + registre de modules (coquille mince).
|
| 2 |
+
|
| 3 |
+
``app`` câble les couches internes et **ne calcule pas** : il appelle pipeline
|
| 4 |
+
puis evaluation. ``__init__`` mince, sans effet de bord.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from cinoc.app.loader import RunSpecError, load_run_spec
|
| 10 |
+
from cinoc.app.modules.registry import (
|
| 11 |
+
ModuleRegistry,
|
| 12 |
+
ModuleResolutionError,
|
| 13 |
+
register_default_modules,
|
| 14 |
+
)
|
| 15 |
+
from cinoc.app.orchestrator import OrchestrationError, run
|
| 16 |
+
from cinoc.app.results import RunResultError, dump_run_result, load_run_result
|
| 17 |
+
from cinoc.app.security import PathSecurityError, validated_path
|
| 18 |
+
from cinoc.app.versioning import resolve_code_version
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"ModuleRegistry",
|
| 22 |
+
"ModuleResolutionError",
|
| 23 |
+
"OrchestrationError",
|
| 24 |
+
"PathSecurityError",
|
| 25 |
+
"RunResultError",
|
| 26 |
+
"RunSpecError",
|
| 27 |
+
"dump_run_result",
|
| 28 |
+
"load_run_result",
|
| 29 |
+
"load_run_spec",
|
| 30 |
+
"register_default_modules",
|
| 31 |
+
"resolve_code_version",
|
| 32 |
+
"run",
|
| 33 |
+
"validated_path",
|
| 34 |
+
]
|
cinoc/app/alto_store.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Persistance des exports **ALTO XML** d'un run (couche 6).
|
| 2 |
+
|
| 3 |
+
Un run avec ``Competitor.alto`` produit un artefact ``ALTO_XML`` par document que
|
| 4 |
+
le ``RunResult`` (métriques scalaires) **ne porte pas** et que le workspace
|
| 5 |
+
temporaire du run **détruit** à la fin. Ce store le **persiste**, indexé par
|
| 6 |
+
``run_id``, pour qu'un endpoint web le restitue en archive ZIP — la forme
|
| 7 |
+
ré-importable dans un outil de relecture (eScriptorium, Transkribus).
|
| 8 |
+
|
| 9 |
+
Un seul chemin : le sink du ``JobRunner`` y dépose les ALTO produits ; le routeur
|
| 10 |
+
des rapports les rezippe à la demande. Pas de second format, pas de cache pré-écrit.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import io
|
| 16 |
+
import threading
|
| 17 |
+
import zipfile
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
|
| 20 |
+
from cinoc.app.security import PathSecurityError, safe_stem, validated_path
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class AltoStore:
|
| 24 |
+
"""Registre disque des exports ALTO (``base_dir/<run>/<entrée>.alto.xml``)."""
|
| 25 |
+
|
| 26 |
+
def __init__(self, base_dir: Path) -> None:
|
| 27 |
+
self._base = base_dir
|
| 28 |
+
self._lock = threading.Lock()
|
| 29 |
+
|
| 30 |
+
def save(
|
| 31 |
+
self, run_id: str, document_id: str, pipeline_name: str, xml: bytes
|
| 32 |
+
) -> None:
|
| 33 |
+
"""Persiste un ALTO XML pour ``(run_id, pipeline_name, document_id)``."""
|
| 34 |
+
with self._lock:
|
| 35 |
+
folder = self._base / safe_stem(run_id)
|
| 36 |
+
folder.mkdir(parents=True, exist_ok=True)
|
| 37 |
+
stem = safe_stem(f"{pipeline_name}::{document_id}")
|
| 38 |
+
(folder / f"{stem}.alto.xml").write_bytes(xml)
|
| 39 |
+
|
| 40 |
+
def has(self, run_id: str) -> bool:
|
| 41 |
+
"""``True`` si au moins un ALTO est persisté pour ce run."""
|
| 42 |
+
folder = self._folder(run_id)
|
| 43 |
+
if folder is None or not folder.is_dir():
|
| 44 |
+
return False
|
| 45 |
+
return any(folder.glob("*.alto.xml"))
|
| 46 |
+
|
| 47 |
+
def archive(self, run_id: str) -> bytes | None:
|
| 48 |
+
"""Archive ZIP **déterministe** (entrées triées) des ALTO du run, ou ``None``.
|
| 49 |
+
|
| 50 |
+
``None`` si aucun ALTO n'est persisté (run sans export) → le routeur répond
|
| 51 |
+
``404``. Les octets XML ne sont jamais altérés (ré-import fidèle)."""
|
| 52 |
+
folder = self._folder(run_id)
|
| 53 |
+
if folder is None or not folder.is_dir():
|
| 54 |
+
return None
|
| 55 |
+
files = sorted(folder.glob("*.alto.xml"))
|
| 56 |
+
if not files:
|
| 57 |
+
return None
|
| 58 |
+
buffer = io.BytesIO()
|
| 59 |
+
with zipfile.ZipFile(buffer, "w", zipfile.ZIP_DEFLATED) as archive:
|
| 60 |
+
for path in files:
|
| 61 |
+
archive.writestr(path.name, path.read_bytes())
|
| 62 |
+
return buffer.getvalue()
|
| 63 |
+
|
| 64 |
+
def _folder(self, run_id: str) -> Path | None:
|
| 65 |
+
"""Dossier du run, **chemin validé** (anti path-traversal sur le run_id URL)."""
|
| 66 |
+
try:
|
| 67 |
+
return validated_path(safe_stem(run_id), self._base)
|
| 68 |
+
except PathSecurityError:
|
| 69 |
+
return None
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
__all__ = ["AltoStore"]
|
cinoc/app/corpus_import.py
ADDED
|
@@ -0,0 +1,449 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Matérialisation d'un corpus IIIF en ``CorpusSpec`` (couche 6).
|
| 2 |
+
|
| 3 |
+
Sépare **localiser** (l'adapter ``IIIFImporter`` parle IIIF) de **matérialiser**
|
| 4 |
+
(ici : télécharger les images sous ``dest`` validé, fabriquer le ``CorpusSpec``).
|
| 5 |
+
Sortie **unique** : un ``CorpusSpec`` (jamais un dict-manifeste), prêt pour un run.
|
| 6 |
+
|
| 7 |
+
Images seules → ``DocumentRef`` sans vérité-terrain : le corpus est OCR-able mais
|
| 8 |
+
non scoré tant qu'une GT n'est pas appariée (cf. ``adapters/corpus/iiif.py``).
|
| 9 |
+
|
| 10 |
+
``importer`` et ``download`` sont **injectables** → le chemin de matérialisation
|
| 11 |
+
se teste sans réseau ; le test ``live`` couvre le vrai fetch IIIF.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import json
|
| 17 |
+
import logging
|
| 18 |
+
from collections.abc import Callable, Iterator
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Protocol
|
| 21 |
+
from urllib.parse import urlsplit
|
| 22 |
+
|
| 23 |
+
from cinoc.adapters.corpus import _http
|
| 24 |
+
from cinoc.adapters.corpus.escriptorium import DEFAULT_LAYER, EScriptoriumImporter
|
| 25 |
+
from cinoc.adapters.corpus.gallica import GALLICA_BASE, GallicaImporter, vue_number
|
| 26 |
+
from cinoc.adapters.corpus.huggingface import (
|
| 27 |
+
HFPage,
|
| 28 |
+
snapshot_curated_layout,
|
| 29 |
+
stream_pages,
|
| 30 |
+
)
|
| 31 |
+
from cinoc.adapters.corpus.iiif import IIIFImage, IIIFImporter
|
| 32 |
+
from cinoc.app.security import PathSecurityError, validated_path
|
| 33 |
+
from cinoc.domain.artifacts import ArtifactType
|
| 34 |
+
from cinoc.domain.corpus import CorpusSpec
|
| 35 |
+
from cinoc.domain.documents import DocumentRef, GroundTruthRef
|
| 36 |
+
from cinoc.domain.errors import CinocError
|
| 37 |
+
|
| 38 |
+
logger = logging.getLogger(__name__)
|
| 39 |
+
|
| 40 |
+
#: Extensions d'image reconnues pour nommer le fichier téléchargé.
|
| 41 |
+
_IMAGE_EXT = frozenset({".png", ".jpg", ".jpeg", ".tif", ".tiff", ".jp2"})
|
| 42 |
+
_DEFAULT_EXT = ".jpg"
|
| 43 |
+
|
| 44 |
+
ImageFetcher = Callable[[str], "tuple[IIIFImage, ...]"]
|
| 45 |
+
OcrFetcher = Callable[[int], str]
|
| 46 |
+
#: Streame les pages d'un dataset HF conforme. Injectable (test sans ``datasets``).
|
| 47 |
+
HFStreamer = Callable[..., "Iterator[HFPage]"]
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
class Downloader(Protocol):
|
| 51 |
+
"""Télécharge ``url`` vers ``dest`` ; ``headers`` optionnels (auth même-hôte)."""
|
| 52 |
+
|
| 53 |
+
def __call__(
|
| 54 |
+
self, url: str, dest: Path, *, headers: dict[str, str] | None = None
|
| 55 |
+
) -> None: ...
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class CorpusImportError(CinocError):
|
| 59 |
+
"""L'import d'un corpus distant n'a produit aucune page exploitable."""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _image_ext(image_url: str) -> str:
|
| 63 |
+
suffix = Path(urlsplit(image_url).path).suffix.lower()
|
| 64 |
+
return suffix if suffix in _IMAGE_EXT else _DEFAULT_EXT
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _download_image(
|
| 68 |
+
fetch_bytes: Downloader,
|
| 69 |
+
image_url: str,
|
| 70 |
+
doc_id: str,
|
| 71 |
+
dest_dir: Path,
|
| 72 |
+
*,
|
| 73 |
+
headers: dict[str, str] | None = None,
|
| 74 |
+
) -> str:
|
| 75 |
+
"""Télécharge une image sous ``dest_dir`` (chemin validé) ; renvoie l'URI local.
|
| 76 |
+
|
| 77 |
+
``headers`` (ex. ``Authorization``) n'est fourni que pour un média **même-hôte**
|
| 78 |
+
(cf. ``import_escriptorium_corpus``) ; les imports publics (IIIF/Gallica)
|
| 79 |
+
passent ``None``.
|
| 80 |
+
"""
|
| 81 |
+
target = validated_path(f"{doc_id}{_image_ext(image_url)}", dest_dir)
|
| 82 |
+
fetch_bytes(image_url, target, headers=headers)
|
| 83 |
+
return str(target)
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def import_iiif_corpus(
|
| 87 |
+
manifest_url: str,
|
| 88 |
+
dest: str | Path,
|
| 89 |
+
*,
|
| 90 |
+
name: str,
|
| 91 |
+
limit: int | None = None,
|
| 92 |
+
importer: IIIFImporter | None = None,
|
| 93 |
+
download: Downloader | None = None,
|
| 94 |
+
) -> CorpusSpec:
|
| 95 |
+
"""Importe un manifeste IIIF → images téléchargées sous ``dest`` → ``CorpusSpec``.
|
| 96 |
+
|
| 97 |
+
``limit`` borne le nombre de pages (les premières). ``dest`` est créé au besoin ;
|
| 98 |
+
chaque fichier passe par ``validated_path`` (anti-traversal).
|
| 99 |
+
"""
|
| 100 |
+
fetch_images: ImageFetcher = (importer or IIIFImporter()).fetch_images
|
| 101 |
+
fetch_bytes: Downloader = download or _http.download
|
| 102 |
+
dest_dir = Path(dest)
|
| 103 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 104 |
+
|
| 105 |
+
images = fetch_images(manifest_url)
|
| 106 |
+
if limit is not None:
|
| 107 |
+
images = images[:limit]
|
| 108 |
+
if not images:
|
| 109 |
+
raise CorpusImportError(
|
| 110 |
+
f"aucune image exploitable dans le manifeste {manifest_url!r}."
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
documents: list[DocumentRef] = []
|
| 114 |
+
for index, image in enumerate(images, start=1):
|
| 115 |
+
doc_id = f"page_{index:04d}"
|
| 116 |
+
image_uri = _download_image(fetch_bytes, image.image_url, doc_id, dest_dir)
|
| 117 |
+
documents.append(
|
| 118 |
+
DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=())
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
return CorpusSpec(
|
| 122 |
+
name=name,
|
| 123 |
+
documents=tuple(documents),
|
| 124 |
+
metadata={"source": "iiif", "manifest_url": manifest_url},
|
| 125 |
+
)
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def import_escriptorium_corpus(
|
| 129 |
+
base_url: str,
|
| 130 |
+
token: str,
|
| 131 |
+
doc_pk: int,
|
| 132 |
+
dest: str | Path,
|
| 133 |
+
*,
|
| 134 |
+
name: str | None = None,
|
| 135 |
+
layer: str = DEFAULT_LAYER,
|
| 136 |
+
limit: int | None = None,
|
| 137 |
+
importer: EScriptoriumImporter | None = None,
|
| 138 |
+
download: Downloader | None = None,
|
| 139 |
+
) -> CorpusSpec:
|
| 140 |
+
"""Importe un document eScriptorium → corpus **scorable** sous ``dest``.
|
| 141 |
+
|
| 142 |
+
Pour chaque page : télécharge l'image et écrit le texte de la couche ``layer``
|
| 143 |
+
comme vérité-terrain (``.gt.txt`` → ``GroundTruthRef`` ``RAW_TEXT``). Une page
|
| 144 |
+
sans texte reste image-seule ; une page sans image est ignorée (avertie).
|
| 145 |
+
|
| 146 |
+
Média gardé : le jeton ``Authorization`` n'est joint au download que si le
|
| 147 |
+
média est **sur le même hôte** que ``base_url`` (règle host de D-050). Un média
|
| 148 |
+
servi par un autre hôte (CDN tiers) est téléchargé **sans jeton** — on ne livre
|
| 149 |
+
pas le credential à un tiers (un média gardé y répondra 401, sans fuite).
|
| 150 |
+
"""
|
| 151 |
+
imp = importer or EScriptoriumImporter(base_url, token, layer=layer)
|
| 152 |
+
fetch_bytes: Downloader = download or _http.download
|
| 153 |
+
base_host = urlsplit(base_url).hostname
|
| 154 |
+
media_auth = {"Authorization": f"Token {token}"}
|
| 155 |
+
dest_dir = Path(dest)
|
| 156 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 157 |
+
|
| 158 |
+
pages = imp.fetch_pages(doc_pk)
|
| 159 |
+
if limit is not None:
|
| 160 |
+
pages = pages[:limit]
|
| 161 |
+
|
| 162 |
+
documents: list[DocumentRef] = []
|
| 163 |
+
for page in pages:
|
| 164 |
+
if not page.image_url:
|
| 165 |
+
logger.warning("[escriptorium] part %s sans image — ignorée.", page.pk)
|
| 166 |
+
continue
|
| 167 |
+
doc_id = f"part_{page.pk:05d}"
|
| 168 |
+
same_host = urlsplit(page.image_url).hostname == base_host
|
| 169 |
+
headers = media_auth if same_host else None
|
| 170 |
+
if not same_host:
|
| 171 |
+
logger.warning(
|
| 172 |
+
"[escriptorium] média part %s hors hôte (%s) — téléchargé sans "
|
| 173 |
+
"jeton (pas de fuite vers un tiers).",
|
| 174 |
+
page.pk,
|
| 175 |
+
urlsplit(page.image_url).hostname,
|
| 176 |
+
)
|
| 177 |
+
image_uri = _download_image(
|
| 178 |
+
fetch_bytes, page.image_url, doc_id, dest_dir, headers=headers
|
| 179 |
+
)
|
| 180 |
+
ground_truths: tuple[GroundTruthRef, ...] = ()
|
| 181 |
+
if page.gt_text.strip():
|
| 182 |
+
gt_target = validated_path(f"{doc_id}.gt.txt", dest_dir)
|
| 183 |
+
gt_target.write_text(page.gt_text, encoding="utf-8")
|
| 184 |
+
ground_truths = (
|
| 185 |
+
GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_target)),
|
| 186 |
+
)
|
| 187 |
+
documents.append(
|
| 188 |
+
DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=ground_truths)
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
if not documents:
|
| 192 |
+
raise CorpusImportError(
|
| 193 |
+
f"document eScriptorium {doc_pk} : aucune page avec image exploitable."
|
| 194 |
+
)
|
| 195 |
+
return CorpusSpec(
|
| 196 |
+
name=name or f"escriptorium-{doc_pk}",
|
| 197 |
+
documents=tuple(documents),
|
| 198 |
+
metadata={
|
| 199 |
+
"source": "escriptorium",
|
| 200 |
+
"base_url": base_url.rstrip("/"),
|
| 201 |
+
"doc_pk": str(doc_pk),
|
| 202 |
+
"layer": layer,
|
| 203 |
+
},
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
|
| 207 |
+
def import_gallica_corpus(
|
| 208 |
+
ark: str,
|
| 209 |
+
dest: str | Path,
|
| 210 |
+
*,
|
| 211 |
+
base_url: str = GALLICA_BASE,
|
| 212 |
+
name: str | None = None,
|
| 213 |
+
limit: int | None = None,
|
| 214 |
+
include_ocr: bool = True,
|
| 215 |
+
image_importer: IIIFImporter | None = None,
|
| 216 |
+
download: Downloader | None = None,
|
| 217 |
+
fetch_ocr: OcrFetcher | None = None,
|
| 218 |
+
) -> CorpusSpec:
|
| 219 |
+
"""Importe un document Gallica (ARK) → images (via IIIF) + OCR Gallica optionnel.
|
| 220 |
+
|
| 221 |
+
Les images viennent du **manifeste IIIF** Gallica ; si ``include_ocr``, l'OCR
|
| 222 |
+
brut de chaque vue (``texteBrut``) est écrit comme **référence étiquetée** :
|
| 223 |
+
type domaine ``REFERENCE_TEXT`` (≠ vérité-terrain manuelle) + métadonnée
|
| 224 |
+
``gt_source=gallica_ocr``. Une vue d'évaluation par défaut **ne le score pas**
|
| 225 |
+
comme une GT ; seule une vue *référence* dédiée (opt-in) le compare.
|
| 226 |
+
|
| 227 |
+
Le numéro de vue est **lu dans l'URL de l'image** (``/f{n}/``), pas déduit de la
|
| 228 |
+
position : un canvas sans image sauté par le parseur IIIF décalerait sinon tout
|
| 229 |
+
l'OCR (le bug ``selected_indices+1`` réintroduit). Position 1-based = repli si
|
| 230 |
+
l'URL ne porte pas de ``/f{n}/``.
|
| 231 |
+
"""
|
| 232 |
+
gallica = GallicaImporter(ark, base_url=base_url)
|
| 233 |
+
fetch_bytes: Downloader = download or _http.download
|
| 234 |
+
ocr_of: OcrFetcher = fetch_ocr or gallica.fetch_ocr_text
|
| 235 |
+
dest_dir = Path(dest)
|
| 236 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 237 |
+
|
| 238 |
+
images = (image_importer or IIIFImporter()).fetch_images(gallica.manifest_url)
|
| 239 |
+
if limit is not None:
|
| 240 |
+
images = images[:limit]
|
| 241 |
+
if not images:
|
| 242 |
+
raise CorpusImportError(f"aucune image pour l'ARK Gallica {gallica.ark!r}.")
|
| 243 |
+
|
| 244 |
+
documents: list[DocumentRef] = []
|
| 245 |
+
has_ocr_gt = False
|
| 246 |
+
for position, image in enumerate(images, start=1):
|
| 247 |
+
vue = vue_number(image.image_url) or position
|
| 248 |
+
doc_id = f"f{vue:04d}"
|
| 249 |
+
image_uri = _download_image(fetch_bytes, image.image_url, doc_id, dest_dir)
|
| 250 |
+
ground_truths: tuple[GroundTruthRef, ...] = ()
|
| 251 |
+
if include_ocr:
|
| 252 |
+
text = ocr_of(vue)
|
| 253 |
+
if text.strip():
|
| 254 |
+
gt_target = validated_path(f"{doc_id}.gallica_ocr.txt", dest_dir)
|
| 255 |
+
gt_target.write_text(text, encoding="utf-8")
|
| 256 |
+
ground_truths = (
|
| 257 |
+
GroundTruthRef(
|
| 258 |
+
type=ArtifactType.REFERENCE_TEXT, uri=str(gt_target)
|
| 259 |
+
),
|
| 260 |
+
)
|
| 261 |
+
has_ocr_gt = True
|
| 262 |
+
documents.append(
|
| 263 |
+
DocumentRef(id=doc_id, image_uri=image_uri, ground_truths=ground_truths)
|
| 264 |
+
)
|
| 265 |
+
|
| 266 |
+
metadata = {
|
| 267 |
+
"source": "gallica",
|
| 268 |
+
"ark": gallica.ark,
|
| 269 |
+
"manifest_url": gallica.manifest_url,
|
| 270 |
+
}
|
| 271 |
+
if has_ocr_gt:
|
| 272 |
+
metadata["gt_source"] = "gallica_ocr"
|
| 273 |
+
return CorpusSpec(
|
| 274 |
+
name=name or f"gallica-{gallica.ark.split('/')[-1]}",
|
| 275 |
+
documents=tuple(documents),
|
| 276 |
+
metadata=metadata,
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def import_hf_corpus(
|
| 281 |
+
dataset_id: str,
|
| 282 |
+
dest: str | Path,
|
| 283 |
+
*,
|
| 284 |
+
name: str | None = None,
|
| 285 |
+
split: str = "train",
|
| 286 |
+
limit: int | None = None,
|
| 287 |
+
stream: HFStreamer | None = None,
|
| 288 |
+
) -> CorpusSpec:
|
| 289 |
+
"""Importe un dataset **HuggingFace** (convention Cinoc) → ``CorpusSpec`` scorable.
|
| 290 |
+
|
| 291 |
+
Streamé **page-par-page** (jamais de snapshot complet) : chaque page écrit son
|
| 292 |
+
image et son texte de vérité-terrain. La GT d'un dataset Cinoc curé est une
|
| 293 |
+
**vraie GT** (``GroundTruthRef`` ``RAW_TEXT``), pas une référence OCR. ``limit``
|
| 294 |
+
borne le nombre de pages. ``stream`` est injectable (test sans ``datasets``).
|
| 295 |
+
"""
|
| 296 |
+
pages = (stream or stream_pages)(dataset_id, split=split, limit=limit)
|
| 297 |
+
dest_dir = Path(dest)
|
| 298 |
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
| 299 |
+
|
| 300 |
+
documents: list[DocumentRef] = []
|
| 301 |
+
for index, page in enumerate(pages, start=1):
|
| 302 |
+
doc_id = f"page_{index:04d}"
|
| 303 |
+
target = validated_path(f"{doc_id}{page.image_ext}", dest_dir)
|
| 304 |
+
target.write_bytes(page.image_bytes)
|
| 305 |
+
ground_truths: tuple[GroundTruthRef, ...] = ()
|
| 306 |
+
if page.gt_text.strip():
|
| 307 |
+
gt_target = validated_path(f"{doc_id}.gt.txt", dest_dir)
|
| 308 |
+
gt_target.write_text(page.gt_text, encoding="utf-8")
|
| 309 |
+
ground_truths = (
|
| 310 |
+
GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_target)),
|
| 311 |
+
)
|
| 312 |
+
documents.append(
|
| 313 |
+
DocumentRef(id=doc_id, image_uri=str(target), ground_truths=ground_truths)
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
if not documents:
|
| 317 |
+
raise CorpusImportError(
|
| 318 |
+
f"dataset HuggingFace {dataset_id!r} (split {split!r}) : aucune page."
|
| 319 |
+
)
|
| 320 |
+
return CorpusSpec(
|
| 321 |
+
name=name or f"hf-{dataset_id.replace('/', '-')}",
|
| 322 |
+
documents=tuple(documents),
|
| 323 |
+
metadata={"source": "huggingface", "dataset_id": dataset_id, "split": split},
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
def _curated_entry(entry: object, root: Path, base_url: str | None) -> DocumentRef:
|
| 328 |
+
"""Une entrée ``corpus.json`` → ``DocumentRef`` (image = référence, GT = vraie).
|
| 329 |
+
|
| 330 |
+
Image : ``base_url`` + chemin relatif si fourni — le chemin est un dérivé
|
| 331 |
+
**IIIF statique** du layout (``iiif/<id>/full/…``), et ``base_url`` la racine
|
| 332 |
+
``resolve`` HF épinglée (ou un serveur IIIF d'institution) ; sinon chemin
|
| 333 |
+
local du dérivé medium. La référence est matérialisée **au run** par
|
| 334 |
+
l'orchestrateur. GT : ``RAW_TEXT`` sur le ``.gt.txt`` local. Chemins relatifs
|
| 335 |
+
durcis (``validated_path`` rejette ``..``) — un dataset tiers hostile ne peut
|
| 336 |
+
pas pointer hors du layout."""
|
| 337 |
+
if not isinstance(entry, dict):
|
| 338 |
+
raise CorpusImportError(f"entrée corpus.json invalide : {entry!r}")
|
| 339 |
+
try:
|
| 340 |
+
doc_id, image_rel, gt_rel = entry["id"], entry["image"], entry["ground_truth"]
|
| 341 |
+
except (KeyError, TypeError) as exc:
|
| 342 |
+
raise CorpusImportError(f"entrée corpus.json incomplète : {entry!r}") from exc
|
| 343 |
+
try:
|
| 344 |
+
gt_path = validated_path(str(gt_rel), root) # rejette le path-traversal
|
| 345 |
+
image_local = validated_path(str(image_rel), root) # idem + valide la forme
|
| 346 |
+
except PathSecurityError as exc:
|
| 347 |
+
raise CorpusImportError(f"chemin non sûr dans corpus.json : {exc}") from exc
|
| 348 |
+
image_uri = (
|
| 349 |
+
f"{base_url.rstrip('/')}/{str(image_rel).lstrip('/')}"
|
| 350 |
+
if base_url
|
| 351 |
+
else str(image_local)
|
| 352 |
+
)
|
| 353 |
+
stratum = entry.get("stratum")
|
| 354 |
+
return DocumentRef(
|
| 355 |
+
id=str(doc_id),
|
| 356 |
+
image_uri=image_uri,
|
| 357 |
+
ground_truths=(GroundTruthRef(type=ArtifactType.RAW_TEXT, uri=str(gt_path)),),
|
| 358 |
+
metadata={"stratum": str(stratum)} if stratum else {},
|
| 359 |
+
)
|
| 360 |
+
|
| 361 |
+
|
| 362 |
+
def import_curated_corpus(
|
| 363 |
+
layout_dir: str | Path,
|
| 364 |
+
*,
|
| 365 |
+
base_url: str | None = None,
|
| 366 |
+
revision: str | None = None,
|
| 367 |
+
name: str | None = None,
|
| 368 |
+
) -> CorpusSpec:
|
| 369 |
+
"""Importe un **layout dataset canonique Cinoc** → ``CorpusSpec`` scorable.
|
| 370 |
+
|
| 371 |
+
Variante **par référence** de ``import_hf_corpus`` (même sortie, pas un format
|
| 372 |
+
parallèle) : produit par ``app.dataset_standardize`` ou snapshot d'un dataset
|
| 373 |
+
HF publié. L'image est une **référence** (``base_url`` + dérivé IIIF statique
|
| 374 |
+
du layout — en pratique une URL ``resolve`` HF épinglée ; sinon chemin local
|
| 375 |
+
du dérivé medium), matérialisée au run par l'orchestrateur ; la GT est une
|
| 376 |
+
**vraie GT** ``RAW_TEXT``. ``base_url`` + ``revision`` (SHA HF) →
|
| 377 |
+
reproductibilité pinnée (``RunManifest``).
|
| 378 |
+
"""
|
| 379 |
+
root = Path(layout_dir)
|
| 380 |
+
manifest_path = root / "corpus.json"
|
| 381 |
+
if not manifest_path.is_file():
|
| 382 |
+
raise CorpusImportError(f"layout cur�� sans corpus.json : {root}")
|
| 383 |
+
try:
|
| 384 |
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 385 |
+
except (OSError, json.JSONDecodeError) as exc:
|
| 386 |
+
raise CorpusImportError(f"corpus.json illisible ({root}) : {exc}") from exc
|
| 387 |
+
entries = manifest.get("documents") if isinstance(manifest, dict) else None
|
| 388 |
+
if not isinstance(entries, list) or not entries:
|
| 389 |
+
raise CorpusImportError(f"corpus.json sans documents : {root}")
|
| 390 |
+
|
| 391 |
+
documents = tuple(_curated_entry(e, root, base_url) for e in entries)
|
| 392 |
+
corpus_meta: dict[str, str] = {"source": "curated"}
|
| 393 |
+
if revision:
|
| 394 |
+
corpus_meta["revision"] = revision
|
| 395 |
+
licenses = manifest.get("license")
|
| 396 |
+
if isinstance(licenses, dict):
|
| 397 |
+
for key, value in licenses.items():
|
| 398 |
+
corpus_meta[f"license_{key}"] = str(value)
|
| 399 |
+
return CorpusSpec(
|
| 400 |
+
name=name or str(manifest.get("name") or root.name),
|
| 401 |
+
documents=documents,
|
| 402 |
+
metadata=corpus_meta,
|
| 403 |
+
)
|
| 404 |
+
|
| 405 |
+
|
| 406 |
+
#: Télécharge un layout curé HF → dossier local (au moins ``corpus.json`` + GT).
|
| 407 |
+
#: Injectable → l'import distant se teste sans réseau ni ``huggingface_hub``.
|
| 408 |
+
CuratedFetcher = Callable[[str, Path, "str | None"], Path]
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
def _hf_resolve_base(repo_id: str, revision: str | None) -> str:
|
| 412 |
+
"""Racine ``resolve`` d'un dataset HF, épinglée à ``revision`` (sinon ``main``)."""
|
| 413 |
+
return f"https://huggingface.co/datasets/{repo_id}/resolve/{revision or 'main'}"
|
| 414 |
+
|
| 415 |
+
|
| 416 |
+
def import_curated_hf_corpus(
|
| 417 |
+
repo_id: str,
|
| 418 |
+
dest: str | Path,
|
| 419 |
+
*,
|
| 420 |
+
revision: str | None = None,
|
| 421 |
+
name: str | None = None,
|
| 422 |
+
fetch: CuratedFetcher | None = None,
|
| 423 |
+
) -> CorpusSpec:
|
| 424 |
+
"""Importe un **dataset curé Cinoc publié sur HF** → ``CorpusSpec`` scorable.
|
| 425 |
+
|
| 426 |
+
Variante **distante** de ``import_curated_corpus`` : télécharge **uniquement**
|
| 427 |
+
le manifeste + la GT (les images restent des références IIIF HF, épinglées à
|
| 428 |
+
``revision``), puis délègue à l'importeur local. Un corpus de référence publié
|
| 429 |
+
devient ainsi sélectionnable au Banc d'essai en quelques secondes, sans
|
| 430 |
+
rapatrier les images. ``fetch`` est injectable (test sans réseau).
|
| 431 |
+
"""
|
| 432 |
+
layout_dir = (fetch or snapshot_curated_layout)(repo_id, Path(dest), revision)
|
| 433 |
+
return import_curated_corpus(
|
| 434 |
+
layout_dir,
|
| 435 |
+
base_url=_hf_resolve_base(repo_id, revision),
|
| 436 |
+
revision=revision,
|
| 437 |
+
name=name,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
__all__ = [
|
| 442 |
+
"CorpusImportError",
|
| 443 |
+
"import_curated_corpus",
|
| 444 |
+
"import_curated_hf_corpus",
|
| 445 |
+
"import_escriptorium_corpus",
|
| 446 |
+
"import_gallica_corpus",
|
| 447 |
+
"import_hf_corpus",
|
| 448 |
+
"import_iiif_corpus",
|
| 449 |
+
]
|