Marcel Bautista-Kuljevan commited on
Commit
adcd765
·
unverified ·
2 Parent(s): 99934092d27757

Merge pull request #55 from maribakulj/claude/repo-analysis-cukvm

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .github/workflows/ci.yml +29 -6
  2. .gitignore +3 -0
  3. BACKLOG_POST_LIVRAISON.md +228 -0
  4. CHANGELOG.md +282 -0
  5. README.md +52 -29
  6. codecov.yml +97 -0
  7. docs/audits/institutional-readiness-2026-05.md +1 -1
  8. docs/migration/executor-equivalence.md +165 -0
  9. docs/migration/rewrite-status-s46.md +185 -0
  10. docs/roadmap/rewrite-2026.md +185 -0
  11. docs/views/alto-view.md +113 -0
  12. docs/views/comparing-views.md +117 -0
  13. docs/views/text-view.md +144 -0
  14. picarones/adapters/__init__.py +28 -0
  15. picarones/adapters/_retry.py +143 -0
  16. picarones/adapters/corpus/__init__.py +16 -0
  17. picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc +0 -0
  18. picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc +0 -0
  19. picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc +0 -0
  20. picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc +0 -0
  21. picarones/adapters/corpus/_fallback_log.py +98 -0
  22. picarones/adapters/corpus/htr_united.py +473 -0
  23. picarones/adapters/corpus/huggingface.py +464 -0
  24. picarones/adapters/llm/__init__.py +16 -0
  25. picarones/adapters/llm/anthropic_adapter.py +111 -0
  26. picarones/adapters/llm/base.py +486 -0
  27. picarones/adapters/llm/mistral_adapter.py +157 -0
  28. picarones/adapters/llm/ollama_adapter.py +109 -0
  29. picarones/adapters/llm/openai_adapter.py +94 -0
  30. picarones/adapters/ocr/__init__.py +39 -0
  31. picarones/adapters/ocr/azure_doc_intel.py +376 -0
  32. picarones/adapters/ocr/base.py +173 -0
  33. picarones/adapters/ocr/confidences.py +164 -0
  34. picarones/adapters/ocr/google_vision.py +306 -0
  35. picarones/adapters/ocr/mistral_ocr.py +336 -0
  36. picarones/adapters/ocr/pero_ocr.py +232 -0
  37. picarones/adapters/ocr/precomputed.py +219 -0
  38. picarones/adapters/ocr/tesseract.py +327 -0
  39. picarones/adapters/output_paths.py +78 -0
  40. picarones/adapters/storage/__init__.py +58 -0
  41. picarones/adapters/storage/artifact_store.py +417 -0
  42. picarones/adapters/storage/job_store.py +470 -0
  43. picarones/adapters/vlm/__init__.py +42 -0
  44. picarones/adapters/vlm/anthropic_vlm.py +32 -0
  45. picarones/adapters/vlm/base.py +240 -0
  46. picarones/adapters/vlm/mistral_vlm.py +26 -0
  47. picarones/adapters/vlm/ollama_vlm.py +26 -0
  48. picarones/adapters/vlm/openai_vlm.py +22 -0
  49. picarones/app/__init__.py +27 -0
  50. 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: ${{ secrets.CODECOV_TOKEN }}
107
  files: coverage.xml
108
  flags: unittests
109
  name: picarones-coverage
110
- fail_ci_if_error: true
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 15.0
 
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 platform**
13
  >
14
- > **Banc d'essai d'OCR / HTR / VLM et de post-correction pour documents patrimoniaux**
15
 
16
- [![CI](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml/badge.svg)](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml)
 
 
 
 
 
 
 
 
17
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
18
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
19
  [![Code style: ruff](https://img.shields.io/badge/lint-ruff-46aef7.svg)](https://github.com/astral-sh/ruff)
@@ -23,22 +31,25 @@ pinned: false
23
 
24
  ## What is Picarones?
25
 
26
- **Picarones** is an open-source benchmarking platform for OCR, HTR, VLM
27
- and post-correction pipelines on **heritage documents** (manuscripts,
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 at every relevant
34
- level (text, ALTO, PAGE, entities, reading order), and produces a
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**: ~3871 tests, ~3 min on a modern laptop. Coverage
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/audits/`](docs/audits/) — institutional readiness audit
419
- and remediation plan (sprints A1A15).
420
-
421
- The **Phase 1 of the institutional readiness plan** (sprints A1–A11)
422
- is complete as of May 2026: CI hardening, doc consistency gates,
423
- 3-circle refactor, web hardening, perf+concurrency tests, WCAG 2.1
424
- AA accessibility, reproducibility ops (lock files, Docker pinning),
425
- PyPI/ghcr.io release pipeline, governance & COI policies,
426
- institutional deployment guide & RGPD documentation.
427
-
428
- Remaining: scientific publication track (CITATION + JOSS, sprint
429
- A12), README/SPECS final polish (this sprint and A14), external
430
- audits (RGAA + security pentest, A15).
 
 
 
 
 
 
 
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 will land in Sprint A12
455
- (scientific publication track). Until then, cite the GitHub repo
456
- with the commit SHA used in your benchmark — every Picarones report
457
- embeds the commit and full snapshot for reproducibility (cf.
458
- [`docs/reproducibility-snapshots.md`](docs/reproducibility-snapshots.md)).
 
 
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
+ [![CI](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml/badge.svg)](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml) [![codecov](https://codecov.io/gh/maribakulj/Picarones/graph/badge.svg)](https://codecov.io/gh/maribakulj/Picarones)
25
  [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/)
26
  [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE)
27
  [![Code style: ruff](https://img.shields.io/badge/lint-ruff-46aef7.svg)](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 (S1S26) 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 15.0` sur Tesseract+Pero. Exécuter
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"]