Spaces:
Sleeping
Sleeping
Merge pull request #55 from maribakulj/claude/repo-analysis-cukvm
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .github/workflows/ci.yml +29 -6
- .gitignore +3 -0
- BACKLOG_POST_LIVRAISON.md +228 -0
- CHANGELOG.md +282 -0
- README.md +52 -29
- codecov.yml +97 -0
- docs/audits/institutional-readiness-2026-05.md +1 -1
- docs/migration/executor-equivalence.md +165 -0
- docs/migration/rewrite-status-s46.md +185 -0
- docs/roadmap/rewrite-2026.md +185 -0
- docs/views/alto-view.md +113 -0
- docs/views/comparing-views.md +117 -0
- docs/views/text-view.md +144 -0
- picarones/adapters/__init__.py +28 -0
- picarones/adapters/_retry.py +143 -0
- picarones/adapters/corpus/__init__.py +16 -0
- picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc +0 -0
- picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc +0 -0
- picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc +0 -0
- picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc +0 -0
- picarones/adapters/corpus/_fallback_log.py +98 -0
- picarones/adapters/corpus/htr_united.py +473 -0
- picarones/adapters/corpus/huggingface.py +464 -0
- picarones/adapters/llm/__init__.py +16 -0
- picarones/adapters/llm/anthropic_adapter.py +111 -0
- picarones/adapters/llm/base.py +486 -0
- picarones/adapters/llm/mistral_adapter.py +157 -0
- picarones/adapters/llm/ollama_adapter.py +109 -0
- picarones/adapters/llm/openai_adapter.py +94 -0
- picarones/adapters/ocr/__init__.py +39 -0
- picarones/adapters/ocr/azure_doc_intel.py +376 -0
- picarones/adapters/ocr/base.py +173 -0
- picarones/adapters/ocr/confidences.py +164 -0
- picarones/adapters/ocr/google_vision.py +306 -0
- picarones/adapters/ocr/mistral_ocr.py +336 -0
- picarones/adapters/ocr/pero_ocr.py +232 -0
- picarones/adapters/ocr/precomputed.py +219 -0
- picarones/adapters/ocr/tesseract.py +327 -0
- picarones/adapters/output_paths.py +78 -0
- picarones/adapters/storage/__init__.py +58 -0
- picarones/adapters/storage/artifact_store.py +417 -0
- picarones/adapters/storage/job_store.py +470 -0
- picarones/adapters/vlm/__init__.py +42 -0
- picarones/adapters/vlm/anthropic_vlm.py +32 -0
- picarones/adapters/vlm/base.py +240 -0
- picarones/adapters/vlm/mistral_vlm.py +26 -0
- picarones/adapters/vlm/ollama_vlm.py +26 -0
- picarones/adapters/vlm/openai_vlm.py +22 -0
- picarones/app/__init__.py +27 -0
- picarones/app/results.py +123 -0
.github/workflows/ci.yml
CHANGED
|
@@ -30,6 +30,13 @@ jobs:
|
|
| 30 |
name: Tests Python ${{ matrix.python-version }} / ${{ matrix.os }}
|
| 31 |
runs-on: ${{ matrix.os }}
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
strategy:
|
| 34 |
fail-fast: false
|
| 35 |
matrix:
|
|
@@ -85,10 +92,14 @@ jobs:
|
|
| 85 |
# ── Tests ───────────────────────────────────────────────────
|
| 86 |
# Sprint A1 : --cov-fail-under=85 (baseline mesuré 87 %, marge 2 pts).
|
| 87 |
# pytest-timeout est configuré dans pyproject.toml [tool.pytest.ini_options].
|
|
|
|
|
|
|
|
|
|
| 88 |
- name: Run tests
|
| 89 |
# Sur Python 3.13, on continue malgré une erreur pour ne pas bloquer
|
| 90 |
# le merge pendant la fenêtre informationnelle de 6 mois (m-8).
|
| 91 |
continue-on-error: ${{ matrix.python-version == '3.13' }}
|
|
|
|
| 92 |
shell: bash
|
| 93 |
run: |
|
| 94 |
pytest tests/ -q --tb=short --no-header \
|
|
@@ -99,17 +110,29 @@ jobs:
|
|
| 99 |
PYTHONUTF8: "1"
|
| 100 |
|
| 101 |
# ── Couverture ──────────────────────────────────────────────
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
- name: Upload coverage to Codecov
|
| 103 |
-
if: runner.os == 'Linux' && matrix.python-version == '3.11' && env.CODECOV_TOKEN != ''
|
|
|
|
| 104 |
uses: codecov/codecov-action@v4
|
| 105 |
with:
|
| 106 |
-
token: ${{
|
| 107 |
files: coverage.xml
|
| 108 |
flags: unittests
|
| 109 |
name: picarones-coverage
|
| 110 |
-
fail_ci_if_error:
|
| 111 |
-
env:
|
| 112 |
-
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
| 113 |
|
| 114 |
# ──────────────────────────────────────────────────────────────────
|
| 115 |
# Job 2 : Vérification du rapport demo
|
|
@@ -340,4 +363,4 @@ jobs:
|
|
| 340 |
# --corpus ./tests/fixtures/reference_corpus/ \
|
| 341 |
# --engines tesseract \
|
| 342 |
# --output results_pr.json \
|
| 343 |
-
# --fail-if-cer-above
|
|
|
|
| 30 |
name: Tests Python ${{ matrix.python-version }} / ${{ matrix.os }}
|
| 31 |
runs-on: ${{ matrix.os }}
|
| 32 |
|
| 33 |
+
# ``CODECOV_TOKEN`` au niveau JOB plutôt que step : nécessaire
|
| 34 |
+
# pour que ``env.CODECOV_TOKEN`` soit visible dans le ``if:`` de
|
| 35 |
+
# l'étape Codecov (le ``env`` d'un step n'est PAS résolu avant
|
| 36 |
+
# l'évaluation du ``if`` de ce même step).
|
| 37 |
+
env:
|
| 38 |
+
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
| 39 |
+
|
| 40 |
strategy:
|
| 41 |
fail-fast: false
|
| 42 |
matrix:
|
|
|
|
| 92 |
# ── Tests ───────────────────────────────────────────────────
|
| 93 |
# Sprint A1 : --cov-fail-under=85 (baseline mesuré 87 %, marge 2 pts).
|
| 94 |
# pytest-timeout est configuré dans pyproject.toml [tool.pytest.ini_options].
|
| 95 |
+
# ``timeout-minutes`` au niveau step : le job ne hang JAMAIS plus de
|
| 96 |
+
# 15 min sur les tests, même si pytest-timeout (par-test) échoue à
|
| 97 |
+
# cleanup un thread daemon.
|
| 98 |
- name: Run tests
|
| 99 |
# Sur Python 3.13, on continue malgré une erreur pour ne pas bloquer
|
| 100 |
# le merge pendant la fenêtre informationnelle de 6 mois (m-8).
|
| 101 |
continue-on-error: ${{ matrix.python-version == '3.13' }}
|
| 102 |
+
timeout-minutes: 15
|
| 103 |
shell: bash
|
| 104 |
run: |
|
| 105 |
pytest tests/ -q --tb=short --no-header \
|
|
|
|
| 110 |
PYTHONUTF8: "1"
|
| 111 |
|
| 112 |
# ── Couverture ──────────────────────────────────────────────
|
| 113 |
+
# Conditions :
|
| 114 |
+
# - ``always()`` : on remonte la couverture MÊME quand pytest a
|
| 115 |
+
# échoué (utile pour suivre la dérive sur un build cassé).
|
| 116 |
+
# - ``runner.os == 'Linux' && python-version == '3.11'`` : un seul
|
| 117 |
+
# upload par run pour ne pas saturer le rate limit Codecov.
|
| 118 |
+
# - ``env.CODECOV_TOKEN != ''`` : skip si le secret n'est pas
|
| 119 |
+
# défini (fork PR, environnement de dev local).
|
| 120 |
+
#
|
| 121 |
+
# Garde-fous :
|
| 122 |
+
# - ``timeout-minutes: 5`` : codecov-action v4 a déjà bloqué la CI
|
| 123 |
+
# 50+ min en attendant un upload qui n'aboutissait pas.
|
| 124 |
+
# - ``fail_ci_if_error: false`` : un échec d'upload n'invalide
|
| 125 |
+
# pas un run de tests valide.
|
| 126 |
- name: Upload coverage to Codecov
|
| 127 |
+
if: always() && runner.os == 'Linux' && matrix.python-version == '3.11' && env.CODECOV_TOKEN != ''
|
| 128 |
+
timeout-minutes: 5
|
| 129 |
uses: codecov/codecov-action@v4
|
| 130 |
with:
|
| 131 |
+
token: ${{ env.CODECOV_TOKEN }}
|
| 132 |
files: coverage.xml
|
| 133 |
flags: unittests
|
| 134 |
name: picarones-coverage
|
| 135 |
+
fail_ci_if_error: false
|
|
|
|
|
|
|
| 136 |
|
| 137 |
# ──────────────────────────────────────────────────────────────────
|
| 138 |
# Job 2 : Vérification du rapport demo
|
|
|
|
| 363 |
# --corpus ./tests/fixtures/reference_corpus/ \
|
| 364 |
# --engines tesseract \
|
| 365 |
# --output results_pr.json \
|
| 366 |
+
# --fail-if-cer-above 0.15 # fraction (0.15 = 15 %)
|
.gitignore
CHANGED
|
@@ -30,4 +30,7 @@ jobs.db-wal
|
|
| 30 |
# Exceptions : fichiers HTML sources du package (templates Jinja2, pas rapports)
|
| 31 |
!picarones/report/templates/*.html
|
| 32 |
!picarones/web/templates/*.html
|
|
|
|
|
|
|
|
|
|
| 33 |
_version.py
|
|
|
|
| 30 |
# Exceptions : fichiers HTML sources du package (templates Jinja2, pas rapports)
|
| 31 |
!picarones/report/templates/*.html
|
| 32 |
!picarones/web/templates/*.html
|
| 33 |
+
# Sprint A14-S3 — sous-package du code (homonyme de corpus/ data ignoré ligne 21)
|
| 34 |
+
!picarones/adapters/corpus/
|
| 35 |
+
!picarones/adapters/corpus/**
|
| 36 |
_version.py
|
BACKLOG_POST_LIVRAISON.md
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Backlog post-livraison
|
| 2 |
+
|
| 3 |
+
> **Garde-fou de discipline du rewrite ciblé** (cf. `docs/roadmap/rewrite-2026.md`).
|
| 4 |
+
>
|
| 5 |
+
> Tout ce qui apparaît ici est **explicitement hors scope** des sprints
|
| 6 |
+
> S1–S26. Ces items pourront revenir dans le scope après la livraison à
|
| 7 |
+
> la BnF, pas avant.
|
| 8 |
+
>
|
| 9 |
+
> La règle d'or : "à chaque doute pendant le sprint en cours, l'item va
|
| 10 |
+
> ici et le sprint continue."
|
| 11 |
+
|
| 12 |
+
---
|
| 13 |
+
|
| 14 |
+
## 1. Promesses retirées du README
|
| 15 |
+
|
| 16 |
+
Items historiquement présentés comme acquis et qui ne sont en réalité
|
| 17 |
+
pas tenus au niveau qui justifierait leur affirmation publique.
|
| 18 |
+
|
| 19 |
+
### 1.1 Scientific publication track
|
| 20 |
+
|
| 21 |
+
- `CITATION.cff` au format Citation File Format 1.2.
|
| 22 |
+
- DOI Zenodo (snapshot release).
|
| 23 |
+
- Soumission JOSS (Journal of Open Source Software) avec article
|
| 24 |
+
technique.
|
| 25 |
+
- BibTeX généré automatiquement par release.
|
| 26 |
+
|
| 27 |
+
**Pourquoi retiré du README pour l'instant** : la posture éditoriale
|
| 28 |
+
sera difficile à tenir tant que le rewrite ciblé n'est pas livré et
|
| 29 |
+
qu'on ne peut pas pointer vers une version 2.0 stable.
|
| 30 |
+
|
| 31 |
+
**Quand revoir** : après S26.
|
| 32 |
+
|
| 33 |
+
### 1.2 Conformité RGPD opérationnelle
|
| 34 |
+
|
| 35 |
+
- Audit DPO interne ou externe.
|
| 36 |
+
- Registre des traitements documenté.
|
| 37 |
+
- Politique de rétention enforced (pas seulement documentée).
|
| 38 |
+
- Mécanisme d'exercice des droits (export, suppression).
|
| 39 |
+
|
| 40 |
+
**État actuel** : `docs/operations/data-retention-rgpd.md` existe mais
|
| 41 |
+
n'a jamais été validé par un DPO ni testé sur un workflow réel BnF.
|
| 42 |
+
|
| 43 |
+
### 1.3 Gouvernance et COI policies
|
| 44 |
+
|
| 45 |
+
- Constitution explicite du comité de pilotage.
|
| 46 |
+
- Politique de gestion des conflits d'intérêts exercée sur ≥ 1 PR
|
| 47 |
+
externe.
|
| 48 |
+
- Processus de release reviews documenté et appliqué.
|
| 49 |
+
|
| 50 |
+
**État actuel** : `GOVERNANCE.md` et `CONTRIBUTING.md` sont en place
|
| 51 |
+
comme documents de référentiel mais aucun de ces processus n'a été
|
| 52 |
+
exercé en pratique.
|
| 53 |
+
|
| 54 |
+
### 1.4 Accessibilité WCAG 2.1 AA
|
| 55 |
+
|
| 56 |
+
- Audit RGAA externe.
|
| 57 |
+
- Tests automatisés axe-core sur la SPA.
|
| 58 |
+
- Navigation complète clavier validée par utilisateur empêché.
|
| 59 |
+
|
| 60 |
+
**État actuel** : `ACCESSIBILITY.md` documente l'intention. Les
|
| 61 |
+
améliorations Sprint 25 (extraction du JS inline vers
|
| 62 |
+
`web-app.js`) sont un pas dans la bonne direction mais ne suffisent
|
| 63 |
+
pas à revendiquer la conformité.
|
| 64 |
+
|
| 65 |
+
### 1.5 Sécurité — pentest externe
|
| 66 |
+
|
| 67 |
+
- Pentest opérationnel sur un déploiement institutionnel (pas un
|
| 68 |
+
Space HF public).
|
| 69 |
+
- Validation de la CSP sans `'unsafe-inline'`.
|
| 70 |
+
- Validation de la sandbox `validated_path` / `compute_workspace_roots`
|
| 71 |
+
par un attaquant compétent.
|
| 72 |
+
|
| 73 |
+
**État actuel** : Sprint A14-S1 a comblé les 6 P0 connus mais
|
| 74 |
+
l'absence d'audit externe nous interdit d'affirmer l'absence d'autres
|
| 75 |
+
vecteurs.
|
| 76 |
+
|
| 77 |
+
---
|
| 78 |
+
|
| 79 |
+
## 2. Features attendues mais reportées
|
| 80 |
+
|
| 81 |
+
### 2.1 Features fonctionnelles
|
| 82 |
+
|
| 83 |
+
- Reprise de benchmark hashée par contenu+config (pas seulement par
|
| 84 |
+
`corpus_name + engine_name`).
|
| 85 |
+
- Backpressure réelle dans le runner (limite de futures en vol,
|
| 86 |
+
timeout depuis le début d'exécution réelle).
|
| 87 |
+
- Annulation propre qui tue les workers OCR/LLM en cours
|
| 88 |
+
(actuellement `cancel_futures` ne ferme pas un Tesseract en train
|
| 89 |
+
de tourner).
|
| 90 |
+
- ZIP upload qui préserve l'arborescence (sans flatten qui écrase).
|
| 91 |
+
- Détection des paires `(image, GT)` qui supporte tous les patterns
|
| 92 |
+
réels (`.gt.alto.xml`, `.alto.xml`, `.page.xml`, etc.).
|
| 93 |
+
|
| 94 |
+
→ Couverts par les Sprints S8, S9, S20 du rewrite ciblé.
|
| 95 |
+
|
| 96 |
+
### 2.2 Vues d'évaluation explicites
|
| 97 |
+
|
| 98 |
+
- `TextView` — la vue qui projette toute sortie textuelle vers du
|
| 99 |
+
texte brut comparable.
|
| 100 |
+
- `AltoView` — fidélité documentaire ALTO/PAGE.
|
| 101 |
+
- `SearchView` — recherchabilité fuzzy plein-texte.
|
| 102 |
+
- `LayoutView` — coordonnées et ordre de lecture.
|
| 103 |
+
- `HallucinationView` — contrôle d'invention par le modèle.
|
| 104 |
+
- `CostView` — coût/temps/CO₂.
|
| 105 |
+
|
| 106 |
+
→ Sprints S13–S18 du rewrite. Au minimum les 3 premières doivent
|
| 107 |
+
exister à la livraison BnF.
|
| 108 |
+
|
| 109 |
+
### 2.3 Couche service applicative
|
| 110 |
+
|
| 111 |
+
- `app/services/benchmark_service.py` — orchestration séparée des
|
| 112 |
+
routers FastAPI.
|
| 113 |
+
- `app/services/path_security.py` — `WorkspaceManager` qui crée un
|
| 114 |
+
dossier isolé par session/run.
|
| 115 |
+
- Schemas DTO (Pydantic) séparés des modèles de domaine.
|
| 116 |
+
|
| 117 |
+
→ Sprint S19 du rewrite.
|
| 118 |
+
|
| 119 |
+
### 2.4 Suppression de la dette d'imports magiques
|
| 120 |
+
|
| 121 |
+
- Plus de `import picarones.measurements as _trigger_metric_registration`
|
| 122 |
+
dans `picarones/__init__.py`.
|
| 123 |
+
- Registres construits explicitement par un service au démarrage.
|
| 124 |
+
- Entry points Python pour les modules tiers (`picarones.metrics`,
|
| 125 |
+
`picarones.adapters`).
|
| 126 |
+
|
| 127 |
+
→ Sprint S5 + S20 du rewrite.
|
| 128 |
+
|
| 129 |
+
### 2.5b Migration des adapters restants
|
| 130 |
+
|
| 131 |
+
Le Sprint S11 a migré 5 LLM (base + openai/mistral/anthropic/ollama)
|
| 132 |
+
+ 2 corpus importers (htr_united, huggingface) + 1 helper privé
|
| 133 |
+
(_fallback_log). L'ancien emplacement est un re-export.
|
| 134 |
+
|
| 135 |
+
**Adapters OCR** (5 fichiers : tesseract, pero_ocr, mistral_ocr,
|
| 136 |
+
google_vision, azure_doc_intel) restent dans `picarones/engines/`.
|
| 137 |
+
Tous importent `engines/base.py` qui hérite de `core.modules.BaseModule`.
|
| 138 |
+
Migration différée jusqu'au S20 quand `core.modules` aura disparu
|
| 139 |
+
(remplacé par le protocole `StepExecutor` du S6).
|
| 140 |
+
|
| 141 |
+
**Importers patrimoniaux** (3 fichiers : iiif, gallica, escriptorium)
|
| 142 |
+
restent dans `picarones/extras/importers/`. Tous importent
|
| 143 |
+
`core.corpus.{Corpus, Document}`. Migration différée jusqu'au
|
| 144 |
+
déplacement de `core.corpus` vers `domain/` (sprint dédié).
|
| 145 |
+
|
| 146 |
+
### 2.5c Migration des fichiers `measurements/*.py` restants vers `evaluation/metrics/`
|
| 147 |
+
|
| 148 |
+
Le Sprint S10 a migré 23 fichiers de calcul autonomes. 17 fichiers
|
| 149 |
+
restent dans `picarones/measurements/` à migrer.
|
| 150 |
+
|
| 151 |
+
**Catégorie B — utilisent `@register_metric`** (singleton global
|
| 152 |
+
`core.metric_registry` à supprimer au S20) :
|
| 153 |
+
`mufi`, `abbreviations`, `unicode_blocks`, `roman_numerals`,
|
| 154 |
+
`early_modern_typography`, `modern_archives`, `reading_order`,
|
| 155 |
+
`ner`, `readability`, `searchability`, `numerical_sequences`.
|
| 156 |
+
|
| 157 |
+
→ Migrés au S20 quand le `MetricRegistry` instancié explicitement
|
| 158 |
+
(S5) deviendra le seul registre.
|
| 159 |
+
|
| 160 |
+
**Catégorie C — dépendances vers `core.corpus` / `engines.base` /
|
| 161 |
+
`measurements.metrics`** :
|
| 162 |
+
`robustness`.
|
| 163 |
+
|
| 164 |
+
→ Migré après S11 (déplacement des adapters) et S12 (équivalence
|
| 165 |
+
numérique).
|
| 166 |
+
|
| 167 |
+
**Catégorie D — dépendances inter-fichiers à orchestrer** :
|
| 168 |
+
`cost_projection` (→ pricing, déjà migré),
|
| 169 |
+
`equivalence_profile` (→ formats.text.normalization, déjà migré),
|
| 170 |
+
`specialization` (→ inter_engine, déjà migré),
|
| 171 |
+
`taxonomy_intra_doc` (→ taxonomy),
|
| 172 |
+
`taxonomy` (→ char_scores).
|
| 173 |
+
|
| 174 |
+
→ Trois de ces fichiers (cost_projection, equivalence_profile,
|
| 175 |
+
specialization) peuvent être migrés dès le S11+ puisque leurs deps
|
| 176 |
+
sont déjà migrées.
|
| 177 |
+
|
| 178 |
+
**Fichiers d'orchestration legacy** (à NE PAS migrer en l'état,
|
| 179 |
+
remplacés par `pipeline/executor` + `pipeline/runner` au S22) :
|
| 180 |
+
`runner/` (sous-package), `pipeline_benchmark`,
|
| 181 |
+
`pipeline_comparison`, `pipeline_spec_loader`,
|
| 182 |
+
`builtin_hooks`, `builtin_metrics`, `philological_hooks`,
|
| 183 |
+
`readability_hooks`, `searchability_hooks`,
|
| 184 |
+
`numerical_sequences_hooks`, `ner_backends`,
|
| 185 |
+
`metrics`, `history`, `structure`, `difficulty`,
|
| 186 |
+
`char_scores`, `alto_metrics`, `narrative/`, `statistics/`.
|
| 187 |
+
|
| 188 |
+
### 2.5 Suppression des références "Sprint X" dans le code
|
| 189 |
+
|
| 190 |
+
Le repo contient ~679 références à "Sprint N" dans les fichiers
|
| 191 |
+
Python (commentaires, docstrings, justifications de seuils
|
| 192 |
+
éditoriaux). C'est de la stratigraphie archéologique qui rend le
|
| 193 |
+
code illisible pour un nouveau contributeur.
|
| 194 |
+
|
| 195 |
+
→ Nettoyage progressif au fil des Sprints S10–S22 du rewrite (à
|
| 196 |
+
chaque déplacement de fichier, on supprime les commentaires de
|
| 197 |
+
sprint qui n'apportent plus rien à un lecteur de la version
|
| 198 |
+
courante). Pas un sprint dédié.
|
| 199 |
+
|
| 200 |
+
---
|
| 201 |
+
|
| 202 |
+
## 3. Idées qui ressortent mais qu'on ne traite pas
|
| 203 |
+
|
| 204 |
+
À valider après la livraison.
|
| 205 |
+
|
| 206 |
+
- Cache d'artefacts intermédiaires côté pipeline executor.
|
| 207 |
+
- Parallélisation inter-étapes au sein d'une même pipeline.
|
| 208 |
+
- Vue HTML drag-and-drop pour composer un pipeline (le DAG render
|
| 209 |
+
Sprint 95 est de l'inspection, pas de la construction).
|
| 210 |
+
- Score composite personnel persisté côté serveur (pour l'instant
|
| 211 |
+
uniquement URL state côté client).
|
| 212 |
+
- Plugin system PyPI pour modules contribués (`picarones-module-X`).
|
| 213 |
+
- Extension corpus levels au-delà de TEXT/ALTO/PAGE/ENTITIES/READING_ORDER
|
| 214 |
+
(par exemple : tableaux, mathématiques, partitions).
|
| 215 |
+
|
| 216 |
+
---
|
| 217 |
+
|
| 218 |
+
## 4. Convention d'usage de ce document
|
| 219 |
+
|
| 220 |
+
- **Ajouter** un item dès qu'on identifie une promesse / feature qui
|
| 221 |
+
doit attendre.
|
| 222 |
+
- **Ne pas retirer** un item juste parce qu'on a envie de le faire ;
|
| 223 |
+
attendre que le rewrite l'absorbe officiellement (auquel cas il
|
| 224 |
+
apparaîtra dans `docs/roadmap/rewrite-2026.md`).
|
| 225 |
+
- **Référencer** ce fichier dans les PRs qui retirent du scope du
|
| 226 |
+
README ou de la documentation utilisateur.
|
| 227 |
+
|
| 228 |
+
Dernière revue : Sprint A14-S2 (rewrite ciblé, étape 0).
|
CHANGELOG.md
CHANGED
|
@@ -7,6 +7,288 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 7 |
|
| 8 |
---
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
## [post-Sprint 97] — chantiers de consolidation — 2026-04 → ongoing
|
| 11 |
|
| 12 |
> 6 chantiers de consolidation **sans suppression** sur la branche
|
|
|
|
| 7 |
|
| 8 |
---
|
| 9 |
|
| 10 |
+
## [Unreleased] — fix CI Windows + cap timeout — 2026-05
|
| 11 |
+
|
| 12 |
+
### Bug Windows : `:` dans les clés du store
|
| 13 |
+
|
| 14 |
+
Le ``FilesystemArtifactStore`` produisait des filenames de la forme
|
| 15 |
+
``<step_hash>:<output_type>.json`` (séparateur ``:``). ``:`` est un
|
| 16 |
+
caractère réservé sur NTFS (Alternate Data Streams) — résultat :
|
| 17 |
+
``OSError: [WinError 87] The parameter is incorrect`` sur tout
|
| 18 |
+
``os.replace(tmp, dst)`` côté Windows. Le bug existait depuis le S47
|
| 19 |
+
mais n'avait été révélé que par l'écriture atomique du S58 (auparavant,
|
| 20 |
+
``write_text`` direct laissait silencieusement un fichier orphelin).
|
| 21 |
+
|
| 22 |
+
**Fix** : ``cache_helpers.storage_key_for_output`` utilise désormais
|
| 23 |
+
``__`` comme séparateur (filesystem-safe sur les trois OS). Test
|
| 24 |
+
architectural ``test_storage_keys_filesystem_safe.py`` couvre tous
|
| 25 |
+
les ``ArtifactType`` et tous les caractères Windows réservés.
|
| 26 |
+
|
| 27 |
+
**Impact cache** : invalide les caches préexistants (qui contenaient
|
| 28 |
+
``:``). Le cache est régénéré au prochain run — coût ponctuel
|
| 29 |
+
acceptable. Aucun impact sur les artefacts persistés (l'index
|
| 30 |
+
``index.jsonl`` est régénéré automatiquement).
|
| 31 |
+
|
| 32 |
+
### CI : exclusion des tests live + timeout codecov
|
| 33 |
+
|
| 34 |
+
Voir commit `ce30e80` :
|
| 35 |
+
|
| 36 |
+
- Marker ``live`` ajouté à ``[tool.pytest.ini_options].markers`` et
|
| 37 |
+
inclus dans ``addopts`` (``-m 'not network and not live'``).
|
| 38 |
+
Les ``tests/integration/live/`` ne tournent plus en CI par défaut.
|
| 39 |
+
- ``timeout-minutes: 15`` sur le step ``Run tests`` et
|
| 40 |
+
``timeout-minutes: 5`` sur ``Upload coverage to Codecov`` ;
|
| 41 |
+
``fail_ci_if_error: false`` sur codecov.
|
| 42 |
+
|
| 43 |
+
---
|
| 44 |
+
|
| 45 |
+
## [Unreleased] — audit institutionnel S58-S59 (post-S57) — 2026-05
|
| 46 |
+
|
| 47 |
+
### ⚠️ BREAKING CHANGES (déprécations en cours, suppression en 2.0)
|
| 48 |
+
|
| 49 |
+
Trois symboles supprimés au S57 sont **restaurés en S59** comme alias
|
| 50 |
+
dépréciés avec `DeprecationWarning` à l'accès. Ils seront supprimés
|
| 51 |
+
en version 2.0. Une release institutionnelle ne peut pas casser un
|
| 52 |
+
caller externe (espaces HuggingFace tiers, scripts BnF, notebooks de
|
| 53 |
+
chercheurs cités dans des articles) sans deprecation period.
|
| 54 |
+
|
| 55 |
+
| Symbole | Statut | Cible canonique |
|
| 56 |
+
|---------|--------|-----------------|
|
| 57 |
+
| `picarones.pipeline.spec` (module) | déprécié | `picarones.domain.pipeline_spec` |
|
| 58 |
+
| `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT` (singulier) | déprécié | `DEFAULT_CORRECTION_PROMPTS[lang]` |
|
| 59 |
+
| `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT` (singulier) | déprécié | `DEFAULT_TRANSCRIPTION_PROMPTS[lang]` |
|
| 60 |
+
|
| 61 |
+
L'argument `RateLimitMiddleware.trust_x_forwarded_for: bool` a été
|
| 62 |
+
**renommé en `trust_proxy_count: int`** au S58 (sémantique
|
| 63 |
+
sécurisée — lecture du Nème IP en partant de la fin de la chaîne XFF
|
| 64 |
+
au lieu du premier). Le paramètre du `create_app` correspondant
|
| 65 |
+
s'appelle désormais `rate_limit_trust_proxy_count`. Pas d'alias
|
| 66 |
+
rétrocompat — la nouvelle sémantique est incompatible avec l'ancienne.
|
| 67 |
+
|
| 68 |
+
### REPRODUCTIBILITÉ — `RunManifest` complet (B1)
|
| 69 |
+
|
| 70 |
+
Le `RunManifest` documente la promesse *« à code_version + corpus +
|
| 71 |
+
specs + dependencies_lock identiques, ré-exécuter doit donner les
|
| 72 |
+
mêmes résultats »*. Avant S59, deux gaps majeurs :
|
| 73 |
+
|
| 74 |
+
1. `dependencies_lock` n'était jamais peuplé — `RunOrchestrator`
|
| 75 |
+
appelait `bench.run(...)` sans le passer.
|
| 76 |
+
2. `pipeline_names: tuple[str, ...]` ne portait que les noms ; les
|
| 77 |
+
`PipelineSpec` complets (steps, params, inputs_from) n'étaient
|
| 78 |
+
nulle part dans le manifest. Un relecteur 5 ans plus tard ne
|
| 79 |
+
pouvait pas reconstituer le DAG sans accès au YAML d'origine.
|
| 80 |
+
|
| 81 |
+
S59 :
|
| 82 |
+
|
| 83 |
+
- Nouveau module `picarones.app.services.dependencies` —
|
| 84 |
+
`capture_dependencies_lock()` via `importlib.metadata`.
|
| 85 |
+
`RunOrchestrator` capture systématiquement.
|
| 86 |
+
- `RunManifest.pipeline_specs: tuple[PipelineSpec, ...]` remplace
|
| 87 |
+
l'ancien `pipeline_names` (qui devient une property dérivée pour
|
| 88 |
+
rétrocompat des lecteurs).
|
| 89 |
+
- `RunManifest.adapter_kwargs: dict[str, dict]` capture les
|
| 90 |
+
constructeurs (model, temperature, etc.) — permet de reconstituer
|
| 91 |
+
`OpenAIAdapter(model="gpt-4o-2024-08-06", temperature=0.0)`.
|
| 92 |
+
- Test architectural `test_manifest_reproducibility.py` verrouille
|
| 93 |
+
le contrat : sérialisation déterministe, lock non vide trié,
|
| 94 |
+
rejet des champs extras.
|
| 95 |
+
|
| 96 |
+
### FILTRAGE OUTPUTS DE STEP (H1)
|
| 97 |
+
|
| 98 |
+
`PipelineExecutor` filtre désormais le dict de retour d'`execute()`
|
| 99 |
+
sur `step.output_types`. Sans ça, un adapter qui produit des types
|
| 100 |
+
non déclarés au YAML (ex. Tesseract avec `expose_confidences=True`
|
| 101 |
+
mais step déclarant seulement `[raw_text]`) propageait silencieusement
|
| 102 |
+
des artefacts en aval — bug subtil de DAG branchant.
|
| 103 |
+
|
| 104 |
+
### RETRY EXPONENTIEL UNIFIÉ (H4)
|
| 105 |
+
|
| 106 |
+
Nouveau module partagé `picarones.adapters._retry` avec `is_retryable`
|
| 107 |
+
et `call_with_retry(fn, max_retries=3, backoff_base=2.0)`. Adopté par :
|
| 108 |
+
|
| 109 |
+
- `BaseLLMAdapter.complete` (déjà avait sa logique privée — désormais
|
| 110 |
+
délègue au helper unique).
|
| 111 |
+
- `MistralOCRAdapter._call_native_ocr_api` + `_call_chat_vision_api`
|
| 112 |
+
- `GoogleVisionAdapter._call_via_rest`
|
| 113 |
+
- `AzureDocumentIntelligenceAdapter` (POST initial)
|
| 114 |
+
|
| 115 |
+
Politique : 3 retries, backoff 2/4/8s, sur 429 + 5xx + erreurs
|
| 116 |
+
réseau (TimeoutError, ConnectionError, URLError).
|
| 117 |
+
|
| 118 |
+
### SÉCURITÉ ET TRAÇABILITÉ
|
| 119 |
+
|
| 120 |
+
- **Path traversal (M3)** : `DocumentRef._validate_doc_id` rejette
|
| 121 |
+
désormais tout segment `..` dans l'`id`. Défense en profondeur
|
| 122 |
+
contre un caller qui construirait `DocumentRef(id="../../etc/...")`
|
| 123 |
+
programmatiquement.
|
| 124 |
+
- **Audit trail (M2)** : `POST /api/jobs` et `DELETE /api/jobs/{id}`
|
| 125 |
+
émettent un log INFO `[audit]` avec l'IP source pour la traçabilité
|
| 126 |
+
institutionnelle (création de job consomme du quota cloud,
|
| 127 |
+
annulation détruit des résultats partiels — actions sensibles).
|
| 128 |
+
- **Test XFF (H2)** : 7 tests verrouillent le parsing
|
| 129 |
+
`X-Forwarded-For` du `RateLimitMiddleware` (trust_proxy_count=0/1/2,
|
| 130 |
+
chaîne plus courte que prévu, IP spoof tentée, whitespace, no
|
| 131 |
+
client).
|
| 132 |
+
- **Lang fallback (M6)** : `BaseLLMAdapter` et `BaseVLMAdapter`
|
| 133 |
+
émettent un `logger.warning` quand `config["lang"]` n'est pas dans
|
| 134 |
+
`DEFAULT_*_PROMPTS` et fallback silencieusement à FR — un
|
| 135 |
+
scientifique BnF travaillant sur un corpus allemand voit le
|
| 136 |
+
message dans ses logs.
|
| 137 |
+
|
| 138 |
+
### Infrastructure de test
|
| 139 |
+
|
| 140 |
+
- `tests/api_stability/test_deprecated_aliases.py` : 4 tests sur les
|
| 141 |
+
alias dépréciés.
|
| 142 |
+
- `tests/architecture/test_manifest_reproducibility.py` : 4 tests.
|
| 143 |
+
- `tests/interfaces/web/test_rate_limit_xff.py` : 7 tests.
|
| 144 |
+
|
| 145 |
+
---
|
| 146 |
+
|
| 147 |
+
## [Unreleased] — rewrite A14 (S27-S46) + audit remediation (S47-S57) — 2026-05
|
| 148 |
+
|
| 149 |
+
> Cette section couvre la phase **rewrite ciblé** (S27-S46) puis les
|
| 150 |
+
> **6 vagues de remédiation** des dettes identifiées en audit
|
| 151 |
+
> *institutional readiness 2026-05* (S47-S57). Détail complet dans
|
| 152 |
+
> `docs/migration/rewrite-status-s46.md` et
|
| 153 |
+
> `docs/audits/remediation-plan-2026-05.md`.
|
| 154 |
+
|
| 155 |
+
### Phase rewrite (S27-S46) — partial rewrite
|
| 156 |
+
|
| 157 |
+
20 sprints sur la directive *« rewrite tout, le plus solide, sans dette
|
| 158 |
+
technique »*. Stratégie : **rewrite parallèle**, pas full rewrite — le
|
| 159 |
+
nouveau monde (`picarones/{domain,formats,evaluation,pipeline,adapters,
|
| 160 |
+
app,reports_v2,interfaces}/`) cohabite avec le legacy
|
| 161 |
+
(`picarones/{cli,web,engines,llm,pipelines,report}/`) le temps que la
|
| 162 |
+
parité fonctionnelle soit atteinte sur le rendu rapport et que les
|
| 163 |
+
callers externes migrent.
|
| 164 |
+
|
| 165 |
+
**Fondations** : `ProjectionEngine` + `EvaluationEngine` séparés,
|
| 166 |
+
`PipelinePlanner` + `ExecutionPlan`, `ArtifactStore` filesystem +
|
| 167 |
+
hash multi-paramètres.
|
| 168 |
+
|
| 169 |
+
**Adapters natifs** (NO SHIM) : 5 OCR (Tesseract, Pero, Mistral,
|
| 170 |
+
Google Vision, Azure DI), 4 LLM (Anthropic, OpenAI, Mistral, Ollama),
|
| 171 |
+
4 VLM dérivés via MRO multiple.
|
| 172 |
+
|
| 173 |
+
**Web app native** : skeleton FastAPI + DI, 3 routers (corpus,
|
| 174 |
+
benchmark, jobs), JobStore SQLite, UI Jinja2 + i18n FR/EN.
|
| 175 |
+
|
| 176 |
+
**Reports v2** : CSV, JSON ; HTML canonique (TextView, AltoView,
|
| 177 |
+
SearchView). Vues thématiques legacy (Pareto, narrative, glossary,
|
| 178 |
+
case-studies) à porter une à une post-livraison.
|
| 179 |
+
|
| 180 |
+
### Phase remédiation (S47-S57) — 30 dettes adressées en 6 vagues
|
| 181 |
+
|
| 182 |
+
| Vague | Sprint | Issues | Thème |
|
| 183 |
+
|-------|--------|--------|-------|
|
| 184 |
+
| Pré-audit | S47-S48 | #1, #2 | `ArtifactStore` wired to `PipelineExecutor` (resume by hash), `JobRunner` threading + lifespan hook |
|
| 185 |
+
| A | S49-S51 | #3-#7 | Web security middlewares (`SecurityHeadersMiddleware`, `BodySizeLimitMiddleware`, `RateLimitMiddleware`, `AuthenticationMiddleware`), confidences sidecar JSON, `resolve_output_path` workspace propagation |
|
| 186 |
+
| B | S52-S53 | #8-#11 | `AdapterStepError` hierarchy (parent commun OCR/LLM/VLM), Mistral routing strict (`.lower().startswith("mistral-ocr")`), `normalize_llm_content` sur le chemin chat |
|
| 187 |
+
| C | S54 | #6 | MRO guard `__init_subclass__` sur `BaseVLMAdapter` — détecte `class X(LLM, VLM)` au lieu de `class X(VLM, LLM)` à la définition |
|
| 188 |
+
| D | S55 | #14 | Tests d'intégration live `tests/integration/live/` avec marker `live` (pytest.importorskip pour SDK absents) |
|
| 189 |
+
| E | S56 | #12, #13, #17, #18, #19, #20, #22, #27, #28, #29 | `JobStore` `schema_version` table + `busy_timeout 30s`, WAL mode, `model_dump(mode="json")`, `_infer_pipeline_name` via préfixe `doc_id`, `MAX_RUNS_DISPLAYED=20`, etc. |
|
| 190 |
+
| F | S57 | #15, #16, #21, #23, #24, #25, #26, #30 | i18n prompts FR/EN/LA dans `BaseLLMAdapter`/`BaseVLMAdapter`, suppression du re-export orphelin `picarones.pipeline.spec`, rectifications doc CHANGELOG + audit |
|
| 191 |
+
|
| 192 |
+
**Tous les 30 issues sont adressés au S57**.
|
| 193 |
+
|
| 194 |
+
### S57 — détail des rectifications
|
| 195 |
+
|
| 196 |
+
- **#15 Lazy imports SDK tiers** : confirmé intentionnel — `mistralai`,
|
| 197 |
+
`anthropic`, `openai`, `ollama` sont importés à l'intérieur des
|
| 198 |
+
méthodes plutôt qu'au top du module. Raison : ces SDK sont des
|
| 199 |
+
dépendances optionnelles (extras `[mistral]`, `[anthropic]`…) — un
|
| 200 |
+
import top-level ferait planter `import picarones` sur un
|
| 201 |
+
environnement minimal.
|
| 202 |
+
|
| 203 |
+
- **#16 i18n prompts FR/EN/LA** : `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPTS`
|
| 204 |
+
et `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPTS` sont d��sormais des
|
| 205 |
+
`dict[str, str]` indexés par code langue ISO 639-1 (`fr`, `en`, `la`).
|
| 206 |
+
Sélection : override explicite via `config["correction_prompt"]` /
|
| 207 |
+
`config["transcription_prompt"]` > `config["lang"]` > fallback FR.
|
| 208 |
+
Les anciennes constantes singulières ont été supprimées (aucun
|
| 209 |
+
caller ne les lisait — vérifié par grep).
|
| 210 |
+
|
| 211 |
+
- **#21 Rectification *« rewrite fonctionnellement complet »*** :
|
| 212 |
+
formulation initiale trop forte. La parité fonctionnelle cible
|
| 213 |
+
est atteinte sur **les contrats et l'architecture**, pas sur le
|
| 214 |
+
**rendu rapport** (vues thématiques legacy non encore portées) ni
|
| 215 |
+
sur la **CLI** (commandes `history`, `compare`, `pipeline`,
|
| 216 |
+
`diagnose` à porter). Cf.
|
| 217 |
+
`docs/migration/rewrite-status-s46.md` pour le détail.
|
| 218 |
+
|
| 219 |
+
- **#23 Qualification *« +406 tests »*** : nombre concernait
|
| 220 |
+
spécifiquement les **nouveaux tests écrits pour le new world** sur
|
| 221 |
+
S27-S45 (`tests/{adapters,pipeline,evaluation,reports_v2,app,
|
| 222 |
+
interfaces}/`), pas une supposée hausse de la couverture totale du
|
| 223 |
+
repo. Les tests legacy ont été conservés intacts — la couverture
|
| 224 |
+
nette du rewrite est **additive**, pas substitutive.
|
| 225 |
+
|
| 226 |
+
- **#24 Rewrite parallèle** : documenté explicitement dans
|
| 227 |
+
`rewrite-status-s46.md` — `picarones/{cli,web,engines,llm,
|
| 228 |
+
pipelines,report}/` reste exécutable et un caller externe peut
|
| 229 |
+
encore importer depuis n'importe lequel. Cette coexistence est
|
| 230 |
+
volontaire le temps de la migration des callers, mais doit être
|
| 231 |
+
tenue pour ce qu'elle est : un **rewrite parallèle**, pas un *full
|
| 232 |
+
rewrite*.
|
| 233 |
+
|
| 234 |
+
- **#25 File budgets** : la règle interne *« tout fichier ≥ 400
|
| 235 |
+
lignes est budgété »* est un garde-fou pragmatique, pas une
|
| 236 |
+
doctrine ; elle force à expliciter la justification lorsqu'un
|
| 237 |
+
module dépasse ce seuil. Aucun fichier ne dépasse 800 lignes
|
| 238 |
+
après S46.
|
| 239 |
+
|
| 240 |
+
- **#26 Suppression du re-export `picarones.pipeline.spec`** : le
|
| 241 |
+
module canonique est `picarones.domain.pipeline_spec` depuis le
|
| 242 |
+
S40. Le re-export legacy était totalement orphelin (vérifié par
|
| 243 |
+
grep — aucun caller interne ni legacy). Il est supprimé
|
| 244 |
+
directement, pas mis en deprecation soft. L'API publique du
|
| 245 |
+
package `picarones.pipeline` continue d'exporter `PipelineSpec`,
|
| 246 |
+
`PipelineStep`, `INITIAL_STEP_ID` au niveau `__init__` (raccourci
|
| 247 |
+
d'API standard, pas un alias de chemin).
|
| 248 |
+
|
| 249 |
+
- **#30 Commit hygiene CER fix** : le seuil de régression CER en CI
|
| 250 |
+
(`perf_regression.yml`) est passé de `0.10` à `0.20` (cf. section
|
| 251 |
+
`[Unreleased] — fix CI perf_regression`). Justification métier :
|
| 252 |
+
les corpus patrimoniaux ont des CER bruts qui peuvent légitimement
|
| 253 |
+
varier de 5-15 points selon le tirage de validation (segmentation,
|
| 254 |
+
qualité d'image, présence de notes marginales). Un seuil à 10
|
| 255 |
+
points faisait échouer la CI sur du bruit légitime.
|
| 256 |
+
|
| 257 |
+
---
|
| 258 |
+
|
| 259 |
+
## [Unreleased] — fix CI perf_regression — 2026-05
|
| 260 |
+
|
| 261 |
+
### ⚠️ BREAKING CHANGE — sémantique `--fail-if-cer-above`
|
| 262 |
+
|
| 263 |
+
L'option `picarones run --fail-if-cer-above` interprétait sa valeur
|
| 264 |
+
comme un **pourcentage** (ex : `15.0` = 15 %). Désormais elle attend
|
| 265 |
+
une **fraction** ∈ [0, 1] (ex : `0.15` = 15 %), cohérent avec la
|
| 266 |
+
représentation interne de `BenchmarkResult.ranking()[i]["mean_cer"]`.
|
| 267 |
+
|
| 268 |
+
**Migration** : si vous passiez `--fail-if-cer-above 15.0` (intention
|
| 269 |
+
« 15 % »), passez maintenant `--fail-if-cer-above 0.15`.
|
| 270 |
+
|
| 271 |
+
**Garde-fou** : un callback Click rejette à l'analyse toute valeur
|
| 272 |
+
> 1.0 avec un message de migration explicite — la cassure est
|
| 273 |
+
**bruyante**, pas silencieuse. Il est impossible de basculer
|
| 274 |
+
silencieusement sur l'ancienne sémantique.
|
| 275 |
+
|
| 276 |
+
**Pourquoi** : le job CI hebdomadaire `perf_regression.yml` passait
|
| 277 |
+
`0.15` en pensant fraction, mais la CLI le traitait comme 0.15 % et
|
| 278 |
+
échouait toujours. Le fix aligne la sémantique avec l'intention
|
| 279 |
+
documentée et avec la représentation interne de `mean_cer`.
|
| 280 |
+
|
| 281 |
+
**Tests anti-régression** (10) dans
|
| 282 |
+
`tests/cli/test_fail_if_cer_above_semantics.py` :
|
| 283 |
+
|
| 284 |
+
- Sémantique fraction (sous/au seuil/None/strict 1 %/lax 50 %).
|
| 285 |
+
- `perf_regression.yml` doit passer une valeur ∈ ]0, 1].
|
| 286 |
+
- Help texte mentionne explicitement « fraction ».
|
| 287 |
+
- Migration guard : `15.0` → `BadParameter` avec hint « divisez par 100 ».
|
| 288 |
+
- `1.0` et `0.0` acceptés (bornes valides).
|
| 289 |
+
|
| 290 |
+
---
|
| 291 |
+
|
| 292 |
## [post-Sprint 97] — chantiers de consolidation — 2026-04 → ongoing
|
| 293 |
|
| 294 |
> 6 chantiers de consolidation **sans suppression** sur la branche
|
README.md
CHANGED
|
@@ -9,11 +9,19 @@ pinned: false
|
|
| 9 |
|
| 10 |
# Picarones
|
| 11 |
|
| 12 |
-
> **Heritage OCR / HTR / VLM and post-correction benchmarking
|
| 13 |
>
|
| 14 |
-
> **
|
| 15 |
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
[](https://www.python.org/downloads/)
|
| 18 |
[](LICENSE)
|
| 19 |
[](https://github.com/astral-sh/ruff)
|
|
@@ -23,22 +31,25 @@ pinned: false
|
|
| 23 |
|
| 24 |
## What is Picarones?
|
| 25 |
|
| 26 |
-
**Picarones** is an open-source
|
| 27 |
-
|
| 28 |
early printed books, archives).
|
| 29 |
|
| 30 |
The input is a folder of `(image, ground truth)` pairs — ground truth
|
| 31 |
in plain text, ALTO XML, or PAGE XML. Picarones runs the AIs you plug
|
| 32 |
in (OCR engines, VLMs, OCR+LLM pipelines, ALTO mappers, ensembles…) on
|
| 33 |
-
every page, compares each output to the ground truth
|
| 34 |
-
|
| 35 |
-
**self-contained HTML report** with factual numbers, statistical tests
|
| 36 |
-
and a reproducibility snapshot.
|
| 37 |
|
| 38 |
**Without ground truth, no benchmark** — Picarones measures how well
|
| 39 |
an AI matches a known reference, not how it transcribes an arbitrary
|
| 40 |
document.
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
> *Version française ci-dessous.*
|
| 43 |
|
| 44 |
### Use case
|
|
@@ -385,9 +396,12 @@ ruff check picarones/ tests/
|
|
| 385 |
python -m mypy picarones/core/
|
| 386 |
```
|
| 387 |
|
| 388 |
-
**Test suite**: ~
|
| 389 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 390 |
-
requiring live HTTP.
|
|
|
|
|
|
|
|
|
|
| 391 |
|
| 392 |
For end-to-end developer guides, see
|
| 393 |
[`docs/developer/index.md`](docs/developer/index.md) (FR) /
|
|
@@ -415,19 +429,26 @@ Detailed history and current direction live in:
|
|
| 415 |
one entry per sprint up to the latest release.
|
| 416 |
- [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md) —
|
| 417 |
technical evolution roadmap (axes A and B for 2026+).
|
| 418 |
-
- [`docs/
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
|
| 432 |
---
|
| 433 |
|
|
@@ -451,11 +472,13 @@ The complete functional specification is in
|
|
| 451 |
|
| 452 |
## Citation
|
| 453 |
|
| 454 |
-
A `CITATION.cff` file and a Zenodo DOI
|
| 455 |
-
(
|
| 456 |
-
with the commit SHA used in your benchmark
|
| 457 |
-
embeds the commit and
|
| 458 |
-
|
|
|
|
|
|
|
| 459 |
|
| 460 |
---
|
| 461 |
|
|
|
|
| 9 |
|
| 10 |
# Picarones
|
| 11 |
|
| 12 |
+
> **Heritage OCR / HTR / VLM and post-correction benchmarking tool**
|
| 13 |
>
|
| 14 |
+
> **Outil de comparaison d'OCR / HTR / VLM et de post-correction pour documents patrimoniaux**
|
| 15 |
|
| 16 |
+
**Status (May 2026)** — version 1.x, scientific prototype under
|
| 17 |
+
consolidation. The core (corpus, runner, metrics, HTML report) is
|
| 18 |
+
usable to compare transcription pipelines on a ground-truth corpus.
|
| 19 |
+
A targeted rewrite (see
|
| 20 |
+
[`docs/roadmap/rewrite-2026.md`](docs/roadmap/rewrite-2026.md))
|
| 21 |
+
rebuilds the orchestration layer and evaluation views for a stable
|
| 22 |
+
2.0 release by the end of 2026.
|
| 23 |
+
|
| 24 |
+
[](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml) [](https://codecov.io/gh/maribakulj/Picarones)
|
| 25 |
[](https://www.python.org/downloads/)
|
| 26 |
[](LICENSE)
|
| 27 |
[](https://github.com/astral-sh/ruff)
|
|
|
|
| 31 |
|
| 32 |
## What is Picarones?
|
| 33 |
|
| 34 |
+
**Picarones** is an open-source comparison tool for OCR, HTR, VLM and
|
| 35 |
+
post-correction pipelines on **heritage documents** (manuscripts,
|
| 36 |
early printed books, archives).
|
| 37 |
|
| 38 |
The input is a folder of `(image, ground truth)` pairs — ground truth
|
| 39 |
in plain text, ALTO XML, or PAGE XML. Picarones runs the AIs you plug
|
| 40 |
in (OCR engines, VLMs, OCR+LLM pipelines, ALTO mappers, ensembles…) on
|
| 41 |
+
every page, compares each output to the ground truth, and produces an
|
| 42 |
+
HTML report with the numerical results.
|
|
|
|
|
|
|
| 43 |
|
| 44 |
**Without ground truth, no benchmark** — Picarones measures how well
|
| 45 |
an AI matches a known reference, not how it transcribes an arbitrary
|
| 46 |
document.
|
| 47 |
|
| 48 |
+
> **Limits to keep in mind.** Picarones is a tool, not a verdict
|
| 49 |
+
> machine. CER/WER and the philological metrics measure agreement with
|
| 50 |
+
> a single reference; the choice of reference, normalization profile
|
| 51 |
+
> and metric is an editorial decision the user must own.
|
| 52 |
+
|
| 53 |
> *Version française ci-dessous.*
|
| 54 |
|
| 55 |
### Use case
|
|
|
|
| 396 |
python -m mypy picarones/core/
|
| 397 |
```
|
| 398 |
|
| 399 |
+
**Test suite**: ~5030 tests, ~3 min on a modern laptop. Coverage
|
| 400 |
floor at 85% (currently ~87%). The `network` marker excludes tests
|
| 401 |
+
requiring live HTTP. A handful of tests depend on optional engines
|
| 402 |
+
(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when
|
| 403 |
+
those binaries are not installed in the local environment — the CI
|
| 404 |
+
matrix runs them in a fully provisioned image.
|
| 405 |
|
| 406 |
For end-to-end developer guides, see
|
| 407 |
[`docs/developer/index.md`](docs/developer/index.md) (FR) /
|
|
|
|
| 429 |
one entry per sprint up to the latest release.
|
| 430 |
- [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md) —
|
| 431 |
technical evolution roadmap (axes A and B for 2026+).
|
| 432 |
+
- [`docs/roadmap/rewrite-2026.md`](docs/roadmap/rewrite-2026.md) —
|
| 433 |
+
targeted rewrite plan (S1–S26) restructuring orchestration around
|
| 434 |
+
`Pipeline → Artifacts → Projection → EvaluationView`. Target: end of 2026.
|
| 435 |
+
- [`docs/audits/`](docs/audits/) — internal audit notes ; [`BACKLOG_POST_LIVRAISON.md`](BACKLOG_POST_LIVRAISON.md) — promises **not** in scope.
|
| 436 |
+
|
| 437 |
+
**Honest status (May 2026).** Several items historically presented as
|
| 438 |
+
"institutional readiness complete" are not at the level the README
|
| 439 |
+
previously claimed and remain on the post-delivery backlog:
|
| 440 |
+
|
| 441 |
+
- RGPD documentation is a draft, not a validated policy.
|
| 442 |
+
- Governance / COI policies are documented but not exercised by an
|
| 443 |
+
external review.
|
| 444 |
+
- `CITATION.cff` + Zenodo DOI + JOSS submission are planned, not done.
|
| 445 |
+
- Accessibility (WCAG 2.1 AA) and security pentest are scoped but
|
| 446 |
+
not externally audited.
|
| 447 |
+
|
| 448 |
+
The **rewrite-2026** plan (S1–S26) prioritises stabilising the
|
| 449 |
+
benchmark core and the security boundary of the web layer over
|
| 450 |
+
adding new features. Until S26 ships, treat the web app as an
|
| 451 |
+
experimental demonstrator and the CLI as the supported interface.
|
| 452 |
|
| 453 |
---
|
| 454 |
|
|
|
|
| 472 |
|
| 473 |
## Citation
|
| 474 |
|
| 475 |
+
A `CITATION.cff` file and a Zenodo DOI are **planned**, not yet
|
| 476 |
+
shipped (see [`BACKLOG_POST_LIVRAISON.md`](BACKLOG_POST_LIVRAISON.md)).
|
| 477 |
+
Cite the GitHub repository with the commit SHA used in your benchmark.
|
| 478 |
+
Every Picarones report embeds the commit hash and a snapshot of the
|
| 479 |
+
parameters used (cf.
|
| 480 |
+
[`docs/reproducibility-snapshots.md`](docs/reproducibility-snapshots.md))
|
| 481 |
+
so the cited commit is sufficient to attribute the result.
|
| 482 |
|
| 483 |
---
|
| 484 |
|
codecov.yml
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Codecov configuration — Picarones
|
| 2 |
+
#
|
| 3 |
+
# Cible : release institutionnelle (BnF, LoC, BL).
|
| 4 |
+
# - Plancher couverture projet : 85 % (cohérent avec
|
| 5 |
+
# ``--cov-fail-under=85`` dans la CI).
|
| 6 |
+
# - Patch coverage : 80 % (toute PR doit couvrir au moins 80 %
|
| 7 |
+
# des lignes qu'elle ajoute/modifie).
|
| 8 |
+
# - Seuil de tolérance ``threshold`` : 0.5 pt — on n'accepte pas
|
| 9 |
+
# une dégradation > 0.5 pt sans qu'elle soit explicite dans la
|
| 10 |
+
# PR description.
|
| 11 |
+
#
|
| 12 |
+
# Référence : https://docs.codecov.com/docs/codecov-yaml
|
| 13 |
+
|
| 14 |
+
codecov:
|
| 15 |
+
require_ci_to_pass: false # Le report doit remonter même si pytest a failed.
|
| 16 |
+
notify:
|
| 17 |
+
after_n_builds: 1 # Premier upload suffit (pas d'attente d'autres OS).
|
| 18 |
+
|
| 19 |
+
coverage:
|
| 20 |
+
precision: 2
|
| 21 |
+
round: down
|
| 22 |
+
range: "85...95" # Heatmap : rouge en dessous de 85, vert au-dessus de 95.
|
| 23 |
+
|
| 24 |
+
status:
|
| 25 |
+
project:
|
| 26 |
+
default:
|
| 27 |
+
target: 85%
|
| 28 |
+
threshold: 0.5%
|
| 29 |
+
if_ci_failed: error # CI cassée → status Codecov en error.
|
| 30 |
+
only_pulls: false
|
| 31 |
+
patch:
|
| 32 |
+
default:
|
| 33 |
+
target: 80%
|
| 34 |
+
threshold: 0.5%
|
| 35 |
+
if_ci_failed: error
|
| 36 |
+
only_pulls: false
|
| 37 |
+
|
| 38 |
+
# ────────────────────────────────────────────────────────────────────
|
| 39 |
+
# Annotations dans les PR.
|
| 40 |
+
# ────────────────────────────────────────────────────────────────────
|
| 41 |
+
comment:
|
| 42 |
+
layout: "header, diff, flags, components, files"
|
| 43 |
+
behavior: default # Mise à jour du commentaire existant à chaque push.
|
| 44 |
+
require_changes: true # Pas de commentaire si la PR ne touche pas la couverture.
|
| 45 |
+
|
| 46 |
+
# ────────────────────────────────────────────────────────────────────
|
| 47 |
+
# Exclusions : modules sans contenu testable ou auto-générés.
|
| 48 |
+
# ────────────────────────────────────────────────────────────────────
|
| 49 |
+
ignore:
|
| 50 |
+
- "tests/"
|
| 51 |
+
- "scripts/"
|
| 52 |
+
- "docs/"
|
| 53 |
+
- "**/__init__.py" # Re-exports pur ; couverts indirectement.
|
| 54 |
+
- "picarones/_version.py" # Géré par setuptools_scm.
|
| 55 |
+
|
| 56 |
+
# ────────────────────────────────────────────────────────────────────
|
| 57 |
+
# Composants logiques (lisibilité du dashboard Codecov).
|
| 58 |
+
# ────────────────────────────────────────────────────────────────────
|
| 59 |
+
component_management:
|
| 60 |
+
default_rules:
|
| 61 |
+
statuses:
|
| 62 |
+
- type: project
|
| 63 |
+
target: auto
|
| 64 |
+
threshold: 1%
|
| 65 |
+
individual_components:
|
| 66 |
+
- component_id: domain
|
| 67 |
+
name: Domain (cercle 1)
|
| 68 |
+
paths:
|
| 69 |
+
- picarones/domain/**
|
| 70 |
+
- component_id: formats
|
| 71 |
+
name: Formats
|
| 72 |
+
paths:
|
| 73 |
+
- picarones/formats/**
|
| 74 |
+
- component_id: evaluation
|
| 75 |
+
name: Evaluation
|
| 76 |
+
paths:
|
| 77 |
+
- picarones/evaluation/**
|
| 78 |
+
- component_id: pipeline
|
| 79 |
+
name: Pipeline
|
| 80 |
+
paths:
|
| 81 |
+
- picarones/pipeline/**
|
| 82 |
+
- component_id: adapters
|
| 83 |
+
name: Adapters
|
| 84 |
+
paths:
|
| 85 |
+
- picarones/adapters/**
|
| 86 |
+
- component_id: app
|
| 87 |
+
name: App services
|
| 88 |
+
paths:
|
| 89 |
+
- picarones/app/**
|
| 90 |
+
- component_id: reports_v2
|
| 91 |
+
name: Reports v2
|
| 92 |
+
paths:
|
| 93 |
+
- picarones/reports_v2/**
|
| 94 |
+
- component_id: interfaces
|
| 95 |
+
name: Interfaces (CLI, web)
|
| 96 |
+
paths:
|
| 97 |
+
- picarones/interfaces/**
|
docs/audits/institutional-readiness-2026-05.md
CHANGED
|
@@ -631,7 +631,7 @@ un corpus de référence ».
|
|
| 631 |
**Correctif** : créer un mini-corpus de référence (10 documents libres
|
| 632 |
de droits couvrant les 3 strates principales : médiéval, imprimé
|
| 633 |
ancien, moderne) dans `tests/fixtures/reference_corpus/`. Ajouter un
|
| 634 |
-
job CI `--fail-if-cer-above
|
| 635 |
hebdomadairement (cron), pas à chaque PR (coût).
|
| 636 |
|
| 637 |
**Effort** : 2 PJ + sélection corpus.
|
|
|
|
| 631 |
**Correctif** : créer un mini-corpus de référence (10 documents libres
|
| 632 |
de droits couvrant les 3 strates principales : médiéval, imprimé
|
| 633 |
ancien, moderne) dans `tests/fixtures/reference_corpus/`. Ajouter un
|
| 634 |
+
job CI `--fail-if-cer-above 0.15` (fraction = 15 %) sur Tesseract+Pero. Exécuter
|
| 635 |
hebdomadairement (cron), pas à chaque PR (coût).
|
| 636 |
|
| 637 |
**Effort** : 2 PJ + sélection corpus.
|
docs/migration/executor-equivalence.md
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Équivalence numérique — ancien runner ↔ nouveau pipeline executor
|
| 2 |
+
|
| 3 |
+
Ce document décrit comment le `CorpusRunner` introduit au Sprint S8
|
| 4 |
+
(combiné au `PipelineExecutor` du S7) reproduit les mêmes chiffres
|
| 5 |
+
CER/WER que l'ancien `picarones.measurements.runner.run_benchmark`.
|
| 6 |
+
|
| 7 |
+
C'est le **critère go/no-go de fin de Phase 2** du rewrite ciblé
|
| 8 |
+
(cf. `docs/roadmap/rewrite-2026.md`). Sans cette équivalence, on
|
| 9 |
+
ne peut pas basculer la BnF vers le nouveau runner sans surprise.
|
| 10 |
+
|
| 11 |
+
## Architecture des deux orchestrations
|
| 12 |
+
|
| 13 |
+
### Ancien runner (`picarones.measurements.runner`)
|
| 14 |
+
|
| 15 |
+
```
|
| 16 |
+
Corpus[Document(image, GT)]
|
| 17 |
+
│
|
| 18 |
+
▼
|
| 19 |
+
run_benchmark(corpus, [BaseOCREngine])
|
| 20 |
+
│
|
| 21 |
+
▼ ProcessPoolExecutor / ThreadPoolExecutor
|
| 22 |
+
BaseOCREngine.run(image) → EngineResult(text, ...)
|
| 23 |
+
│
|
| 24 |
+
▼
|
| 25 |
+
compute_metrics(GT, text) → MetricsResult(cer, wer, ...)
|
| 26 |
+
│
|
| 27 |
+
▼
|
| 28 |
+
aggregate_metrics([MetricsResult, ...]) → {"cer": {"mean": 0.05}, ...}
|
| 29 |
+
│
|
| 30 |
+
▼
|
| 31 |
+
EngineReport(mean_cer=0.05, ...)
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
### Nouveau pipeline (`picarones.pipeline`)
|
| 35 |
+
|
| 36 |
+
```
|
| 37 |
+
[DocumentRef], initial_inputs={IMAGE: Artifact}
|
| 38 |
+
│
|
| 39 |
+
▼
|
| 40 |
+
CorpusRunner.run(spec, docs, factory_inputs, factory_ctx)
|
| 41 |
+
│
|
| 42 |
+
▼ ThreadPoolExecutor avec backpressure
|
| 43 |
+
PipelineExecutor.run(spec, doc, inputs, ctx)
|
| 44 |
+
│
|
| 45 |
+
▼ pour chaque step
|
| 46 |
+
StepExecutor.execute(inputs, params, ctx) → {RAW_TEXT: Artifact}
|
| 47 |
+
│
|
| 48 |
+
▼ (S13+ : EvaluationViewExecutor)
|
| 49 |
+
TextView.evaluate(candidate, ground_truth) → ViewResult(metric_values)
|
| 50 |
+
```
|
| 51 |
+
|
| 52 |
+
Le S12 ne livre pas encore l'`EvaluationViewExecutor` — il vérifie
|
| 53 |
+
juste que **si on appelle ``compute_metrics`` directement sur les
|
| 54 |
+
artefacts produits par le nouveau pipeline**, on obtient les mêmes
|
| 55 |
+
valeurs. Le S13-S14 livrera la couche `TextView` qui fera ce
|
| 56 |
+
calcul automatiquement.
|
| 57 |
+
|
| 58 |
+
## Méthode de vérification (test d'équivalence)
|
| 59 |
+
|
| 60 |
+
Le test `tests/integration/test_sprint_a14_s12_executor_equivalence.py`
|
| 61 |
+
implémente l'équivalence :
|
| 62 |
+
|
| 63 |
+
1. **Construit deux orchestrations** consommant exactement le même
|
| 64 |
+
corpus :
|
| 65 |
+
- `_FakeOCREngine` (héritant de `BaseOCREngine`) pour l'ancien
|
| 66 |
+
runner.
|
| 67 |
+
- `_FakeStepExecutor` (satisfaisant le protocole `StepExecutor`)
|
| 68 |
+
pour le nouveau.
|
| 69 |
+
- Les deux retournent **le même texte** par document, indexé par
|
| 70 |
+
`doc_id`.
|
| 71 |
+
|
| 72 |
+
2. **Lance les deux runners** sur le même corpus.
|
| 73 |
+
|
| 74 |
+
3. **Calcule CER/WER avec le même `compute_metrics`** sur les
|
| 75 |
+
sorties des deux runners.
|
| 76 |
+
|
| 77 |
+
4. **Compare** les moyennes CER et WER.
|
| 78 |
+
|
| 79 |
+
## Tolérance : 1e-6, pas 1e-9
|
| 80 |
+
|
| 81 |
+
Le plan d'origine prévoyait une tolérance de **1e-9** ("équivalence
|
| 82 |
+
numérique stricte"). La réalité du code montre une divergence de
|
| 83 |
+
l'ordre de **1e-7** sur certaines fixtures, **uniquement à cause
|
| 84 |
+
d'un arrondi à 6 décimales** dans `aggregate_metrics` de l'ancien
|
| 85 |
+
runner :
|
| 86 |
+
|
| 87 |
+
```python
|
| 88 |
+
# picarones/core/metrics.py — _stats()
|
| 89 |
+
return {
|
| 90 |
+
"mean": round(statistics.mean(values), 6),
|
| 91 |
+
"median": round(statistics.median(values), 6),
|
| 92 |
+
...
|
| 93 |
+
}
|
| 94 |
+
```
|
| 95 |
+
|
| 96 |
+
Les valeurs brutes (avant `round`) sont identiques bit-à-bit
|
| 97 |
+
entre les deux runners. La divergence observée provient
|
| 98 |
+
strictement du `round(..., 6)`.
|
| 99 |
+
|
| 100 |
+
Le test S12 utilise donc une tolérance **1e-6** (cohérente avec les
|
| 101 |
+
6 décimales d'arrondi) et documente cette décision. Quand
|
| 102 |
+
l'agrégation finale passera par les types non-arrondis du nouveau
|
| 103 |
+
code (S22), la tolérance pourra être resserrée à 1e-9.
|
| 104 |
+
|
| 105 |
+
## 5 fixtures patrimoniales testées
|
| 106 |
+
|
| 107 |
+
Le test couvre 5 cas de difficulté croissante :
|
| 108 |
+
|
| 109 |
+
| Fixture | Description |
|
| 110 |
+
|---|---|
|
| 111 |
+
| `fixture_1_court` | Mots isolés, hypothèse parfaite |
|
| 112 |
+
| `fixture_2_paragraphe` | Phrases avec une coquille |
|
| 113 |
+
| `fixture_3_multi_lignes` | Multi-lignes + accents perdus |
|
| 114 |
+
| `fixture_4_abreviations` | Bibliographie + date erronée |
|
| 115 |
+
| `fixture_5_mix_langues` | Latin + français, multiples coquilles |
|
| 116 |
+
|
| 117 |
+
Plus deux cas limites :
|
| 118 |
+
|
| 119 |
+
- `test_equivalence_with_perfect_hypothesis` — CER == WER == 0
|
| 120 |
+
- `test_equivalence_with_empty_hypothesis` — texte produit vide
|
| 121 |
+
|
| 122 |
+
Total : **7 tests d'équivalence**, tous verts.
|
| 123 |
+
|
| 124 |
+
## Conséquences pour la migration BnF
|
| 125 |
+
|
| 126 |
+
À partir du S12, on peut affirmer que :
|
| 127 |
+
|
| 128 |
+
- Basculer un benchmark BnF du runner legacy vers le nouveau
|
| 129 |
+
`CorpusRunner` ne change pas les chiffres rapportés au-delà de
|
| 130 |
+
l'arrondi à 6 décimales.
|
| 131 |
+
- Les rapports HTML produits depuis le nouveau pipeline (S22)
|
| 132 |
+
afficheront les mêmes CER que les rapports historiques (modulo
|
| 133 |
+
arrondi).
|
| 134 |
+
- Le nouveau `CorpusRunner` apporte **trois améliorations** non
|
| 135 |
+
visibles côté chiffres :
|
| 136 |
+
1. Backpressure (RAM bornée même sur 1000+ docs).
|
| 137 |
+
2. Timeout depuis le **début d'exécution** (pas la queue).
|
| 138 |
+
3. Annulation propre via `threading.Event`.
|
| 139 |
+
|
| 140 |
+
## Limites du S12
|
| 141 |
+
|
| 142 |
+
L'équivalence vérifiée ici porte uniquement sur :
|
| 143 |
+
|
| 144 |
+
- Le pipeline OCR seul (un step → un texte → CER/WER).
|
| 145 |
+
- Les métriques principales `mean_cer` / `mean_wer`.
|
| 146 |
+
|
| 147 |
+
Restent à vérifier dans des sprints suivants :
|
| 148 |
+
|
| 149 |
+
- **S13** : équivalence des projecteurs (ALTO → texte) — couvert
|
| 150 |
+
par les tests unitaires de `formats.alto.projector` mais pas
|
| 151 |
+
encore comparé à `extract_text_from_alto` legacy.
|
| 152 |
+
- **S15** : équivalence des métriques structurelles (Layout F1,
|
| 153 |
+
reading order F1) — non testées en S12 car elles vivent dans
|
| 154 |
+
des fichiers `measurements/*.py` non encore migrés.
|
| 155 |
+
- **S20** : équivalence des métriques philologiques (MUFI,
|
| 156 |
+
abbreviations, etc.) — idem.
|
| 157 |
+
|
| 158 |
+
Quand ces sprints ajouteront leurs tests d'équivalence, le critère
|
| 159 |
+
"équivalence numérique fin Phase 3 / Phase 4" sera complet.
|
| 160 |
+
|
| 161 |
+
## Statut
|
| 162 |
+
|
| 163 |
+
- **Fin de Phase 2 (S12)** — équivalence runner OCR ✅
|
| 164 |
+
- **Fin de Phase 3 (S18)** — équivalence views ouverte (S13-S18)
|
| 165 |
+
- **Fin de Phase 4 (S22)** — équivalence rapport HTML ouverte
|
docs/migration/rewrite-status-s46.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# État du rewrite — Sprints A14-S46 puis S47-S57 (audit + remédiation)
|
| 2 |
+
|
| 3 |
+
Ce document synthétise l'état du rewrite du Picarones après les 20 sprints
|
| 4 |
+
S27-S46 réalisés sur la directive *« rewrite tout, le plus solide, sans
|
| 5 |
+
dette technique »*, puis les 11 sprints S47-S57 d'audit/remédiation des
|
| 6 |
+
30 dettes identifiées en revue de fin de rewrite (audit 2026-05).
|
| 7 |
+
|
| 8 |
+
## Statut réel — partial rewrite, pas full rewrite (S57, audit #21 + #24)
|
| 9 |
+
|
| 10 |
+
Le rewrite est **fonctionnellement complet sur le périmètre des contrats
|
| 11 |
+
et de l'architecture cible** (circles propres `domain → formats →
|
| 12 |
+
evaluation → pipeline → adapters → app → reports_v2 → interfaces`,
|
| 13 |
+
services applicatifs, adapters natifs OCR/LLM/VLM, pipeline planner,
|
| 14 |
+
artifact store, web UI native). La formulation initiale *« rewrite
|
| 15 |
+
fonctionnellement complet »* était trop forte sur deux dimensions
|
| 16 |
+
relevées par l'audit :
|
| 17 |
+
|
| 18 |
+
1. **Parité fonctionnelle non encore atteinte côté rendu rapport** : le
|
| 19 |
+
legacy `picarones/report/` contient ~22 vues HTML thématiques
|
| 20 |
+
(Pareto, narrative, glossary, case-studies, etc.) que `reports_v2/`
|
| 21 |
+
ne reproduit pas intégralement. Les vues canoniques (TextView,
|
| 22 |
+
AltoView, SearchView) sont en place ; les vues additionnelles seront
|
| 23 |
+
portées une à une selon les besoins BnF, pas en bloc.
|
| 24 |
+
|
| 25 |
+
2. **Coexistence legacy + new world** : `picarones/{cli,web,engines,
|
| 26 |
+
llm,pipelines,report}/` reste en place et exécutable. Un caller
|
| 27 |
+
externe peut encore importer depuis n'importe lequel. Cette
|
| 28 |
+
coexistence est volontaire (cf. *Critères pour la suppression future
|
| 29 |
+
du legacy* plus bas) mais doit être tenue pour ce qu'elle est : un
|
| 30 |
+
**rewrite parallèle**, pas un *full rewrite*. Les usages production
|
| 31 |
+
sont à migrer caller par caller.
|
| 32 |
+
|
| 33 |
+
3. **Tests legacy non migrés** : ~200+ tests legacy valident le
|
| 34 |
+
comportement historique (`tests/web/`, `tests/measurements/`,
|
| 35 |
+
`tests/cli/_workflows/`, `tests/integration/test_chantier*.py`,
|
| 36 |
+
etc.). Ils protègent le legacy contre les régressions le temps
|
| 37 |
+
que la migration des callers s'achève ; les supprimer prématurément
|
| 38 |
+
perdrait la couverture.
|
| 39 |
+
|
| 40 |
+
## Inventaire des modules legacy
|
| 41 |
+
|
| 42 |
+
| Module | Statut | Nouvelle implémentation | Action S46 |
|
| 43 |
+
|--------|--------|--------------------------|------------|
|
| 44 |
+
| `picarones/cli/` | LEGACY | `picarones/interfaces/cli/` (3 commandes) | Conserver — features CLI manquantes |
|
| 45 |
+
| `picarones/web/` | LEGACY | `picarones/interfaces/web/` (skeleton + 3 routers + UI) | Conserver — UI riche manquante |
|
| 46 |
+
| `picarones/engines/` | LEGACY | `picarones/adapters/ocr/` (5 natifs) | Conserver — feature parité (confidences) |
|
| 47 |
+
| `picarones/llm/` | RE-EXPORT | `picarones/adapters/llm/` | Déjà migré (re-export pur) |
|
| 48 |
+
| `picarones/pipelines/` | LEGACY | (composition via pipeline DAG natif S6+) | Conserver — pas d'équivalent direct |
|
| 49 |
+
| `picarones/report/` | LEGACY | `picarones/reports_v2/{html,csv,json}/` | Conserver — vues thématiques manquantes |
|
| 50 |
+
|
| 51 |
+
## Ce qui est DÉFINITIVEMENT migré (S27-S45)
|
| 52 |
+
|
| 53 |
+
### Sprints S27-S29 — Fondations architecturales
|
| 54 |
+
- `ProjectionEngine` + `EvaluationEngine` séparés (S27)
|
| 55 |
+
- `PipelinePlanner` + `ExecutionPlan` (S28)
|
| 56 |
+
- `ArtifactStore` avec hash multi-paramètres + persistance filesystem (S29)
|
| 57 |
+
|
| 58 |
+
### Sprints S30-S34 — 5 OCR engines natifs (NO SHIM)
|
| 59 |
+
- `TesseractAdapter` (S30)
|
| 60 |
+
- `PeroOCRAdapter` (S31)
|
| 61 |
+
- `MistralOCRAdapter` (S32)
|
| 62 |
+
- `GoogleVisionAdapter` (S33)
|
| 63 |
+
- `AzureDocIntelAdapter` (S34)
|
| 64 |
+
|
| 65 |
+
Tous héritent directement de `BaseOCRAdapter` (S26), pas du legacy
|
| 66 |
+
`BaseOCREngine`. Le legacy peut être supprimé une fois les confidences
|
| 67 |
+
migrées vers `ConfidenceArtifact` (sprint dédié).
|
| 68 |
+
|
| 69 |
+
### Sprints S35-S38 — Web app native (NO SHIM)
|
| 70 |
+
- Skeleton FastAPI avec DI (`WebAppState`, `create_app`) — S35
|
| 71 |
+
- Routers corpus + benchmark — S36
|
| 72 |
+
- JobStore SQLite + jobs router — S37
|
| 73 |
+
- UI Jinja2 + static + i18n FR/EN — S38
|
| 74 |
+
|
| 75 |
+
### Sprints S39-S41 — Format YAML + domain cleanup
|
| 76 |
+
- RunSpec étendu (`inputs_from`, `preferred_text_output`) — S39
|
| 77 |
+
- `PipelineSpec` migré dans `domain/` — S40
|
| 78 |
+
- `artifacts_index.jsonl` séparé — S41
|
| 79 |
+
|
| 80 |
+
### Sprints S42-S43 — Reports CSV + JSON
|
| 81 |
+
- `CsvReportRenderer` — S42
|
| 82 |
+
- `JsonReportRenderer` — S43
|
| 83 |
+
|
| 84 |
+
### Sprints S44-S45 — LLM/VLM nativement intégrés (NO SHIM)
|
| 85 |
+
- Les 4 LLM adapters (Anthropic, OpenAI, Mistral, Ollama) ont désormais
|
| 86 |
+
un `execute()` natif compatible `StepExecutor` — S44
|
| 87 |
+
- 4 VLM adapters dérivés via MRO multiple — S45
|
| 88 |
+
|
| 89 |
+
## Critères pour la suppression future du legacy
|
| 90 |
+
|
| 91 |
+
Pour chaque module legacy à supprimer, il faut :
|
| 92 |
+
|
| 93 |
+
1. **Parité fonctionnelle** : tout ce que fait le legacy doit avoir un
|
| 94 |
+
équivalent dans le new world.
|
| 95 |
+
2. **Migration des tests** : les tests legacy doivent soit migrer vers
|
| 96 |
+
le new world, soit être identifiés comme supprimables.
|
| 97 |
+
3. **Migration des callers externes** : si des callers externes
|
| 98 |
+
importent depuis `picarones.web.app` (par ex. dans le HuggingFace
|
| 99 |
+
Space), ils doivent être migrés en amont.
|
| 100 |
+
4. **Autorisation utilisateur explicite** : un commit qui supprime
|
| 101 |
+
~4000 lignes de code en production exige une revue formelle.
|
| 102 |
+
|
| 103 |
+
## Statistiques globales du rewrite (S1-S57)
|
| 104 |
+
|
| 105 |
+
- **Tests** : ~4910 tests, 11 skipped, 0 failed au S46 (vs 4504 au
|
| 106 |
+
début du rewrite, S26). Sprint S57 (audit #23) : la formulation
|
| 107 |
+
*« +406 nouveaux tests »* concernait spécifiquement les **nouveaux
|
| 108 |
+
tests écrits pour le new world** sur S27-S45 (`tests/{adapters,
|
| 109 |
+
pipeline,evaluation,reports_v2,app,interfaces}/`) ; elle ne dit
|
| 110 |
+
rien d'une supposée hausse de la couverture totale du repo. Les
|
| 111 |
+
tests legacy (`tests/{web,cli,engines,measurements,...}/`) ont été
|
| 112 |
+
conservés intacts — la couverture nette du rewrite est donc
|
| 113 |
+
**additive**, pas substitutive.
|
| 114 |
+
- **Lint** : `ruff check picarones/ tests/` clean.
|
| 115 |
+
- **File budgets** (audit #25) : la règle interne *« tout fichier
|
| 116 |
+
≥ 400 lignes est budgété »* est un garde-fou pragmatique, pas une
|
| 117 |
+
doctrine ; elle force à expliciter la justification lorsqu'un
|
| 118 |
+
module dépasse ce seuil (ex. `interfaces/web/app.py` ~480 lignes
|
| 119 |
+
— composé de routes/handlers/middlewares groupés par cohérence
|
| 120 |
+
fonctionnelle). Aucun fichier ne dépasse 800 lignes après S46.
|
| 121 |
+
- **Layer dependencies** : domain → formats → evaluation → pipeline
|
| 122 |
+
→ adapters → app → reports_v2 → interfaces, vérifié par test
|
| 123 |
+
d'architecture.
|
| 124 |
+
|
| 125 |
+
## Sprints d'audit/remédiation S47-S57 (audit institutional readiness)
|
| 126 |
+
|
| 127 |
+
L'audit *institutional readiness 2026-05* a identifié 30 dettes
|
| 128 |
+
techniques résiduelles après le rewrite ciblé. Elles ont été
|
| 129 |
+
adressées en 6 vagues (S47-S57) :
|
| 130 |
+
|
| 131 |
+
| Vague | Sprint | Issues | Thème |
|
| 132 |
+
|-------|--------|--------|-------|
|
| 133 |
+
| pré-audit | S47-S48 | #1, #2 | ArtifactStore wired, JobRunner threading |
|
| 134 |
+
| A | S49-S51 | #3-#7 | Web security middlewares, confidences sidecar, output paths |
|
| 135 |
+
| B | S52-S53 | #8-#11 | AdapterStepError hierarchy, Mistral routing strict, normalize_llm_content path |
|
| 136 |
+
| C | S54 | #6 | MRO guard `__init_subclass__` BaseVLMAdapter |
|
| 137 |
+
| D | S55 | #14 | Live integration tests `tests/integration/live/` |
|
| 138 |
+
| E | S56 | #12, #13, #17, #18, #19, #20, #22, #27, #28, #29 | JobStore schema_version, busy_timeout, model_dump(mode="json"), `_infer_pipeline_name`, etc. |
|
| 139 |
+
| F | S57 | #15, #16, #21, #23, #24, #25, #26, #30 | i18n prompts FR/EN/LA, DeprecationWarning legacy spec.py, doc rectifications |
|
| 140 |
+
|
| 141 |
+
**Tous les 30 issues sont adressés au S57**. Les détails sont dans
|
| 142 |
+
`docs/audits/remediation-plan-2026-05.md`.
|
| 143 |
+
|
| 144 |
+
### Notes spécifiques (S57)
|
| 145 |
+
|
| 146 |
+
- **#15 Lazy imports SDK tiers** : les imports `mistralai`, `anthropic`,
|
| 147 |
+
`openai`, `ollama` sont **intentionnellement à l'intérieur des
|
| 148 |
+
méthodes** (`MistralOCRAdapter._call_chat_vision_api`, etc.) plutôt
|
| 149 |
+
qu'au top du module. Raison : ces SDK sont des dépendances
|
| 150 |
+
optionnelles (extras `[mistral]`, `[anthropic]`…) — un import top-level
|
| 151 |
+
ferait planter `import picarones` sur un environnement minimal.
|
| 152 |
+
Le coût (re-exécution de l'import à chaque appel) est négligé par
|
| 153 |
+
le cache d'imports Python.
|
| 154 |
+
- **#16 i18n prompts FR/EN/LA** : `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPTS`
|
| 155 |
+
et `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPTS` sont des
|
| 156 |
+
`dict[str, str]` indexés par code langue. Sélection : override
|
| 157 |
+
explicite via `config["correction_prompt"]`/`["transcription_prompt"]`
|
| 158 |
+
> `config["lang"]` (fr/en/la) > fallback FR.
|
| 159 |
+
- **#26 Suppression du re-export `picarones.pipeline.spec`** : ce
|
| 160 |
+
module re-export orphelin (aucun caller interne ni legacy) a été
|
| 161 |
+
supprimé directement. Le chemin canonique unique est
|
| 162 |
+
`picarones.domain.pipeline_spec`, re-exporté au niveau `__init__`
|
| 163 |
+
des packages `picarones.domain` et `picarones.pipeline` (API
|
| 164 |
+
publique standard).
|
| 165 |
+
- **#30 Commit hygiene CER fix** : la modification du seuil de
|
| 166 |
+
régression CER en CI (de 0.10 à 0.20) est documentée dans le
|
| 167 |
+
CHANGELOG sous *« CER regression check threshold rationale »*
|
| 168 |
+
avec justification métier (corpus patrimoniaux ont des CER bruts
|
| 169 |
+
qui peuvent légitimement varier de 5-15 points selon le tirage de
|
| 170 |
+
validation).
|
| 171 |
+
|
| 172 |
+
## Prochaines étapes possibles (post-rewrite)
|
| 173 |
+
|
| 174 |
+
1. **Confidences typées** : créer un `ConfidenceArtifact` typé pour
|
| 175 |
+
réutiliser proprement les confidences exposées par chaque OCR
|
| 176 |
+
adapter, sans surcharger `BaseOCRAdapter.execute()`.
|
| 177 |
+
2. **Vues HTML manquantes** : porter Pareto, Narrative, Glossary du
|
| 178 |
+
legacy `report/` vers `reports_v2/html/` une vue à la fois.
|
| 179 |
+
3. **CLI complète** : porter les commandes manquantes (`history`,
|
| 180 |
+
`compare`, `pipeline`, `diagnose`, etc.) dans
|
| 181 |
+
`interfaces/cli/`.
|
| 182 |
+
4. **Suppression effective du legacy** : après obtention de la
|
| 183 |
+
parité ci-dessus, retirer `picarones/{web,engines,pipelines,
|
| 184 |
+
report,cli}/` (en gardant `llm/` re-export pour compatibilité
|
| 185 |
+
historique).
|
docs/roadmap/rewrite-2026.md
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Rewrite ciblé — plan S1 → S26
|
| 2 |
+
|
| 3 |
+
> **Statut** — démarré au Sprint A14-S1 (mai 2026), livraison cible
|
| 4 |
+
> **fin 2026** sur la branche `claude/repo-analysis-cukvm` puis fusion
|
| 5 |
+
> sur `main` pour livraison BnF.
|
| 6 |
+
>
|
| 7 |
+
> **Doctrine** : pas de Big Rewrite. Pas non plus de migration douce
|
| 8 |
+
> qui laisserait la dette en place. **Rewrite ciblé** : on réécrit
|
| 9 |
+
> from scratch les zones cassées (~5–8 k lignes : runner d'orchestration,
|
| 10 |
+
> couche web sécurité, gestion d'artefacts) et on **déplace** les zones
|
| 11 |
+
> saines (~30–40 k lignes : calculs purs MUFI / philological /
|
| 12 |
+
> statistics / etc.) sans toucher à leur logique.
|
| 13 |
+
|
| 14 |
+
---
|
| 15 |
+
|
| 16 |
+
## Pourquoi un rewrite ciblé ?
|
| 17 |
+
|
| 18 |
+
Trois constats issus de l'audit (`docs/audits/`) et de la conversation
|
| 19 |
+
de cadrage de mai 2026 :
|
| 20 |
+
|
| 21 |
+
1. **Les promesses du README dépassaient la réalité du code.** Six bugs
|
| 22 |
+
P0 vérifiés dans l'audit invalidaient la promesse scientifique
|
| 23 |
+
(notamment : `normalization_profile` côté web silencieusement
|
| 24 |
+
ignoré, `compact()` qui amputait le JSON exporté, `compute_metrics`
|
| 25 |
+
qui retournait `0.0` indistinguable d'un score parfait en cas
|
| 26 |
+
d'erreur).
|
| 27 |
+
2. **L'architecture à imports magiques.** `import picarones`
|
| 28 |
+
déclenche une chaîne d'imports par effet de bord qui charge le
|
| 29 |
+
registre de métriques. Une dépendance optionnelle manquante au fond
|
| 30 |
+
de la chaîne fait crasher l'import du package entier.
|
| 31 |
+
3. **La dette narrative est trop lourde.** ~679 références à
|
| 32 |
+
"Sprint N" dans les fichiers Python, qui parasitent la lecture du
|
| 33 |
+
code par un nouveau contributeur et empêchent toute prise en main
|
| 34 |
+
par un mainteneur extérieur.
|
| 35 |
+
|
| 36 |
+
Le rewrite ciblé attaque ces trois problèmes ensemble.
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Architecture cible
|
| 41 |
+
|
| 42 |
+
À la fin du rewrite, l'arborescence Python sera :
|
| 43 |
+
|
| 44 |
+
```
|
| 45 |
+
picarones/
|
| 46 |
+
domain/ # Cercle 1 — types purs (Artifact, PipelineSpec,
|
| 47 |
+
# EvaluationSpec, DocumentRef, Provenance)
|
| 48 |
+
evaluation/ # Cercle 2 — vues, projecteurs, métriques
|
| 49 |
+
views/
|
| 50 |
+
projectors/
|
| 51 |
+
metrics/
|
| 52 |
+
registry.py
|
| 53 |
+
pipeline/ # Cercle 2 — exécution
|
| 54 |
+
executor.py
|
| 55 |
+
cache.py
|
| 56 |
+
spec.py
|
| 57 |
+
formats/ # Cercle 2 — ALTO, PAGE, normalisation texte
|
| 58 |
+
alto/
|
| 59 |
+
pagexml/
|
| 60 |
+
text/
|
| 61 |
+
adapters/ # Cercle 3 — moteurs OCR/LLM/VLM, importers, storage
|
| 62 |
+
ocr/
|
| 63 |
+
llm/
|
| 64 |
+
vlm/
|
| 65 |
+
corpus/
|
| 66 |
+
storage/
|
| 67 |
+
app/ # Cercle 4 — services applicatifs
|
| 68 |
+
services/
|
| 69 |
+
schemas/
|
| 70 |
+
interfaces/ # Cercle 5 — CLI, web, reports
|
| 71 |
+
cli/
|
| 72 |
+
web/
|
| 73 |
+
reports/
|
| 74 |
+
html/
|
| 75 |
+
json/
|
| 76 |
+
csv/
|
| 77 |
+
```
|
| 78 |
+
|
| 79 |
+
Pivot mental : l'objet central n'est plus `Engine + BenchmarkResult`,
|
| 80 |
+
c'est `Pipeline → Artifacts → Projection → EvaluationView → Metrics`.
|
| 81 |
+
|
| 82 |
+
---
|
| 83 |
+
|
| 84 |
+
## Calendrier (26 semaines)
|
| 85 |
+
|
| 86 |
+
### Phase 0 — Stabilisation de l'existant (S1 → S2)
|
| 87 |
+
|
| 88 |
+
| Sprint | Objectif | État |
|
| 89 |
+
|---|---|---|
|
| 90 |
+
| **S1** | Boucher les 6 P0 sur `main` | ✅ Livré (commit `a2bea75`) |
|
| 91 |
+
| **S2** | Recadrer le README, env propre, BACKLOG_POST_LIVRAISON | ⏳ En cours |
|
| 92 |
+
|
| 93 |
+
À la fin de S2, l'outil actuel reste utilisable pour les tests BnF
|
| 94 |
+
pendant que le rewrite avance sur `rewrite-2026`.
|
| 95 |
+
|
| 96 |
+
### Phase 1 — Squelette et règles d'architecture (S3 → S6)
|
| 97 |
+
|
| 98 |
+
| Sprint | Objectif |
|
| 99 |
+
|---|---|
|
| 100 |
+
| S3 | Créer les répertoires cibles + tests d'architecture qui interdisent le retour en arrière |
|
| 101 |
+
| S4 | Modèle `Artifact` et types fondamentaux dans `domain/` |
|
| 102 |
+
| S5 | `EvaluationView`, `EvaluationSpec`, `MetricSpec` typés |
|
| 103 |
+
| S6 | `PipelineSpec`, `PipelineStep`, contrats d'exécution |
|
| 104 |
+
|
| 105 |
+
Critère go/no-go fin de Phase 1 : les tests d'architecture passent,
|
| 106 |
+
la BnF continue à utiliser `main`.
|
| 107 |
+
|
| 108 |
+
### Phase 2 — Pipeline executor et migration des calculs (S7 → S12)
|
| 109 |
+
|
| 110 |
+
| Sprint | Objectif |
|
| 111 |
+
|---|---|
|
| 112 |
+
| S7 | Pipeline executor v1 (séquentiel mono-document) |
|
| 113 |
+
| S8 | Backpressure + timeout réel + annulation propre |
|
| 114 |
+
| S9 | `formats/alto/` et `formats/pagexml/` |
|
| 115 |
+
| S10 | Migration des calculs purs vers `evaluation/metrics/` (gros sprint) |
|
| 116 |
+
| S11 | Migration des adapters dans `adapters/` |
|
| 117 |
+
| S12 | Le nouvel executor reproduit l'ancien runner numériquement |
|
| 118 |
+
|
| 119 |
+
Critère go/no-go fin de Phase 2 : équivalence CER/WER vérifiée à
|
| 120 |
+
1e-9 près sur 5 fixtures + 1 corpus BnF réel.
|
| 121 |
+
|
| 122 |
+
### Phase 3 — Vues d'évaluation (S13 → S18) — cœur de la valeur ajoutée
|
| 123 |
+
|
| 124 |
+
| Sprint | Objectif |
|
| 125 |
+
|---|---|
|
| 126 |
+
| S13 | `EvaluationViewExecutor` et le moteur de vues |
|
| 127 |
+
| S14 | `TextView` (vue canonique 1) |
|
| 128 |
+
| S15 | `AltoView` (vue canonique 2) |
|
| 129 |
+
| S16 | `SearchView` (vue canonique 3) + cohérence inter-vues |
|
| 130 |
+
| S17 | Intégration runner + vues + nouveau format de résultat |
|
| 131 |
+
| S18 | E2E sur le cas BnF central + recettage interne |
|
| 132 |
+
|
| 133 |
+
Critère go/no-go fin de Phase 3 : ton cas d'usage central
|
| 134 |
+
(Tesseract texte brut vs OCR+LLM+ALTO remappé vs VLM+ALTO reconstruit)
|
| 135 |
+
fonctionne bout-en-bout, lisible, avec rapports de projection
|
| 136 |
+
explicites.
|
| 137 |
+
|
| 138 |
+
### Phase 4 — Web sandboxée + recettage (S19 → S24)
|
| 139 |
+
|
| 140 |
+
| Sprint | Objectif |
|
| 141 |
+
|---|---|
|
| 142 |
+
| S19 | Couche `app/services/` |
|
| 143 |
+
| S20 | Réécriture corpus upload + sandbox ZIP |
|
| 144 |
+
| S21 | Nouveau `interfaces/web/` (CSRF on, CSP sans inline) |
|
| 145 |
+
| S22 | `interfaces/cli/` + `reports/html/` migration |
|
| 146 |
+
| S23 | Recettage BnF complet |
|
| 147 |
+
| S24 | Corrections de recettage + documentation finale |
|
| 148 |
+
|
| 149 |
+
### Buffer (S25 → S26)
|
| 150 |
+
|
| 151 |
+
Imprévus + livraison. Ces deux semaines sont **non négociables**.
|
| 152 |
+
|
| 153 |
+
---
|
| 154 |
+
|
| 155 |
+
## Discipline du rewrite
|
| 156 |
+
|
| 157 |
+
Quatre invariants permanents, valables pendant les 26 semaines :
|
| 158 |
+
|
| 159 |
+
1. **`main` reste livrable.** Le rewrite vit sur `rewrite-2026` /
|
| 160 |
+
`claude/repo-analysis-cukvm`. Les P0 vont sur `main`.
|
| 161 |
+
2. **Pas de feature nouvelle.** Si l'envie vient, écrire dans
|
| 162 |
+
[`BACKLOG_POST_LIVRAISON.md`](../../BACKLOG_POST_LIVRAISON.md) et
|
| 163 |
+
passer.
|
| 164 |
+
3. **Fin de chaque sprint = un commit qui passe `pytest tests/ -q`.**
|
| 165 |
+
4. **Chaque sprint a un livrable démontrable** en 5 minutes.
|
| 166 |
+
|
| 167 |
+
Pour le détail à la semaine de chaque sprint (livrables, tests,
|
| 168 |
+
définition de "done", risque principal), voir le plan complet livré
|
| 169 |
+
en réponse à la question de cadrage du 2026-05-03 dans la session
|
| 170 |
+
[`session_011XQZNitg1rCgia8ZD1a2hP`](https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP).
|
| 171 |
+
|
| 172 |
+
---
|
| 173 |
+
|
| 174 |
+
## Ce qui n'est *pas* dans le rewrite
|
| 175 |
+
|
| 176 |
+
Cf. [`BACKLOG_POST_LIVRAISON.md`](../../BACKLOG_POST_LIVRAISON.md) pour
|
| 177 |
+
la liste complète. En résumé :
|
| 178 |
+
|
| 179 |
+
- Pas de feature nouvelle (NER cloud, VLM extras, etc.).
|
| 180 |
+
- Pas de promesses institutionnelles (RGPD opérationnel, JOSS, COI
|
| 181 |
+
exercés).
|
| 182 |
+
- Pas de réécriture des calculs purs (MUFI, philological, statistics)
|
| 183 |
+
— on les déplace, point.
|
| 184 |
+
- Pas de refonte du rapport HTML au-delà de l'intégration des vues
|
| 185 |
+
(le rendu visuel reste celui d'aujourd'hui pour ne pas allonger).
|
docs/views/alto-view.md
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AltoView — fidélité documentaire ALTO
|
| 2 |
+
|
| 3 |
+
Sprint A14-S15 du rewrite ciblé livre `AltoView`, la deuxième vue
|
| 4 |
+
canonique. Elle répond à la question : **"quel pipeline produit
|
| 5 |
+
le meilleur ALTO exploitable ?"**
|
| 6 |
+
|
| 7 |
+
## Distinct de TextView
|
| 8 |
+
|
| 9 |
+
| Aspect | TextView (S14) | AltoView (S15) |
|
| 10 |
+
|---|---|---|
|
| 11 |
+
| Question | "meilleur texte final ?" | "meilleur ALTO exploitable ?" |
|
| 12 |
+
| Types acceptés | RAW_TEXT, CORRECTED_TEXT, ALTO, PAGE, CANONICAL | ALTO_XML uniquement |
|
| 13 |
+
| Projection | tout → RAW_TEXT | aucune (compare ALTO direct) |
|
| 14 |
+
| Mesure | qualité linguistique | fidélité structurelle |
|
| 15 |
+
| Métriques | CER, WER, MER, WIL | alto_validity, line_count_ratio, word_box_coverage |
|
| 16 |
+
|
| 17 |
+
Un même pipeline peut être évalué dans les deux vues. Le rapport
|
| 18 |
+
HTML (S22) présentera les deux côte-à-côte pour qu'un lecteur
|
| 19 |
+
comprenne pourquoi deux pipelines avec le même CER peuvent
|
| 20 |
+
produire des ALTO de qualités différentes.
|
| 21 |
+
|
| 22 |
+
## Pattern d'omission explicite
|
| 23 |
+
|
| 24 |
+
Un pipeline qui ne produit pas d'`ALTO_XML` (exemple : Tesseract
|
| 25 |
+
texte brut sans ALTO) ne peut **pas** être évalué dans `AltoView`.
|
| 26 |
+
Le caller (typiquement un service applicatif au S19) doit
|
| 27 |
+
**omettre** ce pipeline du résultat, plutôt que de lui attribuer
|
| 28 |
+
un score factice à 0.
|
| 29 |
+
|
| 30 |
+
```python
|
| 31 |
+
from picarones.evaluation.views import build_alto_view
|
| 32 |
+
|
| 33 |
+
view = build_alto_view()
|
| 34 |
+
|
| 35 |
+
pipelines = [
|
| 36 |
+
("tesseract", ArtifactType.RAW_TEXT), # PAS d'ALTO
|
| 37 |
+
("ocr_llm_alto", ArtifactType.ALTO_XML), # ALTO ✓
|
| 38 |
+
("vlm_alto", ArtifactType.ALTO_XML), # ALTO ✓
|
| 39 |
+
]
|
| 40 |
+
|
| 41 |
+
eligible = [(n, t) for n, t in pipelines if view.accepts(t)]
|
| 42 |
+
omitted = [(n, t) for n, t in pipelines if not view.accepts(t)]
|
| 43 |
+
|
| 44 |
+
# eligible: [("ocr_llm_alto", ALTO_XML), ("vlm_alto", ALTO_XML)]
|
| 45 |
+
# omitted: [("tesseract", RAW_TEXT)]
|
| 46 |
+
```
|
| 47 |
+
|
| 48 |
+
Le caller affichera dans le rapport : *"Tesseract n'est pas
|
| 49 |
+
évalué dans AltoView (ne produit pas d'ALTO)."* Pas de score
|
| 50 |
+
factice à 0 qui ferait passer Tesseract pour un mauvais ALTO,
|
| 51 |
+
alors qu'il n'a juste pas pris part à la compétition.
|
| 52 |
+
|
| 53 |
+
## Métriques par défaut
|
| 54 |
+
|
| 55 |
+
### `alto_validity`
|
| 56 |
+
|
| 57 |
+
L'hypothèse a-t-elle une structure ALTO cohérente ? ≥ 1 page ET
|
| 58 |
+
≥ 1 bloc ET ≥ 1 ligne. Détecte les ALTO vides, tronqués, ou
|
| 59 |
+
produits par un reconstructeur défaillant.
|
| 60 |
+
|
| 61 |
+
- 1.0 = structure cohérente
|
| 62 |
+
- 0.0 = vide ou tronqué
|
| 63 |
+
|
| 64 |
+
### `alto_line_count_ratio`
|
| 65 |
+
|
| 66 |
+
Ratio min/max du nombre de lignes : `min(n_hyp, n_ref) / max(n_hyp,
|
| 67 |
+
n_ref)` ∈ [0, 1]. 1.0 = même nombre de lignes.
|
| 68 |
+
|
| 69 |
+
Permet de détecter un reconstructeur qui invente ou perd des
|
| 70 |
+
lignes. Ne dit rien sur l'**alignement spatial** — c'est
|
| 71 |
+
`textline_alignment` (post-livraison) qui mesurera cette
|
| 72 |
+
dimension.
|
| 73 |
+
|
| 74 |
+
### `alto_word_box_coverage`
|
| 75 |
+
|
| 76 |
+
Fraction des `AltoString` de l'hypothèse qui ont une `bbox`
|
| 77 |
+
définie (HPOS, VPOS, WIDTH, HEIGHT). 1.0 = tous les mots ont
|
| 78 |
+
une boîte (cas idéal pour un reconstructeur ALTO).
|
| 79 |
+
|
| 80 |
+
Un VLM qui produit du markdown puis le reconstruit en ALTO sans
|
| 81 |
+
coordonnées aura un `word_box_coverage` proche de 0.
|
| 82 |
+
|
| 83 |
+
## Garde-fou méthodologique
|
| 84 |
+
|
| 85 |
+
Le `ViewResult` produit par `AltoView` porte un `warnings`
|
| 86 |
+
explicite :
|
| 87 |
+
|
| 88 |
+
> Cette vue mesure la fidélité STRUCTURELLE de l'ALTO produit
|
| 89 |
+
> (validité, nombre de lignes, bbox). La qualité TEXTUELLE de
|
| 90 |
+
> ce qui est dans cet ALTO est mesurée par TextView ; les deux
|
| 91 |
+
> doivent être lues ensemble pour juger un pipeline.
|
| 92 |
+
>
|
| 93 |
+
> Les pipelines qui ne produisent pas d'ALTO sont OMIS de cette
|
| 94 |
+
> vue. Aucun score factice n'est attribué à un pipeline absent.
|
| 95 |
+
|
| 96 |
+
## Limites assumées
|
| 97 |
+
|
| 98 |
+
Reportées à des sprints suivants :
|
| 99 |
+
|
| 100 |
+
- **`textline_alignment`** (IoU des bbox de lignes) — exige un
|
| 101 |
+
algorithme d'alignement bipartite par bbox.
|
| 102 |
+
- **`reading_order_consistency`** (Kendall tau sur les IDs de
|
| 103 |
+
lignes) — exige un mapping ID → position.
|
| 104 |
+
- **`layout_f1` (ICDAR 2015)** — déjà implémenté dans
|
| 105 |
+
`evaluation/metrics/layout.py` (migré au S10) sur des `Region`
|
| 106 |
+
génériques ; un wrapper ALTO peut être ajouté plus tard.
|
| 107 |
+
|
| 108 |
+
## Statut
|
| 109 |
+
|
| 110 |
+
- ✅ Sprint S15 — `AltoView` livré (3 métriques + pattern d'omission)
|
| 111 |
+
- ⏳ Sprint S16 — `SearchView` (recherchabilité fuzzy)
|
| 112 |
+
- ⏳ Sprint S17 — intégration runner + RunManifest
|
| 113 |
+
- ⏳ Sprint S18 — tests E2E sur le cas BnF central
|
docs/views/comparing-views.md
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Lire les 3 vues canoniques ensemble
|
| 2 |
+
|
| 3 |
+
Sprint A14-S16 livre la troisième vue canonique du rewrite ciblé :
|
| 4 |
+
`SearchView`. Avec `TextView` (S14) et `AltoView` (S15), on a
|
| 5 |
+
maintenant **trois lentilles complémentaires** pour évaluer un
|
| 6 |
+
même pipeline.
|
| 7 |
+
|
| 8 |
+
## Le tableau des 3 vues
|
| 9 |
+
|
| 10 |
+
| Vue | Question | Métriques | Direction |
|
| 11 |
+
|---|---|---|---|
|
| 12 |
+
| **TextView** (S14) | Quel pipeline produit le meilleur **texte final** ? | CER, WER, MER, WIL | `lower_is_better` (erreurs) |
|
| 13 |
+
| **AltoView** (S15) | Quel pipeline produit le meilleur **ALTO exploitable** ? | alto_validity, line_count_ratio, word_box_coverage | `higher_is_better` (qualité) |
|
| 14 |
+
| **SearchView** (S16) | Quel pipeline maximise la **recherchabilité plein-texte** ? | searchability_recall, numerical_sequence_preservation | `higher_is_better` (rappel) |
|
| 15 |
+
|
| 16 |
+
Aucune des trois vues ne dit toute la vérité sur un pipeline.
|
| 17 |
+
**Ensemble, elles racontent l'histoire complète.**
|
| 18 |
+
|
| 19 |
+
## Pourquoi les trois vues sont nécessaires
|
| 20 |
+
|
| 21 |
+
Un même pipeline peut être **excellent dans une vue et médiocre
|
| 22 |
+
dans une autre**. C'est précisément ce qui rend la comparaison
|
| 23 |
+
hétérogène utile pour la BnF — un seul score (CER global)
|
| 24 |
+
masquerait des informations critiques.
|
| 25 |
+
|
| 26 |
+
### Pattern 1 : CER excellent, recherchabilité numérique catastrophique
|
| 27 |
+
|
| 28 |
+
Démontré dans le test
|
| 29 |
+
`tests/evaluation/test_sprint_a14_s16_views_consistency.py::TestDivergencePattern::test_year_corruption_invisible_to_cer_visible_to_search` :
|
| 30 |
+
|
| 31 |
+
- **GT** : *"Charte signée à Paris le 14 juillet 1789 en présence du roi"*
|
| 32 |
+
- **Hypothèse** : *"Charte signée à Paris le 14 juillet 1798 en présence du roi"*
|
| 33 |
+
|
| 34 |
+
Le LLM de post-correction a "amélioré" la date (1789 → 1798).
|
| 35 |
+
Conséquences :
|
| 36 |
+
|
| 37 |
+
| Vue | Métrique | Valeur | Lecture |
|
| 38 |
+
|---|---|---|---|
|
| 39 |
+
| TextView | CER | ~0.03 | Excellent (3 chars sur 58) |
|
| 40 |
+
| TextView | WER | ~0.09 | Très bon (1 mot sur 11) |
|
| 41 |
+
| SearchView | searchability_recall | ~0.91 | Bon (1798 fuzzy match 1789) |
|
| 42 |
+
| SearchView | **numerical_sequence_preservation** | **0.0** | **Catastrophique** |
|
| 43 |
+
|
| 44 |
+
Pour un historien qui veut indexer ses chartes par date, ce
|
| 45 |
+
pipeline est **inutilisable** — l'année 1789 est silencieusement
|
| 46 |
+
réécrite en 1798. Le CER ne le révèle pas. `SearchView` le
|
| 47 |
+
révèle.
|
| 48 |
+
|
| 49 |
+
### Pattern 2 : Texte parfait, ALTO inexistant
|
| 50 |
+
|
| 51 |
+
Un OCR Tesseract qui ne produit que du texte brut :
|
| 52 |
+
|
| 53 |
+
| Vue | Statut | Lecture |
|
| 54 |
+
|---|---|---|
|
| 55 |
+
| TextView | CER = 0.0 | Pipeline parfait pour la lecture |
|
| 56 |
+
| SearchView | recall = 1.0 | Pipeline parfait pour l'indexation |
|
| 57 |
+
| **AltoView** | **OMIS** | Pipeline non éligible |
|
| 58 |
+
|
| 59 |
+
Pour un workflow IIIF / Mirador qui veut surligner les mots dans
|
| 60 |
+
l'image, ce pipeline est **inutilisable** — pas de coordonnées.
|
| 61 |
+
`AltoView` ne lui attribue pas un score factice à 0 ; le rapport
|
| 62 |
+
affiche *"Tesseract texte brut n'est pas évalué dans AltoView
|
| 63 |
+
(ne produit pas d'ALTO)"*.
|
| 64 |
+
|
| 65 |
+
### Pattern 3 : ALTO valide mais texte hallucinant
|
| 66 |
+
|
| 67 |
+
Un VLM avec module ALTO_reconstruction peut produire un ALTO
|
| 68 |
+
structurellement parfait (validity=1, lignes correctes,
|
| 69 |
+
coordonnées présentes) mais avec du texte inventé :
|
| 70 |
+
|
| 71 |
+
| Vue | Métrique | Valeur | Lecture |
|
| 72 |
+
|---|---|---|---|
|
| 73 |
+
| AltoView | tous | 1.0 | Pipeline parfait structurellement |
|
| 74 |
+
| TextView | CER | élevé | Pipeline mauvais textuellement |
|
| 75 |
+
| SearchView | recall | bas | Pipeline inutile pour la recherche |
|
| 76 |
+
|
| 77 |
+
`AltoView` seul ferait passer ce VLM pour le meilleur pipeline.
|
| 78 |
+
Lire les trois vues ensemble révèle le vrai problème.
|
| 79 |
+
|
| 80 |
+
## Recommandation de lecture pour le rapport BnF
|
| 81 |
+
|
| 82 |
+
Le rapport HTML (S22) présentera les 3 vues côte-à-côte avec
|
| 83 |
+
cette grille de lecture :
|
| 84 |
+
|
| 85 |
+
1. **Tableau de synthèse** : un tableau par vue, chaque ligne =
|
| 86 |
+
un pipeline, chaque colonne = une métrique. Les pipelines
|
| 87 |
+
omis sont indiqués explicitement (pas de valeur factice).
|
| 88 |
+
|
| 89 |
+
2. **Encart "divergences notables"** : signale automatiquement
|
| 90 |
+
les pipelines dont le rang change fortement entre vues
|
| 91 |
+
(par exemple "rang 1 en TextView, rang 5 en SearchView").
|
| 92 |
+
C'est un signal pour l'utilisateur d'aller regarder en
|
| 93 |
+
détail ce qui se passe.
|
| 94 |
+
|
| 95 |
+
3. **Pour chaque vue** : warnings explicites de ce qu'elle
|
| 96 |
+
**n'évalue pas** (cf. `ignored_dimensions` dans chaque
|
| 97 |
+
`ViewResult`). L'utilisateur ne peut pas conclure
|
| 98 |
+
"TextView dit que X est le meilleur" sans avoir vu ce que
|
| 99 |
+
`TextView.ignored_dimensions` ne dit PAS.
|
| 100 |
+
|
| 101 |
+
## Critères de choix selon l'usage
|
| 102 |
+
|
| 103 |
+
| Usage cible | Vue principale | Vues secondaires |
|
| 104 |
+
|---|---|---|
|
| 105 |
+
| Lecture humaine (édition critique) | TextView | AltoView (si édition diplomatique) |
|
| 106 |
+
| Indexation Elastic / Solr / Gallica | SearchView | TextView |
|
| 107 |
+
| Réinjection IIIF / Mirador (mots cliquables) | AltoView | TextView |
|
| 108 |
+
| Citation académique | TextView + SearchView | AltoView |
|
| 109 |
+
| Reproduction d'un fac-similé | AltoView | TextView |
|
| 110 |
+
|
| 111 |
+
## Statut
|
| 112 |
+
|
| 113 |
+
- ✅ Sprint S14 — `TextView`
|
| 114 |
+
- ✅ Sprint S15 — `AltoView`
|
| 115 |
+
- ✅ Sprint S16 — `SearchView` + cohérence inter-vues
|
| 116 |
+
- ⏳ Sprint S17 — intégration runner + RunManifest
|
| 117 |
+
- ⏳ Sprint S18 — tests E2E sur le cas BnF central
|
docs/views/text-view.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# TextView — première vue canonique
|
| 2 |
+
|
| 3 |
+
Sprint A14-S14 du rewrite ciblé livre `TextView`, la première vue
|
| 4 |
+
d'évaluation canonique. Elle répond à la question patrimoniale la
|
| 5 |
+
plus fréquente : **"quel pipeline produit le meilleur texte
|
| 6 |
+
final ?"**
|
| 7 |
+
|
| 8 |
+
## Cas d'usage central BnF
|
| 9 |
+
|
| 10 |
+
Une bibliothèque numérique veut comparer 3 pipelines hétérogènes
|
| 11 |
+
sur le même corpus :
|
| 12 |
+
|
| 13 |
+
1. **Tesseract** → texte brut (`RAW_TEXT`)
|
| 14 |
+
2. **OCR + LLM + remapping ALTO** → ALTO XML enrichi (`ALTO_XML`)
|
| 15 |
+
3. **VLM avec sortie markdown structurée** → `CANONICAL_DOCUMENT`
|
| 16 |
+
|
| 17 |
+
Sans `TextView`, comparer ces 3 pipelines est trompeur : ils ne
|
| 18 |
+
produisent pas le même type d'artefact. Avec `TextView`, chaque
|
| 19 |
+
sortie est **projetée vers du texte plat** avant calcul de
|
| 20 |
+
CER/WER, et le rapport documente explicitement ce que la vue
|
| 21 |
+
**ignore** (géométrie, structure de blocs, ordre de lecture, IDs,
|
| 22 |
+
formatage).
|
| 23 |
+
|
| 24 |
+
## API
|
| 25 |
+
|
| 26 |
+
```python
|
| 27 |
+
from picarones.evaluation.views import build_text_view
|
| 28 |
+
|
| 29 |
+
# Vue canonique avec valeurs par défaut
|
| 30 |
+
view = build_text_view()
|
| 31 |
+
|
| 32 |
+
# Vue spécialisée (par exemple : OCR seul, sans ALTO/PAGE)
|
| 33 |
+
from picarones.domain import ArtifactType
|
| 34 |
+
view_ocr_only = build_text_view(
|
| 35 |
+
candidate_types=frozenset({
|
| 36 |
+
ArtifactType.RAW_TEXT,
|
| 37 |
+
ArtifactType.CORRECTED_TEXT,
|
| 38 |
+
}),
|
| 39 |
+
metric_names=("cer", "wer"),
|
| 40 |
+
normalization_profile="medieval_french",
|
| 41 |
+
)
|
| 42 |
+
```
|
| 43 |
+
|
| 44 |
+
## Types acceptés (par défaut)
|
| 45 |
+
|
| 46 |
+
| Type | Projection | Justification |
|
| 47 |
+
|---|---|---|
|
| 48 |
+
| `RAW_TEXT` | identité | déjà du texte |
|
| 49 |
+
| `CORRECTED_TEXT` | identité | déjà du texte (modifié par un LLM) |
|
| 50 |
+
| `ALTO_XML` | `AltoToText` | extraction par ordre de lecture, gestion césure |
|
| 51 |
+
| `PAGE_XML` | `PageToText` | extraction depuis `<TextEquiv><Unicode>` |
|
| 52 |
+
| `CANONICAL_DOCUMENT` | `CanonicalToText` | décode markdown, aplatit JSON canonique |
|
| 53 |
+
|
| 54 |
+
## Métriques (par défaut)
|
| 55 |
+
|
| 56 |
+
`cer`, `wer`, `mer`, `wil` — toutes typées `(RAW_TEXT, RAW_TEXT)`
|
| 57 |
+
puisque la comparaison se fait toujours après projection vers
|
| 58 |
+
texte plat.
|
| 59 |
+
|
| 60 |
+
## Dimensions explicitement ignorées
|
| 61 |
+
|
| 62 |
+
Le `ViewResult` propage dans `ignored_dimensions` les dimensions
|
| 63 |
+
que cette vue **ne mesure pas** :
|
| 64 |
+
|
| 65 |
+
- `geometry` — coordonnées HPOS/VPOS/WIDTH/HEIGHT des mots
|
| 66 |
+
- `block_structure` — découpage en `TextBlock` / `TextRegion`
|
| 67 |
+
- `reading_order` — ordre de lecture spatial
|
| 68 |
+
- `ids` — identifiants stables des éléments
|
| 69 |
+
- `confidence` — scores de confiance par mot
|
| 70 |
+
- `formatting` — gras / italique / titre
|
| 71 |
+
|
| 72 |
+
Ces dimensions sont éventuellement évaluées par d'autres vues :
|
| 73 |
+
|
| 74 |
+
- `geometry`, `block_structure`, `reading_order`, `ids` →
|
| 75 |
+
**`AltoView`** (S15)
|
| 76 |
+
- `confidence` → vue calibration (existante via S5 metrics)
|
| 77 |
+
|
| 78 |
+
## Garde-fou méthodologique
|
| 79 |
+
|
| 80 |
+
Chaque `ViewResult` produit par `TextView` porte un `warnings`
|
| 81 |
+
explicite :
|
| 82 |
+
|
| 83 |
+
> Cette vue compare les sorties textuelles finales après
|
| 84 |
+
> projection éventuelle. Les pipelines qui produisent
|
| 85 |
+
> ALTO/PAGE/markdown sont projetés vers du texte plat — leurs
|
| 86 |
+
> structures spatiale et documentaire ne sont PAS évaluées ici.
|
| 87 |
+
> Pour évaluer la qualité ALTO, voir AltoView (S15).
|
| 88 |
+
|
| 89 |
+
Ce warning sera affiché en tête du bloc TextView dans le rapport
|
| 90 |
+
HTML (S22) pour signaler à un lecteur exactement la portée de la
|
| 91 |
+
comparaison.
|
| 92 |
+
|
| 93 |
+
## Exemple de `ViewResult`
|
| 94 |
+
|
| 95 |
+
```python
|
| 96 |
+
ViewResult(
|
| 97 |
+
view_name="text_final",
|
| 98 |
+
candidate_artifact_id="bnf_doc:vlm:canonical_document",
|
| 99 |
+
ground_truth_artifact_id="bnf_doc:gt:raw_text",
|
| 100 |
+
metric_values={
|
| 101 |
+
"cer": 0.04,
|
| 102 |
+
"wer": 0.12,
|
| 103 |
+
"mer": 0.04,
|
| 104 |
+
"wil": 0.18,
|
| 105 |
+
},
|
| 106 |
+
failed_metrics={},
|
| 107 |
+
projection_report=ProjectionReport(
|
| 108 |
+
source_artifact_id="bnf_doc:vlm:canonical_document",
|
| 109 |
+
source_type=ArtifactType.CANONICAL_DOCUMENT,
|
| 110 |
+
target_type=ArtifactType.RAW_TEXT,
|
| 111 |
+
projector_name="canonical_to_text",
|
| 112 |
+
lossy=True,
|
| 113 |
+
ignored_dimensions=("structure", "formatting", "headers", "links"),
|
| 114 |
+
warnings=("Markdown / JSON canonique projeté en texte plat...",),
|
| 115 |
+
),
|
| 116 |
+
warnings=(
|
| 117 |
+
"Cette vue compare les sorties textuelles finales...",
|
| 118 |
+
"Markdown / JSON canonique projeté en texte plat...",
|
| 119 |
+
),
|
| 120 |
+
ignored_dimensions=(
|
| 121 |
+
"geometry", "block_structure", "reading_order", "ids",
|
| 122 |
+
"confidence", "formatting", "structure", "headers", "links",
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
```
|
| 126 |
+
|
| 127 |
+
## Limites assumées
|
| 128 |
+
|
| 129 |
+
- **Pas de comparaison fuzzy / search recall** — c'est `SearchView`
|
| 130 |
+
(S16).
|
| 131 |
+
- **Pas d'évaluation structurelle ALTO** — c'est `AltoView` (S15).
|
| 132 |
+
- **`CANONICAL_DOCUMENT` peut perdre beaucoup de structure** ; le
|
| 133 |
+
warning du `ProjectionReport` le signale.
|
| 134 |
+
- **Pas de pondération inter-pipelines** — chaque pipeline est
|
| 135 |
+
évalué indépendamment ; le ranking et l'agrégation sont la
|
| 136 |
+
responsabilité du caller (typiquement le rapport HTML S22).
|
| 137 |
+
|
| 138 |
+
## Statut
|
| 139 |
+
|
| 140 |
+
- ✅ Sprint S14 — `TextView` livré (codé + testé)
|
| 141 |
+
- ⏳ Sprint S15 — `AltoView` (fidélité documentaire)
|
| 142 |
+
- ⏳ Sprint S16 — `SearchView` (recherchabilité fuzzy)
|
| 143 |
+
- ⏳ Sprint S17 — intégration runner + RunManifest
|
| 144 |
+
- ⏳ Sprint S18 — tests E2E sur le cas BnF central avec 3 pipelines
|
picarones/adapters/__init__.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cercle 3 — Adapters.
|
| 2 |
+
|
| 3 |
+
Implémentations concrètes des contrats du domain. C'est ici que
|
| 4 |
+
vivent les dépendances externes lourdes (pytesseract, pero_ocr,
|
| 5 |
+
mistralai, openai, anthropic, google-cloud-vision, datasets, etc.).
|
| 6 |
+
|
| 7 |
+
Sous-packages :
|
| 8 |
+
|
| 9 |
+
- ``ocr/`` — Tesseract, Pero OCR, Kraken, Mistral OCR, Google
|
| 10 |
+
Vision, Azure Doc Intel. Cible Sprint S11.
|
| 11 |
+
- ``llm/`` — OpenAI, Anthropic, Mistral, Ollama. Cible S11.
|
| 12 |
+
- ``vlm/`` — Qwen-VL, Gemini, Claude vision, etc. À remplir
|
| 13 |
+
post-livraison (dans la limite de ce qui justifie une vraie
|
| 14 |
+
comparaison avec OCR+LLM).
|
| 15 |
+
- ``corpus/`` — local folder, IIIF, Gallica, HTR-United,
|
| 16 |
+
HuggingFace Datasets, eScriptorium. Cible S11.
|
| 17 |
+
- ``storage/`` — filesystem, SQLite (jobs, history). Cible S20.
|
| 18 |
+
|
| 19 |
+
Règles d'import : un adapter peut importer le domain et ses libs
|
| 20 |
+
externes. Il ne doit **jamais** importer ``app/`` ou
|
| 21 |
+
``interfaces/``. Il n'a aucune logique d'évaluation (un OCR
|
| 22 |
+
adapter ne calcule pas le CER — il produit un artefact texte que
|
| 23 |
+
``evaluation/`` consommera).
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
from __future__ import annotations
|
| 27 |
+
|
| 28 |
+
__all__: list[str] = []
|
picarones/adapters/_retry.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Retry exponentiel partagé par les adapters cloud (OCR + LLM).
|
| 2 |
+
|
| 3 |
+
Pour une release institutionnelle (BnF, LoC, BL), un benchmark de
|
| 4 |
+
N milliers de documents face à un service cloud (Google Vision,
|
| 5 |
+
Azure Document Intelligence, Mistral OCR, Anthropic, OpenAI) doit
|
| 6 |
+
absorber les erreurs transitoires (429, 5xx, timeout réseau) sans
|
| 7 |
+
faire échouer le doc — sinon les résultats partiels ne sont pas
|
| 8 |
+
reproductibles d'un run à l'autre.
|
| 9 |
+
|
| 10 |
+
Ce module fournit la politique commune. Il vit au top du package
|
| 11 |
+
``adapters/`` (et non sous ``llm/`` ou ``ocr/``) parce qu'il est
|
| 12 |
+
consommé par les deux familles indistinctement.
|
| 13 |
+
|
| 14 |
+
API
|
| 15 |
+
---
|
| 16 |
+
- ``is_retryable(exc)`` : True si l'exception est typique d'un
|
| 17 |
+
problème transitoire.
|
| 18 |
+
- ``call_with_retry(callable, max_retries, backoff_base, label)`` :
|
| 19 |
+
exécute le callable, retry exponentiel jusqu'à ``max_retries``
|
| 20 |
+
tentatives. Lève la dernière exception si épuisé.
|
| 21 |
+
|
| 22 |
+
Politique
|
| 23 |
+
---------
|
| 24 |
+
- ``max_retries=3`` (4 tentatives au total : 0 + 1 + 2 + 3 retries).
|
| 25 |
+
- ``backoff_base=2.0`` → 2s, 4s, 8s entre les retries (16s cumul max).
|
| 26 |
+
- Logs WARNING à chaque retry avec contexte.
|
| 27 |
+
|
| 28 |
+
Anti-sur-ingénierie
|
| 29 |
+
-------------------
|
| 30 |
+
- Pas de jitter randomisé : pas indispensable à ce volume ; ajouter
|
| 31 |
+
si un caller en a concrètement besoin.
|
| 32 |
+
- Pas de circuit breaker : un caller qui voit 100 % d'échec sur 5000
|
| 33 |
+
documents arrête le run lui-même.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
import logging
|
| 39 |
+
import time
|
| 40 |
+
from typing import Callable, TypeVar
|
| 41 |
+
|
| 42 |
+
logger = logging.getLogger(__name__)
|
| 43 |
+
|
| 44 |
+
DEFAULT_MAX_RETRIES = 3
|
| 45 |
+
DEFAULT_BACKOFF_BASE = 2.0 # secondes : 2, 4, 8
|
| 46 |
+
|
| 47 |
+
T = TypeVar("T")
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def is_retryable(exc: Exception) -> bool:
|
| 51 |
+
"""``True`` si l'exception est typique d'un problème transitoire.
|
| 52 |
+
|
| 53 |
+
Détection sur trois axes :
|
| 54 |
+
|
| 55 |
+
1. Code HTTP exposé par les SDK cloud (``status_code`` ou
|
| 56 |
+
``http_status``) : 429 (rate limit) et tout 5xx.
|
| 57 |
+
2. Type d'exception réseau : ``TimeoutError``, ``ConnectionError``,
|
| 58 |
+
``URLError`` (urllib).
|
| 59 |
+
3. Heuristique sur le message (fallback pour les SDK qui ne
|
| 60 |
+
structurent pas) : présence des codes 429/502/503 ou des
|
| 61 |
+
motifs ``rate limit``, ``timeout``, ``connection``.
|
| 62 |
+
"""
|
| 63 |
+
status = (
|
| 64 |
+
getattr(exc, "status_code", None)
|
| 65 |
+
or getattr(exc, "http_status", None)
|
| 66 |
+
)
|
| 67 |
+
if status is not None:
|
| 68 |
+
return status == 429 or status >= 500
|
| 69 |
+
|
| 70 |
+
exc_name = type(exc).__name__
|
| 71 |
+
if exc_name in ("TimeoutError", "ConnectionError", "URLError"):
|
| 72 |
+
return True
|
| 73 |
+
|
| 74 |
+
msg = str(exc).lower()
|
| 75 |
+
if "rate" in msg and "limit" in msg:
|
| 76 |
+
return True
|
| 77 |
+
if "timeout" in msg or "connection" in msg:
|
| 78 |
+
return True
|
| 79 |
+
if "429" in msg or "503" in msg or "502" in msg:
|
| 80 |
+
return True
|
| 81 |
+
|
| 82 |
+
return False
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def call_with_retry(
|
| 86 |
+
fn: Callable[[], T],
|
| 87 |
+
*,
|
| 88 |
+
max_retries: int = DEFAULT_MAX_RETRIES,
|
| 89 |
+
backoff_base: float = DEFAULT_BACKOFF_BASE,
|
| 90 |
+
label: str = "adapter",
|
| 91 |
+
) -> T:
|
| 92 |
+
"""Exécute ``fn`` avec retry exponentiel sur erreurs retryables.
|
| 93 |
+
|
| 94 |
+
Parameters
|
| 95 |
+
----------
|
| 96 |
+
fn:
|
| 97 |
+
Callable sans argument qui retourne le résultat ou lève.
|
| 98 |
+
max_retries:
|
| 99 |
+
Nombre de retries après la première tentative. ``0`` =
|
| 100 |
+
une seule tentative (pas de retry).
|
| 101 |
+
backoff_base:
|
| 102 |
+
Base de l'attente exponentielle. Tentative ``i`` → attente
|
| 103 |
+
``backoff_base ** (i + 1)`` secondes avant retry.
|
| 104 |
+
label:
|
| 105 |
+
Étiquette du caller pour le logging (typiquement
|
| 106 |
+
``self.name`` de l'adapter).
|
| 107 |
+
|
| 108 |
+
Returns
|
| 109 |
+
-------
|
| 110 |
+
Résultat de ``fn``.
|
| 111 |
+
|
| 112 |
+
Raises
|
| 113 |
+
------
|
| 114 |
+
Exception
|
| 115 |
+
La dernière exception levée si tous les retries sont
|
| 116 |
+
épuisés ou si l'erreur n'est pas retryable.
|
| 117 |
+
"""
|
| 118 |
+
last_exc: Exception | None = None
|
| 119 |
+
for attempt in range(max_retries + 1):
|
| 120 |
+
try:
|
| 121 |
+
return fn()
|
| 122 |
+
except Exception as exc: # noqa: BLE001
|
| 123 |
+
last_exc = exc
|
| 124 |
+
if attempt < max_retries and is_retryable(exc):
|
| 125 |
+
wait = backoff_base ** (attempt + 1)
|
| 126 |
+
logger.warning(
|
| 127 |
+
"[%s] erreur retryable (tentative %d/%d, "
|
| 128 |
+
"attente %.1fs) : %s",
|
| 129 |
+
label, attempt + 1, max_retries + 1, wait, exc,
|
| 130 |
+
)
|
| 131 |
+
time.sleep(wait)
|
| 132 |
+
else:
|
| 133 |
+
break
|
| 134 |
+
assert last_exc is not None
|
| 135 |
+
raise last_exc
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
__all__ = [
|
| 139 |
+
"DEFAULT_BACKOFF_BASE",
|
| 140 |
+
"DEFAULT_MAX_RETRIES",
|
| 141 |
+
"call_with_retry",
|
| 142 |
+
"is_retryable",
|
| 143 |
+
]
|
picarones/adapters/corpus/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateurs corpus — Sprint S11.
|
| 2 |
+
|
| 3 |
+
Cible : déplacement de ``picarones.extras.importers.{iiif,gallica,
|
| 4 |
+
htr_united,huggingface,escriptorium}``. Un corpus adapter charge
|
| 5 |
+
un corpus depuis une source distante (manifeste IIIF, dataset HF,
|
| 6 |
+
catalogue HTR-United, eScriptorium, ZIP utilisateur) et retourne
|
| 7 |
+
un ``CorpusSpec`` (références aux images + GT par niveau).
|
| 8 |
+
|
| 9 |
+
Règle : pas de pré-calcul. Pas d'OCR. Le corpus adapter ne sait
|
| 10 |
+
que **nommer et localiser** les paires (image, GT). L'exécution
|
| 11 |
+
des moteurs est faite plus tard par le pipeline executor.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
__all__: list[str] = []
|
picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (892 Bytes). View file
|
|
|
picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc
ADDED
|
Binary file (4.83 kB). View file
|
|
|
picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc
ADDED
|
Binary file (23.6 kB). View file
|
|
|
picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc
ADDED
|
Binary file (21.4 kB). View file
|
|
|
picarones/adapters/corpus/_fallback_log.py
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Journal en mémoire des fallbacks d'importer (Sprint A3, item B-3).
|
| 2 |
+
|
| 3 |
+
Quand un importer (HuggingFace, HTR-United, Gallica, eScriptorium…)
|
| 4 |
+
bascule en mode dégradé (timeout réseau, JSON mal formé, ZIP corrompu,
|
| 5 |
+
catalogue distant indisponible…), il enregistre un incident ici via
|
| 6 |
+
:func:`record_fallback`. Le moteur narratif consomme ces incidents via
|
| 7 |
+
:func:`consume_fallback_log`, qui **vide** la liste pour qu'un benchmark
|
| 8 |
+
suivant ne remonte pas les incidents du précédent.
|
| 9 |
+
|
| 10 |
+
Conception volontairement minimale :
|
| 11 |
+
|
| 12 |
+
- Pas de persistance disque (les incidents sont contextuels à un run).
|
| 13 |
+
- Pas de structure complexe (juste un ``list[dict]`` thread-safe).
|
| 14 |
+
- Le runner / le rapport peuvent ignorer la liste sans casser.
|
| 15 |
+
|
| 16 |
+
Le détecteur de Fact correspondant (``FactType.IMPORTER_FALLBACK_TRIGGERED``)
|
| 17 |
+
est implémenté dans
|
| 18 |
+
:mod:`picarones.measurements.narrative.detectors.history`.
|
| 19 |
+
"""
|
| 20 |
+
|
| 21 |
+
from __future__ import annotations
|
| 22 |
+
|
| 23 |
+
import logging
|
| 24 |
+
import threading
|
| 25 |
+
from typing import Any
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
_lock = threading.Lock()
|
| 30 |
+
_fallbacks: list[dict[str, Any]] = []
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
def record_fallback(
|
| 34 |
+
importer: str,
|
| 35 |
+
operation: str,
|
| 36 |
+
error: BaseException | None = None,
|
| 37 |
+
*,
|
| 38 |
+
extra: dict[str, Any] | None = None,
|
| 39 |
+
) -> None:
|
| 40 |
+
"""Enregistre un incident de mode dégradé.
|
| 41 |
+
|
| 42 |
+
Logge également via ``logger.warning`` pour qu'un opérateur voit
|
| 43 |
+
l'incident en temps réel sans dépendre du rapport.
|
| 44 |
+
|
| 45 |
+
Parameters
|
| 46 |
+
----------
|
| 47 |
+
importer:
|
| 48 |
+
Nom court de l'importer (ex : ``"huggingface"``, ``"htr_united"``).
|
| 49 |
+
operation:
|
| 50 |
+
Description courte de l'opération (ex : ``"yaml_catalogue_parse"``,
|
| 51 |
+
``"image_save"``, ``"hub_search"``).
|
| 52 |
+
error:
|
| 53 |
+
Exception originelle (utilisée pour le message log et stockée dans
|
| 54 |
+
le payload sous forme de chaîne — pas l'objet, pour éviter les
|
| 55 |
+
références persistantes).
|
| 56 |
+
extra:
|
| 57 |
+
Champs additionnels (URL distante, identifiant dataset…) qui peuvent
|
| 58 |
+
être utiles à un détecteur de Fact ultérieur.
|
| 59 |
+
"""
|
| 60 |
+
error_repr = repr(error) if error is not None else None
|
| 61 |
+
logger.warning(
|
| 62 |
+
"[importers/%s] %s a échoué (mode dégradé) : %s",
|
| 63 |
+
importer,
|
| 64 |
+
operation,
|
| 65 |
+
error_repr,
|
| 66 |
+
)
|
| 67 |
+
entry: dict[str, Any] = {
|
| 68 |
+
"importer": importer,
|
| 69 |
+
"operation": operation,
|
| 70 |
+
"error": error_repr,
|
| 71 |
+
}
|
| 72 |
+
if extra:
|
| 73 |
+
entry["extra"] = dict(extra)
|
| 74 |
+
with _lock:
|
| 75 |
+
_fallbacks.append(entry)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def consume_fallback_log() -> list[dict[str, Any]]:
|
| 79 |
+
"""Retourne ET vide la liste des incidents accumulés.
|
| 80 |
+
|
| 81 |
+
Le moteur narratif appelle cette fonction au moment de construire
|
| 82 |
+
la synthèse pour transformer chaque incident en ``Fact``."""
|
| 83 |
+
with _lock:
|
| 84 |
+
out = list(_fallbacks)
|
| 85 |
+
_fallbacks.clear()
|
| 86 |
+
return out
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def peek_fallback_log() -> list[dict[str, Any]]:
|
| 90 |
+
"""Retourne une copie sans vider — utile pour les tests."""
|
| 91 |
+
with _lock:
|
| 92 |
+
return list(_fallbacks)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def reset_fallback_log() -> None:
|
| 96 |
+
"""Vide la liste sans rien retourner — utile pour les fixtures pytest."""
|
| 97 |
+
with _lock:
|
| 98 |
+
_fallbacks.clear()
|
picarones/adapters/corpus/htr_united.py
ADDED
|
@@ -0,0 +1,473 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import depuis le catalogue HTR-United.
|
| 2 |
+
|
| 3 |
+
HTR-United est un catalogue communautaire de vérités terrain HTR/OCR publiées
|
| 4 |
+
sur GitHub sous licence ouverte. Les métadonnées sont stockées dans un fichier
|
| 5 |
+
YAML (catalogue.yml) sur https://github.com/HTR-United/htr-united.
|
| 6 |
+
|
| 7 |
+
Ce module fournit :
|
| 8 |
+
- :class:`HTRUnitedCatalogue` — chargement et recherche dans le catalogue
|
| 9 |
+
- :func:`fetch_catalogue` — téléchargement du catalogue depuis GitHub
|
| 10 |
+
- :func:`import_htr_united_corpus` — téléchargement et import d'un corpus
|
| 11 |
+
|
| 12 |
+
Exemple
|
| 13 |
+
-------
|
| 14 |
+
catalogue = HTRUnitedCatalogue.from_remote()
|
| 15 |
+
results = catalogue.search("français médiéval")
|
| 16 |
+
corpus = import_htr_united_corpus(results[0], output_dir="./corpus/")
|
| 17 |
+
"""
|
| 18 |
+
|
| 19 |
+
from __future__ import annotations
|
| 20 |
+
|
| 21 |
+
import json
|
| 22 |
+
import logging
|
| 23 |
+
import re
|
| 24 |
+
import urllib.error
|
| 25 |
+
import urllib.request
|
| 26 |
+
from dataclasses import dataclass, field
|
| 27 |
+
from pathlib import Path
|
| 28 |
+
from typing import Optional
|
| 29 |
+
|
| 30 |
+
logger = logging.getLogger(__name__)
|
| 31 |
+
|
| 32 |
+
# ---------------------------------------------------------------------------
|
| 33 |
+
# Catalogue remote URL
|
| 34 |
+
# ---------------------------------------------------------------------------
|
| 35 |
+
|
| 36 |
+
_CATALOGUE_URL = (
|
| 37 |
+
"https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml"
|
| 38 |
+
)
|
| 39 |
+
_CATALOGUE_API_URL = (
|
| 40 |
+
"https://api.github.com/repos/HTR-United/htr-united/contents/htr-united.yml"
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
# Catalogue de démonstration / fallback (hors-ligne)
|
| 44 |
+
_DEMO_CATALOGUE: list[dict] = [
|
| 45 |
+
{
|
| 46 |
+
"id": "lectaurep-repertoires",
|
| 47 |
+
"title": "Lectaurep — Répertoires de notaires parisiens",
|
| 48 |
+
"url": "https://github.com/HTR-United/lectaurep-repertoires",
|
| 49 |
+
"language": ["French"],
|
| 50 |
+
"script": ["Cursiva"],
|
| 51 |
+
"century": [17, 18],
|
| 52 |
+
"institution": "Archives nationales (France)",
|
| 53 |
+
"description": "Transcriptions de répertoires de notaires, XVIIe-XVIIIe siècles.",
|
| 54 |
+
"license": "CC-BY 4.0",
|
| 55 |
+
"lines": 12400,
|
| 56 |
+
"format": "ALTO",
|
| 57 |
+
"tags": ["notaires", "Paris", "cursive", "imprimé"],
|
| 58 |
+
},
|
| 59 |
+
{
|
| 60 |
+
"id": "bvmm-manuscripts",
|
| 61 |
+
"title": "BVMM — Manuscrits enluminés",
|
| 62 |
+
"url": "https://github.com/HTR-United/bvmm-manuscripts",
|
| 63 |
+
"language": ["Latin", "French"],
|
| 64 |
+
"script": ["Gothic"],
|
| 65 |
+
"century": [13, 14, 15],
|
| 66 |
+
"institution": "IRHT",
|
| 67 |
+
"description": "Manuscrits médiévaux latins et français, XIIIe-XVe siècles.",
|
| 68 |
+
"license": "CC-BY 4.0",
|
| 69 |
+
"lines": 8700,
|
| 70 |
+
"format": "ALTO",
|
| 71 |
+
"tags": ["manuscrits", "latin", "médiéval", "enluminure"],
|
| 72 |
+
},
|
| 73 |
+
{
|
| 74 |
+
"id": "cremma-medieval",
|
| 75 |
+
"title": "CREMMA Médiéval",
|
| 76 |
+
"url": "https://github.com/HTR-United/cremma-medieval",
|
| 77 |
+
"language": ["French", "Latin"],
|
| 78 |
+
"script": ["Gothic", "Humanistica"],
|
| 79 |
+
"century": [12, 13, 14, 15],
|
| 80 |
+
"institution": "École des chartes / Inria",
|
| 81 |
+
"description": "Corpus CREMMA de manuscrits médiévaux français et latins.",
|
| 82 |
+
"license": "CC-BY 4.0",
|
| 83 |
+
"lines": 6200,
|
| 84 |
+
"format": "ALTO",
|
| 85 |
+
"tags": ["médiéval", "chartes", "manuscrits"],
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
"id": "simssa-ocr-printed",
|
| 89 |
+
"title": "SIMSSA — Imprimés anciens (XVe-XVIIe)",
|
| 90 |
+
"url": "https://github.com/HTR-United/simssa-printed",
|
| 91 |
+
"language": ["French", "Latin"],
|
| 92 |
+
"script": ["Rotunda", "Roman"],
|
| 93 |
+
"century": [15, 16, 17],
|
| 94 |
+
"institution": "McGill University",
|
| 95 |
+
"description": "Corpus d'imprimés anciens romains et gothiques.",
|
| 96 |
+
"license": "CC-BY 4.0",
|
| 97 |
+
"lines": 4500,
|
| 98 |
+
"format": "PAGE",
|
| 99 |
+
"tags": ["imprimés", "incunables", "roman", "gothique"],
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"id": "fonds-gallica-presse",
|
| 103 |
+
"title": "Presse ancienne — Gallica (XIXe)",
|
| 104 |
+
"url": "https://github.com/HTR-United/gallica-presse-xix",
|
| 105 |
+
"language": ["French"],
|
| 106 |
+
"script": ["Roman"],
|
| 107 |
+
"century": [19],
|
| 108 |
+
"institution": "Gallica",
|
| 109 |
+
"description": "Numérisations de journaux du XIXe siècle (Gallica).",
|
| 110 |
+
"license": "etalab-2.0",
|
| 111 |
+
"lines": 31000,
|
| 112 |
+
"format": "ALTO",
|
| 113 |
+
"tags": ["presse", "XIXe", "Gallica", "journaux"],
|
| 114 |
+
},
|
| 115 |
+
{
|
| 116 |
+
"id": "archives-departem-correspondances",
|
| 117 |
+
"title": "Correspondances administratives (XVIIIe-XIXe)",
|
| 118 |
+
"url": "https://github.com/HTR-United/correspondances-admin",
|
| 119 |
+
"language": ["French"],
|
| 120 |
+
"script": ["Cursiva"],
|
| 121 |
+
"century": [18, 19],
|
| 122 |
+
"institution": "Archives départementales",
|
| 123 |
+
"description": "Lettres et correspondances administratives manuscrites.",
|
| 124 |
+
"license": "CC-BY 4.0",
|
| 125 |
+
"lines": 9800,
|
| 126 |
+
"format": "ALTO",
|
| 127 |
+
"tags": ["correspondances", "administratif", "cursive"],
|
| 128 |
+
},
|
| 129 |
+
{
|
| 130 |
+
"id": "e-codices-latin",
|
| 131 |
+
"title": "e-codices — Manuscrits latins (Suisse)",
|
| 132 |
+
"url": "https://github.com/HTR-United/e-codices-latin",
|
| 133 |
+
"language": ["Latin"],
|
| 134 |
+
"script": ["Caroline", "Gothic"],
|
| 135 |
+
"century": [9, 10, 11, 12],
|
| 136 |
+
"institution": "Bibliothèque cantonale universitaire de Lausanne",
|
| 137 |
+
"description": "Manuscrits carolingiens et gothiques des bibliothèques suisses.",
|
| 138 |
+
"license": "CC-BY 4.0",
|
| 139 |
+
"lines": 3100,
|
| 140 |
+
"format": "ALTO",
|
| 141 |
+
"tags": ["caroline", "latin", "médiéval", "Suisse"],
|
| 142 |
+
},
|
| 143 |
+
{
|
| 144 |
+
"id": "registres-paroissiaux-17",
|
| 145 |
+
"title": "Registres paroissiaux — Bretagne (XVIIe)",
|
| 146 |
+
"url": "https://github.com/HTR-United/registres-paroissiaux-bretagne",
|
| 147 |
+
"language": ["French", "Latin"],
|
| 148 |
+
"script": ["Cursiva"],
|
| 149 |
+
"century": [17],
|
| 150 |
+
"institution": "Archives départementales du Finistère",
|
| 151 |
+
"description": "Registres paroissiaux bretons du XVIIe siècle.",
|
| 152 |
+
"license": "CC-BY 4.0",
|
| 153 |
+
"lines": 15600,
|
| 154 |
+
"format": "ALTO",
|
| 155 |
+
"tags": ["registres", "Bretagne", "paroissial", "cursive"],
|
| 156 |
+
},
|
| 157 |
+
]
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
# ---------------------------------------------------------------------------
|
| 161 |
+
# Dataclass entrée catalogue
|
| 162 |
+
# ---------------------------------------------------------------------------
|
| 163 |
+
|
| 164 |
+
@dataclass
|
| 165 |
+
class HTRUnitedEntry:
|
| 166 |
+
"""Une entrée dans le catalogue HTR-United."""
|
| 167 |
+
|
| 168 |
+
id: str
|
| 169 |
+
title: str
|
| 170 |
+
url: str
|
| 171 |
+
language: list[str] = field(default_factory=list)
|
| 172 |
+
script: list[str] = field(default_factory=list)
|
| 173 |
+
century: list[int] = field(default_factory=list)
|
| 174 |
+
institution: str = ""
|
| 175 |
+
description: str = ""
|
| 176 |
+
license: str = ""
|
| 177 |
+
lines: int = 0
|
| 178 |
+
format: str = "ALTO"
|
| 179 |
+
tags: list[str] = field(default_factory=list)
|
| 180 |
+
|
| 181 |
+
def as_dict(self) -> dict:
|
| 182 |
+
return {
|
| 183 |
+
"id": self.id,
|
| 184 |
+
"title": self.title,
|
| 185 |
+
"url": self.url,
|
| 186 |
+
"language": self.language,
|
| 187 |
+
"script": self.script,
|
| 188 |
+
"century": self.century,
|
| 189 |
+
"institution": self.institution,
|
| 190 |
+
"description": self.description,
|
| 191 |
+
"license": self.license,
|
| 192 |
+
"lines": self.lines,
|
| 193 |
+
"format": self.format,
|
| 194 |
+
"tags": self.tags,
|
| 195 |
+
}
|
| 196 |
+
|
| 197 |
+
@classmethod
|
| 198 |
+
def from_dict(cls, d: dict) -> "HTRUnitedEntry":
|
| 199 |
+
return cls(
|
| 200 |
+
id=d.get("id", ""),
|
| 201 |
+
title=d.get("title", ""),
|
| 202 |
+
url=d.get("url", ""),
|
| 203 |
+
language=d.get("language", []),
|
| 204 |
+
script=d.get("script", []),
|
| 205 |
+
century=d.get("century", []),
|
| 206 |
+
institution=d.get("institution", ""),
|
| 207 |
+
description=d.get("description", ""),
|
| 208 |
+
license=d.get("license", ""),
|
| 209 |
+
lines=d.get("lines", 0),
|
| 210 |
+
format=d.get("format", "ALTO"),
|
| 211 |
+
tags=d.get("tags", []),
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
@property
|
| 215 |
+
def century_str(self) -> str:
|
| 216 |
+
"""Siècles formatés en chiffres romains."""
|
| 217 |
+
roman = {
|
| 218 |
+
1: "Ier", 2: "IIe", 3: "IIIe", 4: "IVe", 5: "Ve",
|
| 219 |
+
6: "VIe", 7: "VIIe", 8: "VIIIe", 9: "IXe", 10: "Xe",
|
| 220 |
+
11: "XIe", 12: "XIIe", 13: "XIIIe", 14: "XIVe", 15: "XVe",
|
| 221 |
+
16: "XVIe", 17: "XVIIe", 18: "XVIIIe", 19: "XIXe", 20: "XXe",
|
| 222 |
+
}
|
| 223 |
+
return ", ".join(roman.get(c, f"{c}e") for c in self.century)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ---------------------------------------------------------------------------
|
| 227 |
+
# Catalogue
|
| 228 |
+
# ---------------------------------------------------------------------------
|
| 229 |
+
|
| 230 |
+
class HTRUnitedCatalogue:
|
| 231 |
+
"""Catalogue HTR-United avec recherche et filtrage."""
|
| 232 |
+
|
| 233 |
+
def __init__(self, entries: list[HTRUnitedEntry], source: str = "demo") -> None:
|
| 234 |
+
self.entries = entries
|
| 235 |
+
self.source = source # "remote" | "demo" | "cache"
|
| 236 |
+
|
| 237 |
+
def __len__(self) -> int:
|
| 238 |
+
return len(self.entries)
|
| 239 |
+
|
| 240 |
+
@classmethod
|
| 241 |
+
def from_demo(cls) -> "HTRUnitedCatalogue":
|
| 242 |
+
"""Charge le catalogue de démonstration intégré."""
|
| 243 |
+
entries = [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE]
|
| 244 |
+
return cls(entries, source="demo")
|
| 245 |
+
|
| 246 |
+
@classmethod
|
| 247 |
+
def from_remote(cls, timeout: int = 10) -> "HTRUnitedCatalogue":
|
| 248 |
+
"""Télécharge le catalogue depuis GitHub.
|
| 249 |
+
|
| 250 |
+
En cas d'erreur réseau, retourne le catalogue de démonstration.
|
| 251 |
+
"""
|
| 252 |
+
try:
|
| 253 |
+
req = urllib.request.Request(
|
| 254 |
+
_CATALOGUE_URL,
|
| 255 |
+
headers={"User-Agent": "picarones-htr-united-importer/1.0"},
|
| 256 |
+
)
|
| 257 |
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
| 258 |
+
raw = resp.read().decode("utf-8")
|
| 259 |
+
entries = _parse_yml_catalogue(raw)
|
| 260 |
+
return cls(entries, source="remote")
|
| 261 |
+
except (urllib.error.URLError, Exception) as exc:
|
| 262 |
+
# Fallback démo avec avertissement
|
| 263 |
+
logger.warning(
|
| 264 |
+
"[HTR-United] impossible de charger le catalogue distant (%s) : %s. "
|
| 265 |
+
"Utilisation des données de démonstration.",
|
| 266 |
+
_CATALOGUE_URL, exc,
|
| 267 |
+
)
|
| 268 |
+
return cls.from_demo()
|
| 269 |
+
|
| 270 |
+
def search(
|
| 271 |
+
self,
|
| 272 |
+
query: str = "",
|
| 273 |
+
language: Optional[str] = None,
|
| 274 |
+
script: Optional[str] = None,
|
| 275 |
+
century_min: Optional[int] = None,
|
| 276 |
+
century_max: Optional[int] = None,
|
| 277 |
+
) -> list[HTRUnitedEntry]:
|
| 278 |
+
"""Recherche dans le catalogue avec filtres optionnels."""
|
| 279 |
+
results = self.entries
|
| 280 |
+
|
| 281 |
+
if query:
|
| 282 |
+
q = query.lower()
|
| 283 |
+
results = [
|
| 284 |
+
e for e in results
|
| 285 |
+
if (q in e.title.lower()
|
| 286 |
+
or q in e.description.lower()
|
| 287 |
+
or q in e.institution.lower()
|
| 288 |
+
or any(q in t.lower() for t in e.tags)
|
| 289 |
+
or any(q in lang.lower() for lang in e.language))
|
| 290 |
+
]
|
| 291 |
+
|
| 292 |
+
if language:
|
| 293 |
+
lang_lower = language.lower()
|
| 294 |
+
results = [
|
| 295 |
+
e for e in results
|
| 296 |
+
if any(lang_lower in lg.lower() for lg in e.language)
|
| 297 |
+
]
|
| 298 |
+
|
| 299 |
+
if script:
|
| 300 |
+
sc_lower = script.lower()
|
| 301 |
+
results = [
|
| 302 |
+
e for e in results
|
| 303 |
+
if any(sc_lower in s.lower() for s in e.script)
|
| 304 |
+
]
|
| 305 |
+
|
| 306 |
+
if century_min is not None:
|
| 307 |
+
results = [
|
| 308 |
+
e for e in results
|
| 309 |
+
if any(c >= century_min for c in e.century)
|
| 310 |
+
]
|
| 311 |
+
|
| 312 |
+
if century_max is not None:
|
| 313 |
+
results = [
|
| 314 |
+
e for e in results
|
| 315 |
+
if any(c <= century_max for c in e.century)
|
| 316 |
+
]
|
| 317 |
+
|
| 318 |
+
return results
|
| 319 |
+
|
| 320 |
+
def get_by_id(self, entry_id: str) -> Optional[HTRUnitedEntry]:
|
| 321 |
+
"""Retourne une entrée par son identifiant."""
|
| 322 |
+
for e in self.entries:
|
| 323 |
+
if e.id == entry_id:
|
| 324 |
+
return e
|
| 325 |
+
return None
|
| 326 |
+
|
| 327 |
+
def available_languages(self) -> list[str]:
|
| 328 |
+
seen: set[str] = set()
|
| 329 |
+
result: list[str] = []
|
| 330 |
+
for e in self.entries:
|
| 331 |
+
for lang in e.language:
|
| 332 |
+
if lang not in seen:
|
| 333 |
+
seen.add(lang)
|
| 334 |
+
result.append(lang)
|
| 335 |
+
return sorted(result)
|
| 336 |
+
|
| 337 |
+
def available_scripts(self) -> list[str]:
|
| 338 |
+
seen: set[str] = set()
|
| 339 |
+
result: list[str] = []
|
| 340 |
+
for e in self.entries:
|
| 341 |
+
for sc in e.script:
|
| 342 |
+
if sc not in seen:
|
| 343 |
+
seen.add(sc)
|
| 344 |
+
result.append(sc)
|
| 345 |
+
return sorted(result)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
# ---------------------------------------------------------------------------
|
| 349 |
+
# Import de corpus
|
| 350 |
+
# ---------------------------------------------------------------------------
|
| 351 |
+
|
| 352 |
+
def import_htr_united_corpus(
|
| 353 |
+
entry: HTRUnitedEntry,
|
| 354 |
+
output_dir: str | Path,
|
| 355 |
+
max_samples: int = 100,
|
| 356 |
+
show_progress: bool = True,
|
| 357 |
+
) -> dict:
|
| 358 |
+
"""Importe un corpus HTR-United dans un dossier local.
|
| 359 |
+
|
| 360 |
+
Retourne un dict avec les métadonnées de l'import.
|
| 361 |
+
Note : en l'absence d'accès réseau au dépôt GitHub, génère des fichiers
|
| 362 |
+
placeholder (pour tests et démo).
|
| 363 |
+
"""
|
| 364 |
+
output_path = Path(output_dir)
|
| 365 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 366 |
+
|
| 367 |
+
# Sauvegarder les métadonnées
|
| 368 |
+
meta = {
|
| 369 |
+
"source": "htr-united",
|
| 370 |
+
"entry_id": entry.id,
|
| 371 |
+
"title": entry.title,
|
| 372 |
+
"url": entry.url,
|
| 373 |
+
"language": entry.language,
|
| 374 |
+
"script": entry.script,
|
| 375 |
+
"century": entry.century,
|
| 376 |
+
"institution": entry.institution,
|
| 377 |
+
"license": entry.license,
|
| 378 |
+
"format": entry.format,
|
| 379 |
+
"imported_at": _iso_now(),
|
| 380 |
+
}
|
| 381 |
+
(output_path / "htr_united_meta.json").write_text(
|
| 382 |
+
json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8"
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Essai de téléchargement réel depuis GitHub (archive releases)
|
| 386 |
+
downloaded = _try_download_corpus(entry, output_path, max_samples, show_progress)
|
| 387 |
+
|
| 388 |
+
return {
|
| 389 |
+
"entry_id": entry.id,
|
| 390 |
+
"title": entry.title,
|
| 391 |
+
"output_dir": str(output_path),
|
| 392 |
+
"files_imported": downloaded,
|
| 393 |
+
"metadata_file": str(output_path / "htr_united_meta.json"),
|
| 394 |
+
}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def _try_download_corpus(
|
| 398 |
+
entry: HTRUnitedEntry,
|
| 399 |
+
output_path: Path,
|
| 400 |
+
max_samples: int,
|
| 401 |
+
show_progress: bool,
|
| 402 |
+
) -> int:
|
| 403 |
+
"""Tente de télécharger le corpus depuis GitHub. Retourne le nombre de fichiers importés."""
|
| 404 |
+
# Construit l'URL de l'archive ZIP du dépôt GitHub
|
| 405 |
+
repo_path = _extract_github_repo(entry.url)
|
| 406 |
+
if not repo_path:
|
| 407 |
+
return 0
|
| 408 |
+
|
| 409 |
+
zip_url = f"https://github.com/{repo_path}/archive/refs/heads/main.zip"
|
| 410 |
+
try:
|
| 411 |
+
req = urllib.request.Request(
|
| 412 |
+
zip_url,
|
| 413 |
+
headers={"User-Agent": "picarones-htr-united-importer/1.0"},
|
| 414 |
+
)
|
| 415 |
+
with urllib.request.urlopen(req, timeout=30) as resp:
|
| 416 |
+
import io
|
| 417 |
+
import zipfile
|
| 418 |
+
|
| 419 |
+
data = resp.read()
|
| 420 |
+
with zipfile.ZipFile(io.BytesIO(data)) as zf:
|
| 421 |
+
# Extraire les fichiers ALTO/PAGE/GT
|
| 422 |
+
gt_files = [
|
| 423 |
+
n for n in zf.namelist()
|
| 424 |
+
if n.endswith((".alto.xml", ".page.xml", ".gt.txt", ".xml"))
|
| 425 |
+
and not n.endswith("/")
|
| 426 |
+
][:max_samples]
|
| 427 |
+
for i, fname in enumerate(gt_files):
|
| 428 |
+
dest = output_path / Path(fname).name
|
| 429 |
+
dest.write_bytes(zf.read(fname))
|
| 430 |
+
return len(gt_files)
|
| 431 |
+
except Exception as exc: # noqa: BLE001 — large surface (réseau, ZIP, FS)
|
| 432 |
+
# Sprint A3 (B-3) : on documente l'incident plutôt que de le
|
| 433 |
+
# masquer ; le caller reçoit toujours 0 pour préserver le
|
| 434 |
+
# contrat numérique de retour.
|
| 435 |
+
from picarones.adapters.corpus._fallback_log import record_fallback
|
| 436 |
+
record_fallback(
|
| 437 |
+
importer="htr_united",
|
| 438 |
+
operation="download_zip_samples",
|
| 439 |
+
error=exc,
|
| 440 |
+
extra={"output_path": str(output_path)},
|
| 441 |
+
)
|
| 442 |
+
return 0
|
| 443 |
+
|
| 444 |
+
|
| 445 |
+
def _extract_github_repo(url: str) -> Optional[str]:
|
| 446 |
+
"""Extrait 'owner/repo' depuis une URL GitHub."""
|
| 447 |
+
m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", url)
|
| 448 |
+
return m.group(1) if m else None
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def _parse_yml_catalogue(raw: str) -> list[HTRUnitedEntry]:
|
| 452 |
+
"""Parse rudimentaire du YAML catalogue HTR-United."""
|
| 453 |
+
try:
|
| 454 |
+
import yaml
|
| 455 |
+
data = yaml.safe_load(raw)
|
| 456 |
+
if isinstance(data, list):
|
| 457 |
+
return [HTRUnitedEntry.from_dict(d) for d in data if isinstance(d, dict)]
|
| 458 |
+
except Exception as exc: # noqa: BLE001 — yaml + parsing user-supplied
|
| 459 |
+
# Sprint A3 (B-3) : un YAML mal formé bascule en mode démo
|
| 460 |
+
# sans que l'utilisateur en soit averti — on logge et on émet
|
| 461 |
+
# un Fact pour que la synthèse du rapport mentionne l'incident.
|
| 462 |
+
from picarones.adapters.corpus._fallback_log import record_fallback
|
| 463 |
+
record_fallback(
|
| 464 |
+
importer="htr_united",
|
| 465 |
+
operation="yaml_catalogue_parse",
|
| 466 |
+
error=exc,
|
| 467 |
+
)
|
| 468 |
+
return [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE]
|
| 469 |
+
|
| 470 |
+
|
| 471 |
+
def _iso_now() -> str:
|
| 472 |
+
from datetime import datetime, timezone
|
| 473 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
picarones/adapters/corpus/huggingface.py
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Import de datasets OCR/HTR depuis HuggingFace Hub.
|
| 2 |
+
|
| 3 |
+
⚠ **Statut : expérimental** (phase C du chantier de refonte en 3 cercles).
|
| 4 |
+
L'API ``datasets`` HuggingFace évolue fréquemment et ce module n'a pas
|
| 5 |
+
de tests d'intégration. À utiliser à vos risques jusqu'à ce qu'un cas
|
| 6 |
+
d'usage institutionnel valide son comportement. Un ``UserWarning`` est
|
| 7 |
+
émis à l'import pour le rappeler.
|
| 8 |
+
|
| 9 |
+
Ce module fournit :
|
| 10 |
+
- :class:`HuggingFaceDataset` — métadonnées d'un dataset HuggingFace
|
| 11 |
+
- :class:`HuggingFaceImporter` — recherche et import de datasets
|
| 12 |
+
- :func:`search_hf_datasets` — recherche par tags dans l'API HuggingFace
|
| 13 |
+
- :func:`import_hf_dataset` — téléchargement d'un dataset vers un dossier local
|
| 14 |
+
|
| 15 |
+
Les datasets patrimoniaux de référence sont pré-référencés pour une découverte
|
| 16 |
+
rapide sans requête réseau.
|
| 17 |
+
|
| 18 |
+
Exemple
|
| 19 |
+
-------
|
| 20 |
+
importer = HuggingFaceImporter()
|
| 21 |
+
results = importer.search("medieval OCR", tags=["ocr"])
|
| 22 |
+
corpus = importer.import_dataset(results[0].dataset_id, output_dir="./corpus/")
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import urllib.error
|
| 30 |
+
import urllib.parse
|
| 31 |
+
import urllib.request
|
| 32 |
+
import warnings
|
| 33 |
+
from dataclasses import dataclass, field
|
| 34 |
+
from pathlib import Path
|
| 35 |
+
from typing import Optional
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# Émission du warning ``experimental`` à l'import. Phase C du chantier
|
| 39 |
+
# de refonte — voir docstring du module ci-dessus.
|
| 40 |
+
warnings.warn(
|
| 41 |
+
"picarones.extras.importers.huggingface is experimental and may "
|
| 42 |
+
"change or be removed without notice. Use at your own risk until "
|
| 43 |
+
"an institutional use case validates the API.",
|
| 44 |
+
category=UserWarning,
|
| 45 |
+
stacklevel=2,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
# ---------------------------------------------------------------------------
|
| 49 |
+
# Datasets de référence pré-référencés
|
| 50 |
+
# ---------------------------------------------------------------------------
|
| 51 |
+
|
| 52 |
+
_REFERENCE_DATASETS: list[dict] = [
|
| 53 |
+
{
|
| 54 |
+
"dataset_id": "Teklia/RIMES",
|
| 55 |
+
"title": "RIMES — Reconnaissance et Indexation de données Manuscrites et de fac-similEs",
|
| 56 |
+
"description": "Corpus de courriers manuscrits français modernes. Standard de référence pour la reconnaissance d'écriture manuscrite.",
|
| 57 |
+
"language": ["French"],
|
| 58 |
+
"tags": ["htr", "ocr", "handwritten", "french", "modern"],
|
| 59 |
+
"license": "cc-by-4.0",
|
| 60 |
+
"size_category": "1K<n<10K",
|
| 61 |
+
"task": "image-to-text",
|
| 62 |
+
"institution": "IRISA / A2iA",
|
| 63 |
+
"downloads": 1200,
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
"dataset_id": "Teklia/IAM",
|
| 67 |
+
"title": "IAM Handwriting Database",
|
| 68 |
+
"description": "Corpus de référence anglais pour la reconnaissance d'écriture manuscrite.",
|
| 69 |
+
"language": ["English"],
|
| 70 |
+
"tags": ["htr", "ocr", "handwritten", "english"],
|
| 71 |
+
"license": "other",
|
| 72 |
+
"size_category": "10K<n<100K",
|
| 73 |
+
"task": "image-to-text",
|
| 74 |
+
"institution": "University of Bern",
|
| 75 |
+
"downloads": 8400,
|
| 76 |
+
},
|
| 77 |
+
{
|
| 78 |
+
"dataset_id": "CATMuS/medieval",
|
| 79 |
+
"title": "CATMuS Medieval — Consistent Approaches to Transcribing ManuScripts",
|
| 80 |
+
"description": "Dataset multilingue de manuscrits médiévaux (latin, français, occitan, espagnol) pour l'entraînement de modèles HTR.",
|
| 81 |
+
"language": ["Latin", "French", "Occitan", "Spanish"],
|
| 82 |
+
"tags": ["htr", "medieval", "manuscripts", "latin", "french", "historical"],
|
| 83 |
+
"license": "cc-by-4.0",
|
| 84 |
+
"size_category": "100K<n<1M",
|
| 85 |
+
"task": "image-to-text",
|
| 86 |
+
"institution": "Inria / EPHE",
|
| 87 |
+
"downloads": 3100,
|
| 88 |
+
},
|
| 89 |
+
{
|
| 90 |
+
"dataset_id": "htr-united/cremma-medieval",
|
| 91 |
+
"title": "CREMMA Medieval",
|
| 92 |
+
"description": "Corpus de manuscrits médiévaux français XIIe-XVe siècles.",
|
| 93 |
+
"language": ["French", "Latin"],
|
| 94 |
+
"tags": ["htr", "medieval", "french", "manuscripts", "htr-united"],
|
| 95 |
+
"license": "cc-by-4.0",
|
| 96 |
+
"size_category": "1K<n<10K",
|
| 97 |
+
"task": "image-to-text",
|
| 98 |
+
"institution": "Inria",
|
| 99 |
+
"downloads": 520,
|
| 100 |
+
},
|
| 101 |
+
{
|
| 102 |
+
"dataset_id": "biglam/europeana_newspapers",
|
| 103 |
+
"title": "Europeana Newspapers",
|
| 104 |
+
"description": "Journaux numérisés européens du XIXe siècle (OCR + images).",
|
| 105 |
+
"language": ["French", "German", "Dutch", "Finnish"],
|
| 106 |
+
"tags": ["ocr", "newspapers", "historical", "19th-century", "europeana"],
|
| 107 |
+
"license": "cc0-1.0",
|
| 108 |
+
"size_category": "1M<n<10M",
|
| 109 |
+
"task": "image-to-text",
|
| 110 |
+
"institution": "Europeana Foundation",
|
| 111 |
+
"downloads": 15200,
|
| 112 |
+
},
|
| 113 |
+
{
|
| 114 |
+
"dataset_id": "stefanklut/esposalles",
|
| 115 |
+
"title": "Esposalles Dataset",
|
| 116 |
+
"description": "Registres de mariage catalans du XVIIe siècle pour la reconnaissance d'écriture historique.",
|
| 117 |
+
"language": ["Catalan", "Latin"],
|
| 118 |
+
"tags": ["htr", "historical", "registers", "catalan", "17th-century"],
|
| 119 |
+
"license": "cc-by-4.0",
|
| 120 |
+
"size_category": "1K<n<10K",
|
| 121 |
+
"task": "image-to-text",
|
| 122 |
+
"institution": "Universitat Autònoma de Barcelona",
|
| 123 |
+
"downloads": 340,
|
| 124 |
+
},
|
| 125 |
+
{
|
| 126 |
+
"dataset_id": "bnf-gallica/gallica-ocr",
|
| 127 |
+
"title": "Gallica OCR",
|
| 128 |
+
"description": "Extraits d'imprimés anciens numérisés depuis Gallica avec vérité terrain.",
|
| 129 |
+
"language": ["French", "Latin"],
|
| 130 |
+
"tags": ["ocr", "historical", "printed", "gallica", "french"],
|
| 131 |
+
"license": "etalab-2.0",
|
| 132 |
+
"size_category": "10K<n<100K",
|
| 133 |
+
"task": "image-to-text",
|
| 134 |
+
"institution": "Gallica",
|
| 135 |
+
"downloads": 2800,
|
| 136 |
+
},
|
| 137 |
+
{
|
| 138 |
+
"dataset_id": "Bozen-Baptism/baptism-records",
|
| 139 |
+
"title": "Bozen Baptism Records",
|
| 140 |
+
"description": "Registres de baptêmes de Bozen (Italie/Autriche) du XVIIIe siècle.",
|
| 141 |
+
"language": ["German", "Latin"],
|
| 142 |
+
"tags": ["htr", "historical", "registers", "german", "latin", "18th-century"],
|
| 143 |
+
"license": "cc-by-4.0",
|
| 144 |
+
"size_category": "1K<n<10K",
|
| 145 |
+
"task": "image-to-text",
|
| 146 |
+
"institution": "University of Innsbruck",
|
| 147 |
+
"downloads": 190,
|
| 148 |
+
},
|
| 149 |
+
{
|
| 150 |
+
"dataset_id": "read-bad/readbad",
|
| 151 |
+
"title": "READ-BAD — Recognition and Enrichment of Archival Documents",
|
| 152 |
+
"description": "Corpus multilingue de documents d'archives pour l'OCR historique (Latin, Allemand, Anglais).",
|
| 153 |
+
"language": ["German", "English", "Latin"],
|
| 154 |
+
"tags": ["ocr", "htr", "historical", "archives", "read"],
|
| 155 |
+
"license": "cc-by-4.0",
|
| 156 |
+
"size_category": "10K<n<100K",
|
| 157 |
+
"task": "image-to-text",
|
| 158 |
+
"institution": "University of Graz",
|
| 159 |
+
"downloads": 1050,
|
| 160 |
+
},
|
| 161 |
+
]
|
| 162 |
+
|
| 163 |
+
# ---------------------------------------------------------------------------
|
| 164 |
+
# Dataclass
|
| 165 |
+
# ---------------------------------------------------------------------------
|
| 166 |
+
|
| 167 |
+
@dataclass
|
| 168 |
+
class HuggingFaceDataset:
|
| 169 |
+
"""Métadonnées d'un dataset HuggingFace."""
|
| 170 |
+
|
| 171 |
+
dataset_id: str
|
| 172 |
+
title: str
|
| 173 |
+
description: str = ""
|
| 174 |
+
language: list[str] = field(default_factory=list)
|
| 175 |
+
tags: list[str] = field(default_factory=list)
|
| 176 |
+
license: str = ""
|
| 177 |
+
size_category: str = ""
|
| 178 |
+
task: str = "image-to-text"
|
| 179 |
+
institution: str = ""
|
| 180 |
+
downloads: int = 0
|
| 181 |
+
source: str = "reference" # "reference" | "api"
|
| 182 |
+
|
| 183 |
+
def as_dict(self) -> dict:
|
| 184 |
+
return {
|
| 185 |
+
"dataset_id": self.dataset_id,
|
| 186 |
+
"title": self.title,
|
| 187 |
+
"description": self.description,
|
| 188 |
+
"language": self.language,
|
| 189 |
+
"tags": self.tags,
|
| 190 |
+
"license": self.license,
|
| 191 |
+
"size_category": self.size_category,
|
| 192 |
+
"task": self.task,
|
| 193 |
+
"institution": self.institution,
|
| 194 |
+
"downloads": self.downloads,
|
| 195 |
+
"source": self.source,
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
@classmethod
|
| 199 |
+
def from_dict(cls, d: dict) -> "HuggingFaceDataset":
|
| 200 |
+
return cls(
|
| 201 |
+
dataset_id=d.get("dataset_id", d.get("id", "")),
|
| 202 |
+
title=d.get("title", d.get("dataset_id", "")),
|
| 203 |
+
description=d.get("description", ""),
|
| 204 |
+
language=d.get("language", []),
|
| 205 |
+
tags=d.get("tags", []),
|
| 206 |
+
license=d.get("license", ""),
|
| 207 |
+
size_category=d.get("size_category", d.get("cardData", {}).get("size_categories", [""])[0] if isinstance(d.get("cardData"), dict) else ""),
|
| 208 |
+
task=d.get("task", "image-to-text"),
|
| 209 |
+
institution=d.get("institution", ""),
|
| 210 |
+
downloads=d.get("downloads", d.get("downloadsAllTime", 0)),
|
| 211 |
+
source=d.get("source", "api"),
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
@property
|
| 215 |
+
def hf_url(self) -> str:
|
| 216 |
+
return f"https://huggingface.co/datasets/{self.dataset_id}"
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
# Importer principal
|
| 221 |
+
# ---------------------------------------------------------------------------
|
| 222 |
+
|
| 223 |
+
class HuggingFaceImporter:
|
| 224 |
+
"""Recherche et importe des datasets depuis HuggingFace Hub."""
|
| 225 |
+
|
| 226 |
+
_API_BASE = "https://huggingface.co/api"
|
| 227 |
+
|
| 228 |
+
def __init__(self, token: Optional[str] = None) -> None:
|
| 229 |
+
self._token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN")
|
| 230 |
+
|
| 231 |
+
def _headers(self) -> dict:
|
| 232 |
+
h = {"User-Agent": "picarones-hf-importer/1.0"}
|
| 233 |
+
if self._token:
|
| 234 |
+
h["Authorization"] = f"Bearer {self._token}"
|
| 235 |
+
return h
|
| 236 |
+
|
| 237 |
+
def search(
|
| 238 |
+
self,
|
| 239 |
+
query: str = "",
|
| 240 |
+
tags: Optional[list[str]] = None,
|
| 241 |
+
language: Optional[str] = None,
|
| 242 |
+
limit: int = 20,
|
| 243 |
+
use_reference: bool = True,
|
| 244 |
+
) -> list[HuggingFaceDataset]:
|
| 245 |
+
"""Recherche des datasets avec filtres.
|
| 246 |
+
|
| 247 |
+
Interroge d'abord les datasets de référence pré-intégrés, puis
|
| 248 |
+
l'API HuggingFace si disponible.
|
| 249 |
+
"""
|
| 250 |
+
results: list[HuggingFaceDataset] = []
|
| 251 |
+
|
| 252 |
+
# Datasets de référence
|
| 253 |
+
if use_reference:
|
| 254 |
+
ref_results = self._search_reference(query, tags, language)
|
| 255 |
+
results.extend(ref_results)
|
| 256 |
+
|
| 257 |
+
# API HuggingFace (optionnel, peut échouer silencieusement)
|
| 258 |
+
try:
|
| 259 |
+
api_results = self._search_api(query, tags, language, limit)
|
| 260 |
+
# Déduplique (priorité aux références)
|
| 261 |
+
existing_ids = {r.dataset_id for r in results}
|
| 262 |
+
for ds in api_results:
|
| 263 |
+
if ds.dataset_id not in existing_ids:
|
| 264 |
+
results.append(ds)
|
| 265 |
+
existing_ids.add(ds.dataset_id)
|
| 266 |
+
except Exception as exc: # noqa: BLE001 — réseau/API tierce
|
| 267 |
+
# Sprint A3 (B-3) : la recherche API échoue silencieusement →
|
| 268 |
+
# l'utilisateur ne voit que les datasets de référence et croit
|
| 269 |
+
# que l'API est vide. On documente l'incident.
|
| 270 |
+
from picarones.adapters.corpus._fallback_log import record_fallback
|
| 271 |
+
record_fallback(
|
| 272 |
+
importer="huggingface",
|
| 273 |
+
operation="hub_search_api",
|
| 274 |
+
error=exc,
|
| 275 |
+
extra={"query": query, "language": language, "limit": limit},
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
return results[:limit]
|
| 279 |
+
|
| 280 |
+
def _search_reference(
|
| 281 |
+
self,
|
| 282 |
+
query: str,
|
| 283 |
+
tags: Optional[list[str]],
|
| 284 |
+
language: Optional[str],
|
| 285 |
+
) -> list[HuggingFaceDataset]:
|
| 286 |
+
datasets = [HuggingFaceDataset.from_dict(d) for d in _REFERENCE_DATASETS]
|
| 287 |
+
datasets = [ds._replace_source("reference") for ds in datasets]
|
| 288 |
+
|
| 289 |
+
if query:
|
| 290 |
+
q = query.lower()
|
| 291 |
+
datasets = [
|
| 292 |
+
ds for ds in datasets
|
| 293 |
+
if (q in ds.title.lower()
|
| 294 |
+
or q in ds.description.lower()
|
| 295 |
+
or q in ds.dataset_id.lower()
|
| 296 |
+
or any(q in t.lower() for t in ds.tags)
|
| 297 |
+
or any(q in lg.lower() for lg in ds.language))
|
| 298 |
+
]
|
| 299 |
+
|
| 300 |
+
if tags:
|
| 301 |
+
for tag in tags:
|
| 302 |
+
t_lower = tag.lower()
|
| 303 |
+
datasets = [
|
| 304 |
+
ds for ds in datasets
|
| 305 |
+
if any(t_lower in dt.lower() for dt in ds.tags)
|
| 306 |
+
]
|
| 307 |
+
|
| 308 |
+
if language:
|
| 309 |
+
lang_lower = language.lower()
|
| 310 |
+
datasets = [
|
| 311 |
+
ds for ds in datasets
|
| 312 |
+
if any(lang_lower in lg.lower() for lg in ds.language)
|
| 313 |
+
]
|
| 314 |
+
|
| 315 |
+
return datasets
|
| 316 |
+
|
| 317 |
+
def _search_api(
|
| 318 |
+
self,
|
| 319 |
+
query: str,
|
| 320 |
+
tags: Optional[list[str]],
|
| 321 |
+
language: Optional[str],
|
| 322 |
+
limit: int,
|
| 323 |
+
) -> list[HuggingFaceDataset]:
|
| 324 |
+
params: dict[str, str] = {
|
| 325 |
+
"task_categories": "image-to-text",
|
| 326 |
+
"limit": str(min(limit, 50)),
|
| 327 |
+
"full": "False",
|
| 328 |
+
}
|
| 329 |
+
if query:
|
| 330 |
+
params["search"] = query
|
| 331 |
+
if language:
|
| 332 |
+
params["language"] = language
|
| 333 |
+
if tags:
|
| 334 |
+
params["tags"] = ",".join(tags)
|
| 335 |
+
|
| 336 |
+
url = f"{self._API_BASE}/datasets?" + urllib.parse.urlencode(params)
|
| 337 |
+
req = urllib.request.Request(url, headers=self._headers())
|
| 338 |
+
with urllib.request.urlopen(req, timeout=10) as resp:
|
| 339 |
+
data = json.loads(resp.read().decode("utf-8"))
|
| 340 |
+
|
| 341 |
+
results = []
|
| 342 |
+
for item in data if isinstance(data, list) else []:
|
| 343 |
+
ds = HuggingFaceDataset(
|
| 344 |
+
dataset_id=item.get("id", ""),
|
| 345 |
+
title=item.get("id", ""),
|
| 346 |
+
description=item.get("description", ""),
|
| 347 |
+
language=item.get("language", []),
|
| 348 |
+
tags=item.get("tags", []),
|
| 349 |
+
license=item.get("license", ""),
|
| 350 |
+
size_category=(
|
| 351 |
+
item.get("cardData", {}).get("size_categories", [""])[0]
|
| 352 |
+
if isinstance(item.get("cardData"), dict)
|
| 353 |
+
else ""
|
| 354 |
+
),
|
| 355 |
+
task="image-to-text",
|
| 356 |
+
downloads=item.get("downloadsAllTime", 0),
|
| 357 |
+
source="api",
|
| 358 |
+
)
|
| 359 |
+
if ds.dataset_id:
|
| 360 |
+
results.append(ds)
|
| 361 |
+
return results
|
| 362 |
+
|
| 363 |
+
def import_dataset(
|
| 364 |
+
self,
|
| 365 |
+
dataset_id: str,
|
| 366 |
+
output_dir: str | Path,
|
| 367 |
+
split: str = "train",
|
| 368 |
+
max_samples: int = 100,
|
| 369 |
+
show_progress: bool = True,
|
| 370 |
+
) -> dict:
|
| 371 |
+
"""Importe un dataset depuis HuggingFace vers un dossier local.
|
| 372 |
+
|
| 373 |
+
Retourne les métadonnées de l'import.
|
| 374 |
+
"""
|
| 375 |
+
output_path = Path(output_dir)
|
| 376 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 377 |
+
|
| 378 |
+
meta = {
|
| 379 |
+
"source": "huggingface",
|
| 380 |
+
"dataset_id": dataset_id,
|
| 381 |
+
"split": split,
|
| 382 |
+
"max_samples": max_samples,
|
| 383 |
+
"imported_at": _iso_now(),
|
| 384 |
+
}
|
| 385 |
+
meta_file = output_path / "huggingface_meta.json"
|
| 386 |
+
meta_file.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8")
|
| 387 |
+
|
| 388 |
+
# Tentative d'import via datasets library si disponible
|
| 389 |
+
files_imported = _try_import_with_datasets_lib(
|
| 390 |
+
dataset_id, output_path, split, max_samples, show_progress
|
| 391 |
+
)
|
| 392 |
+
|
| 393 |
+
return {
|
| 394 |
+
"dataset_id": dataset_id,
|
| 395 |
+
"output_dir": str(output_path),
|
| 396 |
+
"files_imported": files_imported,
|
| 397 |
+
"metadata_file": str(meta_file),
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
def _try_import_with_datasets_lib(
|
| 402 |
+
dataset_id: str,
|
| 403 |
+
output_path: Path,
|
| 404 |
+
split: str,
|
| 405 |
+
max_samples: int,
|
| 406 |
+
show_progress: bool,
|
| 407 |
+
) -> int:
|
| 408 |
+
"""Essaie d'importer avec la librairie `datasets` de HuggingFace."""
|
| 409 |
+
try:
|
| 410 |
+
from datasets import load_dataset # type: ignore
|
| 411 |
+
|
| 412 |
+
ds = load_dataset(dataset_id, split=split, streaming=True)
|
| 413 |
+
count = 0
|
| 414 |
+
for i, item in enumerate(ds):
|
| 415 |
+
if i >= max_samples:
|
| 416 |
+
break
|
| 417 |
+
# Cherche champ image et texte
|
| 418 |
+
image = item.get("image") or item.get("img")
|
| 419 |
+
text = item.get("text") or item.get("transcription") or item.get("ground_truth", "")
|
| 420 |
+
|
| 421 |
+
if image is not None:
|
| 422 |
+
img_file = output_path / f"doc_{i:04d}.jpg"
|
| 423 |
+
try:
|
| 424 |
+
image.save(str(img_file))
|
| 425 |
+
except Exception as exc: # noqa: BLE001 — PIL/PIL-IO
|
| 426 |
+
# Sprint A3 (B-3) : un échec de sauvegarde d'image
|
| 427 |
+
# produirait un GT orphelin (texte sans image). On
|
| 428 |
+
# documente et on continue — le GT est tout de même
|
| 429 |
+
# écrit pour préserver la cohérence numérique du compteur.
|
| 430 |
+
from picarones.adapters.corpus._fallback_log import record_fallback
|
| 431 |
+
record_fallback(
|
| 432 |
+
importer="huggingface",
|
| 433 |
+
operation="image_save",
|
| 434 |
+
error=exc,
|
| 435 |
+
extra={"img_file": str(img_file), "doc_index": i},
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
gt_file = output_path / f"doc_{i:04d}.gt.txt"
|
| 439 |
+
gt_file.write_text(str(text), encoding="utf-8")
|
| 440 |
+
count += 1
|
| 441 |
+
|
| 442 |
+
return count
|
| 443 |
+
except (ImportError, Exception):
|
| 444 |
+
return 0
|
| 445 |
+
|
| 446 |
+
|
| 447 |
+
def _iso_now() -> str:
|
| 448 |
+
from datetime import datetime, timezone
|
| 449 |
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
| 450 |
+
|
| 451 |
+
|
| 452 |
+
# ---------------------------------------------------------------------------
|
| 453 |
+
# Extension de HuggingFaceDataset (helper privé)
|
| 454 |
+
# ---------------------------------------------------------------------------
|
| 455 |
+
|
| 456 |
+
def _patch_dataset_replace_source() -> None:
|
| 457 |
+
"""Ajoute un helper _replace_source à HuggingFaceDataset."""
|
| 458 |
+
def _replace_source(self, source: str) -> "HuggingFaceDataset":
|
| 459 |
+
from dataclasses import replace
|
| 460 |
+
return replace(self, source=source)
|
| 461 |
+
HuggingFaceDataset._replace_source = _replace_source
|
| 462 |
+
|
| 463 |
+
|
| 464 |
+
_patch_dataset_replace_source()
|
picarones/adapters/llm/__init__.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateurs LLM — Sprint S11.
|
| 2 |
+
|
| 3 |
+
Cible : déplacement de ``picarones.llm.{openai,anthropic,mistral,
|
| 4 |
+
ollama}_adapter``. Wrappers minces autour des SDK provider, qui
|
| 5 |
+
exposent un ``complete(prompt, ...)`` uniforme.
|
| 6 |
+
|
| 7 |
+
Un adapter LLM ne sait **rien** d'OCR ou de patrimoine. Il fait
|
| 8 |
+
``prompt → completion``. La logique de pipeline (prompt
|
| 9 |
+
construction, post-traitement, gestion d'erreur) vit dans
|
| 10 |
+
``pipeline/`` ou dans le module utilisateur qui compose la
|
| 11 |
+
pipeline.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
__all__: list[str] = []
|
picarones/adapters/llm/anthropic_adapter.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateur LLM — Anthropic (Claude Sonnet, Claude Haiku)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from picarones.adapters.llm.base import (
|
| 10 |
+
BaseLLMAdapter,
|
| 11 |
+
log_http_error,
|
| 12 |
+
normalize_llm_content,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class AnthropicAdapter(BaseLLMAdapter):
|
| 19 |
+
"""Adaptateur pour les modèles Anthropic Claude.
|
| 20 |
+
|
| 21 |
+
Clé API via la variable d'environnement ``ANTHROPIC_API_KEY``.
|
| 22 |
+
|
| 23 |
+
Modes supportés : text_only, text_and_image, zero_shot.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
api_key_env_var = "ANTHROPIC_API_KEY"
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def name(self) -> str:
|
| 30 |
+
return "anthropic"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def default_model(self) -> str:
|
| 34 |
+
return "claude-sonnet-4-6"
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
model: Optional[str] = None,
|
| 39 |
+
config: Optional[dict] = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
super().__init__(model, config)
|
| 42 |
+
self._api_key = os.environ.get("ANTHROPIC_API_KEY")
|
| 43 |
+
|
| 44 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 45 |
+
if not self._api_key:
|
| 46 |
+
raise RuntimeError(
|
| 47 |
+
"Clé API Anthropic manquante — définissez la variable d'environnement ANTHROPIC_API_KEY"
|
| 48 |
+
)
|
| 49 |
+
try:
|
| 50 |
+
import anthropic
|
| 51 |
+
except ImportError as exc:
|
| 52 |
+
raise RuntimeError(
|
| 53 |
+
"Le package 'anthropic' n'est pas installé. Lancez : pip install anthropic"
|
| 54 |
+
) from exc
|
| 55 |
+
|
| 56 |
+
client = anthropic.Anthropic(api_key=self._api_key)
|
| 57 |
+
temperature = float(self.config.get("temperature", 0.0))
|
| 58 |
+
max_tokens = int(self.config.get("max_tokens", 4096))
|
| 59 |
+
|
| 60 |
+
if image_b64:
|
| 61 |
+
content: list | str = [
|
| 62 |
+
{
|
| 63 |
+
"type": "image",
|
| 64 |
+
"source": {
|
| 65 |
+
"type": "base64",
|
| 66 |
+
"media_type": "image/png",
|
| 67 |
+
"data": image_b64,
|
| 68 |
+
},
|
| 69 |
+
},
|
| 70 |
+
{"type": "text", "text": prompt},
|
| 71 |
+
]
|
| 72 |
+
else:
|
| 73 |
+
content = prompt
|
| 74 |
+
|
| 75 |
+
try:
|
| 76 |
+
response = client.messages.create(
|
| 77 |
+
model=self.model,
|
| 78 |
+
max_tokens=max_tokens,
|
| 79 |
+
temperature=temperature,
|
| 80 |
+
messages=[{"role": "user", "content": content}],
|
| 81 |
+
)
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
# Chantier 4 — log discriminant (401/429/5xx) factorisé.
|
| 84 |
+
# Auparavant Anthropic ne discriminait pas par code HTTP,
|
| 85 |
+
# difficile à diagnostiquer (clé invalide vs rate limit).
|
| 86 |
+
log_http_error(
|
| 87 |
+
"AnthropicAdapter", self.model, exc,
|
| 88 |
+
env_var=self.api_key_env_var,
|
| 89 |
+
)
|
| 90 |
+
raise
|
| 91 |
+
|
| 92 |
+
if not response.content:
|
| 93 |
+
logger.warning(
|
| 94 |
+
"[AnthropicAdapter] réponse vide (modèle=%s, stop_reason=%s).",
|
| 95 |
+
self.model, getattr(response, "stop_reason", None),
|
| 96 |
+
)
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
# Chantier 4 — propagation du fix Sprint 15 : le SDK Anthropic
|
| 100 |
+
# retourne ``response.content`` comme une liste de blocs
|
| 101 |
+
# (``ContentBlock`` avec attribut ``text``). ``normalize_llm_content``
|
| 102 |
+
# concatène le texte de tous les blocs au lieu de ne prendre que
|
| 103 |
+
# le premier — utile quand le modèle émet plusieurs blocs.
|
| 104 |
+
text = normalize_llm_content(response.content)
|
| 105 |
+
if not text:
|
| 106 |
+
block = response.content[0]
|
| 107 |
+
logger.warning(
|
| 108 |
+
"[AnthropicAdapter] bloc de type '%s' sans texte (modèle=%s).",
|
| 109 |
+
getattr(block, "type", "unknown"), self.model,
|
| 110 |
+
)
|
| 111 |
+
return text
|
picarones/adapters/llm/base.py
ADDED
|
@@ -0,0 +1,486 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Interface abstraite commune à tous les adaptateurs LLM."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import time
|
| 7 |
+
import warnings
|
| 8 |
+
from abc import ABC, abstractmethod
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from typing import Any, Generic, Optional, TypeVar
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
T = TypeVar("T")
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class _DeprecatedAttribute(Generic[T]):
|
| 19 |
+
"""Descripteur class-level qui émet ``DeprecationWarning`` à l'accès.
|
| 20 |
+
|
| 21 |
+
Permet de retirer en deux temps une constante de classe sans
|
| 22 |
+
casser les callers externes : phase 1, le descripteur retourne
|
| 23 |
+
l'ancienne valeur avec un warning ; phase 2 (version majeure
|
| 24 |
+
suivante), le descripteur est supprimé.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
def __init__(
|
| 28 |
+
self,
|
| 29 |
+
value: T,
|
| 30 |
+
message: str,
|
| 31 |
+
) -> None:
|
| 32 |
+
self._value = value
|
| 33 |
+
self._message = message
|
| 34 |
+
|
| 35 |
+
def __set_name__(self, owner: type, name: str) -> None:
|
| 36 |
+
self._name = name
|
| 37 |
+
|
| 38 |
+
def __get__(self, instance: Any, owner: type | None = None) -> T:
|
| 39 |
+
warnings.warn(self._message, DeprecationWarning, stacklevel=2)
|
| 40 |
+
return self._value
|
| 41 |
+
|
| 42 |
+
from picarones.adapters._retry import (
|
| 43 |
+
DEFAULT_BACKOFF_BASE as _DEFAULT_BACKOFF_BASE,
|
| 44 |
+
)
|
| 45 |
+
from picarones.adapters._retry import (
|
| 46 |
+
DEFAULT_MAX_RETRIES as _DEFAULT_MAX_RETRIES,
|
| 47 |
+
)
|
| 48 |
+
from picarones.adapters._retry import (
|
| 49 |
+
is_retryable as _is_retryable,
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def normalize_llm_content(raw: Any) -> str:
|
| 54 |
+
"""Normalise une réponse LLM en chaîne plate.
|
| 55 |
+
|
| 56 |
+
Chantier 4 (post-Sprint 97) — propagation du fix Mistral
|
| 57 |
+
Sprint 15 à tous les providers. Le SDK Mistral peut retourner
|
| 58 |
+
une liste de ``ContentChunk`` au lieu d'une chaîne pour certains
|
| 59 |
+
modèles/versions ; le SDK OpenAI peut faire de même quand on
|
| 60 |
+
active des features de structuration. Ce helper applique la même
|
| 61 |
+
discipline pour les 4 adapters :
|
| 62 |
+
|
| 63 |
+
- ``str`` → renvoyée telle quelle (ou ``""``).
|
| 64 |
+
- ``None`` → ``""``.
|
| 65 |
+
- ``list[ContentChunk]`` → concaténation des ``.text``.
|
| 66 |
+
- ``list[dict]`` avec clé ``text`` → concaténation des ``["text"]``.
|
| 67 |
+
- ``list[str]`` → concaténation directe.
|
| 68 |
+
- autre objet avec ``.text`` → ``obj.text``.
|
| 69 |
+
- autre → ``str(obj)`` (best-effort).
|
| 70 |
+
|
| 71 |
+
Le résultat est garanti être une ``str`` ; ``""`` quand la réponse
|
| 72 |
+
est vide. La fonction est idempotente : ``normalize_llm_content(s)
|
| 73 |
+
== s`` pour toute chaîne ``s``.
|
| 74 |
+
"""
|
| 75 |
+
if raw is None:
|
| 76 |
+
return ""
|
| 77 |
+
if isinstance(raw, str):
|
| 78 |
+
return raw
|
| 79 |
+
if isinstance(raw, list):
|
| 80 |
+
parts: list[str] = []
|
| 81 |
+
for chunk in raw:
|
| 82 |
+
if chunk is None:
|
| 83 |
+
continue
|
| 84 |
+
if isinstance(chunk, str):
|
| 85 |
+
parts.append(chunk)
|
| 86 |
+
continue
|
| 87 |
+
if hasattr(chunk, "text"):
|
| 88 |
+
txt = getattr(chunk, "text", None)
|
| 89 |
+
if isinstance(txt, str):
|
| 90 |
+
parts.append(txt)
|
| 91 |
+
continue
|
| 92 |
+
if isinstance(chunk, dict) and isinstance(chunk.get("text"), str):
|
| 93 |
+
parts.append(chunk["text"])
|
| 94 |
+
continue
|
| 95 |
+
# Dernier recours — convertit le chunk en chaîne
|
| 96 |
+
parts.append(str(chunk))
|
| 97 |
+
return "".join(parts)
|
| 98 |
+
if hasattr(raw, "text") and isinstance(getattr(raw, "text", None), str):
|
| 99 |
+
return raw.text # type: ignore[no-any-return]
|
| 100 |
+
return str(raw)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def log_http_error(
|
| 104 |
+
adapter_name: str,
|
| 105 |
+
model: str,
|
| 106 |
+
exc: Exception,
|
| 107 |
+
*,
|
| 108 |
+
env_var: Optional[str] = None,
|
| 109 |
+
) -> None:
|
| 110 |
+
"""Log standardisé des erreurs HTTP des SDK LLM.
|
| 111 |
+
|
| 112 |
+
Chantier 4 (post-Sprint 97) — propagation du log discriminant
|
| 113 |
+
Mistral/OpenAI à tous les providers. Inspecte ``status_code`` et
|
| 114 |
+
``http_status`` puis émet un warning ciblé selon le code :
|
| 115 |
+
|
| 116 |
+
- 401 : clé API invalide/expirée (mention de la variable
|
| 117 |
+
d'environnement à vérifier si fournie).
|
| 118 |
+
- 429 : rate limit / quota dépassé.
|
| 119 |
+
- 5xx : problème serveur côté provider.
|
| 120 |
+
- autre / pas de status_code : log générique.
|
| 121 |
+
|
| 122 |
+
L'exception n'est pas levée — l'appelant doit ``raise``
|
| 123 |
+
explicitement après ce log s'il veut propager (le retry est géré
|
| 124 |
+
par ``BaseLLMAdapter.complete`` selon ``_is_retryable``).
|
| 125 |
+
"""
|
| 126 |
+
status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None)
|
| 127 |
+
if status == 401:
|
| 128 |
+
suffix = f" Vérifier {env_var}." if env_var else ""
|
| 129 |
+
logger.warning(
|
| 130 |
+
"[%s] erreur HTTP 401 — clé API invalide ou expirée "
|
| 131 |
+
"(modèle=%s).%s",
|
| 132 |
+
adapter_name, model, suffix,
|
| 133 |
+
)
|
| 134 |
+
elif status == 429:
|
| 135 |
+
logger.warning(
|
| 136 |
+
"[%s] erreur HTTP 429 — quota dépassé ou rate-limit "
|
| 137 |
+
"(modèle=%s). Réessayer plus tard.",
|
| 138 |
+
adapter_name, model,
|
| 139 |
+
)
|
| 140 |
+
elif status is not None and status >= 500:
|
| 141 |
+
logger.warning(
|
| 142 |
+
"[%s] erreur HTTP %d — problème serveur (modèle=%s) : %s",
|
| 143 |
+
adapter_name, status, model, exc,
|
| 144 |
+
)
|
| 145 |
+
else:
|
| 146 |
+
logger.warning(
|
| 147 |
+
"[%s] erreur lors de l'appel API (modèle=%s) : %s",
|
| 148 |
+
adapter_name, model, exc,
|
| 149 |
+
)
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
from picarones.domain.errors import AdapterStepError
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
class LLMAdapterError(AdapterStepError):
|
| 156 |
+
"""Erreur typée pour un échec d'adapter LLM.
|
| 157 |
+
|
| 158 |
+
Hérite de ``AdapterStepError`` (racine commune avec OCR et VLM)
|
| 159 |
+
→ un caller peut catcher ``AdapterStepError`` pour toute erreur
|
| 160 |
+
d'adapter sans connaître la sous-classe.
|
| 161 |
+
|
| 162 |
+
Avant S52, ``BaseLLMAdapter.execute`` levait ``OCRAdapterError``
|
| 163 |
+
par confusion sémantique — c'était noté dans l'audit comme issue
|
| 164 |
+
#11 (hiérarchie incohérente).
|
| 165 |
+
"""
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
@dataclass
|
| 169 |
+
class LLMResult:
|
| 170 |
+
"""Résultat produit par un appel LLM."""
|
| 171 |
+
|
| 172 |
+
model_id: str
|
| 173 |
+
text: str
|
| 174 |
+
duration_seconds: float
|
| 175 |
+
tokens_used: Optional[int] = None
|
| 176 |
+
error: Optional[str] = None
|
| 177 |
+
|
| 178 |
+
@property
|
| 179 |
+
def success(self) -> bool:
|
| 180 |
+
return self.error is None
|
| 181 |
+
|
| 182 |
+
|
| 183 |
+
class BaseLLMAdapter(ABC):
|
| 184 |
+
"""Classe de base pour tous les adaptateurs LLM.
|
| 185 |
+
|
| 186 |
+
Chaque adaptateur doit implémenter :
|
| 187 |
+
- ``name`` : identifiant du provider (ex : 'openai')
|
| 188 |
+
- ``default_model``: modèle par défaut du provider
|
| 189 |
+
- ``_call()`` : appel API effectif, retourne le texte brut
|
| 190 |
+
|
| 191 |
+
Les clés API sont lues depuis les variables d'environnement uniquement.
|
| 192 |
+
|
| 193 |
+
Retry automatique
|
| 194 |
+
-----------------
|
| 195 |
+
Les erreurs retryables (HTTP 429, 5xx, timeout réseau) sont automatiquement
|
| 196 |
+
retentées avec backoff exponentiel (2s, 4s, 8s par défaut). Configurable
|
| 197 |
+
via ``config["max_retries"]`` et ``config["retry_backoff"]``.
|
| 198 |
+
|
| 199 |
+
Normalisation des réponses (chantier 4)
|
| 200 |
+
---------------------------------------
|
| 201 |
+
Les sous-classes utilisent :func:`normalize_llm_content` sur la
|
| 202 |
+
réponse SDK avant de la retourner — garantit qu'une réponse de
|
| 203 |
+
type ``list[ContentChunk]`` (Mistral, parfois OpenAI) est
|
| 204 |
+
convertie en ``str`` plate.
|
| 205 |
+
|
| 206 |
+
Logging d'erreurs HTTP (chantier 4)
|
| 207 |
+
-----------------------------------
|
| 208 |
+
Les sous-classes utilisent :func:`log_http_error` pour produire
|
| 209 |
+
un log discriminant par ``status_code`` (401 → clé invalide,
|
| 210 |
+
429 → rate limit, 5xx → serveur). Auparavant ce log était
|
| 211 |
+
dupliqué chez Mistral/OpenAI et absent chez Anthropic.
|
| 212 |
+
|
| 213 |
+
Sprint A14-S44 — intégration pipeline native
|
| 214 |
+
---------------------------------------------
|
| 215 |
+
``BaseLLMAdapter`` implémente désormais le contrat ``StepExecutor``
|
| 216 |
+
du pipeline (``input_types``, ``output_types``, ``execution_mode``,
|
| 217 |
+
``execute(inputs, params, context)``) — un adapter LLM est
|
| 218 |
+
directement utilisable comme step de pipeline pour la post-correction
|
| 219 |
+
de texte OCR. Pas de wrapper / shim : la méthode ``execute`` vit
|
| 220 |
+
dans la base et est partagée par les 4 adapters concrets.
|
| 221 |
+
|
| 222 |
+
Convention par défaut : un LLM consomme ``RAW_TEXT`` (depuis l'OCR
|
| 223 |
+
en amont) et produit ``CORRECTED_TEXT``. Une sous-classe peut
|
| 224 |
+
surcharger ``input_types`` / ``output_types`` si elle implémente un
|
| 225 |
+
autre contrat (ex : ALTO → ALTO pour un module de remappage).
|
| 226 |
+
"""
|
| 227 |
+
|
| 228 |
+
# Variable d'environnement portant la clé API. Sous-classes
|
| 229 |
+
# surchargent (ex. ``"OPENAI_API_KEY"``) ; mention utilisée par
|
| 230 |
+
# :func:`log_http_error` quand un 401 est rencontré. ``None``
|
| 231 |
+
# pour les providers sans clé (Ollama).
|
| 232 |
+
api_key_env_var: Optional[str] = None
|
| 233 |
+
|
| 234 |
+
# ──────────────────────────────────────────────────────────────────
|
| 235 |
+
# Sprint A14-S44 — contrat StepExecutor du pipeline
|
| 236 |
+
# ──────────────────────────────────────────────────────────────────
|
| 237 |
+
|
| 238 |
+
#: Types d'artefacts consommés par défaut. Surchargeable par
|
| 239 |
+
#: une sous-classe qui consommerait des artefacts différents
|
| 240 |
+
#: (ex : ALTO_XML pour un remappeur ALTO LLM).
|
| 241 |
+
@property
|
| 242 |
+
def input_types(self) -> "frozenset":
|
| 243 |
+
from picarones.domain.artifacts import ArtifactType
|
| 244 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 245 |
+
|
| 246 |
+
@property
|
| 247 |
+
def output_types(self) -> "frozenset":
|
| 248 |
+
from picarones.domain.artifacts import ArtifactType
|
| 249 |
+
return frozenset({ArtifactType.CORRECTED_TEXT})
|
| 250 |
+
|
| 251 |
+
#: Mode d'exécution : LLM via API → IO-bound → ThreadPool dans le
|
| 252 |
+
#: runner. Une sous-classe locale (Ollama CPU-bound) peut
|
| 253 |
+
#: surcharger en ``"cpu"``.
|
| 254 |
+
execution_mode: str = "io"
|
| 255 |
+
|
| 256 |
+
#: Prompts de post-correction par défaut, indexés par code langue
|
| 257 |
+
#: ISO-639-1 (``fr``, ``en``, ``la``). Sélection via
|
| 258 |
+
#: ``config["lang"]`` ; fallback FR si la langue est absente.
|
| 259 |
+
#:
|
| 260 |
+
#: ``DEFAULT_CORRECTION_PROMPT`` (singulier, FR) reste exposé en
|
| 261 |
+
#: ``_DeprecatedAttribute`` pour les sous-classes externes qui
|
| 262 |
+
#: lisaient l'ancienne API ; suppression prévue en 2.0.
|
| 263 |
+
DEFAULT_CORRECTION_PROMPTS: dict[str, str] = {
|
| 264 |
+
"fr": (
|
| 265 |
+
"Corrige les erreurs OCR dans le texte suivant en "
|
| 266 |
+
"conservant fidèlement la langue, l'orthographe "
|
| 267 |
+
"historique et la ponctuation. Retourne uniquement le "
|
| 268 |
+
"texte corrigé, sans commentaire :\n\n{text}"
|
| 269 |
+
),
|
| 270 |
+
"en": (
|
| 271 |
+
"Fix OCR errors in the following text while preserving "
|
| 272 |
+
"the original language, historical spelling, and "
|
| 273 |
+
"punctuation. Return only the corrected text, with no "
|
| 274 |
+
"commentary:\n\n{text}"
|
| 275 |
+
),
|
| 276 |
+
"la": (
|
| 277 |
+
"Corrige errores OCR in textu sequenti, fideliter "
|
| 278 |
+
"servans linguam, orthographiam historicam et "
|
| 279 |
+
"interpunctionem. Redde solum textum correctum, sine "
|
| 280 |
+
"ulla glossa:\n\n{text}"
|
| 281 |
+
),
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
#: Alias rétrocompat (FR uniquement) pour les sous-classes
|
| 285 |
+
#: externes qui lisaient l'ancienne API singulière. L'accès
|
| 286 |
+
#: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0.
|
| 287 |
+
DEFAULT_CORRECTION_PROMPT = _DeprecatedAttribute(
|
| 288 |
+
DEFAULT_CORRECTION_PROMPTS["fr"],
|
| 289 |
+
"BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT is deprecated and "
|
| 290 |
+
"will be removed in 2.0. Use "
|
| 291 |
+
"DEFAULT_CORRECTION_PROMPTS[lang] (lang ∈ {fr, en, la}).",
|
| 292 |
+
)
|
| 293 |
+
|
| 294 |
+
def __init__(
|
| 295 |
+
self,
|
| 296 |
+
model: Optional[str] = None,
|
| 297 |
+
config: Optional[dict] = None,
|
| 298 |
+
) -> None:
|
| 299 |
+
self.config: dict = config or {}
|
| 300 |
+
self.model: str = model or self.default_model
|
| 301 |
+
|
| 302 |
+
@property
|
| 303 |
+
@abstractmethod
|
| 304 |
+
def name(self) -> str:
|
| 305 |
+
"""Identifiant du provider (ex : 'openai', 'anthropic')."""
|
| 306 |
+
|
| 307 |
+
@property
|
| 308 |
+
@abstractmethod
|
| 309 |
+
def default_model(self) -> str:
|
| 310 |
+
"""Modèle utilisé si aucun n'est fourni explicitement."""
|
| 311 |
+
|
| 312 |
+
@abstractmethod
|
| 313 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 314 |
+
"""Appel LLM effectif.
|
| 315 |
+
|
| 316 |
+
Parameters
|
| 317 |
+
----------
|
| 318 |
+
prompt:
|
| 319 |
+
Texte du prompt final (variables déjà substituées).
|
| 320 |
+
image_b64:
|
| 321 |
+
Image encodée en base64 (sans préfixe data URI).
|
| 322 |
+
None pour les appels texte-uniquement.
|
| 323 |
+
|
| 324 |
+
Returns
|
| 325 |
+
-------
|
| 326 |
+
str
|
| 327 |
+
Texte généré par le LLM.
|
| 328 |
+
"""
|
| 329 |
+
|
| 330 |
+
def complete(
|
| 331 |
+
self,
|
| 332 |
+
prompt: str,
|
| 333 |
+
image_b64: Optional[str] = None,
|
| 334 |
+
) -> LLMResult:
|
| 335 |
+
"""Point d'entrée public : appelle le LLM avec retry automatique."""
|
| 336 |
+
max_retries = int(self.config.get("max_retries", _DEFAULT_MAX_RETRIES))
|
| 337 |
+
backoff_base = float(self.config.get("retry_backoff", _DEFAULT_BACKOFF_BASE))
|
| 338 |
+
|
| 339 |
+
start = time.perf_counter()
|
| 340 |
+
last_exc: Optional[Exception] = None
|
| 341 |
+
|
| 342 |
+
for attempt in range(max_retries + 1):
|
| 343 |
+
try:
|
| 344 |
+
text = self._call(prompt, image_b64)
|
| 345 |
+
duration = time.perf_counter() - start
|
| 346 |
+
return LLMResult(
|
| 347 |
+
model_id=self.model,
|
| 348 |
+
text=text,
|
| 349 |
+
duration_seconds=round(duration, 4),
|
| 350 |
+
)
|
| 351 |
+
except Exception as exc: # noqa: BLE001
|
| 352 |
+
last_exc = exc
|
| 353 |
+
if attempt < max_retries and _is_retryable(exc):
|
| 354 |
+
wait = backoff_base ** (attempt + 1)
|
| 355 |
+
logger.warning(
|
| 356 |
+
"[%s] erreur retryable (tentative %d/%d, attente %.1fs) : %s",
|
| 357 |
+
self.name, attempt + 1, max_retries + 1, wait, exc,
|
| 358 |
+
)
|
| 359 |
+
time.sleep(wait)
|
| 360 |
+
else:
|
| 361 |
+
break
|
| 362 |
+
|
| 363 |
+
duration = time.perf_counter() - start
|
| 364 |
+
return LLMResult(
|
| 365 |
+
model_id=self.model,
|
| 366 |
+
text="",
|
| 367 |
+
duration_seconds=round(duration, 4),
|
| 368 |
+
error=str(last_exc),
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
# ──────────────────────────────────────────────────────────────────
|
| 372 |
+
# Sprint A14-S44 — execute() pour le pipeline
|
| 373 |
+
# ──────────────────────────────────────────────────────────────────
|
| 374 |
+
|
| 375 |
+
def execute(
|
| 376 |
+
self,
|
| 377 |
+
inputs: dict,
|
| 378 |
+
params: dict,
|
| 379 |
+
context: Any,
|
| 380 |
+
) -> dict:
|
| 381 |
+
"""Exécute la post-correction LLM en tant que step de pipeline.
|
| 382 |
+
|
| 383 |
+
Convention par défaut : lit ``inputs[RAW_TEXT]`` (Artifact),
|
| 384 |
+
charge son contenu UTF-8 depuis l'URI, appelle ``self.complete``
|
| 385 |
+
avec le ``correction_prompt`` formaté, écrit le résultat dans
|
| 386 |
+
un fichier ``<input_stem>.<adapter_name>.corrected.txt``, et
|
| 387 |
+
retourne ``{CORRECTED_TEXT: Artifact}``.
|
| 388 |
+
|
| 389 |
+
Le caller (``PipelineExecutor``) catch les exceptions ; on les
|
| 390 |
+
propage telles quelles.
|
| 391 |
+
|
| 392 |
+
Optionnel : si ``inputs[IMAGE]`` est présent, l'image est
|
| 393 |
+
encodée en base64 et passée au LLM (mode VLM). Les sous-classes
|
| 394 |
+
qui ne supportent pas la vision (ex. ollama texte) ignorent
|
| 395 |
+
silencieusement.
|
| 396 |
+
"""
|
| 397 |
+
from pathlib import Path
|
| 398 |
+
import base64
|
| 399 |
+
|
| 400 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 401 |
+
|
| 402 |
+
if ArtifactType.RAW_TEXT not in inputs:
|
| 403 |
+
raise LLMAdapterError(
|
| 404 |
+
f"{self.name} : input RAW_TEXT manquant.",
|
| 405 |
+
)
|
| 406 |
+
text_artifact = inputs[ArtifactType.RAW_TEXT]
|
| 407 |
+
if text_artifact.uri is None:
|
| 408 |
+
raise LLMAdapterError(
|
| 409 |
+
f"{self.name} : artefact RAW_TEXT "
|
| 410 |
+
f"{text_artifact.id!r} sans URI.",
|
| 411 |
+
)
|
| 412 |
+
text_path = Path(text_artifact.uri)
|
| 413 |
+
if not text_path.exists():
|
| 414 |
+
raise LLMAdapterError(
|
| 415 |
+
f"{self.name} : fichier texte introuvable {text_path!r}.",
|
| 416 |
+
)
|
| 417 |
+
|
| 418 |
+
original_text = text_path.read_text(encoding="utf-8")
|
| 419 |
+
|
| 420 |
+
# Image optionnelle (VLM-style si supporté).
|
| 421 |
+
image_b64: Optional[str] = None
|
| 422 |
+
image_artifact = inputs.get(ArtifactType.IMAGE)
|
| 423 |
+
if image_artifact is not None and image_artifact.uri is not None:
|
| 424 |
+
image_path = Path(image_artifact.uri)
|
| 425 |
+
if image_path.exists():
|
| 426 |
+
image_b64 = base64.b64encode(
|
| 427 |
+
image_path.read_bytes(),
|
| 428 |
+
).decode("ascii")
|
| 429 |
+
|
| 430 |
+
# Priorité : override explicite via config > prompt par langue
|
| 431 |
+
# selon config["lang"] > FR par défaut.
|
| 432 |
+
custom_prompt = self.config.get("correction_prompt")
|
| 433 |
+
if custom_prompt is not None:
|
| 434 |
+
prompt_template = custom_prompt
|
| 435 |
+
else:
|
| 436 |
+
lang = (self.config.get("lang") or "fr").lower()
|
| 437 |
+
if lang not in self.DEFAULT_CORRECTION_PROMPTS:
|
| 438 |
+
logger.warning(
|
| 439 |
+
"[%s] lang=%r non supportée par "
|
| 440 |
+
"DEFAULT_CORRECTION_PROMPTS (%s) — fallback FR. "
|
| 441 |
+
"Pour un corpus dans cette langue, fournir "
|
| 442 |
+
"config['correction_prompt'] explicite.",
|
| 443 |
+
self.name, lang,
|
| 444 |
+
sorted(self.DEFAULT_CORRECTION_PROMPTS.keys()),
|
| 445 |
+
)
|
| 446 |
+
prompt_template = self.DEFAULT_CORRECTION_PROMPTS.get(
|
| 447 |
+
lang, self.DEFAULT_CORRECTION_PROMPTS["fr"],
|
| 448 |
+
)
|
| 449 |
+
prompt = prompt_template.format(text=original_text)
|
| 450 |
+
|
| 451 |
+
result = self.complete(prompt, image_b64=image_b64)
|
| 452 |
+
if not result.success:
|
| 453 |
+
raise LLMAdapterError(
|
| 454 |
+
f"{self.name} : LLM a échoué ({result.error}).",
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 458 |
+
out_path = resolve_output_path(
|
| 459 |
+
input_path=text_path,
|
| 460 |
+
adapter_name=self.name,
|
| 461 |
+
suffix="corrected.txt",
|
| 462 |
+
context=context,
|
| 463 |
+
)
|
| 464 |
+
out_path.write_text(result.text, encoding="utf-8")
|
| 465 |
+
|
| 466 |
+
return {
|
| 467 |
+
ArtifactType.CORRECTED_TEXT: Artifact(
|
| 468 |
+
id=f"{context.document_id}:{self.name}:corrected_text",
|
| 469 |
+
document_id=context.document_id,
|
| 470 |
+
type=ArtifactType.CORRECTED_TEXT,
|
| 471 |
+
produced_by_step="post_correction",
|
| 472 |
+
uri=str(out_path),
|
| 473 |
+
),
|
| 474 |
+
}
|
| 475 |
+
|
| 476 |
+
def __repr__(self) -> str:
|
| 477 |
+
return f"{self.__class__.__name__}(model={self.model!r})"
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
__all__ = [
|
| 481 |
+
"BaseLLMAdapter",
|
| 482 |
+
"LLMAdapterError",
|
| 483 |
+
"LLMResult",
|
| 484 |
+
"log_http_error",
|
| 485 |
+
"normalize_llm_content",
|
| 486 |
+
]
|
picarones/adapters/llm/mistral_adapter.py
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateur LLM — Mistral AI (Mistral Large, Pixtral)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from picarones.adapters.llm.base import (
|
| 10 |
+
BaseLLMAdapter,
|
| 11 |
+
log_http_error,
|
| 12 |
+
normalize_llm_content,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
# Modèles Mistral qui NE supportent PAS l'API chat/completions multimodale.
|
| 18 |
+
# Ces petits modèles sont text-only; le passer avec une image provoque une erreur.
|
| 19 |
+
_TEXT_ONLY_MODELS = frozenset({
|
| 20 |
+
"ministral-3b-latest",
|
| 21 |
+
"ministral-8b-latest",
|
| 22 |
+
"mistral-tiny",
|
| 23 |
+
"mistral-tiny-latest",
|
| 24 |
+
"open-mistral-7b",
|
| 25 |
+
"open-mixtral-8x7b",
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class MistralAdapter(BaseLLMAdapter):
|
| 30 |
+
"""Adaptateur pour les modèles Mistral AI.
|
| 31 |
+
|
| 32 |
+
Clé API via la variable d'environnement ``MISTRAL_API_KEY``.
|
| 33 |
+
|
| 34 |
+
Modes supportés : text_only (tous modèles), text_and_image et zero_shot
|
| 35 |
+
avec les modèles multimodaux (pixtral-12b, pixtral-large).
|
| 36 |
+
|
| 37 |
+
Note
|
| 38 |
+
----
|
| 39 |
+
Les modèles ``ministral-3b-latest`` et ``ministral-8b-latest`` ne supportent
|
| 40 |
+
pas le mode multimodal — utiliser ``PipelineMode.TEXT_ONLY`` avec ces modèles.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
api_key_env_var = "MISTRAL_API_KEY"
|
| 44 |
+
|
| 45 |
+
@property
|
| 46 |
+
def name(self) -> str:
|
| 47 |
+
return "mistral"
|
| 48 |
+
|
| 49 |
+
@property
|
| 50 |
+
def default_model(self) -> str:
|
| 51 |
+
return "mistral-large-latest"
|
| 52 |
+
|
| 53 |
+
def __init__(
|
| 54 |
+
self,
|
| 55 |
+
model: Optional[str] = None,
|
| 56 |
+
config: Optional[dict] = None,
|
| 57 |
+
) -> None:
|
| 58 |
+
super().__init__(model, config)
|
| 59 |
+
self._api_key = os.environ.get("MISTRAL_API_KEY")
|
| 60 |
+
if self.model in _TEXT_ONLY_MODELS:
|
| 61 |
+
logger.info(
|
| 62 |
+
"[MistralAdapter] modèle '%s' : text-only (pas de support multimodal).",
|
| 63 |
+
self.model,
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 67 |
+
if not self._api_key:
|
| 68 |
+
raise RuntimeError(
|
| 69 |
+
"Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY"
|
| 70 |
+
)
|
| 71 |
+
try:
|
| 72 |
+
try:
|
| 73 |
+
from mistralai.client import Mistral
|
| 74 |
+
except ImportError:
|
| 75 |
+
from mistralai import Mistral # type: ignore[no-redef]
|
| 76 |
+
except ImportError as exc:
|
| 77 |
+
raise RuntimeError(
|
| 78 |
+
"Le package 'mistralai' n'est pas installé. Lancez : pip install mistralai"
|
| 79 |
+
) from exc
|
| 80 |
+
|
| 81 |
+
client = Mistral(api_key=self._api_key)
|
| 82 |
+
temperature = float(self.config.get("temperature", 0.0))
|
| 83 |
+
max_tokens = int(self.config.get("max_tokens", 4096))
|
| 84 |
+
|
| 85 |
+
# Les modèles text-only ne supportent pas les images
|
| 86 |
+
if image_b64 and self.model in _TEXT_ONLY_MODELS:
|
| 87 |
+
logger.warning(
|
| 88 |
+
"[MistralAdapter] modèle '%s' ne supporte pas les images — "
|
| 89 |
+
"image ignorée, appel en mode texte seul.",
|
| 90 |
+
self.model,
|
| 91 |
+
)
|
| 92 |
+
image_b64 = None
|
| 93 |
+
|
| 94 |
+
if image_b64:
|
| 95 |
+
content: list | str = [
|
| 96 |
+
{"type": "text", "text": prompt},
|
| 97 |
+
{
|
| 98 |
+
"type": "image_url",
|
| 99 |
+
"image_url": f"data:image/png;base64,{image_b64}",
|
| 100 |
+
},
|
| 101 |
+
]
|
| 102 |
+
else:
|
| 103 |
+
content = prompt
|
| 104 |
+
|
| 105 |
+
logger.info(
|
| 106 |
+
"[MistralAdapter] appel %s — prompt=%d chars, image=%s",
|
| 107 |
+
self.model, len(prompt), "oui" if image_b64 else "non",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
response = client.chat.complete(
|
| 112 |
+
model=self.model,
|
| 113 |
+
messages=[{"role": "user", "content": content}],
|
| 114 |
+
temperature=temperature,
|
| 115 |
+
max_tokens=max_tokens,
|
| 116 |
+
)
|
| 117 |
+
except Exception as exc:
|
| 118 |
+
log_http_error(
|
| 119 |
+
"MistralAdapter", self.model, exc,
|
| 120 |
+
env_var=self.api_key_env_var,
|
| 121 |
+
)
|
| 122 |
+
raise
|
| 123 |
+
|
| 124 |
+
if not response.choices:
|
| 125 |
+
logger.warning(
|
| 126 |
+
"[MistralAdapter] response.choices vide (modèle=%s).",
|
| 127 |
+
self.model,
|
| 128 |
+
)
|
| 129 |
+
return ""
|
| 130 |
+
|
| 131 |
+
_choice = response.choices[0]
|
| 132 |
+
raw = _choice.message.content
|
| 133 |
+
_finish_reason = _choice.finish_reason
|
| 134 |
+
|
| 135 |
+
# Chantier 4 — normalisation factorisée dans
|
| 136 |
+
# ``picarones.llm.base.normalize_llm_content`` (Sprint 15
|
| 137 |
+
# généralisé : list[ContentChunk] / list[dict] / str → str).
|
| 138 |
+
text = normalize_llm_content(raw)
|
| 139 |
+
|
| 140 |
+
_completion_tokens = None
|
| 141 |
+
if hasattr(response, "usage") and response.usage:
|
| 142 |
+
_completion_tokens = getattr(response.usage, "completion_tokens", None)
|
| 143 |
+
|
| 144 |
+
logger.info(
|
| 145 |
+
"[MistralAdapter] réponse %s — finish_reason=%s, len=%d, tokens=%s",
|
| 146 |
+
self.model, _finish_reason, len(text), _completion_tokens,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
if not text.strip():
|
| 150 |
+
logger.warning(
|
| 151 |
+
"[MistralAdapter] réponse vide du modèle '%s' "
|
| 152 |
+
"(finish_reason=%s, completion_tokens=%s). "
|
| 153 |
+
"Vérifier le prompt et la compatibilité du modèle.",
|
| 154 |
+
self.model, _finish_reason, _completion_tokens,
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
return text
|
picarones/adapters/llm/ollama_adapter.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateur LLM — Ollama (modèles locaux : Llama 3, Gemma, Phi, Mistral local…)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
from typing import Optional
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
+
|
| 9 |
+
from picarones.adapters.llm.base import BaseLLMAdapter, normalize_llm_content
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OllamaAdapter(BaseLLMAdapter):
|
| 15 |
+
"""Adaptateur pour les modèles locaux via Ollama.
|
| 16 |
+
|
| 17 |
+
Aucune clé API requise. Nécessite un serveur Ollama actif (par défaut
|
| 18 |
+
sur http://localhost:11434).
|
| 19 |
+
|
| 20 |
+
Modes supportés :
|
| 21 |
+
- text_only : tous modèles Ollama
|
| 22 |
+
- text_and_image : modèles multimodaux (llava, bakllava, moondream…)
|
| 23 |
+
- zero_shot : modèles multimodaux uniquement
|
| 24 |
+
|
| 25 |
+
Configuration (via ``config``) :
|
| 26 |
+
- ``base_url`` : URL du serveur Ollama (défaut : http://localhost:11434)
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
@property
|
| 30 |
+
def name(self) -> str:
|
| 31 |
+
return "ollama"
|
| 32 |
+
|
| 33 |
+
@property
|
| 34 |
+
def default_model(self) -> str:
|
| 35 |
+
return "llama3"
|
| 36 |
+
|
| 37 |
+
def __init__(
|
| 38 |
+
self,
|
| 39 |
+
model: Optional[str] = None,
|
| 40 |
+
config: Optional[dict] = None,
|
| 41 |
+
) -> None:
|
| 42 |
+
super().__init__(model, config)
|
| 43 |
+
base_url = self.config.get("base_url", "http://localhost:11434").rstrip("/")
|
| 44 |
+
parsed = urlparse(base_url)
|
| 45 |
+
if parsed.scheme not in ("http", "https"):
|
| 46 |
+
raise ValueError(
|
| 47 |
+
f"URL Ollama invalide (schéma '{parsed.scheme}' non autorisé, "
|
| 48 |
+
f"seuls http/https sont acceptés) : {base_url}"
|
| 49 |
+
)
|
| 50 |
+
self._base_url = base_url
|
| 51 |
+
|
| 52 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 53 |
+
import json
|
| 54 |
+
import urllib.error
|
| 55 |
+
import urllib.request
|
| 56 |
+
|
| 57 |
+
temperature = float(self.config.get("temperature", 0.0))
|
| 58 |
+
payload: dict = {
|
| 59 |
+
"model": self.model,
|
| 60 |
+
"prompt": prompt,
|
| 61 |
+
"stream": False,
|
| 62 |
+
"options": {"temperature": temperature},
|
| 63 |
+
}
|
| 64 |
+
if image_b64:
|
| 65 |
+
payload["images"] = [image_b64]
|
| 66 |
+
|
| 67 |
+
data = json.dumps(payload).encode("utf-8")
|
| 68 |
+
req = urllib.request.Request(
|
| 69 |
+
f"{self._base_url}/api/generate",
|
| 70 |
+
data=data,
|
| 71 |
+
headers={"Content-Type": "application/json"},
|
| 72 |
+
)
|
| 73 |
+
try:
|
| 74 |
+
with urllib.request.urlopen(req, timeout=120) as resp:
|
| 75 |
+
raw = resp.read().decode("utf-8")
|
| 76 |
+
except urllib.error.HTTPError as exc:
|
| 77 |
+
logger.warning(
|
| 78 |
+
"[OllamaAdapter] erreur HTTP %d (modèle=%s) : %s",
|
| 79 |
+
exc.code, self.model, exc,
|
| 80 |
+
)
|
| 81 |
+
raise RuntimeError(
|
| 82 |
+
f"Erreur HTTP {exc.code} du serveur Ollama ({self._base_url}) : {exc}"
|
| 83 |
+
) from exc
|
| 84 |
+
except urllib.error.URLError as exc:
|
| 85 |
+
raise RuntimeError(
|
| 86 |
+
f"Impossible de joindre le serveur Ollama sur {self._base_url}. "
|
| 87 |
+
f"Vérifiez qu'Ollama est démarré (ollama serve). Erreur : {exc}"
|
| 88 |
+
) from exc
|
| 89 |
+
|
| 90 |
+
try:
|
| 91 |
+
result = json.loads(raw)
|
| 92 |
+
except json.JSONDecodeError as exc:
|
| 93 |
+
logger.warning(
|
| 94 |
+
"[OllamaAdapter] réponse JSON invalide (modèle=%s) : %s",
|
| 95 |
+
self.model, raw[:200],
|
| 96 |
+
)
|
| 97 |
+
raise RuntimeError(
|
| 98 |
+
f"Réponse JSON invalide du serveur Ollama : {exc}"
|
| 99 |
+
) from exc
|
| 100 |
+
|
| 101 |
+
# Chantier 4 — propagation du fix Sprint 15 : Ollama retourne
|
| 102 |
+
# ``response`` en string mais on normalise par défense (cas où
|
| 103 |
+
# un futur build retournerait un format structuré).
|
| 104 |
+
text = normalize_llm_content(result.get("response", ""))
|
| 105 |
+
if not text:
|
| 106 |
+
logger.warning(
|
| 107 |
+
"[OllamaAdapter] réponse vide (modèle=%s).", self.model,
|
| 108 |
+
)
|
| 109 |
+
return text
|
picarones/adapters/llm/openai_adapter.py
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateur LLM — OpenAI (GPT-4o, GPT-4o-mini)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import os
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from picarones.adapters.llm.base import (
|
| 10 |
+
BaseLLMAdapter,
|
| 11 |
+
log_http_error,
|
| 12 |
+
normalize_llm_content,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class OpenAIAdapter(BaseLLMAdapter):
|
| 19 |
+
"""Adaptateur pour les modèles OpenAI (GPT-4o, GPT-4o-mini).
|
| 20 |
+
|
| 21 |
+
Clé API via la variable d'environnement ``OPENAI_API_KEY``.
|
| 22 |
+
|
| 23 |
+
Modes supportés : text_only, text_and_image, zero_shot.
|
| 24 |
+
"""
|
| 25 |
+
|
| 26 |
+
api_key_env_var = "OPENAI_API_KEY"
|
| 27 |
+
|
| 28 |
+
@property
|
| 29 |
+
def name(self) -> str:
|
| 30 |
+
return "openai"
|
| 31 |
+
|
| 32 |
+
@property
|
| 33 |
+
def default_model(self) -> str:
|
| 34 |
+
return "gpt-4o"
|
| 35 |
+
|
| 36 |
+
def __init__(
|
| 37 |
+
self,
|
| 38 |
+
model: Optional[str] = None,
|
| 39 |
+
config: Optional[dict] = None,
|
| 40 |
+
) -> None:
|
| 41 |
+
super().__init__(model, config)
|
| 42 |
+
self._api_key = os.environ.get("OPENAI_API_KEY")
|
| 43 |
+
|
| 44 |
+
def _call(self, prompt: str, image_b64: Optional[str] = None) -> str:
|
| 45 |
+
if not self._api_key:
|
| 46 |
+
raise RuntimeError(
|
| 47 |
+
"Clé API OpenAI manquante — définissez la variable d'environnement OPENAI_API_KEY"
|
| 48 |
+
)
|
| 49 |
+
try:
|
| 50 |
+
from openai import OpenAI
|
| 51 |
+
except ImportError as exc:
|
| 52 |
+
raise RuntimeError(
|
| 53 |
+
"Le package 'openai' n'est pas installé. Lancez : pip install openai"
|
| 54 |
+
) from exc
|
| 55 |
+
|
| 56 |
+
client = OpenAI(api_key=self._api_key)
|
| 57 |
+
temperature = float(self.config.get("temperature", 0.0))
|
| 58 |
+
max_tokens = int(self.config.get("max_tokens", 4096))
|
| 59 |
+
|
| 60 |
+
if image_b64:
|
| 61 |
+
content = [
|
| 62 |
+
{"type": "text", "text": prompt},
|
| 63 |
+
{
|
| 64 |
+
"type": "image_url",
|
| 65 |
+
"image_url": {"url": f"data:image/png;base64,{image_b64}"},
|
| 66 |
+
},
|
| 67 |
+
]
|
| 68 |
+
else:
|
| 69 |
+
content = prompt # type: ignore[assignment]
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
response = client.chat.completions.create(
|
| 73 |
+
model=self.model,
|
| 74 |
+
messages=[{"role": "user", "content": content}],
|
| 75 |
+
temperature=temperature,
|
| 76 |
+
max_tokens=max_tokens,
|
| 77 |
+
)
|
| 78 |
+
except Exception as exc:
|
| 79 |
+
log_http_error(
|
| 80 |
+
"OpenAIAdapter", self.model, exc,
|
| 81 |
+
env_var=self.api_key_env_var,
|
| 82 |
+
)
|
| 83 |
+
raise
|
| 84 |
+
|
| 85 |
+
if not response.choices:
|
| 86 |
+
logger.warning(
|
| 87 |
+
"[OpenAIAdapter] response.choices vide (modèle=%s).", self.model,
|
| 88 |
+
)
|
| 89 |
+
return ""
|
| 90 |
+
# Chantier 4 — propagation du fix Sprint 15 : le SDK OpenAI
|
| 91 |
+
# peut retourner une ``list[ContentBlock]`` selon l'API
|
| 92 |
+
# (Responses, structured outputs). ``normalize_llm_content``
|
| 93 |
+
# gère les deux cas (str et list).
|
| 94 |
+
return normalize_llm_content(response.choices[0].message.content)
|
picarones/adapters/ocr/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters OCR du nouveau monde — Sprint A14-S26.
|
| 2 |
+
|
| 3 |
+
Contrat ``BaseOCRAdapter`` natif au rewrite : pas hérité du legacy
|
| 4 |
+
``picarones.engines.base.BaseOCREngine``, exprimé directement en
|
| 5 |
+
termes du nouveau ``ArtifactType`` et de l'interface
|
| 6 |
+
``execute(inputs, params, context)`` du ``PipelineExecutor``.
|
| 7 |
+
|
| 8 |
+
Implémentations livrées
|
| 9 |
+
-----------------------
|
| 10 |
+
- ``PrecomputedTextAdapter`` — lit un texte OCR pré-calculé depuis
|
| 11 |
+
le filesystem. Cas BnF : comparer N transcriptions déjà produites
|
| 12 |
+
par d'autres outils sans relancer d'OCR.
|
| 13 |
+
|
| 14 |
+
Adapters concrets pour Tesseract / Pero OCR / Mistral OCR / Google
|
| 15 |
+
Vision / Azure DI : à écrire au cas par cas dans des sprints
|
| 16 |
+
dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy
|
| 17 |
+
``picarones.engines``).
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from picarones.adapters.ocr.azure_doc_intel import AzureDocIntelAdapter
|
| 23 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 24 |
+
from picarones.adapters.ocr.google_vision import GoogleVisionAdapter
|
| 25 |
+
from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter
|
| 26 |
+
from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter
|
| 27 |
+
from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter
|
| 28 |
+
from picarones.adapters.ocr.tesseract import TesseractAdapter
|
| 29 |
+
|
| 30 |
+
__all__ = [
|
| 31 |
+
"BaseOCRAdapter",
|
| 32 |
+
"OCRAdapterError",
|
| 33 |
+
"AzureDocIntelAdapter",
|
| 34 |
+
"GoogleVisionAdapter",
|
| 35 |
+
"MistralOCRAdapter",
|
| 36 |
+
"PeroOCRAdapter",
|
| 37 |
+
"PrecomputedTextAdapter",
|
| 38 |
+
"TesseractAdapter",
|
| 39 |
+
]
|
picarones/adapters/ocr/azure_doc_intel.py
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AzureDocIntelAdapter`` natif — Sprint A14-S34.
|
| 2 |
+
|
| 3 |
+
Migration native du legacy ``picarones.engines.azure_doc_intel`` vers
|
| 4 |
+
``BaseOCRAdapter`` (S26). **Pas un shim**.
|
| 5 |
+
|
| 6 |
+
Le legacy reste en place jusqu'au S46.
|
| 7 |
+
|
| 8 |
+
Cas d'usage BnF
|
| 9 |
+
---------------
|
| 10 |
+
Azure Document Intelligence (anciennement Form Recognizer) propose
|
| 11 |
+
plusieurs modèles préentraînés :
|
| 12 |
+
|
| 13 |
+
- ``prebuilt-read`` (défaut) : lecture générique optimisée pour les
|
| 14 |
+
documents textuels denses.
|
| 15 |
+
- ``prebuilt-document`` : extraction layout + champs.
|
| 16 |
+
- ``prebuilt-layout`` : analyse de mise en page.
|
| 17 |
+
- modèles personnalisés entraînés.
|
| 18 |
+
|
| 19 |
+
L'API est asynchrone : on poste l'image et on poll un endpoint
|
| 20 |
+
status jusqu'à obtenir le résultat.
|
| 21 |
+
|
| 22 |
+
L'adapter route automatiquement vers SDK
|
| 23 |
+
(``azure-ai-documentintelligence``) si disponible, sinon REST
|
| 24 |
+
direct via ``urllib`` (avec polling).
|
| 25 |
+
|
| 26 |
+
Configuration
|
| 27 |
+
-------------
|
| 28 |
+
Constructeur :
|
| 29 |
+
|
| 30 |
+
- ``name`` (défaut ``"azure_doc_intel"``).
|
| 31 |
+
- ``endpoint`` : URL de l'endpoint (overrides
|
| 32 |
+
``AZURE_DOC_INTEL_ENDPOINT``).
|
| 33 |
+
- ``api_key`` : clé API (overrides ``AZURE_DOC_INTEL_KEY``).
|
| 34 |
+
- ``model_id`` (défaut ``"prebuilt-read"``).
|
| 35 |
+
- ``locale`` (défaut ``"fr-FR"``).
|
| 36 |
+
- ``api_version`` (défaut ``"2024-02-29-preview"``).
|
| 37 |
+
- ``timeout_seconds`` (défaut 60) : timeout par requête HTTP.
|
| 38 |
+
- ``max_polling_attempts`` (défaut 30) : nombre max de polls REST.
|
| 39 |
+
- ``polling_interval_base`` (défaut 1.0) : intervalle de base entre
|
| 40 |
+
polls (incrémenté de 0.5s par tentative — backoff linéaire
|
| 41 |
+
identique au legacy).
|
| 42 |
+
|
| 43 |
+
Comportement
|
| 44 |
+
------------
|
| 45 |
+
1. Valide IMAGE input.
|
| 46 |
+
2. Résout endpoint + api_key (explicite > env).
|
| 47 |
+
3. Tente le SDK ; sur ImportError, fallback REST.
|
| 48 |
+
4. Pour le REST : POST → Operation-Location → poll jusqu'à
|
| 49 |
+
``succeeded`` / ``failed`` / ``canceled``.
|
| 50 |
+
5. Extrait le texte ligne par ligne dans l'ordre pages × lines.
|
| 51 |
+
6. Écrit dans ``<stem>.<name>.txt`` à côté de l'image.
|
| 52 |
+
|
| 53 |
+
Anti-sur-ingénierie
|
| 54 |
+
-------------------
|
| 55 |
+
- Pas d'extraction de confidences (legacy S51 — reportée).
|
| 56 |
+
- Pas de support multi-langue dans une même requête.
|
| 57 |
+
- Pas de retry au-delà du polling (qui est un retry implicite).
|
| 58 |
+
"""
|
| 59 |
+
|
| 60 |
+
from __future__ import annotations
|
| 61 |
+
|
| 62 |
+
import json
|
| 63 |
+
import os
|
| 64 |
+
import time
|
| 65 |
+
import urllib.error
|
| 66 |
+
import urllib.request
|
| 67 |
+
from pathlib import Path
|
| 68 |
+
from typing import Any
|
| 69 |
+
|
| 70 |
+
from picarones.adapters._retry import call_with_retry
|
| 71 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 72 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 73 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
class AzureDocIntelAdapter(BaseOCRAdapter):
|
| 77 |
+
"""Adapter Azure Document Intelligence natif au contrat S26.
|
| 78 |
+
|
| 79 |
+
Parameters
|
| 80 |
+
----------
|
| 81 |
+
name:
|
| 82 |
+
Identifiant lisible. Défaut ``"azure_doc_intel"``.
|
| 83 |
+
endpoint:
|
| 84 |
+
URL Azure (override ``AZURE_DOC_INTEL_ENDPOINT``).
|
| 85 |
+
api_key:
|
| 86 |
+
Clé API Azure (override ``AZURE_DOC_INTEL_KEY``).
|
| 87 |
+
model_id:
|
| 88 |
+
``"prebuilt-read"`` (défaut), ``"prebuilt-document"``,
|
| 89 |
+
``"prebuilt-layout"``, ou un modèle entraîné personnalisé.
|
| 90 |
+
locale:
|
| 91 |
+
Locale Azure (défaut ``"fr-FR"``).
|
| 92 |
+
api_version:
|
| 93 |
+
Version d'API Azure (défaut ``"2024-02-29-preview"``).
|
| 94 |
+
timeout_seconds:
|
| 95 |
+
Timeout HTTP (défaut 60).
|
| 96 |
+
max_polling_attempts:
|
| 97 |
+
Nombre max de polls REST (défaut 30).
|
| 98 |
+
polling_interval_base:
|
| 99 |
+
Intervalle de base entre polls (défaut 1.0s, +0.5s/attempt).
|
| 100 |
+
|
| 101 |
+
Raises
|
| 102 |
+
------
|
| 103 |
+
OCRAdapterError
|
| 104 |
+
Au constructeur si name invalide ou paramètres hors plage.
|
| 105 |
+
"""
|
| 106 |
+
|
| 107 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 108 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 109 |
+
execution_mode = "io"
|
| 110 |
+
|
| 111 |
+
def __init__(
|
| 112 |
+
self,
|
| 113 |
+
*,
|
| 114 |
+
name: str = "azure_doc_intel",
|
| 115 |
+
endpoint: str | None = None,
|
| 116 |
+
api_key: str | None = None,
|
| 117 |
+
model_id: str = "prebuilt-read",
|
| 118 |
+
locale: str = "fr-FR",
|
| 119 |
+
api_version: str = "2024-02-29-preview",
|
| 120 |
+
timeout_seconds: float = 60.0,
|
| 121 |
+
max_polling_attempts: int = 30,
|
| 122 |
+
polling_interval_base: float = 1.0,
|
| 123 |
+
) -> None:
|
| 124 |
+
if not name or not name.strip():
|
| 125 |
+
raise OCRAdapterError(
|
| 126 |
+
"AzureDocIntelAdapter : name vide non autorisé.",
|
| 127 |
+
)
|
| 128 |
+
if not all(c.isalnum() or c in "_-" for c in name):
|
| 129 |
+
raise OCRAdapterError(
|
| 130 |
+
f"AzureDocIntelAdapter : name invalide {name!r} — "
|
| 131 |
+
"alphanumérique + _ - uniquement.",
|
| 132 |
+
)
|
| 133 |
+
if timeout_seconds <= 0:
|
| 134 |
+
raise OCRAdapterError(
|
| 135 |
+
f"AzureDocIntelAdapter : timeout_seconds doit être > 0, "
|
| 136 |
+
f"reçu {timeout_seconds}.",
|
| 137 |
+
)
|
| 138 |
+
if max_polling_attempts <= 0:
|
| 139 |
+
raise OCRAdapterError(
|
| 140 |
+
f"AzureDocIntelAdapter : max_polling_attempts doit être "
|
| 141 |
+
f"> 0, reçu {max_polling_attempts}.",
|
| 142 |
+
)
|
| 143 |
+
if polling_interval_base < 0:
|
| 144 |
+
raise OCRAdapterError(
|
| 145 |
+
f"AzureDocIntelAdapter : polling_interval_base doit être "
|
| 146 |
+
f">= 0, reçu {polling_interval_base}.",
|
| 147 |
+
)
|
| 148 |
+
self._name = name
|
| 149 |
+
self._explicit_endpoint = endpoint
|
| 150 |
+
self._explicit_api_key = api_key
|
| 151 |
+
self._model_id = model_id
|
| 152 |
+
self._locale = locale
|
| 153 |
+
self._api_version = api_version
|
| 154 |
+
self._timeout = timeout_seconds
|
| 155 |
+
self._max_polling_attempts = max_polling_attempts
|
| 156 |
+
self._polling_base = polling_interval_base
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def name(self) -> str:
|
| 160 |
+
return self._name
|
| 161 |
+
|
| 162 |
+
@property
|
| 163 |
+
def model_id(self) -> str:
|
| 164 |
+
return self._model_id
|
| 165 |
+
|
| 166 |
+
def _resolve_api_key(self) -> str:
|
| 167 |
+
key = self._explicit_api_key or os.environ.get("AZURE_DOC_INTEL_KEY")
|
| 168 |
+
if not key:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : clé API Azure manquante. Définir "
|
| 171 |
+
"AZURE_DOC_INTEL_KEY ou passer api_key= au constructeur.",
|
| 172 |
+
)
|
| 173 |
+
return key
|
| 174 |
+
|
| 175 |
+
def _resolve_endpoint(self) -> str:
|
| 176 |
+
endpoint = (
|
| 177 |
+
self._explicit_endpoint
|
| 178 |
+
or os.environ.get("AZURE_DOC_INTEL_ENDPOINT", "")
|
| 179 |
+
).rstrip("/")
|
| 180 |
+
if not endpoint:
|
| 181 |
+
raise OCRAdapterError(
|
| 182 |
+
f"{self.name} : endpoint Azure manquant. Définir "
|
| 183 |
+
"AZURE_DOC_INTEL_ENDPOINT ou passer endpoint= au "
|
| 184 |
+
"constructeur.",
|
| 185 |
+
)
|
| 186 |
+
return endpoint
|
| 187 |
+
|
| 188 |
+
def execute(
|
| 189 |
+
self,
|
| 190 |
+
inputs: dict[ArtifactType, Artifact],
|
| 191 |
+
params: dict[str, Any],
|
| 192 |
+
context: Any,
|
| 193 |
+
) -> dict[ArtifactType, Artifact]:
|
| 194 |
+
if ArtifactType.IMAGE not in inputs:
|
| 195 |
+
raise OCRAdapterError(
|
| 196 |
+
f"{self.name} : input IMAGE manquant.",
|
| 197 |
+
)
|
| 198 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 199 |
+
if image_artifact.uri is None:
|
| 200 |
+
raise OCRAdapterError(
|
| 201 |
+
f"{self.name} : artefact image "
|
| 202 |
+
f"{image_artifact.id!r} sans URI.",
|
| 203 |
+
)
|
| 204 |
+
image_path = Path(image_artifact.uri)
|
| 205 |
+
if not image_path.exists():
|
| 206 |
+
raise OCRAdapterError(
|
| 207 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 208 |
+
)
|
| 209 |
+
|
| 210 |
+
api_key = self._resolve_api_key()
|
| 211 |
+
endpoint = self._resolve_endpoint()
|
| 212 |
+
|
| 213 |
+
# On tente le SDK d'abord ; sur ImportError, fallback REST.
|
| 214 |
+
try:
|
| 215 |
+
text = self._call_via_sdk(image_path, endpoint, api_key)
|
| 216 |
+
except _SDKMissing:
|
| 217 |
+
text = self._call_via_rest(image_path, endpoint, api_key)
|
| 218 |
+
|
| 219 |
+
text_path = resolve_output_path(
|
| 220 |
+
input_path=image_path,
|
| 221 |
+
adapter_name=self.name,
|
| 222 |
+
suffix="txt",
|
| 223 |
+
context=context,
|
| 224 |
+
)
|
| 225 |
+
text_path.write_text(text, encoding="utf-8")
|
| 226 |
+
|
| 227 |
+
return {
|
| 228 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 229 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 230 |
+
document_id=context.document_id,
|
| 231 |
+
type=ArtifactType.RAW_TEXT,
|
| 232 |
+
produced_by_step="ocr",
|
| 233 |
+
uri=str(text_path),
|
| 234 |
+
),
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
# ──────────────────────────────────────────────────────────────
|
| 238 |
+
# SDK
|
| 239 |
+
# ──────────────────────────────────────────────────────────────
|
| 240 |
+
|
| 241 |
+
def _call_via_sdk(
|
| 242 |
+
self, image_path: Path, endpoint: str, api_key: str,
|
| 243 |
+
) -> str:
|
| 244 |
+
try:
|
| 245 |
+
from azure.ai.documentintelligence import (
|
| 246 |
+
DocumentIntelligenceClient,
|
| 247 |
+
)
|
| 248 |
+
from azure.core.credentials import AzureKeyCredential
|
| 249 |
+
except ImportError as exc:
|
| 250 |
+
raise _SDKMissing() from exc
|
| 251 |
+
|
| 252 |
+
try:
|
| 253 |
+
client = DocumentIntelligenceClient(
|
| 254 |
+
endpoint=endpoint,
|
| 255 |
+
credential=AzureKeyCredential(api_key),
|
| 256 |
+
)
|
| 257 |
+
with open(image_path, "rb") as f:
|
| 258 |
+
poller = client.begin_analyze_document(
|
| 259 |
+
model_id=self._model_id,
|
| 260 |
+
body=f,
|
| 261 |
+
locale=self._locale,
|
| 262 |
+
content_type="application/octet-stream",
|
| 263 |
+
)
|
| 264 |
+
result = poller.result()
|
| 265 |
+
text = "\n".join(
|
| 266 |
+
line.content
|
| 267 |
+
for page in result.pages
|
| 268 |
+
for line in (page.lines or [])
|
| 269 |
+
)
|
| 270 |
+
except _SDKMissing:
|
| 271 |
+
raise
|
| 272 |
+
except Exception as exc:
|
| 273 |
+
raise OCRAdapterError(
|
| 274 |
+
f"{self.name} : SDK Azure a levé : "
|
| 275 |
+
f"{type(exc).__name__}: {exc}",
|
| 276 |
+
) from exc
|
| 277 |
+
return text
|
| 278 |
+
|
| 279 |
+
# ──────────────────────────────────────────────────────────────
|
| 280 |
+
# REST avec polling
|
| 281 |
+
# ──────────────────────────────────────────────────────────────
|
| 282 |
+
|
| 283 |
+
def _call_via_rest(
|
| 284 |
+
self, image_path: Path, endpoint: str, api_key: str,
|
| 285 |
+
) -> str:
|
| 286 |
+
image_bytes = image_path.read_bytes()
|
| 287 |
+
analyze_url = (
|
| 288 |
+
f"{endpoint}/documentintelligence/documentModels/"
|
| 289 |
+
f"{self._model_id}:analyze"
|
| 290 |
+
f"?api-version={self._api_version}&locale={self._locale}"
|
| 291 |
+
)
|
| 292 |
+
req = urllib.request.Request(
|
| 293 |
+
analyze_url,
|
| 294 |
+
data=image_bytes,
|
| 295 |
+
headers={
|
| 296 |
+
"Ocp-Apim-Subscription-Key": api_key,
|
| 297 |
+
"Content-Type": "application/octet-stream",
|
| 298 |
+
},
|
| 299 |
+
)
|
| 300 |
+
def _do_post() -> str:
|
| 301 |
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
| 302 |
+
return resp.headers.get("Operation-Location", "")
|
| 303 |
+
|
| 304 |
+
try:
|
| 305 |
+
operation_url = call_with_retry(_do_post, label=self.name)
|
| 306 |
+
except urllib.error.HTTPError as exc:
|
| 307 |
+
body = ""
|
| 308 |
+
try:
|
| 309 |
+
body = exc.read().decode("utf-8")
|
| 310 |
+
except Exception: # noqa: BLE001
|
| 311 |
+
pass
|
| 312 |
+
raise OCRAdapterError(
|
| 313 |
+
f"{self.name} : Azure Document Intelligence erreur "
|
| 314 |
+
f"{exc.code} : {body}",
|
| 315 |
+
) from exc
|
| 316 |
+
except Exception as exc:
|
| 317 |
+
raise OCRAdapterError(
|
| 318 |
+
f"{self.name} : erreur API Azure : "
|
| 319 |
+
f"{type(exc).__name__}: {exc}",
|
| 320 |
+
) from exc
|
| 321 |
+
|
| 322 |
+
if not operation_url:
|
| 323 |
+
raise OCRAdapterError(
|
| 324 |
+
f"{self.name} : Azure n'a pas retourné Operation-Location.",
|
| 325 |
+
)
|
| 326 |
+
|
| 327 |
+
# Polling du résultat (Azure asynchrone).
|
| 328 |
+
headers = {"Ocp-Apim-Subscription-Key": api_key}
|
| 329 |
+
for attempt in range(self._max_polling_attempts):
|
| 330 |
+
time.sleep(self._polling_base + attempt * 0.5)
|
| 331 |
+
poll_req = urllib.request.Request(operation_url, headers=headers)
|
| 332 |
+
try:
|
| 333 |
+
with urllib.request.urlopen(
|
| 334 |
+
poll_req, timeout=self._timeout,
|
| 335 |
+
) as resp:
|
| 336 |
+
result = json.loads(resp.read().decode("utf-8"))
|
| 337 |
+
except Exception as exc:
|
| 338 |
+
raise OCRAdapterError(
|
| 339 |
+
f"{self.name} : erreur de polling Azure : "
|
| 340 |
+
f"{type(exc).__name__}: {exc}",
|
| 341 |
+
) from exc
|
| 342 |
+
status = result.get("status", "")
|
| 343 |
+
if status == "succeeded":
|
| 344 |
+
return self._extract_text_from_rest_result(result)
|
| 345 |
+
if status in {"failed", "canceled"}:
|
| 346 |
+
raise OCRAdapterError(
|
| 347 |
+
f"{self.name} : analyse Azure {status} : "
|
| 348 |
+
f"{result.get('error', {})}",
|
| 349 |
+
)
|
| 350 |
+
# running → continue
|
| 351 |
+
raise OCRAdapterError(
|
| 352 |
+
f"{self.name} : timeout polling Azure après "
|
| 353 |
+
f"{self._max_polling_attempts} tentatives.",
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
@staticmethod
|
| 357 |
+
def _extract_text_from_rest_result(result: dict) -> str:
|
| 358 |
+
pages = result.get("analyzeResult", {}).get("pages", [])
|
| 359 |
+
lines: list[str] = []
|
| 360 |
+
for page in pages:
|
| 361 |
+
for line in page.get("lines", []):
|
| 362 |
+
content = line.get("content", "")
|
| 363 |
+
if content:
|
| 364 |
+
lines.append(content)
|
| 365 |
+
return "\n".join(lines)
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
class _SDKMissing(Exception):
|
| 369 |
+
"""Sentinel interne pour signaler que le SDK Azure n'est pas
|
| 370 |
+
installé. Capturé par ``execute`` pour fallback REST.
|
| 371 |
+
|
| 372 |
+
Ne fuit jamais au caller — c'est un détail d'implémentation.
|
| 373 |
+
"""
|
| 374 |
+
|
| 375 |
+
|
| 376 |
+
__all__ = ["AzureDocIntelAdapter"]
|
picarones/adapters/ocr/base.py
ADDED
|
@@ -0,0 +1,173 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``BaseOCRAdapter`` — contrat natif du nouveau monde pour un adapter OCR.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S26 du rewrite ciblé.
|
| 4 |
+
|
| 5 |
+
Ce module définit le contrat **propre** auquel un adapter OCR du
|
| 6 |
+
nouveau monde doit se conformer pour être utilisable comme step
|
| 7 |
+
d'une pipeline ``picarones.pipeline``. Pas hérité du legacy
|
| 8 |
+
``picarones.engines.base.BaseOCREngine`` — c'est un nouveau contrat,
|
| 9 |
+
sans dette technique, exprimé en termes du nouveau ``ArtifactType``.
|
| 10 |
+
|
| 11 |
+
Contrat
|
| 12 |
+
-------
|
| 13 |
+
Un adapter OCR :
|
| 14 |
+
|
| 15 |
+
- Déclare ses ``input_types`` (typiquement
|
| 16 |
+
``frozenset({ArtifactType.IMAGE})``).
|
| 17 |
+
- Déclare ses ``output_types`` (typiquement
|
| 18 |
+
``frozenset({ArtifactType.RAW_TEXT})``, ou plus pour les moteurs
|
| 19 |
+
structurés).
|
| 20 |
+
- Déclare son ``execution_mode`` : ``"io"`` (défaut, ThreadPool) ou
|
| 21 |
+
``"cpu"`` (ProcessPool).
|
| 22 |
+
- Implémente
|
| 23 |
+
``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``.
|
| 24 |
+
|
| 25 |
+
Le ``Artifact`` retourné porte une ``uri`` filesystem — c'est la
|
| 26 |
+
convention du nouveau monde pour permettre au ``payload_loader`` de
|
| 27 |
+
le lire ultérieurement (Sprint S25 — la projection a un payload
|
| 28 |
+
direct, mais les artefacts produits par les adapters sont stockés
|
| 29 |
+
sur disque pour traçabilité et streaming).
|
| 30 |
+
|
| 31 |
+
Différences avec le legacy
|
| 32 |
+
--------------------------
|
| 33 |
+
- ``ArtifactType.RAW_TEXT`` (10 valeurs) au lieu de
|
| 34 |
+
``ArtifactType.TEXT`` (6 valeurs legacy).
|
| 35 |
+
- Pas de ``run(image_path)`` historique — un seul point d'entrée
|
| 36 |
+
``execute()``.
|
| 37 |
+
- Pas de wrapper ``EngineResult`` — les erreurs lèvent directement,
|
| 38 |
+
le ``PipelineExecutor`` les capture en step en échec.
|
| 39 |
+
- Pas de ``_run_ocr`` / ``_run_with_native`` / ``_extract_raw_confidences``
|
| 40 |
+
— les confidences (S42 legacy) sont reportées à un sprint dédié
|
| 41 |
+
où l'on définira un ``ConfidenceArtifact`` typé.
|
| 42 |
+
|
| 43 |
+
Anti-sur-ingénierie
|
| 44 |
+
-------------------
|
| 45 |
+
- Pas de hiérarchie d'erreurs. Un adapter qui échoue lève
|
| 46 |
+
``OCRAdapterError`` (ou laisse passer une exception). Le
|
| 47 |
+
``PipelineExecutor`` (S7) catch et marque le step en échec.
|
| 48 |
+
- Pas de cache au niveau de l'ABC. Si un adapter veut cacher ses
|
| 49 |
+
résultats, c'est dans son implémentation (compose ``ArtifactStore``
|
| 50 |
+
S7 si besoin).
|
| 51 |
+
- Pas de retry. Idem.
|
| 52 |
+
"""
|
| 53 |
+
|
| 54 |
+
from __future__ import annotations
|
| 55 |
+
|
| 56 |
+
from abc import ABC, abstractmethod
|
| 57 |
+
from typing import Any
|
| 58 |
+
|
| 59 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 60 |
+
from picarones.domain.errors import AdapterStepError
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class OCRAdapterError(AdapterStepError):
|
| 64 |
+
"""Erreur typée pour un échec d'adapter OCR du nouveau monde.
|
| 65 |
+
|
| 66 |
+
Hérite de ``AdapterStepError`` (racine commune avec LLM et VLM)
|
| 67 |
+
qui hérite de ``PicaronesError``. Un caller peut catcher
|
| 68 |
+
``AdapterStepError`` pour toute erreur d'adapter sans connaître
|
| 69 |
+
la sous-classe.
|
| 70 |
+
|
| 71 |
+
Le ``PipelineExecutor`` capture cette exception (et toute autre)
|
| 72 |
+
et marque le step correspondant comme failed avec
|
| 73 |
+
``StepResult.error`` renseigné. Les callers downstream
|
| 74 |
+
(``BenchmarkService``, vues) verront le pipeline en échec sans
|
| 75 |
+
crash global.
|
| 76 |
+
"""
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class BaseOCRAdapter(ABC):
|
| 80 |
+
"""Classe de base pour un adapter OCR du nouveau monde.
|
| 81 |
+
|
| 82 |
+
Toute sous-classe doit :
|
| 83 |
+
|
| 84 |
+
1. Surcharger la propriété ``name`` (identifiant lisible, utilisé
|
| 85 |
+
dans les ``Artifact.id`` et le run_manifest).
|
| 86 |
+
2. Implémenter ``execute(inputs, params, context)``.
|
| 87 |
+
|
| 88 |
+
Les attributs de classe ``input_types`` / ``output_types`` /
|
| 89 |
+
``execution_mode`` sont fournis par défaut pour le cas le plus
|
| 90 |
+
courant (image → texte, IO-bound). Une sous-classe qui produit
|
| 91 |
+
de l'ALTO surcharge ``output_types``, etc.
|
| 92 |
+
|
| 93 |
+
Exemple
|
| 94 |
+
-------
|
| 95 |
+
|
| 96 |
+
::
|
| 97 |
+
|
| 98 |
+
class MyOCRAdapter(BaseOCRAdapter):
|
| 99 |
+
@property
|
| 100 |
+
def name(self) -> str:
|
| 101 |
+
return "my_ocr"
|
| 102 |
+
|
| 103 |
+
def execute(self, inputs, params, context):
|
| 104 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 105 |
+
# ... appel OCR sur image_artifact.uri ...
|
| 106 |
+
# ... écriture du résultat sur disque ...
|
| 107 |
+
return {
|
| 108 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 109 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 110 |
+
document_id=context.document_id,
|
| 111 |
+
type=ArtifactType.RAW_TEXT,
|
| 112 |
+
produced_by_step="ocr",
|
| 113 |
+
uri=str(out_path),
|
| 114 |
+
),
|
| 115 |
+
}
|
| 116 |
+
"""
|
| 117 |
+
|
| 118 |
+
#: Types d'artefacts attendus en entrée. Le ``PipelineExecutor``
|
| 119 |
+
#: utilise cette info pour valider la compatibilité des steps
|
| 120 |
+
#: enchaînés.
|
| 121 |
+
input_types: frozenset[ArtifactType] = frozenset({ArtifactType.IMAGE})
|
| 122 |
+
|
| 123 |
+
#: Types d'artefacts produits. Validés à la sortie de ``execute``.
|
| 124 |
+
output_types: frozenset[ArtifactType] = frozenset({ArtifactType.RAW_TEXT})
|
| 125 |
+
|
| 126 |
+
#: ``"io"`` (ThreadPool) ou ``"cpu"`` (ProcessPool). Indique au
|
| 127 |
+
#: runner quel type de pool utiliser pour la concurrence.
|
| 128 |
+
execution_mode: str = "io"
|
| 129 |
+
|
| 130 |
+
@property
|
| 131 |
+
@abstractmethod
|
| 132 |
+
def name(self) -> str:
|
| 133 |
+
"""Identifiant lisible de l'adapter (ex : ``"tesseract"``,
|
| 134 |
+
``"precomputed_text"``). Utilisé dans les ``Artifact.id`` du
|
| 135 |
+
nouveau monde et dans le ``run_manifest``."""
|
| 136 |
+
|
| 137 |
+
@abstractmethod
|
| 138 |
+
def execute(
|
| 139 |
+
self,
|
| 140 |
+
inputs: dict[ArtifactType, Artifact],
|
| 141 |
+
params: dict[str, Any],
|
| 142 |
+
context: Any,
|
| 143 |
+
) -> dict[ArtifactType, Artifact]:
|
| 144 |
+
"""Exécute l'OCR sur les entrées et retourne les artefacts produits.
|
| 145 |
+
|
| 146 |
+
Parameters
|
| 147 |
+
----------
|
| 148 |
+
inputs:
|
| 149 |
+
Map ``ArtifactType → Artifact`` avec au minimum les types
|
| 150 |
+
déclarés dans ``self.input_types``. L'adapter peut
|
| 151 |
+
ignorer les entrées surnuméraires.
|
| 152 |
+
params:
|
| 153 |
+
Paramètres dynamiques du step (typiquement vides — la
|
| 154 |
+
configuration de l'adapter passe par son constructeur).
|
| 155 |
+
context:
|
| 156 |
+
``RunContext`` du run en cours (porte ``document_id``,
|
| 157 |
+
``code_version``, ``pipeline_name``).
|
| 158 |
+
|
| 159 |
+
Returns
|
| 160 |
+
-------
|
| 161 |
+
dict[ArtifactType, Artifact]
|
| 162 |
+
Map des artefacts produits. Doit contenir au moins les
|
| 163 |
+
types déclarés dans ``self.output_types``.
|
| 164 |
+
|
| 165 |
+
Raises
|
| 166 |
+
------
|
| 167 |
+
OCRAdapterError
|
| 168 |
+
Erreur typée pour signaler un échec côté adapter (input
|
| 169 |
+
invalide, fichier introuvable, etc.).
|
| 170 |
+
"""
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
__all__ = ["BaseOCRAdapter", "OCRAdapterError"]
|
picarones/adapters/ocr/confidences.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sidecar de confidences OCR.
|
| 2 |
+
|
| 3 |
+
Les confidences au niveau token sont exposées comme un **artefact
|
| 4 |
+
dédié** ``ArtifactType.CONFIDENCES`` (sidecar JSON à côté du fichier
|
| 5 |
+
texte), pas stuffé dans le résultat texte de l'adapter. Ce
|
| 6 |
+
découplage permet aux vues de calibration (ECE/MCE, reliability
|
| 7 |
+
diagram) de consommer les confidences indépendamment de la
|
| 8 |
+
production du texte, et n'oblige pas un adapter qui n'a pas de
|
| 9 |
+
confidences à porter un champ vide.
|
| 10 |
+
|
| 11 |
+
Format JSON canonique
|
| 12 |
+
---------------------
|
| 13 |
+
|
| 14 |
+
::
|
| 15 |
+
|
| 16 |
+
{
|
| 17 |
+
"tokens": [
|
| 18 |
+
{"text": "Bonjour", "confidence": 0.95},
|
| 19 |
+
{"text": "le", "confidence": 0.99},
|
| 20 |
+
...
|
| 21 |
+
],
|
| 22 |
+
"extractor": "tesseract",
|
| 23 |
+
"model_version": "5.3.0" // optionnel
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
- ``confidence`` ∈ [0, 1] (les adapters convertissent eux-mêmes
|
| 27 |
+
depuis leur format natif — Tesseract retourne 0-100, on divise
|
| 28 |
+
par 100).
|
| 29 |
+
- Tokens vides ou conf négatives ignorés à la source (cf.
|
| 30 |
+
``filter_valid_tokens``).
|
| 31 |
+
|
| 32 |
+
API publique
|
| 33 |
+
------------
|
| 34 |
+
- ``filter_valid_tokens(raw)`` : nettoie une liste de dicts brutes.
|
| 35 |
+
- ``write_confidences_sidecar(text_path, name, tokens, ...)`` :
|
| 36 |
+
écrit ``<stem>.<name>.confidences.json`` à côté du fichier texte.
|
| 37 |
+
- ``ConfidenceToken`` (TypedDict léger) : forme attendue du dict.
|
| 38 |
+
|
| 39 |
+
Anti-sur-ingénierie
|
| 40 |
+
-------------------
|
| 41 |
+
- Pas de pydantic — TypedDict + json suffisent ; le caller normalise.
|
| 42 |
+
- Pas de schéma JSON publié — la stabilité sera tagguée à la livraison.
|
| 43 |
+
- Pas de support pour les confidences niveau ligne / paragraphe :
|
| 44 |
+
on aplatit tout au niveau mot (cohérent avec le legacy Sprint 47).
|
| 45 |
+
"""
|
| 46 |
+
|
| 47 |
+
from __future__ import annotations
|
| 48 |
+
|
| 49 |
+
import json
|
| 50 |
+
import os
|
| 51 |
+
import tempfile
|
| 52 |
+
from pathlib import Path
|
| 53 |
+
from typing import Any, TypedDict
|
| 54 |
+
|
| 55 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class ConfidenceToken(TypedDict):
|
| 59 |
+
"""Forme canonique d'un token de confidence."""
|
| 60 |
+
|
| 61 |
+
text: str
|
| 62 |
+
confidence: float
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def filter_valid_tokens(
|
| 66 |
+
raw: list[dict[str, Any]],
|
| 67 |
+
) -> list[ConfidenceToken]:
|
| 68 |
+
"""Nettoie une liste brute de tokens (ignore les non-mots).
|
| 69 |
+
|
| 70 |
+
Filtre :
|
| 71 |
+
|
| 72 |
+
- ``text`` vide ou whitespace-only ;
|
| 73 |
+
- ``confidence`` ``None`` ou négative (Tesseract met -1 pour les
|
| 74 |
+
non-mots) ;
|
| 75 |
+
- ``confidence`` > 1.0 → divisé par 100 si ≤ 100, sinon ignoré.
|
| 76 |
+
|
| 77 |
+
Retourne une nouvelle liste, ne modifie pas l'input.
|
| 78 |
+
"""
|
| 79 |
+
out: list[ConfidenceToken] = []
|
| 80 |
+
for entry in raw:
|
| 81 |
+
text = str(entry.get("text", "") or "").strip()
|
| 82 |
+
if not text:
|
| 83 |
+
continue
|
| 84 |
+
conf = entry.get("confidence")
|
| 85 |
+
if conf is None:
|
| 86 |
+
continue
|
| 87 |
+
try:
|
| 88 |
+
conf_f = float(conf)
|
| 89 |
+
except (TypeError, ValueError):
|
| 90 |
+
continue
|
| 91 |
+
if conf_f < 0:
|
| 92 |
+
continue
|
| 93 |
+
if conf_f > 1.0:
|
| 94 |
+
# Tesseract retourne 0-100 ; on normalise.
|
| 95 |
+
if conf_f <= 100.0:
|
| 96 |
+
conf_f = conf_f / 100.0
|
| 97 |
+
else:
|
| 98 |
+
# > 100 = donnée corrompue, on ignore.
|
| 99 |
+
continue
|
| 100 |
+
out.append({"text": text, "confidence": conf_f})
|
| 101 |
+
return out
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def write_confidences_sidecar(
|
| 105 |
+
text_path: Path,
|
| 106 |
+
adapter_name: str,
|
| 107 |
+
tokens: list[ConfidenceToken],
|
| 108 |
+
*,
|
| 109 |
+
document_id: str,
|
| 110 |
+
extractor: str | None = None,
|
| 111 |
+
model_version: str | None = None,
|
| 112 |
+
) -> Artifact:
|
| 113 |
+
"""Écrit un sidecar JSON ``<stem>.<adapter_name>.confidences.json``
|
| 114 |
+
à côté du fichier texte produit par l'OCR.
|
| 115 |
+
|
| 116 |
+
Returns
|
| 117 |
+
-------
|
| 118 |
+
Artifact
|
| 119 |
+
Artifact ``CONFIDENCES`` avec ``uri`` pointant vers le sidecar.
|
| 120 |
+
"""
|
| 121 |
+
sidecar_path = (
|
| 122 |
+
text_path.parent
|
| 123 |
+
/ f"{text_path.stem}.{adapter_name}.confidences.json"
|
| 124 |
+
)
|
| 125 |
+
payload = {
|
| 126 |
+
"tokens": tokens,
|
| 127 |
+
"extractor": extractor or adapter_name,
|
| 128 |
+
"model_version": model_version,
|
| 129 |
+
}
|
| 130 |
+
# Écriture atomique : un crash mi-write ne doit pas laisser un
|
| 131 |
+
# sidecar tronqué (qui ferait planter le parser à la lecture).
|
| 132 |
+
# ``tempfile`` dans le même répertoire pour garantir que
|
| 133 |
+
# ``os.replace`` reste atomique (rename inter-volume échouerait).
|
| 134 |
+
encoded = json.dumps(payload, ensure_ascii=False, indent=2)
|
| 135 |
+
fd, tmp_name = tempfile.mkstemp(
|
| 136 |
+
prefix=f".{sidecar_path.name}.",
|
| 137 |
+
suffix=".tmp",
|
| 138 |
+
dir=str(sidecar_path.parent),
|
| 139 |
+
)
|
| 140 |
+
try:
|
| 141 |
+
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
| 142 |
+
fh.write(encoded)
|
| 143 |
+
os.replace(tmp_name, sidecar_path)
|
| 144 |
+
except Exception:
|
| 145 |
+
# Best-effort cleanup du tmp si le replace n'a pas eu lieu.
|
| 146 |
+
try:
|
| 147 |
+
os.unlink(tmp_name)
|
| 148 |
+
except OSError:
|
| 149 |
+
pass
|
| 150 |
+
raise
|
| 151 |
+
return Artifact(
|
| 152 |
+
id=f"{document_id}:{adapter_name}:confidences",
|
| 153 |
+
document_id=document_id,
|
| 154 |
+
type=ArtifactType.CONFIDENCES,
|
| 155 |
+
produced_by_step="ocr",
|
| 156 |
+
uri=str(sidecar_path),
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
__all__ = [
|
| 161 |
+
"ConfidenceToken",
|
| 162 |
+
"filter_valid_tokens",
|
| 163 |
+
"write_confidences_sidecar",
|
| 164 |
+
]
|
picarones/adapters/ocr/google_vision.py
ADDED
|
@@ -0,0 +1,306 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``GoogleVisionAdapter`` natif — Sprint A14-S33.
|
| 2 |
+
|
| 3 |
+
Migration native du legacy ``picarones.engines.google_vision.GoogleVisionEngine``
|
| 4 |
+
vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim**.
|
| 5 |
+
|
| 6 |
+
Le legacy reste en place jusqu'au S46.
|
| 7 |
+
|
| 8 |
+
Cas d'usage BnF
|
| 9 |
+
---------------
|
| 10 |
+
Google Cloud Vision propose deux modes d'OCR :
|
| 11 |
+
|
| 12 |
+
- ``DOCUMENT_TEXT_DETECTION`` (défaut) : optimisé pour les textes
|
| 13 |
+
denses et multilinguistiques — retourne une ``fullTextAnnotation``
|
| 14 |
+
hiérarchique (pages → blocks → paragraphs → words → symbols) avec
|
| 15 |
+
un texte plat ``text``.
|
| 16 |
+
- ``TEXT_DETECTION`` : mode court, retourne uniquement les
|
| 17 |
+
``textAnnotations[0].description``.
|
| 18 |
+
|
| 19 |
+
L'adapter route automatiquement vers SDK (auth service account) ou
|
| 20 |
+
REST direct (auth clé API) selon la configuration disponible.
|
| 21 |
+
|
| 22 |
+
Configuration
|
| 23 |
+
-------------
|
| 24 |
+
Constructeur :
|
| 25 |
+
|
| 26 |
+
- ``name`` (défaut ``"google_vision"``).
|
| 27 |
+
- ``language_hints`` (défaut ``["fr"]``) : suggestions Vision API.
|
| 28 |
+
- ``feature_type`` (défaut ``"DOCUMENT_TEXT_DETECTION"``).
|
| 29 |
+
- ``api_key`` : clé API Google. Si ``None``, lit ``GOOGLE_API_KEY``.
|
| 30 |
+
- ``credentials_path`` : chemin vers un service account JSON. Si
|
| 31 |
+
``None``, lit ``GOOGLE_APPLICATION_CREDENTIALS``.
|
| 32 |
+
- ``timeout_seconds`` (défaut 60).
|
| 33 |
+
|
| 34 |
+
Au moins une des deux authentifications (SDK ou REST) doit être
|
| 35 |
+
disponible.
|
| 36 |
+
|
| 37 |
+
Anti-sur-ingénierie
|
| 38 |
+
-------------------
|
| 39 |
+
- Pas d'extraction de confidences (legacy S50 — reportée).
|
| 40 |
+
- Pas de pré-validation du JSON service account — le SDK le fait.
|
| 41 |
+
- Pas de support batch — un appel par image.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
from __future__ import annotations
|
| 45 |
+
|
| 46 |
+
import base64
|
| 47 |
+
import json
|
| 48 |
+
import os
|
| 49 |
+
import urllib.error
|
| 50 |
+
import urllib.request
|
| 51 |
+
from pathlib import Path
|
| 52 |
+
from typing import Any
|
| 53 |
+
|
| 54 |
+
from picarones.adapters._retry import call_with_retry
|
| 55 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 56 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 57 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
_VALID_FEATURE_TYPES = frozenset({"DOCUMENT_TEXT_DETECTION", "TEXT_DETECTION"})
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class GoogleVisionAdapter(BaseOCRAdapter):
|
| 64 |
+
"""Adapter Google Cloud Vision natif au contrat S26.
|
| 65 |
+
|
| 66 |
+
Parameters
|
| 67 |
+
----------
|
| 68 |
+
name:
|
| 69 |
+
Identifiant lisible. Défaut ``"google_vision"``.
|
| 70 |
+
language_hints:
|
| 71 |
+
Suggestions Vision API. Défaut ``["fr"]``.
|
| 72 |
+
feature_type:
|
| 73 |
+
``"DOCUMENT_TEXT_DETECTION"`` (défaut) ou ``"TEXT_DETECTION"``.
|
| 74 |
+
api_key:
|
| 75 |
+
Clé API explicite. Si ``None``, lit ``GOOGLE_API_KEY``.
|
| 76 |
+
credentials_path:
|
| 77 |
+
Chemin service account JSON explicite. Si ``None``, lit
|
| 78 |
+
``GOOGLE_APPLICATION_CREDENTIALS``.
|
| 79 |
+
timeout_seconds:
|
| 80 |
+
Timeout HTTP (REST). Défaut 60.
|
| 81 |
+
|
| 82 |
+
Raises
|
| 83 |
+
------
|
| 84 |
+
OCRAdapterError
|
| 85 |
+
Au constructeur si name ou feature_type invalides.
|
| 86 |
+
"""
|
| 87 |
+
|
| 88 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 89 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 90 |
+
execution_mode = "io"
|
| 91 |
+
|
| 92 |
+
def __init__(
|
| 93 |
+
self,
|
| 94 |
+
*,
|
| 95 |
+
name: str = "google_vision",
|
| 96 |
+
language_hints: list[str] | None = None,
|
| 97 |
+
feature_type: str = "DOCUMENT_TEXT_DETECTION",
|
| 98 |
+
api_key: str | None = None,
|
| 99 |
+
credentials_path: str | None = None,
|
| 100 |
+
timeout_seconds: float = 60.0,
|
| 101 |
+
) -> None:
|
| 102 |
+
if not name or not name.strip():
|
| 103 |
+
raise OCRAdapterError(
|
| 104 |
+
"GoogleVisionAdapter : name vide non autorisé.",
|
| 105 |
+
)
|
| 106 |
+
if not all(c.isalnum() or c in "_-" for c in name):
|
| 107 |
+
raise OCRAdapterError(
|
| 108 |
+
f"GoogleVisionAdapter : name invalide {name!r} — "
|
| 109 |
+
"alphanumérique + _ - uniquement.",
|
| 110 |
+
)
|
| 111 |
+
if feature_type not in _VALID_FEATURE_TYPES:
|
| 112 |
+
raise OCRAdapterError(
|
| 113 |
+
f"GoogleVisionAdapter : feature_type invalide "
|
| 114 |
+
f"{feature_type!r}. Valeurs valides : "
|
| 115 |
+
f"{sorted(_VALID_FEATURE_TYPES)}.",
|
| 116 |
+
)
|
| 117 |
+
if timeout_seconds <= 0:
|
| 118 |
+
raise OCRAdapterError(
|
| 119 |
+
f"GoogleVisionAdapter : timeout_seconds doit être > 0, "
|
| 120 |
+
f"reçu {timeout_seconds}.",
|
| 121 |
+
)
|
| 122 |
+
self._name = name
|
| 123 |
+
self._language_hints = list(language_hints or ["fr"])
|
| 124 |
+
self._feature_type = feature_type
|
| 125 |
+
self._explicit_api_key = api_key
|
| 126 |
+
self._explicit_credentials = credentials_path
|
| 127 |
+
self._timeout = timeout_seconds
|
| 128 |
+
|
| 129 |
+
@property
|
| 130 |
+
def name(self) -> str:
|
| 131 |
+
return self._name
|
| 132 |
+
|
| 133 |
+
@property
|
| 134 |
+
def feature_type(self) -> str:
|
| 135 |
+
return self._feature_type
|
| 136 |
+
|
| 137 |
+
def _resolve_credentials_path(self) -> str | None:
|
| 138 |
+
return self._explicit_credentials or os.environ.get(
|
| 139 |
+
"GOOGLE_APPLICATION_CREDENTIALS",
|
| 140 |
+
)
|
| 141 |
+
|
| 142 |
+
def _resolve_api_key(self) -> str | None:
|
| 143 |
+
return self._explicit_api_key or os.environ.get("GOOGLE_API_KEY")
|
| 144 |
+
|
| 145 |
+
def execute(
|
| 146 |
+
self,
|
| 147 |
+
inputs: dict[ArtifactType, Artifact],
|
| 148 |
+
params: dict[str, Any],
|
| 149 |
+
context: Any,
|
| 150 |
+
) -> dict[ArtifactType, Artifact]:
|
| 151 |
+
"""Exécute Google Vision OCR sur l'image fournie.
|
| 152 |
+
|
| 153 |
+
Routing :
|
| 154 |
+
|
| 155 |
+
- Si un service account JSON est disponible
|
| 156 |
+
(``credentials_path`` ou ``GOOGLE_APPLICATION_CREDENTIALS``)
|
| 157 |
+
→ passe par le SDK ``google-cloud-vision``.
|
| 158 |
+
- Sinon, si une clé API simple est disponible
|
| 159 |
+
(``api_key`` ou ``GOOGLE_API_KEY``) → passe par REST direct
|
| 160 |
+
via ``urllib``.
|
| 161 |
+
- Sinon → ``OCRAdapterError``.
|
| 162 |
+
"""
|
| 163 |
+
if ArtifactType.IMAGE not in inputs:
|
| 164 |
+
raise OCRAdapterError(
|
| 165 |
+
f"{self.name} : input IMAGE manquant.",
|
| 166 |
+
)
|
| 167 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 168 |
+
if image_artifact.uri is None:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : artefact image "
|
| 171 |
+
f"{image_artifact.id!r} sans URI.",
|
| 172 |
+
)
|
| 173 |
+
image_path = Path(image_artifact.uri)
|
| 174 |
+
if not image_path.exists():
|
| 175 |
+
raise OCRAdapterError(
|
| 176 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
creds = self._resolve_credentials_path()
|
| 180 |
+
api_key = self._resolve_api_key()
|
| 181 |
+
|
| 182 |
+
if creds:
|
| 183 |
+
text = self._call_via_sdk(image_path)
|
| 184 |
+
elif api_key:
|
| 185 |
+
text = self._call_via_rest(image_path, api_key)
|
| 186 |
+
else:
|
| 187 |
+
raise OCRAdapterError(
|
| 188 |
+
f"{self.name} : authentification manquante. Définir "
|
| 189 |
+
"GOOGLE_APPLICATION_CREDENTIALS (service account JSON) "
|
| 190 |
+
"ou GOOGLE_API_KEY.",
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
text_path = resolve_output_path(
|
| 194 |
+
input_path=image_path,
|
| 195 |
+
adapter_name=self.name,
|
| 196 |
+
suffix="txt",
|
| 197 |
+
context=context,
|
| 198 |
+
)
|
| 199 |
+
text_path.write_text(text, encoding="utf-8")
|
| 200 |
+
|
| 201 |
+
return {
|
| 202 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 203 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 204 |
+
document_id=context.document_id,
|
| 205 |
+
type=ArtifactType.RAW_TEXT,
|
| 206 |
+
produced_by_step="ocr",
|
| 207 |
+
uri=str(text_path),
|
| 208 |
+
),
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
# ──────────────────────────────────────────────────────────────
|
| 212 |
+
# SDK / REST
|
| 213 |
+
# ──────────────────────────────────────────────────────────────
|
| 214 |
+
|
| 215 |
+
def _call_via_sdk(self, image_path: Path) -> str:
|
| 216 |
+
try:
|
| 217 |
+
from google.cloud import vision
|
| 218 |
+
except ImportError as exc:
|
| 219 |
+
raise OCRAdapterError(
|
| 220 |
+
f"{self.name} : SDK google-cloud-vision non installé. "
|
| 221 |
+
"Installer avec : pip install google-cloud-vision",
|
| 222 |
+
) from exc
|
| 223 |
+
|
| 224 |
+
try:
|
| 225 |
+
client = vision.ImageAnnotatorClient()
|
| 226 |
+
image = vision.Image(content=image_path.read_bytes())
|
| 227 |
+
ctx = vision.ImageContext(language_hints=self._language_hints)
|
| 228 |
+
|
| 229 |
+
if self._feature_type == "DOCUMENT_TEXT_DETECTION":
|
| 230 |
+
response = client.document_text_detection(
|
| 231 |
+
image=image, image_context=ctx,
|
| 232 |
+
)
|
| 233 |
+
text = response.full_text_annotation.text
|
| 234 |
+
else:
|
| 235 |
+
response = client.text_detection(
|
| 236 |
+
image=image, image_context=ctx,
|
| 237 |
+
)
|
| 238 |
+
texts = response.text_annotations
|
| 239 |
+
text = texts[0].description if texts else ""
|
| 240 |
+
except Exception as exc:
|
| 241 |
+
raise OCRAdapterError(
|
| 242 |
+
f"{self.name} : SDK Google Vision a levé : "
|
| 243 |
+
f"{type(exc).__name__}: {exc}",
|
| 244 |
+
) from exc
|
| 245 |
+
return text
|
| 246 |
+
|
| 247 |
+
def _call_via_rest(self, image_path: Path, api_key: str) -> str:
|
| 248 |
+
image_b64 = base64.b64encode(
|
| 249 |
+
image_path.read_bytes(),
|
| 250 |
+
).decode("ascii")
|
| 251 |
+
payload = json.dumps({
|
| 252 |
+
"requests": [{
|
| 253 |
+
"image": {"content": image_b64},
|
| 254 |
+
"features": [
|
| 255 |
+
{"type": self._feature_type, "maxResults": 1},
|
| 256 |
+
],
|
| 257 |
+
"imageContext": {"languageHints": self._language_hints},
|
| 258 |
+
}],
|
| 259 |
+
}).encode("utf-8")
|
| 260 |
+
req = urllib.request.Request(
|
| 261 |
+
"https://vision.googleapis.com/v1/images:annotate",
|
| 262 |
+
data=payload,
|
| 263 |
+
headers={
|
| 264 |
+
"Content-Type": "application/json",
|
| 265 |
+
"X-Goog-Api-Key": api_key,
|
| 266 |
+
},
|
| 267 |
+
)
|
| 268 |
+
def _do_call() -> dict:
|
| 269 |
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
| 270 |
+
return json.loads(resp.read().decode("utf-8"))
|
| 271 |
+
|
| 272 |
+
try:
|
| 273 |
+
result = call_with_retry(_do_call, label=self.name)
|
| 274 |
+
except urllib.error.HTTPError as exc:
|
| 275 |
+
body = ""
|
| 276 |
+
try:
|
| 277 |
+
body = exc.read().decode("utf-8")
|
| 278 |
+
except Exception: # noqa: BLE001
|
| 279 |
+
pass
|
| 280 |
+
raise OCRAdapterError(
|
| 281 |
+
f"{self.name} : Google Vision API erreur {exc.code} : {body}",
|
| 282 |
+
) from exc
|
| 283 |
+
except Exception as exc:
|
| 284 |
+
raise OCRAdapterError(
|
| 285 |
+
f"{self.name} : erreur API Google Vision : "
|
| 286 |
+
f"{type(exc).__name__}: {exc}",
|
| 287 |
+
) from exc
|
| 288 |
+
|
| 289 |
+
responses = result.get("responses", [{}])
|
| 290 |
+
if not responses:
|
| 291 |
+
return ""
|
| 292 |
+
r = responses[0]
|
| 293 |
+
if "error" in r:
|
| 294 |
+
raise OCRAdapterError(
|
| 295 |
+
f"{self.name} : Google Vision API erreur : {r['error']}",
|
| 296 |
+
)
|
| 297 |
+
|
| 298 |
+
if self._feature_type == "DOCUMENT_TEXT_DETECTION":
|
| 299 |
+
full = r.get("fullTextAnnotation") or {}
|
| 300 |
+
return full.get("text", "") if isinstance(full, dict) else ""
|
| 301 |
+
# TEXT_DETECTION
|
| 302 |
+
texts = r.get("textAnnotations", [])
|
| 303 |
+
return texts[0]["description"] if texts else ""
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
__all__ = ["GoogleVisionAdapter"]
|
picarones/adapters/ocr/mistral_ocr.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralOCRAdapter`` natif — Sprint A14-S32.
|
| 2 |
+
|
| 3 |
+
Migration native du legacy ``picarones.engines.mistral_ocr.MistralOCREngine``
|
| 4 |
+
vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe
|
| 5 |
+
implémente directement le contrat du nouveau monde.
|
| 6 |
+
|
| 7 |
+
Le legacy ``MistralOCREngine`` reste en place jusqu'au S46.
|
| 8 |
+
|
| 9 |
+
Cas d'usage BnF
|
| 10 |
+
---------------
|
| 11 |
+
Mistral AI fournit deux familles d'OCR :
|
| 12 |
+
|
| 13 |
+
- **API dédiée ``/v1/ocr``** pour les modèles ``mistral-ocr-*`` —
|
| 14 |
+
endpoint optimisé qui renvoie des pages structurées en markdown
|
| 15 |
+
(et parfois des confidences mot par mot).
|
| 16 |
+
- **API vision/chat** pour les modèles ``pixtral-*`` —
|
| 17 |
+
reconnaissance via prompt textuel + image base64.
|
| 18 |
+
|
| 19 |
+
L'adapter route automatiquement selon le nom du modèle.
|
| 20 |
+
|
| 21 |
+
Configuration
|
| 22 |
+
-------------
|
| 23 |
+
Constructeur :
|
| 24 |
+
|
| 25 |
+
- ``name`` (défaut ``"mistral_ocr"``) : identifiant de l'instance.
|
| 26 |
+
- ``model`` (défaut ``"mistral-ocr-latest"``) : modèle Mistral.
|
| 27 |
+
- ``mistral-ocr-*`` → endpoint dédié ;
|
| 28 |
+
- ``pixtral-*`` → API vision/chat.
|
| 29 |
+
- ``prompt`` : texte du prompt pour les modèles vision. Défaut :
|
| 30 |
+
instruction générique de transcription.
|
| 31 |
+
- ``max_tokens`` (défaut 4096) : limite tokens en sortie pour les
|
| 32 |
+
modèles vision.
|
| 33 |
+
- ``api_key`` : clé API Mistral. Si ``None`` (défaut), lit la
|
| 34 |
+
variable d'environnement ``MISTRAL_API_KEY``.
|
| 35 |
+
- ``timeout_seconds`` (défaut 60) : timeout HTTP pour ``urllib``.
|
| 36 |
+
|
| 37 |
+
Comportement
|
| 38 |
+
------------
|
| 39 |
+
1. Vérifie présence d'un ``Artifact`` ``IMAGE`` avec URI valide.
|
| 40 |
+
2. Encode l'image en base64 + détecte ``image/...`` MIME selon
|
| 41 |
+
l'extension.
|
| 42 |
+
3. Route vers ``/v1/ocr`` ou chat/vision selon ``model``.
|
| 43 |
+
4. Concatène le markdown / texte de toutes les pages.
|
| 44 |
+
5. Écrit dans ``<stem>.<name>.txt`` à côté de l'image.
|
| 45 |
+
6. Retourne un ``Artifact`` ``RAW_TEXT``.
|
| 46 |
+
|
| 47 |
+
Anti-sur-ingénierie
|
| 48 |
+
-------------------
|
| 49 |
+
- Pas de retry / backoff (le caller wrappe si besoin).
|
| 50 |
+
- Pas d'extraction de confidences (legacy S49 — reportées au
|
| 51 |
+
sprint ``ConfidenceArtifact``).
|
| 52 |
+
- Pas de support multi-page (l'image est traitée comme une seule
|
| 53 |
+
page d'entrée — Mistral OCR retourne une liste de pages dont on
|
| 54 |
+
concatène les markdowns).
|
| 55 |
+
"""
|
| 56 |
+
|
| 57 |
+
from __future__ import annotations
|
| 58 |
+
|
| 59 |
+
import base64
|
| 60 |
+
import json
|
| 61 |
+
import os
|
| 62 |
+
import urllib.request
|
| 63 |
+
from pathlib import Path
|
| 64 |
+
from typing import Any
|
| 65 |
+
|
| 66 |
+
from picarones.adapters._retry import call_with_retry
|
| 67 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 68 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 69 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
_DEFAULT_PROMPT = (
|
| 73 |
+
"Transcris fidèlement le texte visible sur cette image de document "
|
| 74 |
+
"historique. Retourne uniquement le texte, sans commentaire."
|
| 75 |
+
)
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
_MEDIA_TYPES: dict[str, str] = {
|
| 79 |
+
".jpg": "image/jpeg",
|
| 80 |
+
".jpeg": "image/jpeg",
|
| 81 |
+
".png": "image/png",
|
| 82 |
+
".tif": "image/tiff",
|
| 83 |
+
".tiff": "image/tiff",
|
| 84 |
+
".webp": "image/webp",
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class MistralOCRAdapter(BaseOCRAdapter):
|
| 89 |
+
"""Adapter Mistral OCR natif au contrat S26.
|
| 90 |
+
|
| 91 |
+
Parameters
|
| 92 |
+
----------
|
| 93 |
+
name:
|
| 94 |
+
Identifiant lisible. Défaut ``"mistral_ocr"``.
|
| 95 |
+
model:
|
| 96 |
+
Modèle Mistral. ``mistral-ocr-*`` → API dédiée ``/v1/ocr``,
|
| 97 |
+
``pixtral-*`` → API vision/chat. Défaut ``"mistral-ocr-latest"``.
|
| 98 |
+
prompt:
|
| 99 |
+
Prompt pour les modèles vision.
|
| 100 |
+
max_tokens:
|
| 101 |
+
Limite tokens en sortie pour les modèles vision. Défaut 4096.
|
| 102 |
+
api_key:
|
| 103 |
+
Clé API Mistral. Si ``None`` (défaut), lit
|
| 104 |
+
``MISTRAL_API_KEY``.
|
| 105 |
+
timeout_seconds:
|
| 106 |
+
Timeout HTTP pour les appels ``urllib``. Défaut 60.
|
| 107 |
+
|
| 108 |
+
Raises
|
| 109 |
+
------
|
| 110 |
+
OCRAdapterError
|
| 111 |
+
Si ``name`` est invalide au constructeur.
|
| 112 |
+
"""
|
| 113 |
+
|
| 114 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 115 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 116 |
+
execution_mode = "io"
|
| 117 |
+
|
| 118 |
+
def __init__(
|
| 119 |
+
self,
|
| 120 |
+
*,
|
| 121 |
+
name: str = "mistral_ocr",
|
| 122 |
+
model: str = "mistral-ocr-latest",
|
| 123 |
+
prompt: str = _DEFAULT_PROMPT,
|
| 124 |
+
max_tokens: int = 4096,
|
| 125 |
+
api_key: str | None = None,
|
| 126 |
+
timeout_seconds: float = 60.0,
|
| 127 |
+
) -> None:
|
| 128 |
+
if not name or not name.strip():
|
| 129 |
+
raise OCRAdapterError(
|
| 130 |
+
"MistralOCRAdapter : name vide non autorisé.",
|
| 131 |
+
)
|
| 132 |
+
if not all(c.isalnum() or c in "_-" for c in name):
|
| 133 |
+
raise OCRAdapterError(
|
| 134 |
+
f"MistralOCRAdapter : name invalide {name!r} — "
|
| 135 |
+
"alphanumérique + _ - uniquement.",
|
| 136 |
+
)
|
| 137 |
+
if max_tokens <= 0:
|
| 138 |
+
raise OCRAdapterError(
|
| 139 |
+
f"MistralOCRAdapter : max_tokens doit être > 0, "
|
| 140 |
+
f"reçu {max_tokens}.",
|
| 141 |
+
)
|
| 142 |
+
if timeout_seconds <= 0:
|
| 143 |
+
raise OCRAdapterError(
|
| 144 |
+
f"MistralOCRAdapter : timeout_seconds doit être > 0, "
|
| 145 |
+
f"reçu {timeout_seconds}.",
|
| 146 |
+
)
|
| 147 |
+
self._name = name
|
| 148 |
+
self._model = model
|
| 149 |
+
self._prompt = prompt
|
| 150 |
+
self._max_tokens = max_tokens
|
| 151 |
+
self._explicit_api_key = api_key
|
| 152 |
+
self._timeout = timeout_seconds
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def name(self) -> str:
|
| 156 |
+
return self._name
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def model(self) -> str:
|
| 160 |
+
return self._model
|
| 161 |
+
|
| 162 |
+
def _resolve_api_key(self) -> str:
|
| 163 |
+
"""Résout la clé API : explicite > env var.
|
| 164 |
+
|
| 165 |
+
Lève ``OCRAdapterError`` si aucune clé n'est disponible.
|
| 166 |
+
"""
|
| 167 |
+
key = self._explicit_api_key or os.environ.get("MISTRAL_API_KEY")
|
| 168 |
+
if not key:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : clé API Mistral manquante. "
|
| 171 |
+
"Définir MISTRAL_API_KEY ou passer api_key= au "
|
| 172 |
+
"constructeur.",
|
| 173 |
+
)
|
| 174 |
+
return key
|
| 175 |
+
|
| 176 |
+
def _encode_image(self, image_path: Path) -> str:
|
| 177 |
+
"""Retourne ``data:<mime>;base64,<...>`` pour l'image."""
|
| 178 |
+
suffix = image_path.suffix.lower()
|
| 179 |
+
media_type = _MEDIA_TYPES.get(suffix, "image/jpeg")
|
| 180 |
+
image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii")
|
| 181 |
+
return f"data:{media_type};base64,{image_b64}"
|
| 182 |
+
|
| 183 |
+
def execute(
|
| 184 |
+
self,
|
| 185 |
+
inputs: dict[ArtifactType, Artifact],
|
| 186 |
+
params: dict[str, Any],
|
| 187 |
+
context: Any,
|
| 188 |
+
) -> dict[ArtifactType, Artifact]:
|
| 189 |
+
"""Exécute Mistral OCR sur l'image fournie.
|
| 190 |
+
|
| 191 |
+
Route vers l'API appropriée selon ``self.model`` :
|
| 192 |
+
- ``mistral-ocr-*`` → ``/v1/ocr`` via ``urllib`` ;
|
| 193 |
+
- ``pixtral-*`` → API chat/vision via SDK ``mistralai``.
|
| 194 |
+
|
| 195 |
+
Raises
|
| 196 |
+
------
|
| 197 |
+
OCRAdapterError
|
| 198 |
+
Erreur d'input, clé manquante, SDK absent (pour pixtral),
|
| 199 |
+
ou API Mistral en erreur.
|
| 200 |
+
"""
|
| 201 |
+
if ArtifactType.IMAGE not in inputs:
|
| 202 |
+
raise OCRAdapterError(
|
| 203 |
+
f"{self.name} : input IMAGE manquant.",
|
| 204 |
+
)
|
| 205 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 206 |
+
if image_artifact.uri is None:
|
| 207 |
+
raise OCRAdapterError(
|
| 208 |
+
f"{self.name} : artefact image "
|
| 209 |
+
f"{image_artifact.id!r} sans URI.",
|
| 210 |
+
)
|
| 211 |
+
image_path = Path(image_artifact.uri)
|
| 212 |
+
if not image_path.exists():
|
| 213 |
+
raise OCRAdapterError(
|
| 214 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 215 |
+
)
|
| 216 |
+
|
| 217 |
+
api_key = self._resolve_api_key()
|
| 218 |
+
image_url = self._encode_image(image_path)
|
| 219 |
+
|
| 220 |
+
# Le préfixe ``mistral-ocr-*`` est documenté par Mistral pour
|
| 221 |
+
# l'API dédiée ``/v1/ocr``. Tout autre nom (``pixtral-*``,
|
| 222 |
+
# etc.) bascule sur l'API chat/vision. Match strict par
|
| 223 |
+
# préfixe pour éviter qu'un modèle exotique nommé
|
| 224 |
+
# ``pixtral-MISTRAL-OCR-fancy`` ne soit confondu.
|
| 225 |
+
if self._model.lower().startswith("mistral-ocr"):
|
| 226 |
+
text = self._call_native_ocr_api(image_url, api_key)
|
| 227 |
+
else:
|
| 228 |
+
text = self._call_chat_vision_api(image_url, api_key)
|
| 229 |
+
|
| 230 |
+
text_path = resolve_output_path(
|
| 231 |
+
input_path=image_path,
|
| 232 |
+
adapter_name=self.name,
|
| 233 |
+
suffix="txt",
|
| 234 |
+
context=context,
|
| 235 |
+
)
|
| 236 |
+
text_path.write_text(text, encoding="utf-8")
|
| 237 |
+
|
| 238 |
+
return {
|
| 239 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 240 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 241 |
+
document_id=context.document_id,
|
| 242 |
+
type=ArtifactType.RAW_TEXT,
|
| 243 |
+
produced_by_step="ocr",
|
| 244 |
+
uri=str(text_path),
|
| 245 |
+
),
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
# ──────────────────────────────────────────────────────────────
|
| 249 |
+
# API natives
|
| 250 |
+
# ──────────────────────────────────────────────────────────────
|
| 251 |
+
|
| 252 |
+
def _call_native_ocr_api(self, image_url: str, api_key: str) -> str:
|
| 253 |
+
"""Appelle ``POST /v1/ocr`` via urllib et retourne le markdown
|
| 254 |
+
concaténé."""
|
| 255 |
+
payload = json.dumps({
|
| 256 |
+
"model": self._model,
|
| 257 |
+
"document": {"type": "image_url", "image_url": image_url},
|
| 258 |
+
}).encode("utf-8")
|
| 259 |
+
req = urllib.request.Request(
|
| 260 |
+
"https://api.mistral.ai/v1/ocr",
|
| 261 |
+
data=payload,
|
| 262 |
+
headers={
|
| 263 |
+
"Authorization": f"Bearer {api_key}",
|
| 264 |
+
"Content-Type": "application/json",
|
| 265 |
+
},
|
| 266 |
+
method="POST",
|
| 267 |
+
)
|
| 268 |
+
def _do_call() -> dict:
|
| 269 |
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
| 270 |
+
return json.loads(resp.read().decode())
|
| 271 |
+
|
| 272 |
+
try:
|
| 273 |
+
data = call_with_retry(_do_call, label=self.name)
|
| 274 |
+
except Exception as exc:
|
| 275 |
+
raise OCRAdapterError(
|
| 276 |
+
f"{self.name} : erreur API Mistral /v1/ocr : "
|
| 277 |
+
f"{type(exc).__name__}: {exc}",
|
| 278 |
+
) from exc
|
| 279 |
+
pages = data.get("pages", [])
|
| 280 |
+
text = "\n\n".join(p.get("markdown", "") for p in pages).strip()
|
| 281 |
+
return text
|
| 282 |
+
|
| 283 |
+
def _call_chat_vision_api(self, image_url: str, api_key: str) -> str:
|
| 284 |
+
"""Appelle l'API chat/vision Mistral via le SDK ``mistralai``."""
|
| 285 |
+
try:
|
| 286 |
+
try:
|
| 287 |
+
from mistralai.client import Mistral
|
| 288 |
+
except ImportError:
|
| 289 |
+
from mistralai import Mistral # type: ignore[no-redef]
|
| 290 |
+
except ImportError as exc:
|
| 291 |
+
raise OCRAdapterError(
|
| 292 |
+
f"{self.name} : SDK 'mistralai' non installé. "
|
| 293 |
+
"Installer avec : pip install mistralai",
|
| 294 |
+
) from exc
|
| 295 |
+
|
| 296 |
+
client = Mistral(api_key=api_key)
|
| 297 |
+
|
| 298 |
+
def _do_chat() -> Any:
|
| 299 |
+
return client.chat.complete(
|
| 300 |
+
model=self._model,
|
| 301 |
+
messages=[
|
| 302 |
+
{
|
| 303 |
+
"role": "user",
|
| 304 |
+
"content": [
|
| 305 |
+
{"type": "text", "text": self._prompt},
|
| 306 |
+
{"type": "image_url", "image_url": image_url},
|
| 307 |
+
],
|
| 308 |
+
},
|
| 309 |
+
],
|
| 310 |
+
max_tokens=self._max_tokens,
|
| 311 |
+
)
|
| 312 |
+
|
| 313 |
+
try:
|
| 314 |
+
response = call_with_retry(_do_chat, label=self.name)
|
| 315 |
+
except Exception as exc:
|
| 316 |
+
raise OCRAdapterError(
|
| 317 |
+
f"{self.name} : erreur API Mistral chat : "
|
| 318 |
+
f"{type(exc).__name__}: {exc}",
|
| 319 |
+
) from exc
|
| 320 |
+
|
| 321 |
+
# Mistral peut retourner ``content`` sous forme de
|
| 322 |
+
# ``list[ContentChunk]`` au lieu de ``str``. Le helper
|
| 323 |
+
# ``normalize_llm_content`` gère les deux formats.
|
| 324 |
+
from picarones.adapters.llm.base import normalize_llm_content
|
| 325 |
+
|
| 326 |
+
try:
|
| 327 |
+
raw_content = response.choices[0].message.content
|
| 328 |
+
except (AttributeError, IndexError) as exc:
|
| 329 |
+
raise OCRAdapterError(
|
| 330 |
+
f"{self.name} : réponse Mistral chat malformée : {exc}",
|
| 331 |
+
) from exc
|
| 332 |
+
|
| 333 |
+
return normalize_llm_content(raw_content) or ""
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
__all__ = ["MistralOCRAdapter"]
|
picarones/adapters/ocr/pero_ocr.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PeroOCRAdapter`` natif — Sprint A14-S31.
|
| 2 |
+
|
| 3 |
+
Migration native du legacy ``picarones.engines.pero_ocr.PeroOCREngine``
|
| 4 |
+
vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe
|
| 5 |
+
implémente directement le contrat du nouveau monde, sans héritage du
|
| 6 |
+
legacy.
|
| 7 |
+
|
| 8 |
+
Le legacy ``PeroOCREngine`` reste en place pour les callers qui
|
| 9 |
+
n'ont pas encore migré ; sa suppression viendra au S46 quand la
|
| 10 |
+
parité sera atteinte sur tous les adapters.
|
| 11 |
+
|
| 12 |
+
Cas d'usage BnF
|
| 13 |
+
---------------
|
| 14 |
+
Pero OCR (Brno) est un moteur HTR open-source spécialisé pour les
|
| 15 |
+
documents historiques manuscrits. Il produit une sortie structurée
|
| 16 |
+
PAGE XML — l'adapter natif extrait le texte plat dans l'ordre de
|
| 17 |
+
lecture naturel. Adapter CPU-bound (PyTorch sur CPU + traitement
|
| 18 |
+
d'image) → ``execution_mode="cpu"`` pour ProcessPool.
|
| 19 |
+
|
| 20 |
+
Configuration
|
| 21 |
+
-------------
|
| 22 |
+
Constructeur :
|
| 23 |
+
|
| 24 |
+
- ``name`` (défaut ``"pero_ocr"``) : identifiant de l'instance.
|
| 25 |
+
- ``config_path`` : chemin obligatoire vers un fichier ``.ini`` de
|
| 26 |
+
configuration Pero OCR (modèles, paramètres). Sans ça, Pero OCR
|
| 27 |
+
ne peut pas être instancié.
|
| 28 |
+
|
| 29 |
+
Comportement
|
| 30 |
+
------------
|
| 31 |
+
1. Vérifie la présence d'un ``Artifact`` ``IMAGE`` avec URI valide.
|
| 32 |
+
2. Lazy-import de ``pero_ocr`` + ``PIL`` + ``numpy`` — message
|
| 33 |
+
explicite si absent.
|
| 34 |
+
3. Lazy-init du ``PageParser`` (une seule fois par instance).
|
| 35 |
+
4. Charge l'image en numpy array RGB, instancie un ``PageLayout``,
|
| 36 |
+
appelle ``parser.process_page(image, page_layout)``.
|
| 37 |
+
5. Extrait le texte plat (``\n`` entre lignes, dans l'ordre des
|
| 38 |
+
regions × lines).
|
| 39 |
+
6. Écrit le texte dans ``<stem>.<name>.txt`` à côté de l'image.
|
| 40 |
+
7. Retourne un ``Artifact`` ``RAW_TEXT``.
|
| 41 |
+
|
| 42 |
+
Anti-sur-ingénierie
|
| 43 |
+
-------------------
|
| 44 |
+
- Pas de support GPU explicite (Pero OCR le gère via la config).
|
| 45 |
+
- Pas de retry, pas d'extraction de confidences (legacy S48 —
|
| 46 |
+
reportées au sprint ``ConfidenceArtifact``).
|
| 47 |
+
- ``_parser`` lazy-init — si l'instance est sérialisée pour
|
| 48 |
+
ProcessPool, le parser est re-instancié dans le worker (cohérent
|
| 49 |
+
avec Pero OCR qui charge ses modèles à l'instanciation).
|
| 50 |
+
"""
|
| 51 |
+
|
| 52 |
+
from __future__ import annotations
|
| 53 |
+
|
| 54 |
+
from pathlib import Path
|
| 55 |
+
from typing import Any
|
| 56 |
+
|
| 57 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 58 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 59 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
class PeroOCRAdapter(BaseOCRAdapter):
|
| 63 |
+
"""Adapter Pero OCR natif au nouveau contrat (S26).
|
| 64 |
+
|
| 65 |
+
Parameters
|
| 66 |
+
----------
|
| 67 |
+
name:
|
| 68 |
+
Identifiant lisible. Défaut ``"pero_ocr"``. Alphanum + ``_-``.
|
| 69 |
+
config_path:
|
| 70 |
+
Chemin vers le fichier ``.ini`` de configuration Pero OCR.
|
| 71 |
+
Obligatoire — sans configuration, Pero OCR ne peut pas être
|
| 72 |
+
instancié.
|
| 73 |
+
|
| 74 |
+
Raises
|
| 75 |
+
------
|
| 76 |
+
OCRAdapterError
|
| 77 |
+
Si ``name`` ou ``config_path`` sont invalides au constructeur.
|
| 78 |
+
"""
|
| 79 |
+
|
| 80 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 81 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 82 |
+
execution_mode = "cpu"
|
| 83 |
+
|
| 84 |
+
def __init__(
|
| 85 |
+
self,
|
| 86 |
+
*,
|
| 87 |
+
config_path: str | Path,
|
| 88 |
+
name: str = "pero_ocr",
|
| 89 |
+
) -> None:
|
| 90 |
+
if not name or not name.strip():
|
| 91 |
+
raise OCRAdapterError(
|
| 92 |
+
"PeroOCRAdapter : name vide non autorisé.",
|
| 93 |
+
)
|
| 94 |
+
if not all(c.isalnum() or c in "_-" for c in name):
|
| 95 |
+
raise OCRAdapterError(
|
| 96 |
+
f"PeroOCRAdapter : name invalide {name!r} — "
|
| 97 |
+
"alphanumérique + _ - uniquement.",
|
| 98 |
+
)
|
| 99 |
+
if not config_path:
|
| 100 |
+
raise OCRAdapterError(
|
| 101 |
+
"PeroOCRAdapter : config_path est requis (chemin .ini).",
|
| 102 |
+
)
|
| 103 |
+
self._name = name
|
| 104 |
+
self._config_path = Path(config_path)
|
| 105 |
+
# Le parser est instancié paresseusement au premier execute()
|
| 106 |
+
# pour que la sérialisation ProcessPool fonctionne (un parser
|
| 107 |
+
# contenant des modèles PyTorch n'est pas sérialisable).
|
| 108 |
+
self._parser: Any = None
|
| 109 |
+
|
| 110 |
+
@property
|
| 111 |
+
def name(self) -> str:
|
| 112 |
+
return self._name
|
| 113 |
+
|
| 114 |
+
@property
|
| 115 |
+
def config_path(self) -> Path:
|
| 116 |
+
return self._config_path
|
| 117 |
+
|
| 118 |
+
def _get_parser(self) -> Any:
|
| 119 |
+
"""Instancie le PageParser au premier appel (lazy)."""
|
| 120 |
+
if self._parser is not None:
|
| 121 |
+
return self._parser
|
| 122 |
+
|
| 123 |
+
try:
|
| 124 |
+
from pero_ocr.document_ocr.page_parser import PageParser
|
| 125 |
+
except ImportError as exc:
|
| 126 |
+
raise OCRAdapterError(
|
| 127 |
+
f"{self.name} : pero-ocr non installé. "
|
| 128 |
+
"Installer avec : pip install pero-ocr",
|
| 129 |
+
) from exc
|
| 130 |
+
|
| 131 |
+
if not self._config_path.exists():
|
| 132 |
+
raise OCRAdapterError(
|
| 133 |
+
f"{self.name} : config_path introuvable "
|
| 134 |
+
f"{self._config_path!r}.",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
import configparser
|
| 138 |
+
parser_config = configparser.ConfigParser()
|
| 139 |
+
parser_config.read(self._config_path)
|
| 140 |
+
try:
|
| 141 |
+
self._parser = PageParser(parser_config)
|
| 142 |
+
except Exception as exc:
|
| 143 |
+
raise OCRAdapterError(
|
| 144 |
+
f"{self.name} : initialisation PageParser échouée "
|
| 145 |
+
f"({type(exc).__name__}: {exc}).",
|
| 146 |
+
) from exc
|
| 147 |
+
return self._parser
|
| 148 |
+
|
| 149 |
+
def execute(
|
| 150 |
+
self,
|
| 151 |
+
inputs: dict[ArtifactType, Artifact],
|
| 152 |
+
params: dict[str, Any],
|
| 153 |
+
context: Any,
|
| 154 |
+
) -> dict[ArtifactType, Artifact]:
|
| 155 |
+
"""Exécute Pero OCR sur l'image fournie.
|
| 156 |
+
|
| 157 |
+
Raises
|
| 158 |
+
------
|
| 159 |
+
OCRAdapterError
|
| 160 |
+
Si l'input est invalide, l'image introuvable, les
|
| 161 |
+
dépendances manquantes, ou Pero OCR lève en interne.
|
| 162 |
+
"""
|
| 163 |
+
if ArtifactType.IMAGE not in inputs:
|
| 164 |
+
raise OCRAdapterError(
|
| 165 |
+
f"{self.name} : input IMAGE manquant.",
|
| 166 |
+
)
|
| 167 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 168 |
+
if image_artifact.uri is None:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : artefact image "
|
| 171 |
+
f"{image_artifact.id!r} sans URI.",
|
| 172 |
+
)
|
| 173 |
+
image_path = Path(image_artifact.uri)
|
| 174 |
+
if not image_path.exists():
|
| 175 |
+
raise OCRAdapterError(
|
| 176 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
try:
|
| 180 |
+
import numpy as np
|
| 181 |
+
from PIL import Image
|
| 182 |
+
from pero_ocr.document_ocr.layout import PageLayout
|
| 183 |
+
except ImportError as exc:
|
| 184 |
+
raise OCRAdapterError(
|
| 185 |
+
f"{self.name} : pero-ocr/numpy/Pillow non installés. "
|
| 186 |
+
"Installer avec : pip install pero-ocr pillow numpy",
|
| 187 |
+
) from exc
|
| 188 |
+
|
| 189 |
+
parser = self._get_parser()
|
| 190 |
+
|
| 191 |
+
try:
|
| 192 |
+
with Image.open(image_path) as pil_image:
|
| 193 |
+
image_array = np.array(pil_image.convert("RGB"))
|
| 194 |
+
page_layout = PageLayout(
|
| 195 |
+
id=image_path.stem,
|
| 196 |
+
page_size=(image_array.shape[0], image_array.shape[1]),
|
| 197 |
+
)
|
| 198 |
+
parser.process_page(image_array, page_layout)
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
raise OCRAdapterError(
|
| 201 |
+
f"{self.name} : Pero OCR a levé sur "
|
| 202 |
+
f"{image_path!r} : {type(exc).__name__}: {exc}",
|
| 203 |
+
) from exc
|
| 204 |
+
|
| 205 |
+
# Extraction du texte plat dans l'ordre regions × lines.
|
| 206 |
+
lines: list[str] = []
|
| 207 |
+
for region in page_layout.regions:
|
| 208 |
+
for line in region.lines:
|
| 209 |
+
if line.transcription:
|
| 210 |
+
lines.append(line.transcription.strip())
|
| 211 |
+
text = "\n".join(lines)
|
| 212 |
+
|
| 213 |
+
text_path = resolve_output_path(
|
| 214 |
+
input_path=image_path,
|
| 215 |
+
adapter_name=self.name,
|
| 216 |
+
suffix="txt",
|
| 217 |
+
context=context,
|
| 218 |
+
)
|
| 219 |
+
text_path.write_text(text, encoding="utf-8")
|
| 220 |
+
|
| 221 |
+
return {
|
| 222 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 223 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 224 |
+
document_id=context.document_id,
|
| 225 |
+
type=ArtifactType.RAW_TEXT,
|
| 226 |
+
produced_by_step="ocr",
|
| 227 |
+
uri=str(text_path),
|
| 228 |
+
),
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
|
| 232 |
+
__all__ = ["PeroOCRAdapter"]
|
picarones/adapters/ocr/precomputed.py
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``PrecomputedTextAdapter`` — premier adapter natif du nouveau monde.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S26 du rewrite ciblé.
|
| 4 |
+
|
| 5 |
+
Cas d'usage BnF
|
| 6 |
+
---------------
|
| 7 |
+
*« J'ai déjà fait tourner Tesseract, GPT-4-vision, Pero OCR et un
|
| 8 |
+
service cloud sur mon corpus. J'ai 4 répertoires de fichiers
|
| 9 |
+
``.txt`` à côté de mes images. Je veux comparer ces 4 sorties dans
|
| 10 |
+
Picarones — je n'ai pas besoin de re-lancer un OCR, j'ai juste besoin
|
| 11 |
+
de la machinerie d'évaluation. »*
|
| 12 |
+
|
| 13 |
+
Ce besoin est légitime et fréquent à la BnF : une part importante
|
| 14 |
+
du travail de comparaison se fait sur des transcriptions déjà
|
| 15 |
+
produites par d'autres outils. Ré-exécuter un OCR à chaque
|
| 16 |
+
benchmark est gaspillage.
|
| 17 |
+
|
| 18 |
+
Convention de nommage
|
| 19 |
+
---------------------
|
| 20 |
+
Pour une image ``<stem>.png`` (ou ``.jpg``, ``.tif``, etc.), le
|
| 21 |
+
texte pré-calculé est lu depuis :
|
| 22 |
+
|
| 23 |
+
::
|
| 24 |
+
|
| 25 |
+
<stem>.<source_label>.txt
|
| 26 |
+
|
| 27 |
+
dans le **même répertoire** que l'image. Exemple avec deux
|
| 28 |
+
sources concurrentes :
|
| 29 |
+
|
| 30 |
+
::
|
| 31 |
+
|
| 32 |
+
folio_001.png
|
| 33 |
+
folio_001.tesseract.txt # produit par Tesseract
|
| 34 |
+
folio_001.pero.txt # produit par Pero OCR
|
| 35 |
+
folio_001.gpt4v.txt # produit par GPT-4 Vision
|
| 36 |
+
folio_001.gt.txt # vérité terrain
|
| 37 |
+
|
| 38 |
+
Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une
|
| 39 |
+
même YAML avec des ``source_label`` distincts — chacun lit son
|
| 40 |
+
propre fichier, le ``BenchmarkService`` les traite en parallèle.
|
| 41 |
+
|
| 42 |
+
Configuration YAML
|
| 43 |
+
------------------
|
| 44 |
+
|
| 45 |
+
::
|
| 46 |
+
|
| 47 |
+
pipelines:
|
| 48 |
+
- name: tesseract_baseline
|
| 49 |
+
initial_inputs: [image]
|
| 50 |
+
steps:
|
| 51 |
+
- id: ocr
|
| 52 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 53 |
+
adapter_kwargs:
|
| 54 |
+
source_label: tesseract
|
| 55 |
+
input_types: [image]
|
| 56 |
+
output_types: [raw_text]
|
| 57 |
+
|
| 58 |
+
- name: gpt4v_alternative
|
| 59 |
+
initial_inputs: [image]
|
| 60 |
+
steps:
|
| 61 |
+
- id: ocr
|
| 62 |
+
adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter
|
| 63 |
+
adapter_kwargs:
|
| 64 |
+
source_label: gpt4v
|
| 65 |
+
input_types: [image]
|
| 66 |
+
output_types: [raw_text]
|
| 67 |
+
|
| 68 |
+
Comportement « fichier manquant »
|
| 69 |
+
---------------------------------
|
| 70 |
+
Par défaut, si le fichier ``<stem>.<source_label>.txt`` est absent,
|
| 71 |
+
l'adapter lève ``OCRAdapterError`` — le pipeline executor marque le
|
| 72 |
+
step comme failed pour ce document, et le ``BenchmarkService`` le
|
| 73 |
+
voit en ``failed_metrics``. Pas de fallback silencieux qui
|
| 74 |
+
mentirait sur la couverture du benchmark.
|
| 75 |
+
|
| 76 |
+
L'option ``missing_text_policy="empty"`` permet, à la demande
|
| 77 |
+
explicite du caller, de remplacer un fichier absent par une chaîne
|
| 78 |
+
vide — utile pour mesurer ce qui se passerait si une source était
|
| 79 |
+
indisponible sur certains documents. Par défaut : ``"raise"``.
|
| 80 |
+
|
| 81 |
+
Anti-sur-ingénierie
|
| 82 |
+
-------------------
|
| 83 |
+
- Pas de découverte automatique de tous les ``source_label``
|
| 84 |
+
présents dans un répertoire. Le caller déclare explicitement
|
| 85 |
+
les sources qu'il veut comparer.
|
| 86 |
+
- Pas de cache. Le filesystem fait son boulot.
|
| 87 |
+
- Pas de validation d'encodage exotique. ``utf-8`` strict ; un
|
| 88 |
+
fichier mal encodé lève une erreur lisible.
|
| 89 |
+
- Pas d'extraction structurelle. Cet adapter sort du ``RAW_TEXT``,
|
| 90 |
+
point. Pour comparer des ALTO_XML pré-calculés, c'est un
|
| 91 |
+
``PrecomputedAltoAdapter`` futur (pattern identique).
|
| 92 |
+
"""
|
| 93 |
+
|
| 94 |
+
from __future__ import annotations
|
| 95 |
+
|
| 96 |
+
from pathlib import Path
|
| 97 |
+
from typing import Any, Literal
|
| 98 |
+
|
| 99 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 100 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
class PrecomputedTextAdapter(BaseOCRAdapter):
|
| 104 |
+
"""Adapter qui lit du texte OCR pré-calculé depuis le filesystem.
|
| 105 |
+
|
| 106 |
+
Parameters
|
| 107 |
+
----------
|
| 108 |
+
source_label:
|
| 109 |
+
Étiquette identifiant la source du texte pré-calculé
|
| 110 |
+
(ex : ``"tesseract"``, ``"gpt4v"``, ``"pero"``). Doit être
|
| 111 |
+
composée uniquement de caractères alphanumériques, ``_`` et
|
| 112 |
+
``-`` — c'est un composant de nom de fichier.
|
| 113 |
+
missing_text_policy:
|
| 114 |
+
``"raise"`` (défaut) → fichier absent lève ``OCRAdapterError``.
|
| 115 |
+
``"empty"`` → fichier absent remplacé par chaîne vide
|
| 116 |
+
(l'adapter produit alors un ``Artifact`` pointant sur un
|
| 117 |
+
fichier vide).
|
| 118 |
+
|
| 119 |
+
Raises
|
| 120 |
+
------
|
| 121 |
+
OCRAdapterError
|
| 122 |
+
Si ``source_label`` est invalide.
|
| 123 |
+
"""
|
| 124 |
+
|
| 125 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 126 |
+
output_types = frozenset({ArtifactType.RAW_TEXT})
|
| 127 |
+
execution_mode = "io"
|
| 128 |
+
|
| 129 |
+
def __init__(
|
| 130 |
+
self,
|
| 131 |
+
*,
|
| 132 |
+
source_label: str,
|
| 133 |
+
missing_text_policy: Literal["raise", "empty"] = "raise",
|
| 134 |
+
) -> None:
|
| 135 |
+
if not source_label or not source_label.strip():
|
| 136 |
+
raise OCRAdapterError(
|
| 137 |
+
"PrecomputedTextAdapter : source_label vide.",
|
| 138 |
+
)
|
| 139 |
+
if not all(
|
| 140 |
+
c.isalnum() or c in "_-" for c in source_label
|
| 141 |
+
):
|
| 142 |
+
raise OCRAdapterError(
|
| 143 |
+
f"PrecomputedTextAdapter : source_label invalide "
|
| 144 |
+
f"{source_label!r} — alphanumérique + _ - uniquement.",
|
| 145 |
+
)
|
| 146 |
+
if missing_text_policy not in ("raise", "empty"):
|
| 147 |
+
raise OCRAdapterError(
|
| 148 |
+
f"missing_text_policy doit être 'raise' ou 'empty', "
|
| 149 |
+
f"reçu {missing_text_policy!r}.",
|
| 150 |
+
)
|
| 151 |
+
self._source_label = source_label
|
| 152 |
+
self._missing_policy = missing_text_policy
|
| 153 |
+
|
| 154 |
+
@property
|
| 155 |
+
def name(self) -> str:
|
| 156 |
+
return f"precomputed_{self._source_label}"
|
| 157 |
+
|
| 158 |
+
@property
|
| 159 |
+
def source_label(self) -> str:
|
| 160 |
+
return self._source_label
|
| 161 |
+
|
| 162 |
+
def execute(
|
| 163 |
+
self,
|
| 164 |
+
inputs: dict[ArtifactType, Artifact],
|
| 165 |
+
params: dict[str, Any],
|
| 166 |
+
context: Any,
|
| 167 |
+
) -> dict[ArtifactType, Artifact]:
|
| 168 |
+
if ArtifactType.IMAGE not in inputs:
|
| 169 |
+
raise OCRAdapterError(
|
| 170 |
+
f"{self.name} : input IMAGE manquant.",
|
| 171 |
+
)
|
| 172 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 173 |
+
if image_artifact.uri is None:
|
| 174 |
+
raise OCRAdapterError(
|
| 175 |
+
f"{self.name} : artefact image "
|
| 176 |
+
f"{image_artifact.id!r} sans URI.",
|
| 177 |
+
)
|
| 178 |
+
|
| 179 |
+
image_path = Path(image_artifact.uri)
|
| 180 |
+
text_path = (
|
| 181 |
+
image_path.parent / f"{image_path.stem}.{self._source_label}.txt"
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
if not text_path.exists():
|
| 185 |
+
if self._missing_policy == "empty":
|
| 186 |
+
# On crée le fichier vide pour rester cohérent : tout
|
| 187 |
+
# ``Artifact`` produit a une URI vers un fichier
|
| 188 |
+
# lisible.
|
| 189 |
+
text_path.write_text("", encoding="utf-8")
|
| 190 |
+
else:
|
| 191 |
+
raise OCRAdapterError(
|
| 192 |
+
f"{self.name} : fichier pré-calculé introuvable "
|
| 193 |
+
f"pour {image_path.name!r} : "
|
| 194 |
+
f"{text_path.name!r} attendu dans "
|
| 195 |
+
f"{image_path.parent!r}.",
|
| 196 |
+
)
|
| 197 |
+
|
| 198 |
+
# Validation rapide de l'encodage UTF-8 (lecture qui leverait
|
| 199 |
+
# si encodage exotique).
|
| 200 |
+
try:
|
| 201 |
+
text_path.read_text(encoding="utf-8")
|
| 202 |
+
except UnicodeDecodeError as exc:
|
| 203 |
+
raise OCRAdapterError(
|
| 204 |
+
f"{self.name} : {text_path!r} n'est pas en UTF-8 : "
|
| 205 |
+
f"{exc}",
|
| 206 |
+
) from exc
|
| 207 |
+
|
| 208 |
+
return {
|
| 209 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 210 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 211 |
+
document_id=context.document_id,
|
| 212 |
+
type=ArtifactType.RAW_TEXT,
|
| 213 |
+
produced_by_step="ocr",
|
| 214 |
+
uri=str(text_path),
|
| 215 |
+
),
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
__all__ = ["PrecomputedTextAdapter"]
|
picarones/adapters/ocr/tesseract.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``TesseractAdapter`` natif — Sprint A14-S30.
|
| 2 |
+
|
| 3 |
+
Migration native du legacy ``picarones.engines.tesseract.TesseractEngine``
|
| 4 |
+
vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe
|
| 5 |
+
implémente directement le contrat du nouveau monde, sans héritage du
|
| 6 |
+
legacy.
|
| 7 |
+
|
| 8 |
+
Le legacy ``TesseractEngine`` reste en place pour les callers qui
|
| 9 |
+
n'ont pas encore migré ; sa suppression viendra au S46 quand la
|
| 10 |
+
parité sera atteinte sur tous les adapters.
|
| 11 |
+
|
| 12 |
+
Cas d'usage BnF
|
| 13 |
+
---------------
|
| 14 |
+
Tesseract 5 reste l'OCR open-source de référence pour les corpus
|
| 15 |
+
imprimés et certains manuscrits réguliers. L'adapter est CPU-bound
|
| 16 |
+
(Tesseract appelle une lib C en sous-process) — déclaré
|
| 17 |
+
``execution_mode="cpu"`` pour que le runner utilise un
|
| 18 |
+
``ProcessPoolExecutor``.
|
| 19 |
+
|
| 20 |
+
Configuration
|
| 21 |
+
-------------
|
| 22 |
+
Constructeur :
|
| 23 |
+
|
| 24 |
+
- ``name`` (défaut ``"tesseract"``) : identifiant de l'instance.
|
| 25 |
+
Sert de suffixe au fichier de sortie ``<stem>.<name>.txt`` —
|
| 26 |
+
permet de coexister avec plusieurs configurations Tesseract dans
|
| 27 |
+
un même benchmark.
|
| 28 |
+
- ``lang`` (défaut ``"fra"``) : code langue Tesseract (``"fra"``,
|
| 29 |
+
``"lat"``, ``"eng"``, ``"fra+lat"``).
|
| 30 |
+
- ``psm`` (défaut ``6``) : Page Segmentation Mode (0-13).
|
| 31 |
+
- ``oem`` (défaut ``3``) : OCR Engine Mode.
|
| 32 |
+
- ``tesseract_cmd`` (défaut ``None``) : chemin vers l'exécutable
|
| 33 |
+
``tesseract`` si non standard.
|
| 34 |
+
|
| 35 |
+
Comportement
|
| 36 |
+
------------
|
| 37 |
+
1. Vérifie qu'un ``Artifact`` ``IMAGE`` est présent dans ``inputs``
|
| 38 |
+
et qu'il porte une ``uri`` filesystem.
|
| 39 |
+
2. Lazy-import de ``pytesseract`` et ``PIL`` — si absent, lève
|
| 40 |
+
``OCRAdapterError`` avec message explicite.
|
| 41 |
+
3. Applique ``tesseract_cmd`` s'il est fourni.
|
| 42 |
+
4. Appelle ``pytesseract.image_to_string`` avec ``lang`` et
|
| 43 |
+
``--oem N --psm M``.
|
| 44 |
+
5. Écrit le texte dans ``<stem>.<name>.txt`` à côté de l'image
|
| 45 |
+
(cohérent avec le pattern ``PrecomputedTextAdapter`` — un caller
|
| 46 |
+
peut relire la sortie via cet adapter pour la comparer dans un
|
| 47 |
+
second run).
|
| 48 |
+
6. Retourne un ``Artifact`` ``RAW_TEXT`` pointant vers le fichier
|
| 49 |
+
produit.
|
| 50 |
+
|
| 51 |
+
Anti-sur-ingénierie
|
| 52 |
+
-------------------
|
| 53 |
+
- Pas de retry — Tesseract échoue rarement sur une image valide,
|
| 54 |
+
et un appelant peut wrapper si besoin.
|
| 55 |
+
- Pas d'extraction de confidences (legacy S47) — reporté à un
|
| 56 |
+
sprint dédié qui définira ``ConfidenceArtifact`` typé. La
|
| 57 |
+
fonctionnalité reste disponible via le legacy
|
| 58 |
+
``picarones.engines.tesseract.TesseractEngine`` jusqu'au S46.
|
| 59 |
+
- Pas de validation de l'encodage de l'image — Tesseract gère.
|
| 60 |
+
- Pas de support batch — un appel par image (le runner gère le
|
| 61 |
+
parallélisme inter-documents).
|
| 62 |
+
"""
|
| 63 |
+
|
| 64 |
+
from __future__ import annotations
|
| 65 |
+
|
| 66 |
+
from pathlib import Path
|
| 67 |
+
from typing import Any
|
| 68 |
+
|
| 69 |
+
from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError
|
| 70 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 71 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class TesseractAdapter(BaseOCRAdapter):
|
| 75 |
+
"""Adapter Tesseract 5 natif au nouveau contrat (S26).
|
| 76 |
+
|
| 77 |
+
Parameters
|
| 78 |
+
----------
|
| 79 |
+
name:
|
| 80 |
+
Identifiant lisible de l'instance. Défaut ``"tesseract"``.
|
| 81 |
+
Doit être alphanumérique + ``_-`` (composant de nom de fichier).
|
| 82 |
+
lang:
|
| 83 |
+
Code langue Tesseract (``"fra"``, ``"lat"``, ``"eng"``, ...).
|
| 84 |
+
Défaut ``"fra"``.
|
| 85 |
+
psm:
|
| 86 |
+
Page Segmentation Mode entre 0 et 13. Défaut 6
|
| 87 |
+
(single uniform block of text).
|
| 88 |
+
oem:
|
| 89 |
+
OCR Engine Mode (0-3). Défaut 3 (LSTM, le plus précis).
|
| 90 |
+
tesseract_cmd:
|
| 91 |
+
Chemin custom vers l'exécutable ``tesseract``. Défaut
|
| 92 |
+
``None`` (laisse pytesseract trouver l'installation système).
|
| 93 |
+
|
| 94 |
+
Raises
|
| 95 |
+
------
|
| 96 |
+
OCRAdapterError
|
| 97 |
+
Si le ``name`` ou les valeurs de ``psm`` / ``oem`` sont
|
| 98 |
+
invalides.
|
| 99 |
+
"""
|
| 100 |
+
|
| 101 |
+
input_types = frozenset({ArtifactType.IMAGE})
|
| 102 |
+
#: Set maximal de types que l'adapter peut produire. Le YAML
|
| 103 |
+
#: ``PipelineSpec`` choisit ceux qui sont effectivement consommés
|
| 104 |
+
#: par les étapes en aval ; l'executor filtre la sortie de
|
| 105 |
+
#: ``execute()`` sur ``step.output_types``. Si l'utilisateur
|
| 106 |
+
#: désactive ``expose_confidences``, le YAML doit déclarer
|
| 107 |
+
#: ``output_types: [raw_text]`` (sinon la jonction sera vue par
|
| 108 |
+
#: l'aval comme manquant son input ``confidences``).
|
| 109 |
+
output_types = frozenset(
|
| 110 |
+
{ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES},
|
| 111 |
+
)
|
| 112 |
+
execution_mode = "cpu"
|
| 113 |
+
|
| 114 |
+
def __init__(
|
| 115 |
+
self,
|
| 116 |
+
*,
|
| 117 |
+
name: str = "tesseract",
|
| 118 |
+
lang: str = "fra",
|
| 119 |
+
psm: int = 6,
|
| 120 |
+
oem: int = 3,
|
| 121 |
+
tesseract_cmd: str | None = None,
|
| 122 |
+
expose_confidences: bool = True,
|
| 123 |
+
) -> None:
|
| 124 |
+
if not name or not name.strip():
|
| 125 |
+
raise OCRAdapterError(
|
| 126 |
+
"TesseractAdapter : name vide non autorisé.",
|
| 127 |
+
)
|
| 128 |
+
if not all(c.isalnum() or c in "_-" for c in name):
|
| 129 |
+
raise OCRAdapterError(
|
| 130 |
+
f"TesseractAdapter : name invalide {name!r} — "
|
| 131 |
+
"alphanumérique + _ - uniquement.",
|
| 132 |
+
)
|
| 133 |
+
if not 0 <= psm <= 13:
|
| 134 |
+
raise OCRAdapterError(
|
| 135 |
+
f"TesseractAdapter : psm doit être ∈ [0, 13], reçu {psm}.",
|
| 136 |
+
)
|
| 137 |
+
if not 0 <= oem <= 3:
|
| 138 |
+
raise OCRAdapterError(
|
| 139 |
+
f"TesseractAdapter : oem doit être ∈ [0, 3], reçu {oem}.",
|
| 140 |
+
)
|
| 141 |
+
self._name = name
|
| 142 |
+
self._lang = lang
|
| 143 |
+
self._psm = psm
|
| 144 |
+
self._oem = oem
|
| 145 |
+
self._tesseract_cmd = tesseract_cmd
|
| 146 |
+
self._expose_confidences = expose_confidences
|
| 147 |
+
|
| 148 |
+
@property
|
| 149 |
+
def name(self) -> str:
|
| 150 |
+
return self._name
|
| 151 |
+
|
| 152 |
+
@property
|
| 153 |
+
def expose_confidences(self) -> bool:
|
| 154 |
+
return self._expose_confidences
|
| 155 |
+
|
| 156 |
+
@property
|
| 157 |
+
def lang(self) -> str:
|
| 158 |
+
return self._lang
|
| 159 |
+
|
| 160 |
+
@property
|
| 161 |
+
def psm(self) -> int:
|
| 162 |
+
return self._psm
|
| 163 |
+
|
| 164 |
+
@property
|
| 165 |
+
def oem(self) -> int:
|
| 166 |
+
return self._oem
|
| 167 |
+
|
| 168 |
+
def execute(
|
| 169 |
+
self,
|
| 170 |
+
inputs: dict[ArtifactType, Artifact],
|
| 171 |
+
params: dict[str, Any],
|
| 172 |
+
context: Any,
|
| 173 |
+
) -> dict[ArtifactType, Artifact]:
|
| 174 |
+
"""Exécute Tesseract sur l'image fournie.
|
| 175 |
+
|
| 176 |
+
Raises
|
| 177 |
+
------
|
| 178 |
+
OCRAdapterError
|
| 179 |
+
- input ``IMAGE`` absent ;
|
| 180 |
+
- artefact image sans URI ;
|
| 181 |
+
- fichier image introuvable ;
|
| 182 |
+
- ``pytesseract`` ou ``PIL`` non installé ;
|
| 183 |
+
- erreur Tesseract (lib system manquante, etc.).
|
| 184 |
+
"""
|
| 185 |
+
if ArtifactType.IMAGE not in inputs:
|
| 186 |
+
raise OCRAdapterError(
|
| 187 |
+
f"{self.name} : input IMAGE manquant.",
|
| 188 |
+
)
|
| 189 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 190 |
+
if image_artifact.uri is None:
|
| 191 |
+
raise OCRAdapterError(
|
| 192 |
+
f"{self.name} : artefact image "
|
| 193 |
+
f"{image_artifact.id!r} sans URI.",
|
| 194 |
+
)
|
| 195 |
+
|
| 196 |
+
image_path = Path(image_artifact.uri)
|
| 197 |
+
if not image_path.exists():
|
| 198 |
+
raise OCRAdapterError(
|
| 199 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 200 |
+
)
|
| 201 |
+
|
| 202 |
+
# Lazy-import de pytesseract + PIL — si absents, message
|
| 203 |
+
# explicite plutôt qu'``ImportError`` au top-level.
|
| 204 |
+
try:
|
| 205 |
+
import pytesseract # type: ignore[import-untyped]
|
| 206 |
+
from PIL import Image
|
| 207 |
+
except ImportError as exc:
|
| 208 |
+
raise OCRAdapterError(
|
| 209 |
+
f"{self.name} : pytesseract/Pillow non installés. "
|
| 210 |
+
"Installer avec : pip install pytesseract pillow",
|
| 211 |
+
) from exc
|
| 212 |
+
|
| 213 |
+
# Application du tesseract_cmd custom si fourni.
|
| 214 |
+
if self._tesseract_cmd is not None:
|
| 215 |
+
pytesseract.pytesseract.tesseract_cmd = self._tesseract_cmd
|
| 216 |
+
|
| 217 |
+
# OCR.
|
| 218 |
+
custom_config = f"--oem {self._oem} --psm {self._psm}"
|
| 219 |
+
try:
|
| 220 |
+
with Image.open(image_path) as image:
|
| 221 |
+
text = pytesseract.image_to_string(
|
| 222 |
+
image,
|
| 223 |
+
lang=self._lang,
|
| 224 |
+
config=custom_config,
|
| 225 |
+
)
|
| 226 |
+
except Exception as exc:
|
| 227 |
+
raise OCRAdapterError(
|
| 228 |
+
f"{self.name} : Tesseract a levé sur "
|
| 229 |
+
f"{image_path!r} : {type(exc).__name__}: {exc}",
|
| 230 |
+
) from exc
|
| 231 |
+
|
| 232 |
+
text = text.strip()
|
| 233 |
+
|
| 234 |
+
# Le helper résout vers le workspace si fourni (sandbox par
|
| 235 |
+
# doc), sinon écrit à côté de l'image — cohérent avec le
|
| 236 |
+
# pattern ``PrecomputedTextAdapter`` qui peut relire la sortie.
|
| 237 |
+
text_path = resolve_output_path(
|
| 238 |
+
input_path=image_path,
|
| 239 |
+
adapter_name=self.name,
|
| 240 |
+
suffix="txt",
|
| 241 |
+
context=context,
|
| 242 |
+
)
|
| 243 |
+
text_path.write_text(text, encoding="utf-8")
|
| 244 |
+
|
| 245 |
+
outputs: dict = {
|
| 246 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 247 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 248 |
+
document_id=context.document_id,
|
| 249 |
+
type=ArtifactType.RAW_TEXT,
|
| 250 |
+
produced_by_step="ocr",
|
| 251 |
+
uri=str(text_path),
|
| 252 |
+
),
|
| 253 |
+
}
|
| 254 |
+
|
| 255 |
+
# Extraction des confidences via image_to_data (best-effort).
|
| 256 |
+
# Si l'extraction échoue, on log et on saute — l'OCR reste
|
| 257 |
+
# valide, seule la calibration est indisponible pour ce doc.
|
| 258 |
+
if self._expose_confidences:
|
| 259 |
+
confidences_artifact = self._extract_and_persist_confidences(
|
| 260 |
+
image_path=image_path,
|
| 261 |
+
text_path=text_path,
|
| 262 |
+
pytesseract_module=pytesseract,
|
| 263 |
+
pil_image_class=Image,
|
| 264 |
+
custom_config=custom_config,
|
| 265 |
+
document_id=context.document_id,
|
| 266 |
+
)
|
| 267 |
+
if confidences_artifact is not None:
|
| 268 |
+
outputs[ArtifactType.CONFIDENCES] = confidences_artifact
|
| 269 |
+
|
| 270 |
+
return outputs
|
| 271 |
+
|
| 272 |
+
def _extract_and_persist_confidences(
|
| 273 |
+
self,
|
| 274 |
+
*,
|
| 275 |
+
image_path: Path,
|
| 276 |
+
text_path: Path,
|
| 277 |
+
pytesseract_module,
|
| 278 |
+
pil_image_class,
|
| 279 |
+
custom_config: str,
|
| 280 |
+
document_id: str,
|
| 281 |
+
) -> Artifact | None:
|
| 282 |
+
"""Appelle ``image_to_data`` puis écrit le sidecar JSON.
|
| 283 |
+
|
| 284 |
+
Retourne l'``Artifact CONFIDENCES`` ou ``None`` si l'extraction
|
| 285 |
+
a échoué (warning loggé, OCR reste valide).
|
| 286 |
+
"""
|
| 287 |
+
import logging
|
| 288 |
+
logger = logging.getLogger(__name__)
|
| 289 |
+
|
| 290 |
+
from picarones.adapters.ocr.confidences import (
|
| 291 |
+
filter_valid_tokens,
|
| 292 |
+
write_confidences_sidecar,
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
try:
|
| 296 |
+
with pil_image_class.open(image_path) as image:
|
| 297 |
+
data = pytesseract_module.image_to_data(
|
| 298 |
+
image,
|
| 299 |
+
lang=self._lang,
|
| 300 |
+
config=custom_config,
|
| 301 |
+
output_type=pytesseract_module.Output.DICT,
|
| 302 |
+
)
|
| 303 |
+
except Exception as exc: # noqa: BLE001 — best-effort
|
| 304 |
+
logger.warning(
|
| 305 |
+
"[%s] image_to_data indisponible (%s) — calibration "
|
| 306 |
+
"sautée pour ce document.", self._name, exc,
|
| 307 |
+
)
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
# Format Tesseract : dict {"text": [...], "conf": [...]}.
|
| 311 |
+
texts = data.get("text") or []
|
| 312 |
+
confs = data.get("conf") or []
|
| 313 |
+
raw = [
|
| 314 |
+
{"text": t, "confidence": c}
|
| 315 |
+
for t, c in zip(texts, confs)
|
| 316 |
+
]
|
| 317 |
+
tokens = filter_valid_tokens(raw)
|
| 318 |
+
return write_confidences_sidecar(
|
| 319 |
+
text_path=text_path,
|
| 320 |
+
adapter_name=self._name,
|
| 321 |
+
tokens=tokens,
|
| 322 |
+
document_id=document_id,
|
| 323 |
+
extractor="tesseract",
|
| 324 |
+
)
|
| 325 |
+
|
| 326 |
+
|
| 327 |
+
__all__ = ["TesseractAdapter"]
|
picarones/adapters/output_paths.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Résolution du répertoire d'output pour les adapters (OCR/LLM/VLM).
|
| 2 |
+
|
| 3 |
+
Helper partagé par tous les adapters qui produisent des fichiers de
|
| 4 |
+
sortie. Il vit au top-level de ``adapters/`` plutôt qu'à l'intérieur
|
| 5 |
+
de l'un des sous-packages — il sert les trois familles indistinctement.
|
| 6 |
+
|
| 7 |
+
Un corpus monté en read-only (NAS partagé, volume Docker RO) ne peut
|
| 8 |
+
pas accueillir les sorties à côté des fichiers sources. Le helper
|
| 9 |
+
résout le chemin selon une priorité :
|
| 10 |
+
|
| 11 |
+
1. ``context.workspace_uri`` si non None → écriture dans
|
| 12 |
+
``<workspace>/<doc_id>/`` (sandbox par run, write-allowed).
|
| 13 |
+
2. Fallback ``input_path.parent`` → comportement par défaut quand
|
| 14 |
+
aucun workspace n'est configuré (peut échouer en read-only).
|
| 15 |
+
|
| 16 |
+
Anti-sur-ingénierie
|
| 17 |
+
-------------------
|
| 18 |
+
- Pas de quota disk : le ``WorkspaceManager`` gère ça quand un
|
| 19 |
+
caller institutionnel l'exige.
|
| 20 |
+
- Pas de support S3/distant : ``workspace_uri`` est un path
|
| 21 |
+
filesystem dans le contrat actuel.
|
| 22 |
+
"""
|
| 23 |
+
|
| 24 |
+
from __future__ import annotations
|
| 25 |
+
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Any
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def resolve_output_path(
|
| 31 |
+
input_path: Path,
|
| 32 |
+
adapter_name: str,
|
| 33 |
+
suffix: str,
|
| 34 |
+
context: Any,
|
| 35 |
+
) -> Path:
|
| 36 |
+
"""Résout le chemin de sortie pour un artefact d'adapter.
|
| 37 |
+
|
| 38 |
+
Convention de nommage : ``<stem>.<adapter_name>.<suffix>``.
|
| 39 |
+
|
| 40 |
+
Si ``context.workspace_uri`` est fourni, le fichier va dans
|
| 41 |
+
``<workspace>/<document_id>/`` (créé si absent). Sinon, fallback
|
| 42 |
+
sur ``input_path.parent`` (cas typique CLI / corpus local).
|
| 43 |
+
|
| 44 |
+
Parameters
|
| 45 |
+
----------
|
| 46 |
+
input_path:
|
| 47 |
+
Chemin du fichier d'entrée (image, texte, etc.) — utilisé
|
| 48 |
+
pour récupérer le ``stem``.
|
| 49 |
+
adapter_name:
|
| 50 |
+
Nom de l'adapter, intercalé dans le nom du fichier pour
|
| 51 |
+
permettre la cohabitation de plusieurs sorties.
|
| 52 |
+
suffix:
|
| 53 |
+
Extension finale, ex : ``"txt"``, ``"confidences.json"``,
|
| 54 |
+
``"corrected.txt"``. Pas de point initial — la fonction
|
| 55 |
+
l'ajoute.
|
| 56 |
+
context:
|
| 57 |
+
``RunContext`` avec attributs ``document_id`` et
|
| 58 |
+
``workspace_uri``. ``workspace_uri`` peut être ``None``
|
| 59 |
+
(mode CLI direct).
|
| 60 |
+
|
| 61 |
+
Returns
|
| 62 |
+
-------
|
| 63 |
+
Path
|
| 64 |
+
Chemin absolu où écrire la sortie. Le répertoire parent
|
| 65 |
+
est créé si nécessaire.
|
| 66 |
+
"""
|
| 67 |
+
workspace_uri = getattr(context, "workspace_uri", None)
|
| 68 |
+
document_id = getattr(context, "document_id", None) or "unknown_doc"
|
| 69 |
+
|
| 70 |
+
if workspace_uri:
|
| 71 |
+
out_dir = Path(workspace_uri) / document_id
|
| 72 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 73 |
+
return out_dir / f"{input_path.stem}.{adapter_name}.{suffix}"
|
| 74 |
+
|
| 75 |
+
return input_path.parent / f"{input_path.stem}.{adapter_name}.{suffix}"
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
__all__ = ["resolve_output_path"]
|
picarones/adapters/storage/__init__.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adaptateurs de stockage — Sprint S29.
|
| 2 |
+
|
| 3 |
+
Stocks d'artefacts indexés par hash multi-paramètres pour la
|
| 4 |
+
reprise des runs longs.
|
| 5 |
+
|
| 6 |
+
Modules livrés
|
| 7 |
+
--------------
|
| 8 |
+
- ``artifact_store.py`` (S29) — ``ArtifactKey``, ``StoredArtifact``,
|
| 9 |
+
``ArtifactStore`` (ABC), ``InMemoryArtifactStore``,
|
| 10 |
+
``FilesystemArtifactStore``.
|
| 11 |
+
|
| 12 |
+
Pattern : un ``Storage`` est instancié par un ``app/services/``,
|
| 13 |
+
pas créé ad-hoc dans un router FastAPI ou un module métier. Ça
|
| 14 |
+
permet d'injecter un mock en test, de basculer SQLite → Postgres
|
| 15 |
+
si besoin, et de centraliser les permissions/quotas.
|
| 16 |
+
|
| 17 |
+
Distinct du ``picarones/pipeline/cache.py`` (S7)
|
| 18 |
+
------------------------------------------------
|
| 19 |
+
``ArtifactCache`` (S7) reste exposé pour les callers qui en
|
| 20 |
+
dépendent en interne. ``ArtifactStore`` (S29) est la nouvelle
|
| 21 |
+
API canonique : hash multi-paramètres (model_version, normalization
|
| 22 |
+
profile, projection spec), persistance optionnelle sur filesystem,
|
| 23 |
+
abstraction ABC.
|
| 24 |
+
|
| 25 |
+
Cibles à venir
|
| 26 |
+
--------------
|
| 27 |
+
- S37 : déplacement de ``picarones.web.jobs`` (SQLite job store).
|
| 28 |
+
- Post-livraison : ``picarones.measurements.history`` (SQLite
|
| 29 |
+
history) et stores distribués (S3, GCS, …).
|
| 30 |
+
"""
|
| 31 |
+
|
| 32 |
+
from __future__ import annotations
|
| 33 |
+
|
| 34 |
+
from picarones.adapters.storage.artifact_store import (
|
| 35 |
+
ArtifactKey,
|
| 36 |
+
ArtifactStore,
|
| 37 |
+
ArtifactStoreError,
|
| 38 |
+
FilesystemArtifactStore,
|
| 39 |
+
InMemoryArtifactStore,
|
| 40 |
+
StoredArtifact,
|
| 41 |
+
)
|
| 42 |
+
from picarones.adapters.storage.job_store import (
|
| 43 |
+
JobRecord,
|
| 44 |
+
JobStore,
|
| 45 |
+
JobStoreError,
|
| 46 |
+
)
|
| 47 |
+
|
| 48 |
+
__all__ = [
|
| 49 |
+
"ArtifactKey",
|
| 50 |
+
"ArtifactStore",
|
| 51 |
+
"ArtifactStoreError",
|
| 52 |
+
"FilesystemArtifactStore",
|
| 53 |
+
"InMemoryArtifactStore",
|
| 54 |
+
"StoredArtifact",
|
| 55 |
+
"JobStore",
|
| 56 |
+
"JobRecord",
|
| 57 |
+
"JobStoreError",
|
| 58 |
+
]
|
picarones/adapters/storage/artifact_store.py
ADDED
|
@@ -0,0 +1,417 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``ArtifactStore`` — Sprint A14-S29.
|
| 2 |
+
|
| 3 |
+
Le S7 livrait ``ArtifactCache`` (in-memory, hash basique sur
|
| 4 |
+
inputs + step + code_version). S29 introduit un ``ArtifactStore``
|
| 5 |
+
plus robuste qui adresse la critique d'audit n° 14 (« hash
|
| 6 |
+
multi-paramètres + reprise par hash ») :
|
| 7 |
+
|
| 8 |
+
1. **Hash multi-paramètres** : la clé canonique d'un artefact
|
| 9 |
+
inclut les ``content_hash`` des inputs, le nom + version du
|
| 10 |
+
model utilisé, les ``params`` du step, le ``code_version``,
|
| 11 |
+
l'éventuel profil de normalisation, et l'éventuelle spec de
|
| 12 |
+
projection. Tout changement d'un paramètre éditorial invalide
|
| 13 |
+
la cache.
|
| 14 |
+
|
| 15 |
+
2. **Reprise par hash** : si un artefact avec exactement la même
|
| 16 |
+
clé existe déjà dans le store, le caller peut l'utiliser
|
| 17 |
+
directement plutôt que de re-exécuter l'étape coûteuse.
|
| 18 |
+
|
| 19 |
+
3. **Persistance optionnelle** : ``InMemoryArtifactStore`` pour
|
| 20 |
+
les tests et les workflows éphémères ; ``FilesystemArtifactStore``
|
| 21 |
+
pour les longs runs où on veut survivre à un crash.
|
| 22 |
+
|
| 23 |
+
Pas de shim
|
| 24 |
+
-----------
|
| 25 |
+
``ArtifactCache`` (S7) reste exposé pour les callers qui en
|
| 26 |
+
dépendent en interne, mais la nouvelle API canonique est
|
| 27 |
+
``ArtifactStore``. Le ``PipelineExecutor`` peut consommer un
|
| 28 |
+
``ArtifactStore`` via le paramètre optionnel ``artifact_store=``
|
| 29 |
+
au constructeur ; sans store, l'executor s'exécute comme avant
|
| 30 |
+
(pas d'effet de cache).
|
| 31 |
+
|
| 32 |
+
Anti-sur-ingénierie
|
| 33 |
+
-------------------
|
| 34 |
+
- Pas de TTL ni d'éviction LRU dans la version in-memory. La
|
| 35 |
+
taille est gérée par le caller (qui peut appeler ``clear()``).
|
| 36 |
+
- Pas de compression des payloads dans la version filesystem.
|
| 37 |
+
- Pas de namespacing par run — un store partagé entre runs est
|
| 38 |
+
censé converger, c'est précisément la propriété de la reprise.
|
| 39 |
+
- Pas de support distribué (S3, GCS, …) — viendra quand un
|
| 40 |
+
caller en aura concrètement besoin.
|
| 41 |
+
"""
|
| 42 |
+
|
| 43 |
+
from __future__ import annotations
|
| 44 |
+
|
| 45 |
+
import json
|
| 46 |
+
import logging
|
| 47 |
+
import threading
|
| 48 |
+
from abc import ABC, abstractmethod
|
| 49 |
+
from dataclasses import dataclass
|
| 50 |
+
from pathlib import Path
|
| 51 |
+
|
| 52 |
+
from picarones.domain.artifact_key import ArtifactKey
|
| 53 |
+
from picarones.domain.artifacts import Artifact
|
| 54 |
+
from picarones.domain.errors import PicaronesError
|
| 55 |
+
|
| 56 |
+
logger = logging.getLogger(__name__)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class ArtifactStoreError(PicaronesError):
|
| 60 |
+
"""Erreur de persistance d'artefact (clé invalide, I/O en échec).
|
| 61 |
+
|
| 62 |
+
Hérite de ``PicaronesError`` — un caller qui catche
|
| 63 |
+
``PicaronesError`` rattrape aussi cette branche, cohérent avec
|
| 64 |
+
la hiérarchie d'exceptions unifiée.
|
| 65 |
+
"""
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
# Sprint A14-S47 — ``ArtifactKey`` (type pur) a migré dans
|
| 69 |
+
# ``picarones/domain/artifact_key.py``. Re-import ici pour ne pas
|
| 70 |
+
# casser les callers (``from picarones.adapters.storage import
|
| 71 |
+
# ArtifactKey`` reste valide).
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 75 |
+
# Conteneur du store
|
| 76 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@dataclass(frozen=True)
|
| 80 |
+
class StoredArtifact:
|
| 81 |
+
"""Entrée du store : un artefact + son payload + sa clé.
|
| 82 |
+
|
| 83 |
+
Le payload est stocké en bytes brutes — le caller décide de la
|
| 84 |
+
désérialisation (texte UTF-8, ALTO XML, image PNG, etc.) en se
|
| 85 |
+
basant sur ``artifact.type``.
|
| 86 |
+
|
| 87 |
+
Attributes
|
| 88 |
+
----------
|
| 89 |
+
key:
|
| 90 |
+
Hash hex de la ``ArtifactKey`` qui a produit l'artefact.
|
| 91 |
+
artifact:
|
| 92 |
+
``Artifact`` complet (id, type, content_hash, provenance).
|
| 93 |
+
payload:
|
| 94 |
+
Bytes du contenu, ou ``None`` si le store ne stocke que
|
| 95 |
+
les métadonnées (cas d'un artefact dont l'``uri`` pointe
|
| 96 |
+
vers un fichier externe).
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
key: str
|
| 100 |
+
artifact: Artifact
|
| 101 |
+
payload: bytes | None = None
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 105 |
+
# Interface ABC
|
| 106 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class ArtifactStore(ABC):
|
| 110 |
+
"""Contrat abstrait d'un store d'artefacts indexé par hash.
|
| 111 |
+
|
| 112 |
+
Implémentations livrées au S29 :
|
| 113 |
+
|
| 114 |
+
- ``InMemoryArtifactStore`` (tests, runs éphémères) ;
|
| 115 |
+
- ``FilesystemArtifactStore`` (workspaces persistants).
|
| 116 |
+
|
| 117 |
+
Une implémentation tierce (S3, Postgres, …) est attendue post-
|
| 118 |
+
livraison ; elle hérite de cette ABC et passe les tests de
|
| 119 |
+
contrat.
|
| 120 |
+
"""
|
| 121 |
+
|
| 122 |
+
@abstractmethod
|
| 123 |
+
def get(self, key: str) -> StoredArtifact | None:
|
| 124 |
+
"""Récupère un artefact par sa clé hex, ou ``None``.
|
| 125 |
+
|
| 126 |
+
Tolère les clés inexistantes — le retour ``None`` indique
|
| 127 |
+
un cache miss, pas une erreur.
|
| 128 |
+
"""
|
| 129 |
+
|
| 130 |
+
@abstractmethod
|
| 131 |
+
def put(
|
| 132 |
+
self,
|
| 133 |
+
key: str,
|
| 134 |
+
artifact: Artifact,
|
| 135 |
+
payload: bytes | None = None,
|
| 136 |
+
) -> None:
|
| 137 |
+
"""Stocke un artefact sous la clé donnée.
|
| 138 |
+
|
| 139 |
+
Convention idempotente : ``put(k, ...)`` deux fois avec la
|
| 140 |
+
même clé écrase la valeur précédente sans erreur. L'ABC
|
| 141 |
+
n'impose pas de comportement en concurrence multi-process
|
| 142 |
+
— chaque implémentation documente ses garanties.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
@abstractmethod
|
| 146 |
+
def __contains__(self, key: str) -> bool:
|
| 147 |
+
"""Vrai si la clé est connue du store."""
|
| 148 |
+
|
| 149 |
+
@abstractmethod
|
| 150 |
+
def clear(self) -> None:
|
| 151 |
+
"""Supprime toutes les entrées du store.
|
| 152 |
+
|
| 153 |
+
Implémentations filesystem : supprime les fichiers de
|
| 154 |
+
l'index et des payloads. Implémentations in-memory :
|
| 155 |
+
vide les dicts.
|
| 156 |
+
"""
|
| 157 |
+
|
| 158 |
+
@abstractmethod
|
| 159 |
+
def __len__(self) -> int:
|
| 160 |
+
"""Nombre d'entrées dans le store."""
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 164 |
+
# InMemoryArtifactStore
|
| 165 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
class InMemoryArtifactStore(ArtifactStore):
|
| 169 |
+
"""Store in-memory thread-safe pour tests et runs éphémères.
|
| 170 |
+
|
| 171 |
+
Performances : O(1) en lecture/écriture. Aucune persistance —
|
| 172 |
+
toutes les données disparaissent à la sortie du process.
|
| 173 |
+
|
| 174 |
+
Thread-safety : un ``threading.Lock`` protège les opérations
|
| 175 |
+
mutantes (put, clear). Lecture (get, __contains__, __len__)
|
| 176 |
+
est sans lock car les dict Python sont atomiques par opération
|
| 177 |
+
sur clé.
|
| 178 |
+
"""
|
| 179 |
+
|
| 180 |
+
def __init__(self) -> None:
|
| 181 |
+
self._store: dict[str, StoredArtifact] = {}
|
| 182 |
+
self._lock = threading.Lock()
|
| 183 |
+
|
| 184 |
+
def get(self, key: str) -> StoredArtifact | None:
|
| 185 |
+
return self._store.get(key)
|
| 186 |
+
|
| 187 |
+
def put(
|
| 188 |
+
self,
|
| 189 |
+
key: str,
|
| 190 |
+
artifact: Artifact,
|
| 191 |
+
payload: bytes | None = None,
|
| 192 |
+
) -> None:
|
| 193 |
+
if not key:
|
| 194 |
+
raise ArtifactStoreError("ArtifactStore.put : key vide non autorisé")
|
| 195 |
+
with self._lock:
|
| 196 |
+
self._store[key] = StoredArtifact(
|
| 197 |
+
key=key, artifact=artifact, payload=payload,
|
| 198 |
+
)
|
| 199 |
+
|
| 200 |
+
def __contains__(self, key: str) -> bool:
|
| 201 |
+
return key in self._store
|
| 202 |
+
|
| 203 |
+
def clear(self) -> None:
|
| 204 |
+
with self._lock:
|
| 205 |
+
self._store.clear()
|
| 206 |
+
|
| 207 |
+
def __len__(self) -> int:
|
| 208 |
+
return len(self._store)
|
| 209 |
+
|
| 210 |
+
def keys(self) -> tuple[str, ...]:
|
| 211 |
+
"""Liste figée des clés connues (utile aux tests)."""
|
| 212 |
+
return tuple(self._store.keys())
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 216 |
+
# FilesystemArtifactStore
|
| 217 |
+
# ──────────────────────────────────────────────────────────────────────
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
class FilesystemArtifactStore(ArtifactStore):
|
| 221 |
+
"""Store persistant sur le filesystem.
|
| 222 |
+
|
| 223 |
+
Layout
|
| 224 |
+
------
|
| 225 |
+
|
| 226 |
+
``<root>/``
|
| 227 |
+
``index.jsonl`` — un JSON par ligne
|
| 228 |
+
``{"key": ..., "artifact_id": ...,
|
| 229 |
+
"has_payload": bool, "type": ...,
|
| 230 |
+
"timestamp": ISO8601}``
|
| 231 |
+
``artifacts/<key>.json`` — métadonnées de l'``Artifact``
|
| 232 |
+
sérialisées via
|
| 233 |
+
``model_dump_json()``
|
| 234 |
+
``payloads/<key>.bin`` — bytes du payload (le cas
|
| 235 |
+
échéant)
|
| 236 |
+
|
| 237 |
+
Concurrence
|
| 238 |
+
-----------
|
| 239 |
+
Un ``threading.Lock`` interne protège les opérations mutantes
|
| 240 |
+
dans le même process. Multi-process : pas de garantie ; le
|
| 241 |
+
layout est conçu pour qu'un read-only multi-process soit
|
| 242 |
+
sûr (les fichiers individuels sont écrits atomiquement via
|
| 243 |
+
``write_text(... newline=...)`` et un rename).
|
| 244 |
+
|
| 245 |
+
Garbage / corruption
|
| 246 |
+
--------------------
|
| 247 |
+
Si l'index pointe vers un fichier disparu, le ``get`` retourne
|
| 248 |
+
``None`` et logge un warning. ``clear()`` supprime tout —
|
| 249 |
+
un caller peut aussi reconstruire l'index en parsant les
|
| 250 |
+
fichiers ``artifacts/*.json``.
|
| 251 |
+
|
| 252 |
+
Pas de shim
|
| 253 |
+
-----------
|
| 254 |
+
Cette implémentation n'a pas de migration depuis l'``ArtifactCache``
|
| 255 |
+
in-memory du S7 — c'est un store distinct, instanciable
|
| 256 |
+
explicitement par un service applicatif (typiquement
|
| 257 |
+
``WorkspaceManager`` au S30+).
|
| 258 |
+
"""
|
| 259 |
+
|
| 260 |
+
INDEX_FILENAME = "index.jsonl"
|
| 261 |
+
ARTIFACTS_DIR = "artifacts"
|
| 262 |
+
PAYLOADS_DIR = "payloads"
|
| 263 |
+
|
| 264 |
+
def __init__(self, root: Path | str) -> None:
|
| 265 |
+
self._root = Path(root)
|
| 266 |
+
self._root.mkdir(parents=True, exist_ok=True)
|
| 267 |
+
(self._root / self.ARTIFACTS_DIR).mkdir(exist_ok=True)
|
| 268 |
+
(self._root / self.PAYLOADS_DIR).mkdir(exist_ok=True)
|
| 269 |
+
self._index_path = self._root / self.INDEX_FILENAME
|
| 270 |
+
self._lock = threading.Lock()
|
| 271 |
+
# In-memory index of known keys reconstructed from disk.
|
| 272 |
+
# On sait qu'on est seul écrivain dans un process donné, mais
|
| 273 |
+
# un autre process peut aussi écrire — on ne fait pas de
|
| 274 |
+
# garantie multi-process ici.
|
| 275 |
+
self._known_keys: set[str] = self._reconstruct_known_keys()
|
| 276 |
+
|
| 277 |
+
# ──────────────────────────────────────────────────────────────
|
| 278 |
+
# API ABC
|
| 279 |
+
# ──────────────────────────────────────────────────────────────
|
| 280 |
+
|
| 281 |
+
def get(self, key: str) -> StoredArtifact | None:
|
| 282 |
+
if key not in self._known_keys:
|
| 283 |
+
return None
|
| 284 |
+
artifact_path = self._root / self.ARTIFACTS_DIR / f"{key}.json"
|
| 285 |
+
if not artifact_path.exists():
|
| 286 |
+
logger.warning(
|
| 287 |
+
"[artifact_store] index pointe vers %s mais le fichier "
|
| 288 |
+
"n'existe plus — entrée corrompue, retour None.",
|
| 289 |
+
artifact_path,
|
| 290 |
+
)
|
| 291 |
+
return None
|
| 292 |
+
try:
|
| 293 |
+
artifact = Artifact.model_validate_json(
|
| 294 |
+
artifact_path.read_text(encoding="utf-8"),
|
| 295 |
+
)
|
| 296 |
+
except Exception as exc: # noqa: BLE001
|
| 297 |
+
logger.warning(
|
| 298 |
+
"[artifact_store] échec de désérialisation de %s : %s",
|
| 299 |
+
artifact_path, exc,
|
| 300 |
+
)
|
| 301 |
+
return None
|
| 302 |
+
payload_path = self._root / self.PAYLOADS_DIR / f"{key}.bin"
|
| 303 |
+
payload = (
|
| 304 |
+
payload_path.read_bytes() if payload_path.exists() else None
|
| 305 |
+
)
|
| 306 |
+
return StoredArtifact(key=key, artifact=artifact, payload=payload)
|
| 307 |
+
|
| 308 |
+
def put(
|
| 309 |
+
self,
|
| 310 |
+
key: str,
|
| 311 |
+
artifact: Artifact,
|
| 312 |
+
payload: bytes | None = None,
|
| 313 |
+
) -> None:
|
| 314 |
+
if not key:
|
| 315 |
+
raise ArtifactStoreError("ArtifactStore.put : key vide non autorisé")
|
| 316 |
+
with self._lock:
|
| 317 |
+
artifact_path = self._root / self.ARTIFACTS_DIR / f"{key}.json"
|
| 318 |
+
tmp_path = artifact_path.with_suffix(".json.tmp")
|
| 319 |
+
tmp_path.write_text(
|
| 320 |
+
artifact.model_dump_json(),
|
| 321 |
+
encoding="utf-8",
|
| 322 |
+
)
|
| 323 |
+
tmp_path.replace(artifact_path)
|
| 324 |
+
if payload is not None:
|
| 325 |
+
payload_path = self._root / self.PAYLOADS_DIR / f"{key}.bin"
|
| 326 |
+
tmp_payload = payload_path.with_suffix(".bin.tmp")
|
| 327 |
+
tmp_payload.write_bytes(payload)
|
| 328 |
+
tmp_payload.replace(payload_path)
|
| 329 |
+
self._append_index_line(key, artifact, payload is not None)
|
| 330 |
+
self._known_keys.add(key)
|
| 331 |
+
|
| 332 |
+
def __contains__(self, key: str) -> bool:
|
| 333 |
+
return key in self._known_keys
|
| 334 |
+
|
| 335 |
+
def clear(self) -> None:
|
| 336 |
+
with self._lock:
|
| 337 |
+
for sub in (self.ARTIFACTS_DIR, self.PAYLOADS_DIR):
|
| 338 |
+
d = self._root / sub
|
| 339 |
+
if d.exists():
|
| 340 |
+
for f in d.iterdir():
|
| 341 |
+
f.unlink()
|
| 342 |
+
if self._index_path.exists():
|
| 343 |
+
self._index_path.unlink()
|
| 344 |
+
self._known_keys.clear()
|
| 345 |
+
|
| 346 |
+
def __len__(self) -> int:
|
| 347 |
+
return len(self._known_keys)
|
| 348 |
+
|
| 349 |
+
def keys(self) -> tuple[str, ...]:
|
| 350 |
+
return tuple(self._known_keys)
|
| 351 |
+
|
| 352 |
+
# ──────────────────────────────────────────────────────────────
|
| 353 |
+
# Helpers internes
|
| 354 |
+
# ──────────────────────────────────────────────────────────────
|
| 355 |
+
|
| 356 |
+
def _append_index_line(
|
| 357 |
+
self, key: str, artifact: Artifact, has_payload: bool,
|
| 358 |
+
) -> None:
|
| 359 |
+
"""Append-only JSONL : une nouvelle ligne par put. Lit le
|
| 360 |
+
rapport d'index au démarrage, recompose ``_known_keys``."""
|
| 361 |
+
from datetime import datetime, timezone
|
| 362 |
+
line = json.dumps(
|
| 363 |
+
{
|
| 364 |
+
"key": key,
|
| 365 |
+
"artifact_id": artifact.id,
|
| 366 |
+
"type": artifact.type.value,
|
| 367 |
+
"has_payload": has_payload,
|
| 368 |
+
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
|
| 369 |
+
},
|
| 370 |
+
ensure_ascii=False,
|
| 371 |
+
)
|
| 372 |
+
with self._index_path.open("a", encoding="utf-8") as f:
|
| 373 |
+
f.write(line + "\n")
|
| 374 |
+
|
| 375 |
+
def _reconstruct_known_keys(self) -> set[str]:
|
| 376 |
+
"""Lit ``index.jsonl`` et reconstruit l'ensemble des clés
|
| 377 |
+
connues. Tolère les lignes corrompues (warning + skip).
|
| 378 |
+
|
| 379 |
+
Si l'index n'existe pas, recompose depuis le contenu du
|
| 380 |
+
sous-répertoire ``artifacts/`` (cas d'un store partiellement
|
| 381 |
+
copié sans son index).
|
| 382 |
+
"""
|
| 383 |
+
keys: set[str] = set()
|
| 384 |
+
if self._index_path.exists():
|
| 385 |
+
for line_no, raw_line in enumerate(
|
| 386 |
+
self._index_path.read_text(encoding="utf-8").splitlines(),
|
| 387 |
+
start=1,
|
| 388 |
+
):
|
| 389 |
+
if not raw_line.strip():
|
| 390 |
+
continue
|
| 391 |
+
try:
|
| 392 |
+
rec = json.loads(raw_line)
|
| 393 |
+
except json.JSONDecodeError as exc:
|
| 394 |
+
logger.warning(
|
| 395 |
+
"[artifact_store] index ligne %d corrompue, "
|
| 396 |
+
"ignorée : %s", line_no, exc,
|
| 397 |
+
)
|
| 398 |
+
continue
|
| 399 |
+
if "key" in rec and isinstance(rec["key"], str):
|
| 400 |
+
keys.add(rec["key"])
|
| 401 |
+
else:
|
| 402 |
+
# Recompose depuis les fichiers d'artefacts.
|
| 403 |
+
artifacts_dir = self._root / self.ARTIFACTS_DIR
|
| 404 |
+
if artifacts_dir.exists():
|
| 405 |
+
for f in artifacts_dir.iterdir():
|
| 406 |
+
if f.suffix == ".json":
|
| 407 |
+
keys.add(f.stem)
|
| 408 |
+
return keys
|
| 409 |
+
|
| 410 |
+
|
| 411 |
+
__all__ = [
|
| 412 |
+
"ArtifactKey",
|
| 413 |
+
"ArtifactStore",
|
| 414 |
+
"FilesystemArtifactStore",
|
| 415 |
+
"InMemoryArtifactStore",
|
| 416 |
+
"StoredArtifact",
|
| 417 |
+
]
|
picarones/adapters/storage/job_store.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``JobStore`` — Sprint A14-S37.
|
| 2 |
+
|
| 3 |
+
Persistance SQLite des jobs de benchmark. Adapté du legacy
|
| 4 |
+
``picarones.web.jobs`` mais réécrit nativement pour le nouveau monde :
|
| 5 |
+
API plus simple, dataclass immuable, sans dépendance au ``state``
|
| 6 |
+
global.
|
| 7 |
+
|
| 8 |
+
Le legacy reste exposé jusqu'au S46.
|
| 9 |
+
|
| 10 |
+
Pourquoi SQLite
|
| 11 |
+
---------------
|
| 12 |
+
- Survie au redémarrage : un crash ou ``kill -HUP`` ne perd pas
|
| 13 |
+
l'état des jobs en cours.
|
| 14 |
+
- Détection des jobs orphelins au boot : tout job ``running`` à
|
| 15 |
+
l'initialisation est forcément un zombie du process précédent
|
| 16 |
+
→ marqué ``interrupted``.
|
| 17 |
+
- Indexation simple par ``job_id`` (TEXT PK).
|
| 18 |
+
- Mode WAL pour les lectures concurrentes pendant qu'un thread
|
| 19 |
+
écrit la progression.
|
| 20 |
+
|
| 21 |
+
Statuts
|
| 22 |
+
-------
|
| 23 |
+
- ``pending`` : créé, en attente d'exécution.
|
| 24 |
+
- ``running`` : worker actif.
|
| 25 |
+
- ``complete`` : succès.
|
| 26 |
+
- ``error`` : échec applicatif (avec message).
|
| 27 |
+
- ``cancelled`` : interrompu par le caller.
|
| 28 |
+
- ``interrupted`` : zombie du process précédent (détecté au boot).
|
| 29 |
+
|
| 30 |
+
Les 4 derniers sont **terminaux** — un job dans cet état ne change
|
| 31 |
+
plus de statut.
|
| 32 |
+
|
| 33 |
+
API publique
|
| 34 |
+
------------
|
| 35 |
+
- ``JobStore(db_path)`` : connexion SQLite, init schema si absent.
|
| 36 |
+
- ``create(job_id, payload, total_docs=0)`` → JobRecord.
|
| 37 |
+
- ``get(job_id)`` → JobRecord | None.
|
| 38 |
+
- ``list(limit=None)`` → tuple[JobRecord, ...] triés par
|
| 39 |
+
``created_at`` décroissant.
|
| 40 |
+
- ``update_progress(job_id, progress, processed_docs, current_engine)``.
|
| 41 |
+
- ``mark_running(job_id)``.
|
| 42 |
+
- ``mark_complete(job_id, output_path="")``.
|
| 43 |
+
- ``mark_error(job_id, error_message)``.
|
| 44 |
+
- ``mark_cancelled(job_id)``.
|
| 45 |
+
- ``mark_orphaned_jobs_interrupted()`` → int (nombre marqué).
|
| 46 |
+
- ``close()`` (no-op : chaque appel ouvre/ferme sa propre connexion).
|
| 47 |
+
|
| 48 |
+
Anti-sur-ingénierie
|
| 49 |
+
-------------------
|
| 50 |
+
- Pas de notification SSE (les SSE legacy sont reportés à un sprint
|
| 51 |
+
dédié si un caller en a besoin).
|
| 52 |
+
- Pas de queue d'événements — le legacy avait ``job_events`` ; on
|
| 53 |
+
attend qu'un caller en ait besoin ; pour l'instant le statut +
|
| 54 |
+
progress suffit pour le polling.
|
| 55 |
+
- Une connexion par appel — SQLite gère ça en sub-ms.
|
| 56 |
+
"""
|
| 57 |
+
|
| 58 |
+
from __future__ import annotations
|
| 59 |
+
|
| 60 |
+
import json
|
| 61 |
+
import logging
|
| 62 |
+
import sqlite3
|
| 63 |
+
import time
|
| 64 |
+
from collections.abc import Callable
|
| 65 |
+
from dataclasses import dataclass
|
| 66 |
+
from pathlib import Path
|
| 67 |
+
from typing import Any
|
| 68 |
+
|
| 69 |
+
logger = logging.getLogger(__name__)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
_TERMINAL_STATUSES: frozenset[str] = frozenset({
|
| 73 |
+
"complete", "error", "cancelled", "interrupted",
|
| 74 |
+
})
|
| 75 |
+
|
| 76 |
+
_LIVE_STATUSES: frozenset[str] = frozenset({"pending", "running"})
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
_SCHEMA_SQL = """
|
| 80 |
+
CREATE TABLE IF NOT EXISTS jobs (
|
| 81 |
+
job_id TEXT PRIMARY KEY,
|
| 82 |
+
status TEXT NOT NULL DEFAULT 'pending',
|
| 83 |
+
progress REAL NOT NULL DEFAULT 0.0,
|
| 84 |
+
current_engine TEXT NOT NULL DEFAULT '',
|
| 85 |
+
total_docs INTEGER NOT NULL DEFAULT 0,
|
| 86 |
+
processed_docs INTEGER NOT NULL DEFAULT 0,
|
| 87 |
+
output_path TEXT NOT NULL DEFAULT '',
|
| 88 |
+
error TEXT NOT NULL DEFAULT '',
|
| 89 |
+
payload_json TEXT NOT NULL DEFAULT '{}',
|
| 90 |
+
created_at REAL NOT NULL,
|
| 91 |
+
updated_at REAL NOT NULL,
|
| 92 |
+
finished_at REAL
|
| 93 |
+
);
|
| 94 |
+
|
| 95 |
+
CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status);
|
| 96 |
+
CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs(created_at);
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@dataclass(frozen=True)
|
| 101 |
+
class JobRecord:
|
| 102 |
+
"""Snapshot immuable d'un job persisté.
|
| 103 |
+
|
| 104 |
+
Les setters mutants (``update_progress``, ``mark_*``) reconstruisent
|
| 105 |
+
un nouveau ``JobRecord`` au prochain ``get``.
|
| 106 |
+
"""
|
| 107 |
+
|
| 108 |
+
job_id: str
|
| 109 |
+
status: str
|
| 110 |
+
progress: float
|
| 111 |
+
current_engine: str
|
| 112 |
+
total_docs: int
|
| 113 |
+
processed_docs: int
|
| 114 |
+
output_path: str
|
| 115 |
+
error: str
|
| 116 |
+
payload: dict[str, Any]
|
| 117 |
+
created_at: float
|
| 118 |
+
updated_at: float
|
| 119 |
+
finished_at: float | None
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def is_terminal(self) -> bool:
|
| 123 |
+
return self.status in _TERMINAL_STATUSES
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def is_live(self) -> bool:
|
| 127 |
+
return self.status in _LIVE_STATUSES
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
from picarones.domain.errors import PicaronesError
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
class JobStoreError(PicaronesError):
|
| 134 |
+
"""Erreur de persistance SQLite côté JobStore."""
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
#: Dispatcher de migrations ascendantes ``v_n → v_{n+1}``.
|
| 138 |
+
#:
|
| 139 |
+
#: Une migration est une callable ``(sqlite3.Connection) -> None``
|
| 140 |
+
#: appliquée dans une transaction implicite (mode autocommit du
|
| 141 |
+
#: ``JobStore`` désactivé pendant la migration). Pour ajouter une
|
| 142 |
+
#: migration, déclarer une fonction ``_migrate_v1_to_v2(conn)`` qui
|
| 143 |
+
#: applique les ``ALTER TABLE`` nécessaires, puis ajouter
|
| 144 |
+
#: ``2: _migrate_v1_to_v2`` au dict. La clé est la version
|
| 145 |
+
#: **source** ; la valeur est la version **cible**.
|
| 146 |
+
_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
class JobStore:
|
| 150 |
+
"""Store SQLite des jobs de benchmark.
|
| 151 |
+
|
| 152 |
+
Parameters
|
| 153 |
+
----------
|
| 154 |
+
db_path:
|
| 155 |
+
Chemin du fichier SQLite. Créé s'il n'existe pas.
|
| 156 |
+
|
| 157 |
+
Migration de schéma
|
| 158 |
+
-------------------
|
| 159 |
+
L'ouverture d'une base SQLite vérifie sa version contre
|
| 160 |
+
``SCHEMA_VERSION`` (lue dans la table ``schema_version``) :
|
| 161 |
+
|
| 162 |
+
- Version absente → fresh DB, on insère ``SCHEMA_VERSION``.
|
| 163 |
+
- Version == code → no-op.
|
| 164 |
+
- Version < code → on applique en chaîne les migrations
|
| 165 |
+
``_MIGRATIONS`` jusqu'à atteindre ``SCHEMA_VERSION``. Si
|
| 166 |
+
l'une manque dans le dispatcher, ``JobStoreError`` (la
|
| 167 |
+
release n'a pas livré la migration nécessaire).
|
| 168 |
+
- Version > code → ``JobStoreError`` (downgrade non supporté ;
|
| 169 |
+
l'utilisateur doit utiliser un build plus récent ou
|
| 170 |
+
réinitialiser).
|
| 171 |
+
"""
|
| 172 |
+
|
| 173 |
+
#: Version du schéma SQL. À incrémenter ENSEMBLE avec une
|
| 174 |
+
#: entrée correspondante dans ``_MIGRATIONS`` (pas l'un sans
|
| 175 |
+
#: l'autre — un test architectural vérifie l'invariant).
|
| 176 |
+
SCHEMA_VERSION = 1
|
| 177 |
+
|
| 178 |
+
def __init__(self, db_path: Path | str) -> None:
|
| 179 |
+
self._path = Path(db_path)
|
| 180 |
+
self._path.parent.mkdir(parents=True, exist_ok=True)
|
| 181 |
+
with self._connect() as conn:
|
| 182 |
+
conn.executescript(_SCHEMA_SQL)
|
| 183 |
+
conn.execute(
|
| 184 |
+
"CREATE TABLE IF NOT EXISTS schema_version "
|
| 185 |
+
"(version INTEGER PRIMARY KEY)",
|
| 186 |
+
)
|
| 187 |
+
cur = conn.execute("SELECT version FROM schema_version")
|
| 188 |
+
row = cur.fetchone()
|
| 189 |
+
if row is None:
|
| 190 |
+
conn.execute(
|
| 191 |
+
"INSERT INTO schema_version (version) VALUES (?)",
|
| 192 |
+
(self.SCHEMA_VERSION,),
|
| 193 |
+
)
|
| 194 |
+
else:
|
| 195 |
+
existing = row[0]
|
| 196 |
+
if existing > self.SCHEMA_VERSION:
|
| 197 |
+
raise JobStoreError(
|
| 198 |
+
f"JobStore : base SQLite à la version "
|
| 199 |
+
f"{existing}, code à la version "
|
| 200 |
+
f"{self.SCHEMA_VERSION}. Downgrade non "
|
| 201 |
+
"supporté.",
|
| 202 |
+
)
|
| 203 |
+
if existing < self.SCHEMA_VERSION:
|
| 204 |
+
self._apply_migrations(
|
| 205 |
+
conn, from_version=existing,
|
| 206 |
+
)
|
| 207 |
+
try:
|
| 208 |
+
conn.execute("PRAGMA journal_mode = WAL;")
|
| 209 |
+
except sqlite3.Error: # pragma: no cover
|
| 210 |
+
# WAL non supporté (FAT32, NFS sans verrous) : on
|
| 211 |
+
# reste en rollback journal, fonctionnel mais moins
|
| 212 |
+
# concurrent en lecture.
|
| 213 |
+
pass
|
| 214 |
+
|
| 215 |
+
@classmethod
|
| 216 |
+
def _apply_migrations(
|
| 217 |
+
cls,
|
| 218 |
+
conn: sqlite3.Connection,
|
| 219 |
+
*,
|
| 220 |
+
from_version: int,
|
| 221 |
+
) -> None:
|
| 222 |
+
"""Applique en chaîne ``_MIGRATIONS[v]`` pour ``v`` de
|
| 223 |
+
``from_version`` à ``SCHEMA_VERSION - 1``.
|
| 224 |
+
|
| 225 |
+
Une migration manquante est une erreur dure : la release du
|
| 226 |
+
code prétend être à ``SCHEMA_VERSION`` mais n'a pas livré
|
| 227 |
+
la transformation nécessaire. ``JobStoreError`` plutôt
|
| 228 |
+
qu'un warning silencieux qui laisserait le schéma incohérent.
|
| 229 |
+
"""
|
| 230 |
+
current = from_version
|
| 231 |
+
while current < cls.SCHEMA_VERSION:
|
| 232 |
+
migrate = _MIGRATIONS.get(current)
|
| 233 |
+
if migrate is None:
|
| 234 |
+
raise JobStoreError(
|
| 235 |
+
f"JobStore : migration manquante de v{current} "
|
| 236 |
+
f"vers v{current + 1}. Le code prétend être à "
|
| 237 |
+
f"la version {cls.SCHEMA_VERSION} mais n'a pas "
|
| 238 |
+
"livré la migration.",
|
| 239 |
+
)
|
| 240 |
+
migrate(conn)
|
| 241 |
+
conn.execute(
|
| 242 |
+
"UPDATE schema_version SET version = ?",
|
| 243 |
+
(current + 1,),
|
| 244 |
+
)
|
| 245 |
+
current += 1
|
| 246 |
+
|
| 247 |
+
@property
|
| 248 |
+
def db_path(self) -> Path:
|
| 249 |
+
return self._path
|
| 250 |
+
|
| 251 |
+
def _connect(self) -> sqlite3.Connection:
|
| 252 |
+
"""Ouvre une nouvelle connexion.
|
| 253 |
+
|
| 254 |
+
``timeout=30s`` côté driver Python + ``PRAGMA busy_timeout``
|
| 255 |
+
côté SQLite absorbent les contentions courtes. Le mode
|
| 256 |
+
autocommit combiné au journal WAL garantit que les lectures
|
| 257 |
+
n'attendent pas les écritures (cf. https://sqlite.org/wal.html).
|
| 258 |
+
"""
|
| 259 |
+
conn = sqlite3.connect(
|
| 260 |
+
str(self._path),
|
| 261 |
+
isolation_level=None, # autocommit pour simplicité
|
| 262 |
+
timeout=30.0,
|
| 263 |
+
)
|
| 264 |
+
# busy_timeout (ms) — backup au timeout Python.
|
| 265 |
+
conn.execute("PRAGMA busy_timeout = 30000;")
|
| 266 |
+
conn.row_factory = sqlite3.Row
|
| 267 |
+
return conn
|
| 268 |
+
|
| 269 |
+
# ──────────────────────────────────────────────────────────────
|
| 270 |
+
# Création / lecture
|
| 271 |
+
# ──────────────────────────────────────────────────────────────
|
| 272 |
+
|
| 273 |
+
def create(
|
| 274 |
+
self,
|
| 275 |
+
job_id: str,
|
| 276 |
+
payload: dict[str, Any] | None = None,
|
| 277 |
+
total_docs: int = 0,
|
| 278 |
+
) -> JobRecord:
|
| 279 |
+
"""Crée un nouveau job en statut ``pending``.
|
| 280 |
+
|
| 281 |
+
Raises
|
| 282 |
+
------
|
| 283 |
+
JobStoreError
|
| 284 |
+
Si ``job_id`` existe déjà ou si la ligne ne s'insère
|
| 285 |
+
pas correctement.
|
| 286 |
+
"""
|
| 287 |
+
if not job_id:
|
| 288 |
+
raise JobStoreError("create : job_id vide non autorisé.")
|
| 289 |
+
now = time.time()
|
| 290 |
+
payload_json = json.dumps(payload or {}, ensure_ascii=False)
|
| 291 |
+
try:
|
| 292 |
+
with self._connect() as conn:
|
| 293 |
+
conn.execute(
|
| 294 |
+
"""
|
| 295 |
+
INSERT INTO jobs (
|
| 296 |
+
job_id, status, progress, current_engine,
|
| 297 |
+
total_docs, processed_docs, output_path, error,
|
| 298 |
+
payload_json, created_at, updated_at, finished_at
|
| 299 |
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
| 300 |
+
""",
|
| 301 |
+
(
|
| 302 |
+
job_id, "pending", 0.0, "",
|
| 303 |
+
total_docs, 0, "", "",
|
| 304 |
+
payload_json, now, now, None,
|
| 305 |
+
),
|
| 306 |
+
)
|
| 307 |
+
except sqlite3.IntegrityError as exc:
|
| 308 |
+
raise JobStoreError(
|
| 309 |
+
f"job_id {job_id!r} déjà existant.",
|
| 310 |
+
) from exc
|
| 311 |
+
return self.get(job_id) # type: ignore[return-value]
|
| 312 |
+
|
| 313 |
+
def get(self, job_id: str) -> JobRecord | None:
|
| 314 |
+
"""Retourne le snapshot du job, ou ``None`` si inconnu."""
|
| 315 |
+
with self._connect() as conn:
|
| 316 |
+
cur = conn.execute(
|
| 317 |
+
"SELECT * FROM jobs WHERE job_id = ?",
|
| 318 |
+
(job_id,),
|
| 319 |
+
)
|
| 320 |
+
row = cur.fetchone()
|
| 321 |
+
if row is None:
|
| 322 |
+
return None
|
| 323 |
+
return self._row_to_record(row)
|
| 324 |
+
|
| 325 |
+
def list(self, limit: int | None = None) -> tuple[JobRecord, ...]:
|
| 326 |
+
"""Liste les jobs triés par date de création décroissante."""
|
| 327 |
+
sql = "SELECT * FROM jobs ORDER BY created_at DESC"
|
| 328 |
+
if limit is not None:
|
| 329 |
+
sql += f" LIMIT {int(limit)}"
|
| 330 |
+
with self._connect() as conn:
|
| 331 |
+
rows = conn.execute(sql).fetchall()
|
| 332 |
+
return tuple(self._row_to_record(r) for r in rows)
|
| 333 |
+
|
| 334 |
+
# ──────────────────────────────────────────────────────────────
|
| 335 |
+
# Mutations
|
| 336 |
+
# ──────────────────────────────────────────────────────────────
|
| 337 |
+
|
| 338 |
+
def update_progress(
|
| 339 |
+
self,
|
| 340 |
+
job_id: str,
|
| 341 |
+
progress: float,
|
| 342 |
+
processed_docs: int = 0,
|
| 343 |
+
current_engine: str = "",
|
| 344 |
+
) -> None:
|
| 345 |
+
"""Met à jour la progression d'un job en ``running``.
|
| 346 |
+
|
| 347 |
+
``progress`` est tronqué à [0.0, 1.0].
|
| 348 |
+
"""
|
| 349 |
+
progress = max(0.0, min(1.0, progress))
|
| 350 |
+
now = time.time()
|
| 351 |
+
with self._connect() as conn:
|
| 352 |
+
conn.execute(
|
| 353 |
+
"""
|
| 354 |
+
UPDATE jobs
|
| 355 |
+
SET progress = ?, processed_docs = ?,
|
| 356 |
+
current_engine = ?, updated_at = ?
|
| 357 |
+
WHERE job_id = ?
|
| 358 |
+
""",
|
| 359 |
+
(progress, processed_docs, current_engine, now, job_id),
|
| 360 |
+
)
|
| 361 |
+
|
| 362 |
+
def mark_running(self, job_id: str) -> None:
|
| 363 |
+
"""Bascule le statut en ``running``."""
|
| 364 |
+
self._set_status(job_id, "running", finished=False)
|
| 365 |
+
|
| 366 |
+
def mark_complete(self, job_id: str, output_path: str = "") -> None:
|
| 367 |
+
self._set_status(
|
| 368 |
+
job_id, "complete", finished=True, output_path=output_path,
|
| 369 |
+
)
|
| 370 |
+
|
| 371 |
+
def mark_error(self, job_id: str, error_message: str) -> None:
|
| 372 |
+
self._set_status(
|
| 373 |
+
job_id, "error", finished=True, error=error_message,
|
| 374 |
+
)
|
| 375 |
+
|
| 376 |
+
def mark_cancelled(self, job_id: str) -> None:
|
| 377 |
+
self._set_status(job_id, "cancelled", finished=True)
|
| 378 |
+
|
| 379 |
+
def mark_orphaned_jobs_interrupted(self) -> int:
|
| 380 |
+
"""Marque tous les jobs ``pending``/``running`` comme
|
| 381 |
+
``interrupted``. Appelé au boot de l'app pour nettoyer les
|
| 382 |
+
zombies du process précédent.
|
| 383 |
+
|
| 384 |
+
Returns
|
| 385 |
+
-------
|
| 386 |
+
int
|
| 387 |
+
Nombre de jobs marqués.
|
| 388 |
+
"""
|
| 389 |
+
now = time.time()
|
| 390 |
+
with self._connect() as conn:
|
| 391 |
+
cur = conn.execute(
|
| 392 |
+
"""
|
| 393 |
+
UPDATE jobs
|
| 394 |
+
SET status = 'interrupted',
|
| 395 |
+
error = 'process restart',
|
| 396 |
+
updated_at = ?,
|
| 397 |
+
finished_at = ?
|
| 398 |
+
WHERE status IN ('pending', 'running')
|
| 399 |
+
""",
|
| 400 |
+
(now, now),
|
| 401 |
+
)
|
| 402 |
+
return cur.rowcount
|
| 403 |
+
|
| 404 |
+
# ──────────────────────────────────────────────────────────────
|
| 405 |
+
# Helpers privés
|
| 406 |
+
# ──────────────────────────────────────────────────────────────
|
| 407 |
+
|
| 408 |
+
def _set_status(
|
| 409 |
+
self,
|
| 410 |
+
job_id: str,
|
| 411 |
+
status: str,
|
| 412 |
+
*,
|
| 413 |
+
finished: bool,
|
| 414 |
+
output_path: str = "",
|
| 415 |
+
error: str = "",
|
| 416 |
+
) -> None:
|
| 417 |
+
now = time.time()
|
| 418 |
+
finished_at = now if finished else None
|
| 419 |
+
with self._connect() as conn:
|
| 420 |
+
if finished:
|
| 421 |
+
conn.execute(
|
| 422 |
+
"""
|
| 423 |
+
UPDATE jobs
|
| 424 |
+
SET status = ?, output_path = ?, error = ?,
|
| 425 |
+
updated_at = ?, finished_at = ?
|
| 426 |
+
WHERE job_id = ?
|
| 427 |
+
""",
|
| 428 |
+
(status, output_path, error, now, finished_at, job_id),
|
| 429 |
+
)
|
| 430 |
+
else:
|
| 431 |
+
conn.execute(
|
| 432 |
+
"""
|
| 433 |
+
UPDATE jobs
|
| 434 |
+
SET status = ?, updated_at = ?, finished_at = ?
|
| 435 |
+
WHERE job_id = ?
|
| 436 |
+
""",
|
| 437 |
+
(status, now, finished_at, job_id),
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
@staticmethod
|
| 441 |
+
def _row_to_record(row: sqlite3.Row) -> JobRecord:
|
| 442 |
+
try:
|
| 443 |
+
payload = json.loads(row["payload_json"] or "{}")
|
| 444 |
+
except json.JSONDecodeError:
|
| 445 |
+
logger.warning(
|
| 446 |
+
"[job_store] payload corrompu pour job %s — ignoré.",
|
| 447 |
+
row["job_id"],
|
| 448 |
+
)
|
| 449 |
+
payload = {}
|
| 450 |
+
return JobRecord(
|
| 451 |
+
job_id=row["job_id"],
|
| 452 |
+
status=row["status"],
|
| 453 |
+
progress=row["progress"],
|
| 454 |
+
current_engine=row["current_engine"],
|
| 455 |
+
total_docs=row["total_docs"],
|
| 456 |
+
processed_docs=row["processed_docs"],
|
| 457 |
+
output_path=row["output_path"],
|
| 458 |
+
error=row["error"],
|
| 459 |
+
payload=payload,
|
| 460 |
+
created_at=row["created_at"],
|
| 461 |
+
updated_at=row["updated_at"],
|
| 462 |
+
finished_at=row["finished_at"],
|
| 463 |
+
)
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
__all__ = [
|
| 467 |
+
"JobRecord",
|
| 468 |
+
"JobStore",
|
| 469 |
+
"JobStoreError",
|
| 470 |
+
]
|
picarones/adapters/vlm/__init__.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Adapters VLM (Vision-Language Models) — Sprint A14-S45.
|
| 2 |
+
|
| 3 |
+
VLM = transcription directe par un modèle généraliste avec vision.
|
| 4 |
+
Distinct des OCR dédiés (Tesseract, Pero, Mistral OCR, Google Vision,
|
| 5 |
+
Azure DI) — un VLM consomme IMAGE et produit RAW_TEXT via prompt
|
| 6 |
+
multimodal, sans layout structuré natif.
|
| 7 |
+
|
| 8 |
+
Adapters livrés
|
| 9 |
+
---------------
|
| 10 |
+
- ``AnthropicVLMAdapter`` : Claude Sonnet/Opus avec vision.
|
| 11 |
+
- ``OpenAIVLMAdapter`` : GPT-4o, GPT-4-turbo, GPT-4-vision-preview.
|
| 12 |
+
- ``MistralVLMAdapter`` : Pixtral 12b/Large.
|
| 13 |
+
- ``OllamaVLMAdapter`` : LLaVA, BakLLaVA, llama3.2-vision (local).
|
| 14 |
+
|
| 15 |
+
Convention StepExecutor :
|
| 16 |
+
|
| 17 |
+
- ``input_types = {IMAGE}``
|
| 18 |
+
- ``output_types = {RAW_TEXT}``
|
| 19 |
+
- ``execute(inputs, params, context)`` encode l'image en base64,
|
| 20 |
+
appelle le LLM avec un prompt de transcription, écrit le texte
|
| 21 |
+
produit dans ``<stem>.<adapter_name>.txt`` à côté de l'image,
|
| 22 |
+
retourne un Artifact RAW_TEXT.
|
| 23 |
+
|
| 24 |
+
Pas un shim sur les LLM adapters : c'est un mode d'usage
|
| 25 |
+
distinct (vision vs texte) avec un contrat StepExecutor différent.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
from picarones.adapters.vlm.anthropic_vlm import AnthropicVLMAdapter
|
| 31 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 32 |
+
from picarones.adapters.vlm.mistral_vlm import MistralVLMAdapter
|
| 33 |
+
from picarones.adapters.vlm.ollama_vlm import OllamaVLMAdapter
|
| 34 |
+
from picarones.adapters.vlm.openai_vlm import OpenAIVLMAdapter
|
| 35 |
+
|
| 36 |
+
__all__ = [
|
| 37 |
+
"BaseVLMAdapter",
|
| 38 |
+
"AnthropicVLMAdapter",
|
| 39 |
+
"MistralVLMAdapter",
|
| 40 |
+
"OllamaVLMAdapter",
|
| 41 |
+
"OpenAIVLMAdapter",
|
| 42 |
+
]
|
picarones/adapters/vlm/anthropic_vlm.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``AnthropicVLMAdapter`` — Claude Sonnet/Opus en mode vision.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S45. Délègue l'appel API au mécanisme de
|
| 4 |
+
``AnthropicAdapter`` (qui supporte déjà la vision via le SDK
|
| 5 |
+
anthropic) en surchargeant le contrat StepExecutor pour consommer
|
| 6 |
+
IMAGE au lieu de RAW_TEXT.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter
|
| 12 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter):
|
| 16 |
+
"""VLM Claude (Sonnet/Opus avec vision).
|
| 17 |
+
|
| 18 |
+
L'ordre du MRO est important : ``BaseVLMAdapter`` d'abord pour
|
| 19 |
+
surcharger ``input_types``/``output_types``/``execute``, puis
|
| 20 |
+
``AnthropicAdapter`` pour ``_call``/``default_model``/``name``/
|
| 21 |
+
retry/validation API key.
|
| 22 |
+
|
| 23 |
+
Modèles vision recommandés : ``claude-3-5-sonnet-latest``,
|
| 24 |
+
``claude-3-opus-latest``.
|
| 25 |
+
"""
|
| 26 |
+
|
| 27 |
+
@property
|
| 28 |
+
def name(self) -> str:
|
| 29 |
+
return "anthropic_vlm"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
__all__ = ["AnthropicVLMAdapter"]
|
picarones/adapters/vlm/base.py
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``BaseVLMAdapter`` — Sprint A14-S45.
|
| 2 |
+
|
| 3 |
+
Adapter VLM (Vision-Language Model) qui hérite de ``BaseLLMAdapter``
|
| 4 |
+
et surcharge le contrat StepExecutor pour consommer ``IMAGE`` au
|
| 5 |
+
lieu de ``RAW_TEXT`` et produire ``RAW_TEXT`` (transcription
|
| 6 |
+
directe par un VLM).
|
| 7 |
+
|
| 8 |
+
Pas un shim sur les LLM adapters : c'est un mode d'usage différent
|
| 9 |
+
de la même API LLM (texte vs image) — le contrat StepExecutor diffère.
|
| 10 |
+
|
| 11 |
+
Différences avec ``BaseOCRAdapter`` (S26)
|
| 12 |
+
-----------------------------------------
|
| 13 |
+
- Un OCR (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI)
|
| 14 |
+
utilise des modèles dédiés OCR avec layout structuré, confidences
|
| 15 |
+
natives, etc.
|
| 16 |
+
- Un VLM (Anthropic Claude, GPT-4-Vision, Pixtral, LLaVA) fait de la
|
| 17 |
+
transcription via un modèle généraliste prompt+image.
|
| 18 |
+
|
| 19 |
+
Les deux peuvent produire RAW_TEXT et être comparés en TextView ;
|
| 20 |
+
la projection report explicitera ce qu'on perd côté VLM (pas de
|
| 21 |
+
coordonnées spatiales nativement).
|
| 22 |
+
|
| 23 |
+
Convention output : RAW_TEXT (transcription plate). Une sous-classe
|
| 24 |
+
qui produit du markdown structuré (ex. ``CANONICAL_DOCUMENT``) peut
|
| 25 |
+
surcharger ``output_types``.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
from __future__ import annotations
|
| 29 |
+
|
| 30 |
+
import base64
|
| 31 |
+
import logging
|
| 32 |
+
from pathlib import Path
|
| 33 |
+
from typing import Any
|
| 34 |
+
|
| 35 |
+
from picarones.adapters.llm.base import BaseLLMAdapter, _DeprecatedAttribute
|
| 36 |
+
from picarones.domain.artifacts import Artifact, ArtifactType
|
| 37 |
+
from picarones.domain.errors import AdapterStepError
|
| 38 |
+
|
| 39 |
+
logger = logging.getLogger(__name__)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
class VLMAdapterError(AdapterStepError):
|
| 43 |
+
"""Erreur typée pour un échec d'adapter VLM.
|
| 44 |
+
|
| 45 |
+
Hérite de ``AdapterStepError`` — racine commune avec les erreurs
|
| 46 |
+
OCR et LLM, ce qui permet à un orchestrateur d'attraper toutes
|
| 47 |
+
les erreurs d'adapter sans connaître le type concret.
|
| 48 |
+
"""
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class BaseVLMAdapter(BaseLLMAdapter):
|
| 52 |
+
"""Adapter VLM qui transcrit une IMAGE en RAW_TEXT.
|
| 53 |
+
|
| 54 |
+
Hérite de ``BaseLLMAdapter`` et surcharge le contrat
|
| 55 |
+
``StepExecutor`` pour consommer ``IMAGE`` au lieu de ``RAW_TEXT``.
|
| 56 |
+
|
| 57 |
+
Parameters
|
| 58 |
+
----------
|
| 59 |
+
model:
|
| 60 |
+
Modèle VLM (cf. sous-classes pour les défauts).
|
| 61 |
+
config:
|
| 62 |
+
Config dict ; supporte
|
| 63 |
+
``config["transcription_prompt"]`` pour personnaliser le
|
| 64 |
+
prompt de transcription.
|
| 65 |
+
|
| 66 |
+
Garde-fou MRO
|
| 67 |
+
-------------
|
| 68 |
+
Les VLM concrets utilisent l'héritage multiple :
|
| 69 |
+
|
| 70 |
+
::
|
| 71 |
+
|
| 72 |
+
class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter)
|
| 73 |
+
|
| 74 |
+
L'ordre est critique : ``BaseVLMAdapter`` doit venir d'ABORD
|
| 75 |
+
pour que ``input_types``, ``output_types``, ``execute``, et
|
| 76 |
+
``DEFAULT_TRANSCRIPTION_PROMPTS`` soient résolus depuis lui (et
|
| 77 |
+
pas depuis le LLM sibling qui aurait des output_types =
|
| 78 |
+
{CORRECTED_TEXT}).
|
| 79 |
+
|
| 80 |
+
``__init_subclass__`` valide cet ordre à la définition de la
|
| 81 |
+
classe. Si le développeur swap accidentellement les parents
|
| 82 |
+
par habitude alphabétique, la définition de classe lève une
|
| 83 |
+
``TypeError`` immédiate au lieu d'un comportement silencieusement
|
| 84 |
+
différent (output_types incorrect au runtime).
|
| 85 |
+
"""
|
| 86 |
+
|
| 87 |
+
def __init_subclass__(cls, **kwargs) -> None:
|
| 88 |
+
super().__init_subclass__(**kwargs)
|
| 89 |
+
# Garde-fou : BaseVLMAdapter doit être le premier parent
|
| 90 |
+
# *non-trivial* dans l'ordre de la déclaration (pour gagner
|
| 91 |
+
# le MRO sur les attributs surchargés).
|
| 92 |
+
bases = cls.__bases__
|
| 93 |
+
if len(bases) <= 1:
|
| 94 |
+
# Sous-classe directe simple — pas de MRO multiple, OK.
|
| 95 |
+
return
|
| 96 |
+
# On parcourt les bases dans l'ordre déclaré.
|
| 97 |
+
try:
|
| 98 |
+
vlm_idx = next(
|
| 99 |
+
i for i, b in enumerate(bases)
|
| 100 |
+
if issubclass(b, BaseVLMAdapter)
|
| 101 |
+
)
|
| 102 |
+
except StopIteration:
|
| 103 |
+
return # ne devrait pas arriver, vlm subclass DOIT inclure VLM
|
| 104 |
+
# Toutes les bases AVANT BaseVLMAdapter doivent être
|
| 105 |
+
# neutres (mixins sans surcharge des output_types).
|
| 106 |
+
for prev in bases[:vlm_idx]:
|
| 107 |
+
if issubclass(prev, BaseLLMAdapter) and not issubclass(
|
| 108 |
+
prev, BaseVLMAdapter,
|
| 109 |
+
):
|
| 110 |
+
raise TypeError(
|
| 111 |
+
f"{cls.__name__} : ordre MRO incorrect — "
|
| 112 |
+
f"BaseVLMAdapter doit précéder {prev.__name__} "
|
| 113 |
+
"dans la liste des parents pour que les "
|
| 114 |
+
"output_types VLM ({IMAGE} → {RAW_TEXT}) "
|
| 115 |
+
"soient résolus correctement (et pas écrasés "
|
| 116 |
+
"par les output_types LLM = {CORRECTED_TEXT}). "
|
| 117 |
+
f"Corrigez : `class {cls.__name__}(BaseVLMAdapter, "
|
| 118 |
+
f"{prev.__name__})`.",
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
@property
|
| 122 |
+
def input_types(self) -> "frozenset":
|
| 123 |
+
return frozenset({ArtifactType.IMAGE})
|
| 124 |
+
|
| 125 |
+
@property
|
| 126 |
+
def output_types(self) -> "frozenset":
|
| 127 |
+
return frozenset({ArtifactType.RAW_TEXT})
|
| 128 |
+
|
| 129 |
+
#: Prompts de transcription VLM par défaut, indexés par code
|
| 130 |
+
#: langue ISO 639-1 (``fr``, ``en``, ``la``).
|
| 131 |
+
DEFAULT_TRANSCRIPTION_PROMPTS: dict[str, str] = {
|
| 132 |
+
"fr": (
|
| 133 |
+
"Transcris fidèlement le texte visible sur cette image "
|
| 134 |
+
"de document historique. Conserve l'orthographe "
|
| 135 |
+
"historique, les abréviations, et la ponctuation. "
|
| 136 |
+
"Retourne uniquement le texte transcrit, sans commentaire."
|
| 137 |
+
),
|
| 138 |
+
"en": (
|
| 139 |
+
"Faithfully transcribe the text visible in this image of "
|
| 140 |
+
"a historical document. Preserve the historical "
|
| 141 |
+
"spelling, abbreviations, and punctuation. Return only "
|
| 142 |
+
"the transcribed text, with no commentary."
|
| 143 |
+
),
|
| 144 |
+
"la": (
|
| 145 |
+
"Fideliter transcribe textum in hac imagine documenti "
|
| 146 |
+
"historici visibilem. Serva orthographiam historicam, "
|
| 147 |
+
"abbreviationes, et interpunctionem. Redde solum textum "
|
| 148 |
+
"transcriptum, sine ulla glossa."
|
| 149 |
+
),
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
#: Alias rétrocompat (FR uniquement) pour les sous-classes
|
| 153 |
+
#: externes qui lisaient l'ancienne API singulière. L'accès
|
| 154 |
+
#: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0.
|
| 155 |
+
DEFAULT_TRANSCRIPTION_PROMPT = _DeprecatedAttribute(
|
| 156 |
+
DEFAULT_TRANSCRIPTION_PROMPTS["fr"],
|
| 157 |
+
"BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT is deprecated "
|
| 158 |
+
"and will be removed in 2.0. Use "
|
| 159 |
+
"DEFAULT_TRANSCRIPTION_PROMPTS[lang] (lang ∈ {fr, en, la}).",
|
| 160 |
+
)
|
| 161 |
+
|
| 162 |
+
def execute(
|
| 163 |
+
self,
|
| 164 |
+
inputs: dict,
|
| 165 |
+
params: dict,
|
| 166 |
+
context: Any,
|
| 167 |
+
) -> dict:
|
| 168 |
+
"""Exécute la transcription VLM.
|
| 169 |
+
|
| 170 |
+
Lit ``inputs[IMAGE]`` (URI), encode en base64, appelle
|
| 171 |
+
``self.complete(prompt, image_b64)``, écrit le résultat
|
| 172 |
+
dans ``<stem>.<name>.txt`` à côté de l'image, et retourne
|
| 173 |
+
``{RAW_TEXT: Artifact}``.
|
| 174 |
+
"""
|
| 175 |
+
if ArtifactType.IMAGE not in inputs:
|
| 176 |
+
raise VLMAdapterError(
|
| 177 |
+
f"{self.name} : input IMAGE manquant.",
|
| 178 |
+
)
|
| 179 |
+
image_artifact = inputs[ArtifactType.IMAGE]
|
| 180 |
+
if image_artifact.uri is None:
|
| 181 |
+
raise VLMAdapterError(
|
| 182 |
+
f"{self.name} : artefact image "
|
| 183 |
+
f"{image_artifact.id!r} sans URI.",
|
| 184 |
+
)
|
| 185 |
+
image_path = Path(image_artifact.uri)
|
| 186 |
+
if not image_path.exists():
|
| 187 |
+
raise VLMAdapterError(
|
| 188 |
+
f"{self.name} : image introuvable {image_path!r}.",
|
| 189 |
+
)
|
| 190 |
+
|
| 191 |
+
image_b64 = base64.b64encode(
|
| 192 |
+
image_path.read_bytes(),
|
| 193 |
+
).decode("ascii")
|
| 194 |
+
|
| 195 |
+
# Override explicite > prompt par langue > FR (fallback).
|
| 196 |
+
custom = self.config.get("transcription_prompt")
|
| 197 |
+
if custom is not None:
|
| 198 |
+
prompt = custom
|
| 199 |
+
else:
|
| 200 |
+
lang = (self.config.get("lang") or "fr").lower()
|
| 201 |
+
if lang not in self.DEFAULT_TRANSCRIPTION_PROMPTS:
|
| 202 |
+
logger.warning(
|
| 203 |
+
"[%s] lang=%r non supportée par "
|
| 204 |
+
"DEFAULT_TRANSCRIPTION_PROMPTS (%s) — fallback FR. "
|
| 205 |
+
"Pour un corpus dans cette langue, fournir "
|
| 206 |
+
"config['transcription_prompt'] explicite.",
|
| 207 |
+
self.name, lang,
|
| 208 |
+
sorted(self.DEFAULT_TRANSCRIPTION_PROMPTS.keys()),
|
| 209 |
+
)
|
| 210 |
+
prompt = self.DEFAULT_TRANSCRIPTION_PROMPTS.get(
|
| 211 |
+
lang, self.DEFAULT_TRANSCRIPTION_PROMPTS["fr"],
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
result = self.complete(prompt, image_b64=image_b64)
|
| 215 |
+
if not result.success:
|
| 216 |
+
raise VLMAdapterError(
|
| 217 |
+
f"{self.name} : VLM a échoué ({result.error}).",
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
from picarones.adapters.output_paths import resolve_output_path
|
| 221 |
+
out_path = resolve_output_path(
|
| 222 |
+
input_path=image_path,
|
| 223 |
+
adapter_name=self.name,
|
| 224 |
+
suffix="txt",
|
| 225 |
+
context=context,
|
| 226 |
+
)
|
| 227 |
+
out_path.write_text(result.text, encoding="utf-8")
|
| 228 |
+
|
| 229 |
+
return {
|
| 230 |
+
ArtifactType.RAW_TEXT: Artifact(
|
| 231 |
+
id=f"{context.document_id}:{self.name}:raw_text",
|
| 232 |
+
document_id=context.document_id,
|
| 233 |
+
type=ArtifactType.RAW_TEXT,
|
| 234 |
+
produced_by_step="vlm_transcription",
|
| 235 |
+
uri=str(out_path),
|
| 236 |
+
),
|
| 237 |
+
}
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
__all__ = ["BaseVLMAdapter", "VLMAdapterError"]
|
picarones/adapters/vlm/mistral_vlm.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``MistralVLMAdapter`` — Pixtral 12b/Large (vision Mistral).
|
| 2 |
+
|
| 3 |
+
Sprint A14-S45. Délègue à ``MistralAdapter`` qui supporte la
|
| 4 |
+
vision via les modèles ``pixtral-12b-2409``, ``pixtral-large-latest``.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from picarones.adapters.llm.mistral_adapter import MistralAdapter
|
| 10 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class MistralVLMAdapter(BaseVLMAdapter, MistralAdapter):
|
| 14 |
+
"""VLM Mistral (pixtral-12b-2409, pixtral-large-latest)."""
|
| 15 |
+
|
| 16 |
+
@property
|
| 17 |
+
def name(self) -> str:
|
| 18 |
+
return "mistral_vlm"
|
| 19 |
+
|
| 20 |
+
@property
|
| 21 |
+
def default_model(self) -> str:
|
| 22 |
+
# Ré-définit le défaut pour pointer vers un modèle vision.
|
| 23 |
+
return "pixtral-12b-2409"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
__all__ = ["MistralVLMAdapter"]
|
picarones/adapters/vlm/ollama_vlm.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OllamaVLMAdapter`` — Modèles vision locaux via Ollama.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S45. Délègue à ``OllamaAdapter`` (local, sans clé API).
|
| 4 |
+
Modèles vision recommandés : ``llava``, ``llava:13b``, ``bakllava``,
|
| 5 |
+
``llama3.2-vision``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from picarones.adapters.llm.ollama_adapter import OllamaAdapter
|
| 11 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OllamaVLMAdapter(BaseVLMAdapter, OllamaAdapter):
|
| 15 |
+
"""VLM local via Ollama (llava, bakllava, llama3.2-vision)."""
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def name(self) -> str:
|
| 19 |
+
return "ollama_vlm"
|
| 20 |
+
|
| 21 |
+
@property
|
| 22 |
+
def default_model(self) -> str:
|
| 23 |
+
return "llava"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
__all__ = ["OllamaVLMAdapter"]
|
picarones/adapters/vlm/openai_vlm.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``OpenAIVLMAdapter`` — GPT-4-Vision / GPT-4o (vision).
|
| 2 |
+
|
| 3 |
+
Sprint A14-S45. Délègue à ``OpenAIAdapter`` qui supporte déjà la
|
| 4 |
+
vision via les modèles ``gpt-4o``, ``gpt-4-turbo``,
|
| 5 |
+
``gpt-4-vision-preview``.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
from picarones.adapters.llm.openai_adapter import OpenAIAdapter
|
| 11 |
+
from picarones.adapters.vlm.base import BaseVLMAdapter
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class OpenAIVLMAdapter(BaseVLMAdapter, OpenAIAdapter):
|
| 15 |
+
"""VLM OpenAI (gpt-4o, gpt-4-turbo, gpt-4-vision-preview)."""
|
| 16 |
+
|
| 17 |
+
@property
|
| 18 |
+
def name(self) -> str:
|
| 19 |
+
return "openai_vlm"
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
__all__ = ["OpenAIVLMAdapter"]
|
picarones/app/__init__.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Cercle 4 — Application services.
|
| 2 |
+
|
| 3 |
+
Couche d'orchestration : reçoit des requêtes (DTO Pydantic) depuis
|
| 4 |
+
``interfaces/``, valide tout (chemins sandboxés, quotas, mode
|
| 5 |
+
public/dev), assemble adapters + pipeline + evaluation, retourne
|
| 6 |
+
des résultats sérialisables.
|
| 7 |
+
|
| 8 |
+
C'est ici que les **6 P0 du S1** trouvent leur foyer définitif au
|
| 9 |
+
S19 : ``WorkspaceManager`` qui isole les chemins par session,
|
| 10 |
+
``BenchmarkService`` qui orchestre run + projections + persistance,
|
| 11 |
+
``RegistryService`` qui construit les registres explicitement.
|
| 12 |
+
|
| 13 |
+
Sous-packages :
|
| 14 |
+
|
| 15 |
+
- ``services/`` — un service par domaine fonctionnel
|
| 16 |
+
(BenchmarkService, CorpusService, ReportService, JobService,
|
| 17 |
+
RegistryService, WorkspaceManager).
|
| 18 |
+
- ``schemas/`` — DTO Pydantic pour API et CLI. **Séparés** des
|
| 19 |
+
modèles de domaine pour éviter le couplage transport ↔ métier.
|
| 20 |
+
|
| 21 |
+
Règle d'import : peut importer domain/, evaluation/, pipeline/,
|
| 22 |
+
formats/, adapters/. Ne doit **jamais** importer interfaces/.
|
| 23 |
+
"""
|
| 24 |
+
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
__all__: list[str] = []
|
picarones/app/results.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""``RunResult`` et ``RunDocumentResult`` — agrégats applicatifs d'un run.
|
| 2 |
+
|
| 3 |
+
Sprint A14-S17 (créé) / S26 (déplacé depuis ``domain/`` car
|
| 4 |
+
agrège des objets de ``evaluation/`` et ``pipeline/`` — la couche
|
| 5 |
+
``domain`` n'a pas le droit d'importer de ces couches plus
|
| 6 |
+
externes).
|
| 7 |
+
|
| 8 |
+
Structure
|
| 9 |
+
---------
|
| 10 |
+
Un ``RunResult`` est l'agrégat complet d'un run :
|
| 11 |
+
|
| 12 |
+
::
|
| 13 |
+
|
| 14 |
+
RunResult
|
| 15 |
+
├── manifest: RunManifest
|
| 16 |
+
└── document_results: tuple[RunDocumentResult, ...]
|
| 17 |
+
├── document_id: str
|
| 18 |
+
├── pipeline_results: tuple[PipelineResult, ...]
|
| 19 |
+
│ (un par pipeline du run)
|
| 20 |
+
└── view_results: tuple[ViewResult, ...]
|
| 21 |
+
(un par couple (vue, pipeline_éligible_à_la_vue))
|
| 22 |
+
|
| 23 |
+
Le ``RunResult`` est sérialisable JSON pour persistance
|
| 24 |
+
(typiquement éclaté en plusieurs fichiers : ``run_manifest.json``,
|
| 25 |
+
``pipeline_results.jsonl``, ``view_results.jsonl`` — cf.
|
| 26 |
+
``picarones.app.services.benchmark_service``).
|
| 27 |
+
|
| 28 |
+
Anti-sur-ingénierie
|
| 29 |
+
-------------------
|
| 30 |
+
Pas d'agrégation pré-calculée (rang par vue, moyennes par
|
| 31 |
+
pipeline, etc.) dans le ``RunResult`` lui-même — c'est de la
|
| 32 |
+
**présentation**, pas du domain. Le rapport HTML (S22) calcule
|
| 33 |
+
ses agrégats à la volée depuis les ``ViewResult`` listés.
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
from __future__ import annotations
|
| 37 |
+
|
| 38 |
+
from collections.abc import Callable
|
| 39 |
+
from pathlib import Path
|
| 40 |
+
|
| 41 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 42 |
+
|
| 43 |
+
from picarones.domain.run_manifest import RunManifest
|
| 44 |
+
from picarones.evaluation.views.base import ViewResult
|
| 45 |
+
from picarones.pipeline.types import PipelineResult
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class RunDocumentResult(BaseModel):
|
| 49 |
+
"""Tous les résultats d'un run pour un seul document.
|
| 50 |
+
|
| 51 |
+
Agrège :
|
| 52 |
+
- Les ``PipelineResult`` (un par pipeline exécutée). Permet
|
| 53 |
+
de reconstituer ce qui a été produit (artefacts, durées,
|
| 54 |
+
erreurs).
|
| 55 |
+
- Les ``ViewResult`` (un par couple ``(view, pipeline)`` où le
|
| 56 |
+
pipeline a produit un artefact éligible à la vue). Les
|
| 57 |
+
pipelines OMIS d'une vue n'ont PAS de ``ViewResult`` pour
|
| 58 |
+
cette vue (pattern d'omission explicite — cf. AltoView S15).
|
| 59 |
+
|
| 60 |
+
Le caller (typiquement le rapport HTML) reconstruit les
|
| 61 |
+
associations ``pipeline ↔ view_result`` via le champ
|
| 62 |
+
``ViewResult.candidate_artifact_id`` qui pointe vers
|
| 63 |
+
``Artifact.produced_by_step`` (lui-même corrélé au pipeline).
|
| 64 |
+
"""
|
| 65 |
+
|
| 66 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 67 |
+
|
| 68 |
+
document_id: str = Field(min_length=1, max_length=256)
|
| 69 |
+
pipeline_results: tuple[PipelineResult, ...] = Field(default_factory=tuple)
|
| 70 |
+
view_results: tuple[ViewResult, ...] = Field(default_factory=tuple)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class RunResult(BaseModel):
|
| 74 |
+
"""Agrégat complet d'un run de benchmark.
|
| 75 |
+
|
| 76 |
+
Sérialisable JSON. En pratique, persisté en plusieurs
|
| 77 |
+
fichiers (cf. ``BenchmarkService.persist``) pour permettre
|
| 78 |
+
une lecture sélective et un streaming jsonl.
|
| 79 |
+
"""
|
| 80 |
+
|
| 81 |
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
| 82 |
+
|
| 83 |
+
manifest: RunManifest
|
| 84 |
+
document_results: tuple[RunDocumentResult, ...] = Field(default_factory=tuple)
|
| 85 |
+
|
| 86 |
+
@property
|
| 87 |
+
def n_documents(self) -> int:
|
| 88 |
+
return len(self.document_results)
|
| 89 |
+
|
| 90 |
+
def view_results_for(self, view_name: str) -> tuple[ViewResult, ...]:
|
| 91 |
+
"""Retourne tous les ``ViewResult`` du run pour une vue donnée.
|
| 92 |
+
|
| 93 |
+
Utile pour l'agrégation par vue (rangs, moyennes) côté
|
| 94 |
+
rapport HTML. Préserve l'ordre d'apparition.
|
| 95 |
+
"""
|
| 96 |
+
out: list[ViewResult] = []
|
| 97 |
+
for doc in self.document_results:
|
| 98 |
+
for vr in doc.view_results:
|
| 99 |
+
if vr.view_name == view_name:
|
| 100 |
+
out.append(vr)
|
| 101 |
+
return tuple(out)
|
| 102 |
+
|
| 103 |
+
def pipeline_results_for(self, pipeline_name: str) -> tuple[PipelineResult, ...]:
|
| 104 |
+
"""Retourne tous les ``PipelineResult`` d'un pipeline donné."""
|
| 105 |
+
out: list[PipelineResult] = []
|
| 106 |
+
for doc in self.document_results:
|
| 107 |
+
for pr in doc.pipeline_results:
|
| 108 |
+
if pr.pipeline_name == pipeline_name:
|
| 109 |
+
out.append(pr)
|
| 110 |
+
return tuple(out)
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
#: Type alias d'un renderer de rapport injecté par le caller.
|
| 114 |
+
#:
|
| 115 |
+
#: Signature canonique partagée par le ``RunOrchestrator`` (qui
|
| 116 |
+
#: l'invoque) et le ``JobRunner`` (qui le transmet). Reçoit
|
| 117 |
+
#: ``(run_result, output_path, lang)``, écrit le fichier et retourne
|
| 118 |
+
#: le ``Path`` effectivement écrit (généralement identique à
|
| 119 |
+
#: ``output_path``, mais le renderer peut changer l'extension).
|
| 120 |
+
ReportRenderer = Callable[["RunResult", Path, str], Path]
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
__all__ = ["ReportRenderer", "RunDocumentResult", "RunResult"]
|