diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000000000000000000000000000000000000..f17f26d520a67f4294aea96c5c2e15ea4d92dbe5 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,8 @@ +*.png filter=lfs diff=lfs merge=lfs -text +*.jpg filter=lfs diff=lfs merge=lfs -text +*.jpeg filter=lfs diff=lfs merge=lfs -text +*.pdf filter=lfs diff=lfs merge=lfs -text +*.woff2 filter=lfs diff=lfs merge=lfs -text +*.woff filter=lfs diff=lfs merge=lfs -text +*.otf filter=lfs diff=lfs merge=lfs -text +*.ttf filter=lfs diff=lfs merge=lfs -text diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..51b06bb4ef3ba5048ba7942db274ea55171d2482 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,90 @@ +# Image **moteur** Cinoc pour un Hugging Face Space (SDK: docker). +# +# ≠ la vitrine lecture seule (deploy/Dockerfile{,.space}) : celle-ci **bake un vrai +# moteur OCR gratuit** (Tesseract 5 + langues fra/lat/eng) pour qu'un visiteur du +# Space public lance un run Tesseract **sans clé ni installation**. Les moteurs +# cloud et les plugins tiers restent *gated* (mode public, fail-closed côté appli). +# +# Contexte de build « racine plate » (Dockerfile / requirements.txt / pyproject.toml +# / cinoc / reports TOUS à la racine) — assemblé par .github/workflows/deploy-space.yml, +# comme Dockerfile.space. +FROM python:3.11-slim + +# ── Dépendances système : Tesseract 5 + langues + libs image (Pillow) ────────── +# Les langues sont baquées (pas téléchargées au runtime) : un Space free-tier n'a +# pas d'accès réseau garanti au démarrage, et le déterminisme exige une version de +# binaire figée. ``fra`` est le besoin patrimonial central ; ``lat`` (latin) et +# ``eng`` complètent. Pas de pin de version exacte (Debian point-release rebump +# fréquemment ``5.3.0-2`` → ``+deb12u1`` → casse le build) : la repro repose sur la +# capture de ``tesseract --version`` dans ``RunManifest.system_binaries_lock``. +RUN apt-get update -o Acquire::Retries=3 \ + && apt-get install -y --no-install-recommends -o Acquire::Retries=3 \ + tesseract-ocr \ + tesseract-ocr-fra \ + tesseract-ocr-lat \ + tesseract-ocr-eng \ + libtiff6 \ + libjpeg62-turbo \ + libpng16-16 \ + libwebp7 \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# ── Variables d'exécution ────────────────────────────────────────────────────── +# TESSDATA_PREFIX : emplacement Debian bookworm des modèles Tesseract 5. +ENV TESSDATA_PREFIX=/usr/share/tesseract-ocr/5/tessdata +# OMP_THREAD_LIMIT=1 : OBLIGATOIRE sur free-tier 2 vCPU. Tesseract LSTM (oem 3) +# parallélise via OpenMP ; non borné sur un CPU partagé il **deadlock** (incident +# documenté côté Picarones, 2026-05-16) — l'OCR gèle et chaque document timeoute. +# Mono-thread par appel = plus rapide ET déterministe ici. +ENV OMP_THREAD_LIMIT=1 +# Mode public (verrou *fail-closed*) **non forcé** : par défaut l'instance exécute +# ses moteurs (cloud compris) avec la clé de l'opérateur — pas de 403. Qui expose +# un Space **public** et veut protéger sa clé d'un visiteur pose lui-même +# CINOC_PUBLIC_MODE=true (alors seul le socle gratuit tesseract s'exécute). +ENV PYTHONUNBUFFERED=1 + +# ── Smoke-test du binaire au BUILD (fail-fast) ───────────────────────────────── +# Un Tesseract cassé OU figé doit casser le BUILD (le Space garde l'image +# précédente fonctionnelle), pas déployer une image qui gèle en silence : +# (a) le binaire charge ses libs et expose la langue ``fra`` (sinon build refusé) ; +# (b) une reconnaissance réelle sur une image générée se TERMINE sous 30 s — un +# timeout (rc 124) = deadlock OpenMP / libs incohérentes → build refusé. +RUN set -eu; \ + timeout 30 tesseract --version; \ + timeout 30 tesseract --list-langs 2>&1 | grep -qx fra; \ + printf 'P1\n16 16\n' > /tmp/smoke.pbm; \ + i=0; while [ "$i" -lt 16 ]; do \ + printf '1 0 1 0 1 0 1 0 1 0 1 0 1 0 1 0\n' >> /tmp/smoke.pbm; \ + i=$((i + 1)); \ + done; \ + rc=0; \ + timeout 30 tesseract /tmp/smoke.pbm - -l fra --psm 6 >/dev/null 2>&1 || rc=$?; \ + rm -f /tmp/smoke.pbm; \ + if [ "$rc" = 124 ]; then \ + echo "FATAL: tesseract a gelé (timeout) — build refusé, libs incohérentes." >&2; \ + exit 1; \ + fi + +# ── Utilisateur non-root (sécurité d'un service exposé) ──────────────────────── +RUN useradd --create-home --uid 1000 cinoc +WORKDIR /home/cinoc/app + +# ── Dépendances Python d'abord (cache de couche) ─────────────────────────────── +# requirements.txt fige le cœur + l'extra [serve] + pytesseract (wrapper du binaire). +COPY requirements.txt ./requirements.txt +RUN pip install --no-cache-dir -r requirements.txt + +# ── Code + paquet (sans deps : tout est déjà figé ci-dessus) ─────────────────── +COPY pyproject.toml README.md ./ +COPY cinoc ./cinoc +RUN pip install --no-cache-dir --no-deps . + +# ── Rapports bakés (lecture seule) — dossier toujours présent ────────────────── +COPY reports ./reports +ENV CINOC_REPORTS_DIR=/home/cinoc/app/reports + +USER cinoc +EXPOSE 7860 +# 0.0.0.0 attendu en conteneur (le réseau est géré par la plateforme). +CMD ["cinoc", "serve", "--host", "0.0.0.0", "--port", "7860"] diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..584fbe6c9aa845ca3c7f4a2fc2d09605fbb70be1 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +--- +title: Cinoc — banc d'essai OCR/HTR/VLM +emoji: 📜 +colorFrom: indigo +colorTo: gray +sdk: docker +app_port: 7860 +pinned: false +license: apache-2.0 +--- + +# Cinoc — banc d'essai OCR/HTR/VLM + +Banc d'essai **déterministe** de pipelines de transcription (OCR / HTR / VLM). +Ce Space **exécute un vrai OCR Tesseract gratuitement** — **sans clé ni +installation** : déposez un corpus, lancez Tesseract, obtenez un rapport CER/WER/MER +réel. Il sert aussi des rapports `RunResult` pré-calculés, rendus en HTML autonome. + +- `/` — liste des rapports disponibles +- `/library` — préparer un corpus (upload ZIP) +- `/benchmark` — lancer un run **Tesseract** (gratuit, local) sur un corpus +- `/reports/` — un rapport rendu en HTML +- `/health` — sonde de vivacité + +**Par défaut, l'instance exécute ses moteurs avec la clé de son opérateur** — +aucun blocage. Sans clé posée, seul le **socle gratuit** tourne (Tesseract, aucun +secret, aucun appel facturé) ; dès qu'une clé est présente (`MISTRAL_API_KEY`, +`OPENAI_API_KEY`…), les moteurs **cloud** (OCR→LLM, VLM) s'exécutent normalement. + +## Utiliser un moteur cloud (Mistral, OpenAI…) + +1. **Secret** : `MISTRAL_API_KEY` = votre clé (Settings → Variables and secrets). + Le SDK `mistralai` est déjà dans `requirements.txt`. Redéployez. +2. C'est tout : le menu déroulant « Modèle » du Banc d'essai se remplit directement + depuis l'API Mistral (`models.list`) et les prompts sont éditables par concurrent. + +> 🔴 **Sécurité** : sur un Space **public**, la clé que vous posez est dépensable par +> n'importe quel visiteur. Rendez le Space **Private** (Settings → Visibility → +> Private) si vous y posez une clé facturée, **ou** activez le verrou ci-dessous. + +## Verrou « mode public » — opt-in, pour protéger une clé sur un Space public + +Si vous tenez à exposer un Space **public** *tout en* gardant une clé posée sans +qu'un visiteur puisse la dépenser, activez le verrou **fail-closed** : variable +`CINOC_PUBLIC_MODE` = `true` (Settings → Variables and secrets). Alors seul le +**socle gratuit** (Tesseract) s'exécute ; les moteurs **cloud**, les **imports +distants** (IIIF/Gallica/…) et les **plugins tiers** sont **refusés** (`403`). Le +verrou est **désactivé par défaut** : ne le posez que si ce scénario est le vôtre. + +> Ce fichier est l'en-tête de configuration du Space Hugging Face (`sdk: docker`). +> Au déploiement, il devient le `README.md` racine du dépôt du Space ; le +> `Dockerfile` et `requirements.txt` vivent dans `deploy/`. diff --git a/cinoc/_version.py b/cinoc/_version.py new file mode 100644 index 0000000000000000000000000000000000000000..b8e5f34a9b90665b36c92166910e9da5b283f040 --- /dev/null +++ b/cinoc/_version.py @@ -0,0 +1,24 @@ +# file generated by vcs-versioning +# don't change, don't track in version control +from __future__ import annotations + +__all__ = [ + "__version__", + "__version_tuple__", + "version", + "version_tuple", + "__commit_id__", + "commit_id", +] + +version: str +__version__: str +__version_tuple__: tuple[int | str, ...] +version_tuple: tuple[int | str, ...] +commit_id: str | None +__commit_id__: str | None + +__version__ = version = '0.1.1.dev1+g7ca8e2eab' +__version_tuple__ = version_tuple = (0, 1, 1, 'dev1', 'g7ca8e2eab') + +__commit_id__ = commit_id = 'g7ca8e2eab' diff --git a/cinoc/adapters/ANALYSE_COUCHE_5.md b/cinoc/adapters/ANALYSE_COUCHE_5.md new file mode 100644 index 0000000000000000000000000000000000000000..e093a4c77c44c18dcbe3120a5ecf0130b993903f --- /dev/null +++ b/cinoc/adapters/ANALYSE_COUCHE_5.md @@ -0,0 +1,271 @@ +# ANALYSE_COUCHE_5.md — `adapters/` (Picarones → Cinoc) + +> **Type** : session d'ANALYSE (ne code rien). Guide de portage **durable**. +> **Couche** : 5 = `adapters` (cf. `CLAUDE.md` §3 : `domain(1) ← formats(2) ← evaluation(3) ← pipeline(4) ← adapters(5) ← app(6) ← reports(7) ← interfaces(8)`). +> **Source gelée** : `../Picarones/picarones/adapters/` — **41 fichiers, 9 682 LOC** (mesuré). +> **Cible** : `cinoc/adapters/`. +> **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**). +> **Partie 1 = durable** (source figée). **Partie 2 = périssable** (« à confirmer à la tranche »). + +--- + +## 0. Drapeaux à lever AVANT toute construction (lire en premier) + +| # | Drapeau | Statut | Action | +|---|---|---|---| +| **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`. | +| **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. | +| **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. | +| **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. | + +Aucun de ces points ne contredit une couche **mergée** ni une décision actée → analyse poursuivie. + +--- + +# PARTIE 1 — ANALYSE DE LA SOURCE PICARONES (durable) + +## 1.1 Inventaire exact (41 fichiers / 9 682 LOC) + +| Sous-paquet | Fichiers | LOC | Rôle vérifié | +|---|---|---|---| +| **`/` (utils racine)** | 6 | **866** | I/O atomique, retry, http(x), downscale image, résolution de chemins. Mutualisés OCR+LLM. | +| **`ocr/`** | 12 | **3 206** | Contrat `BaseOCRAdapter` + factory + 8 moteurs + sidecar confidences. | +| **`corpus/`** | 8 | **3 045** | Importeurs de corpus patrimoniaux (IIIF, Gallica, eScriptorium, HTR-United, HuggingFace) + http urllib anti-SSRF + journal fallback. | +| **`llm/`** | 6 | **1 208** | Post-correction texte (`RAW_TEXT → CORRECTED_TEXT`). Base + 4 providers. | +| **`storage/`** | 3 | **964** | `ArtifactStore` (cache fichier) + `JobStore` (SQLite jobs web). | +| **`vlm/`** | 6 | **393** | Transcription `IMAGE → RAW_TEXT` zéro-OCR. Base + 4 manifestes de composition. | + +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`. + +## 1.2 Le contrat réel (la couture décisive `domain↔pipeline↔adapters`) + +**Trois contrats coexistent ; un seul est vivant côté runtime.** + +| Contrat | Lieu | Forme | Statut réel | +|---|---|---|---| +| `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). | +| **`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é). | +| `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)`. | + +**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. + +> ⚠️ **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.)* + +## 1.3 Verdicts fichier par fichier — **PROVISOIRE — à confirmer au build** + +Légende verdict : **G**=garder · **M**=modifier · **C**=changer de couche · **S**=supprimer · **I**=incrémental (porter à la tranche qui le consomme, pas avant). + +### Utils racine (`adapters/*.py`) + +| Fichier | LOC | Rôle vérifié | Consommateurs réels (hors tests) | Bugs / dette | Verdict | +|---|---|---|---|---|---| +| `__init__.py` | 28 | Docstring de paquet, `__all__=[]`. | 0 (imports par chemin complet). | — | **G** (mince) | +| `_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). | +| `_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). | +| `_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). | +| `_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. | +| `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**. | + +### `ocr/` + +| Fichier | LOC | Rôle / sortie | Consommateurs réels | Bugs / dette | Verdict | +|---|---|---|---|---|---| +| `__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). | +| `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. | +| `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). | +| `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`). | +| `precomputed.py` | 221 | **Starter.** Lit `.