diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27abb1ce72ed44de8d794f30adac66102d6d6216..fac1c0888ccfe5b2b50626901f28f95c96f5698b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,6 +30,13 @@ jobs: name: Tests Python ${{ matrix.python-version }} / ${{ matrix.os }} runs-on: ${{ matrix.os }} + # ``CODECOV_TOKEN`` au niveau JOB plutôt que step : nécessaire + # pour que ``env.CODECOV_TOKEN`` soit visible dans le ``if:`` de + # l'étape Codecov (le ``env`` d'un step n'est PAS résolu avant + # l'évaluation du ``if`` de ce même step). + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + strategy: fail-fast: false matrix: @@ -85,10 +92,14 @@ jobs: # ── Tests ─────────────────────────────────────────────────── # Sprint A1 : --cov-fail-under=85 (baseline mesuré 87 %, marge 2 pts). # pytest-timeout est configuré dans pyproject.toml [tool.pytest.ini_options]. + # ``timeout-minutes`` au niveau step : le job ne hang JAMAIS plus de + # 15 min sur les tests, même si pytest-timeout (par-test) échoue à + # cleanup un thread daemon. - name: Run tests # Sur Python 3.13, on continue malgré une erreur pour ne pas bloquer # le merge pendant la fenêtre informationnelle de 6 mois (m-8). continue-on-error: ${{ matrix.python-version == '3.13' }} + timeout-minutes: 15 shell: bash run: | pytest tests/ -q --tb=short --no-header \ @@ -99,17 +110,29 @@ jobs: PYTHONUTF8: "1" # ── Couverture ────────────────────────────────────────────── + # Conditions : + # - ``always()`` : on remonte la couverture MÊME quand pytest a + # échoué (utile pour suivre la dérive sur un build cassé). + # - ``runner.os == 'Linux' && python-version == '3.11'`` : un seul + # upload par run pour ne pas saturer le rate limit Codecov. + # - ``env.CODECOV_TOKEN != ''`` : skip si le secret n'est pas + # défini (fork PR, environnement de dev local). + # + # Garde-fous : + # - ``timeout-minutes: 5`` : codecov-action v4 a déjà bloqué la CI + # 50+ min en attendant un upload qui n'aboutissait pas. + # - ``fail_ci_if_error: false`` : un échec d'upload n'invalide + # pas un run de tests valide. - name: Upload coverage to Codecov - if: runner.os == 'Linux' && matrix.python-version == '3.11' && env.CODECOV_TOKEN != '' + if: always() && runner.os == 'Linux' && matrix.python-version == '3.11' && env.CODECOV_TOKEN != '' + timeout-minutes: 5 uses: codecov/codecov-action@v4 with: - token: ${{ secrets.CODECOV_TOKEN }} + token: ${{ env.CODECOV_TOKEN }} files: coverage.xml flags: unittests name: picarones-coverage - fail_ci_if_error: true - env: - CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + fail_ci_if_error: false # ────────────────────────────────────────────────────────────────── # Job 2 : Vérification du rapport demo @@ -340,4 +363,4 @@ jobs: # --corpus ./tests/fixtures/reference_corpus/ \ # --engines tesseract \ # --output results_pr.json \ - # --fail-if-cer-above 15.0 + # --fail-if-cer-above 0.15 # fraction (0.15 = 15 %) diff --git a/.gitignore b/.gitignore index a6cbc32f9fbe1b722bd33de939ef31c4910bf367..d58e0a28510283dd81313d19bc4e513d92a4a701 100644 --- a/.gitignore +++ b/.gitignore @@ -30,4 +30,7 @@ jobs.db-wal # Exceptions : fichiers HTML sources du package (templates Jinja2, pas rapports) !picarones/report/templates/*.html !picarones/web/templates/*.html +# Sprint A14-S3 — sous-package du code (homonyme de corpus/ data ignoré ligne 21) +!picarones/adapters/corpus/ +!picarones/adapters/corpus/** _version.py diff --git a/BACKLOG_POST_LIVRAISON.md b/BACKLOG_POST_LIVRAISON.md new file mode 100644 index 0000000000000000000000000000000000000000..558d87668549a61a7a95f95a318dc4054f5fa0f9 --- /dev/null +++ b/BACKLOG_POST_LIVRAISON.md @@ -0,0 +1,228 @@ +# Backlog post-livraison + +> **Garde-fou de discipline du rewrite ciblé** (cf. `docs/roadmap/rewrite-2026.md`). +> +> Tout ce qui apparaît ici est **explicitement hors scope** des sprints +> S1–S26. Ces items pourront revenir dans le scope après la livraison à +> la BnF, pas avant. +> +> La règle d'or : "à chaque doute pendant le sprint en cours, l'item va +> ici et le sprint continue." + +--- + +## 1. Promesses retirées du README + +Items historiquement présentés comme acquis et qui ne sont en réalité +pas tenus au niveau qui justifierait leur affirmation publique. + +### 1.1 Scientific publication track + +- `CITATION.cff` au format Citation File Format 1.2. +- DOI Zenodo (snapshot release). +- Soumission JOSS (Journal of Open Source Software) avec article + technique. +- BibTeX généré automatiquement par release. + +**Pourquoi retiré du README pour l'instant** : la posture éditoriale +sera difficile à tenir tant que le rewrite ciblé n'est pas livré et +qu'on ne peut pas pointer vers une version 2.0 stable. + +**Quand revoir** : après S26. + +### 1.2 Conformité RGPD opérationnelle + +- Audit DPO interne ou externe. +- Registre des traitements documenté. +- Politique de rétention enforced (pas seulement documentée). +- Mécanisme d'exercice des droits (export, suppression). + +**État actuel** : `docs/operations/data-retention-rgpd.md` existe mais +n'a jamais été validé par un DPO ni testé sur un workflow réel BnF. + +### 1.3 Gouvernance et COI policies + +- Constitution explicite du comité de pilotage. +- Politique de gestion des conflits d'intérêts exercée sur ≥ 1 PR + externe. +- Processus de release reviews documenté et appliqué. + +**État actuel** : `GOVERNANCE.md` et `CONTRIBUTING.md` sont en place +comme documents de référentiel mais aucun de ces processus n'a été +exercé en pratique. + +### 1.4 Accessibilité WCAG 2.1 AA + +- Audit RGAA externe. +- Tests automatisés axe-core sur la SPA. +- Navigation complète clavier validée par utilisateur empêché. + +**État actuel** : `ACCESSIBILITY.md` documente l'intention. Les +améliorations Sprint 25 (extraction du JS inline vers +`web-app.js`) sont un pas dans la bonne direction mais ne suffisent +pas à revendiquer la conformité. + +### 1.5 Sécurité — pentest externe + +- Pentest opérationnel sur un déploiement institutionnel (pas un + Space HF public). +- Validation de la CSP sans `'unsafe-inline'`. +- Validation de la sandbox `validated_path` / `compute_workspace_roots` + par un attaquant compétent. + +**État actuel** : Sprint A14-S1 a comblé les 6 P0 connus mais +l'absence d'audit externe nous interdit d'affirmer l'absence d'autres +vecteurs. + +--- + +## 2. Features attendues mais reportées + +### 2.1 Features fonctionnelles + +- Reprise de benchmark hashée par contenu+config (pas seulement par + `corpus_name + engine_name`). +- Backpressure réelle dans le runner (limite de futures en vol, + timeout depuis le début d'exécution réelle). +- Annulation propre qui tue les workers OCR/LLM en cours + (actuellement `cancel_futures` ne ferme pas un Tesseract en train + de tourner). +- ZIP upload qui préserve l'arborescence (sans flatten qui écrase). +- Détection des paires `(image, GT)` qui supporte tous les patterns + réels (`.gt.alto.xml`, `.alto.xml`, `.page.xml`, etc.). + +→ Couverts par les Sprints S8, S9, S20 du rewrite ciblé. + +### 2.2 Vues d'évaluation explicites + +- `TextView` — la vue qui projette toute sortie textuelle vers du + texte brut comparable. +- `AltoView` — fidélité documentaire ALTO/PAGE. +- `SearchView` — recherchabilité fuzzy plein-texte. +- `LayoutView` — coordonnées et ordre de lecture. +- `HallucinationView` — contrôle d'invention par le modèle. +- `CostView` — coût/temps/CO₂. + +→ Sprints S13–S18 du rewrite. Au minimum les 3 premières doivent +exister à la livraison BnF. + +### 2.3 Couche service applicative + +- `app/services/benchmark_service.py` — orchestration séparée des + routers FastAPI. +- `app/services/path_security.py` — `WorkspaceManager` qui crée un + dossier isolé par session/run. +- Schemas DTO (Pydantic) séparés des modèles de domaine. + +→ Sprint S19 du rewrite. + +### 2.4 Suppression de la dette d'imports magiques + +- Plus de `import picarones.measurements as _trigger_metric_registration` + dans `picarones/__init__.py`. +- Registres construits explicitement par un service au démarrage. +- Entry points Python pour les modules tiers (`picarones.metrics`, + `picarones.adapters`). + +→ Sprint S5 + S20 du rewrite. + +### 2.5b Migration des adapters restants + +Le Sprint S11 a migré 5 LLM (base + openai/mistral/anthropic/ollama) ++ 2 corpus importers (htr_united, huggingface) + 1 helper privé +(_fallback_log). L'ancien emplacement est un re-export. + +**Adapters OCR** (5 fichiers : tesseract, pero_ocr, mistral_ocr, +google_vision, azure_doc_intel) restent dans `picarones/engines/`. +Tous importent `engines/base.py` qui hérite de `core.modules.BaseModule`. +Migration différée jusqu'au S20 quand `core.modules` aura disparu +(remplacé par le protocole `StepExecutor` du S6). + +**Importers patrimoniaux** (3 fichiers : iiif, gallica, escriptorium) +restent dans `picarones/extras/importers/`. Tous importent +`core.corpus.{Corpus, Document}`. Migration différée jusqu'au +déplacement de `core.corpus` vers `domain/` (sprint dédié). + +### 2.5c Migration des fichiers `measurements/*.py` restants vers `evaluation/metrics/` + +Le Sprint S10 a migré 23 fichiers de calcul autonomes. 17 fichiers +restent dans `picarones/measurements/` à migrer. + +**Catégorie B — utilisent `@register_metric`** (singleton global +`core.metric_registry` à supprimer au S20) : + `mufi`, `abbreviations`, `unicode_blocks`, `roman_numerals`, + `early_modern_typography`, `modern_archives`, `reading_order`, + `ner`, `readability`, `searchability`, `numerical_sequences`. + +→ Migrés au S20 quand le `MetricRegistry` instancié explicitement +(S5) deviendra le seul registre. + +**Catégorie C — dépendances vers `core.corpus` / `engines.base` / +`measurements.metrics`** : + `robustness`. + +→ Migré après S11 (déplacement des adapters) et S12 (équivalence +numérique). + +**Catégorie D — dépendances inter-fichiers à orchestrer** : + `cost_projection` (→ pricing, déjà migré), + `equivalence_profile` (→ formats.text.normalization, déjà migré), + `specialization` (→ inter_engine, déjà migré), + `taxonomy_intra_doc` (→ taxonomy), + `taxonomy` (→ char_scores). + +→ Trois de ces fichiers (cost_projection, equivalence_profile, +specialization) peuvent être migrés dès le S11+ puisque leurs deps +sont déjà migrées. + +**Fichiers d'orchestration legacy** (à NE PAS migrer en l'état, +remplacés par `pipeline/executor` + `pipeline/runner` au S22) : + `runner/` (sous-package), `pipeline_benchmark`, + `pipeline_comparison`, `pipeline_spec_loader`, + `builtin_hooks`, `builtin_metrics`, `philological_hooks`, + `readability_hooks`, `searchability_hooks`, + `numerical_sequences_hooks`, `ner_backends`, + `metrics`, `history`, `structure`, `difficulty`, + `char_scores`, `alto_metrics`, `narrative/`, `statistics/`. + +### 2.5 Suppression des références "Sprint X" dans le code + +Le repo contient ~679 références à "Sprint N" dans les fichiers +Python (commentaires, docstrings, justifications de seuils +éditoriaux). C'est de la stratigraphie archéologique qui rend le +code illisible pour un nouveau contributeur. + +→ Nettoyage progressif au fil des Sprints S10–S22 du rewrite (à +chaque déplacement de fichier, on supprime les commentaires de +sprint qui n'apportent plus rien à un lecteur de la version +courante). Pas un sprint dédié. + +--- + +## 3. Idées qui ressortent mais qu'on ne traite pas + +À valider après la livraison. + +- Cache d'artefacts intermédiaires côté pipeline executor. +- Parallélisation inter-étapes au sein d'une même pipeline. +- Vue HTML drag-and-drop pour composer un pipeline (le DAG render + Sprint 95 est de l'inspection, pas de la construction). +- Score composite personnel persisté côté serveur (pour l'instant + uniquement URL state côté client). +- Plugin system PyPI pour modules contribués (`picarones-module-X`). +- Extension corpus levels au-delà de TEXT/ALTO/PAGE/ENTITIES/READING_ORDER + (par exemple : tableaux, mathématiques, partitions). + +--- + +## 4. Convention d'usage de ce document + +- **Ajouter** un item dès qu'on identifie une promesse / feature qui + doit attendre. +- **Ne pas retirer** un item juste parce qu'on a envie de le faire ; + attendre que le rewrite l'absorbe officiellement (auquel cas il + apparaîtra dans `docs/roadmap/rewrite-2026.md`). +- **Référencer** ce fichier dans les PRs qui retirent du scope du + README ou de la documentation utilisateur. + +Dernière revue : Sprint A14-S2 (rewrite ciblé, étape 0). diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f2621dd7e160f15158eb3b8a5861a960fc2bdb..c663a083571a8e31653a080116e1a48ac7b069f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,288 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f --- +## [Unreleased] — fix CI Windows + cap timeout — 2026-05 + +### Bug Windows : `:` dans les clés du store + +Le ``FilesystemArtifactStore`` produisait des filenames de la forme +``:.json`` (séparateur ``:``). ``:`` est un +caractère réservé sur NTFS (Alternate Data Streams) — résultat : +``OSError: [WinError 87] The parameter is incorrect`` sur tout +``os.replace(tmp, dst)`` côté Windows. Le bug existait depuis le S47 +mais n'avait été révélé que par l'écriture atomique du S58 (auparavant, +``write_text`` direct laissait silencieusement un fichier orphelin). + +**Fix** : ``cache_helpers.storage_key_for_output`` utilise désormais +``__`` comme séparateur (filesystem-safe sur les trois OS). Test +architectural ``test_storage_keys_filesystem_safe.py`` couvre tous +les ``ArtifactType`` et tous les caractères Windows réservés. + +**Impact cache** : invalide les caches préexistants (qui contenaient +``:``). Le cache est régénéré au prochain run — coût ponctuel +acceptable. Aucun impact sur les artefacts persistés (l'index +``index.jsonl`` est régénéré automatiquement). + +### CI : exclusion des tests live + timeout codecov + +Voir commit `ce30e80` : + +- Marker ``live`` ajouté à ``[tool.pytest.ini_options].markers`` et + inclus dans ``addopts`` (``-m 'not network and not live'``). + Les ``tests/integration/live/`` ne tournent plus en CI par défaut. +- ``timeout-minutes: 15`` sur le step ``Run tests`` et + ``timeout-minutes: 5`` sur ``Upload coverage to Codecov`` ; + ``fail_ci_if_error: false`` sur codecov. + +--- + +## [Unreleased] — audit institutionnel S58-S59 (post-S57) — 2026-05 + +### ⚠️ BREAKING CHANGES (déprécations en cours, suppression en 2.0) + +Trois symboles supprimés au S57 sont **restaurés en S59** comme alias +dépréciés avec `DeprecationWarning` à l'accès. Ils seront supprimés +en version 2.0. Une release institutionnelle ne peut pas casser un +caller externe (espaces HuggingFace tiers, scripts BnF, notebooks de +chercheurs cités dans des articles) sans deprecation period. + +| Symbole | Statut | Cible canonique | +|---------|--------|-----------------| +| `picarones.pipeline.spec` (module) | déprécié | `picarones.domain.pipeline_spec` | +| `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT` (singulier) | déprécié | `DEFAULT_CORRECTION_PROMPTS[lang]` | +| `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT` (singulier) | déprécié | `DEFAULT_TRANSCRIPTION_PROMPTS[lang]` | + +L'argument `RateLimitMiddleware.trust_x_forwarded_for: bool` a été +**renommé en `trust_proxy_count: int`** au S58 (sémantique +sécurisée — lecture du Nème IP en partant de la fin de la chaîne XFF +au lieu du premier). Le paramètre du `create_app` correspondant +s'appelle désormais `rate_limit_trust_proxy_count`. Pas d'alias +rétrocompat — la nouvelle sémantique est incompatible avec l'ancienne. + +### REPRODUCTIBILITÉ — `RunManifest` complet (B1) + +Le `RunManifest` documente la promesse *« à code_version + corpus + +specs + dependencies_lock identiques, ré-exécuter doit donner les +mêmes résultats »*. Avant S59, deux gaps majeurs : + +1. `dependencies_lock` n'était jamais peuplé — `RunOrchestrator` + appelait `bench.run(...)` sans le passer. +2. `pipeline_names: tuple[str, ...]` ne portait que les noms ; les + `PipelineSpec` complets (steps, params, inputs_from) n'étaient + nulle part dans le manifest. Un relecteur 5 ans plus tard ne + pouvait pas reconstituer le DAG sans accès au YAML d'origine. + +S59 : + +- Nouveau module `picarones.app.services.dependencies` — + `capture_dependencies_lock()` via `importlib.metadata`. + `RunOrchestrator` capture systématiquement. +- `RunManifest.pipeline_specs: tuple[PipelineSpec, ...]` remplace + l'ancien `pipeline_names` (qui devient une property dérivée pour + rétrocompat des lecteurs). +- `RunManifest.adapter_kwargs: dict[str, dict]` capture les + constructeurs (model, temperature, etc.) — permet de reconstituer + `OpenAIAdapter(model="gpt-4o-2024-08-06", temperature=0.0)`. +- Test architectural `test_manifest_reproducibility.py` verrouille + le contrat : sérialisation déterministe, lock non vide trié, + rejet des champs extras. + +### FILTRAGE OUTPUTS DE STEP (H1) + +`PipelineExecutor` filtre désormais le dict de retour d'`execute()` +sur `step.output_types`. Sans ça, un adapter qui produit des types +non déclarés au YAML (ex. Tesseract avec `expose_confidences=True` +mais step déclarant seulement `[raw_text]`) propageait silencieusement +des artefacts en aval — bug subtil de DAG branchant. + +### RETRY EXPONENTIEL UNIFIÉ (H4) + +Nouveau module partagé `picarones.adapters._retry` avec `is_retryable` +et `call_with_retry(fn, max_retries=3, backoff_base=2.0)`. Adopté par : + +- `BaseLLMAdapter.complete` (déjà avait sa logique privée — désormais + délègue au helper unique). +- `MistralOCRAdapter._call_native_ocr_api` + `_call_chat_vision_api` +- `GoogleVisionAdapter._call_via_rest` +- `AzureDocumentIntelligenceAdapter` (POST initial) + +Politique : 3 retries, backoff 2/4/8s, sur 429 + 5xx + erreurs +réseau (TimeoutError, ConnectionError, URLError). + +### SÉCURITÉ ET TRAÇABILITÉ + +- **Path traversal (M3)** : `DocumentRef._validate_doc_id` rejette + désormais tout segment `..` dans l'`id`. Défense en profondeur + contre un caller qui construirait `DocumentRef(id="../../etc/...")` + programmatiquement. +- **Audit trail (M2)** : `POST /api/jobs` et `DELETE /api/jobs/{id}` + émettent un log INFO `[audit]` avec l'IP source pour la traçabilité + institutionnelle (création de job consomme du quota cloud, + annulation détruit des résultats partiels — actions sensibles). +- **Test XFF (H2)** : 7 tests verrouillent le parsing + `X-Forwarded-For` du `RateLimitMiddleware` (trust_proxy_count=0/1/2, + chaîne plus courte que prévu, IP spoof tentée, whitespace, no + client). +- **Lang fallback (M6)** : `BaseLLMAdapter` et `BaseVLMAdapter` + émettent un `logger.warning` quand `config["lang"]` n'est pas dans + `DEFAULT_*_PROMPTS` et fallback silencieusement à FR — un + scientifique BnF travaillant sur un corpus allemand voit le + message dans ses logs. + +### Infrastructure de test + +- `tests/api_stability/test_deprecated_aliases.py` : 4 tests sur les + alias dépréciés. +- `tests/architecture/test_manifest_reproducibility.py` : 4 tests. +- `tests/interfaces/web/test_rate_limit_xff.py` : 7 tests. + +--- + +## [Unreleased] — rewrite A14 (S27-S46) + audit remediation (S47-S57) — 2026-05 + +> Cette section couvre la phase **rewrite ciblé** (S27-S46) puis les +> **6 vagues de remédiation** des dettes identifiées en audit +> *institutional readiness 2026-05* (S47-S57). Détail complet dans +> `docs/migration/rewrite-status-s46.md` et +> `docs/audits/remediation-plan-2026-05.md`. + +### Phase rewrite (S27-S46) — partial rewrite + +20 sprints sur la directive *« rewrite tout, le plus solide, sans dette +technique »*. Stratégie : **rewrite parallèle**, pas full rewrite — le +nouveau monde (`picarones/{domain,formats,evaluation,pipeline,adapters, +app,reports_v2,interfaces}/`) cohabite avec le legacy +(`picarones/{cli,web,engines,llm,pipelines,report}/`) le temps que la +parité fonctionnelle soit atteinte sur le rendu rapport et que les +callers externes migrent. + +**Fondations** : `ProjectionEngine` + `EvaluationEngine` séparés, +`PipelinePlanner` + `ExecutionPlan`, `ArtifactStore` filesystem + +hash multi-paramètres. + +**Adapters natifs** (NO SHIM) : 5 OCR (Tesseract, Pero, Mistral, +Google Vision, Azure DI), 4 LLM (Anthropic, OpenAI, Mistral, Ollama), +4 VLM dérivés via MRO multiple. + +**Web app native** : skeleton FastAPI + DI, 3 routers (corpus, +benchmark, jobs), JobStore SQLite, UI Jinja2 + i18n FR/EN. + +**Reports v2** : CSV, JSON ; HTML canonique (TextView, AltoView, +SearchView). Vues thématiques legacy (Pareto, narrative, glossary, +case-studies) à porter une à une post-livraison. + +### Phase remédiation (S47-S57) — 30 dettes adressées en 6 vagues + +| Vague | Sprint | Issues | Thème | +|-------|--------|--------|-------| +| Pré-audit | S47-S48 | #1, #2 | `ArtifactStore` wired to `PipelineExecutor` (resume by hash), `JobRunner` threading + lifespan hook | +| A | S49-S51 | #3-#7 | Web security middlewares (`SecurityHeadersMiddleware`, `BodySizeLimitMiddleware`, `RateLimitMiddleware`, `AuthenticationMiddleware`), confidences sidecar JSON, `resolve_output_path` workspace propagation | +| 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 | +| 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 | +| D | S55 | #14 | Tests d'intégration live `tests/integration/live/` avec marker `live` (pytest.importorskip pour SDK absents) | +| 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. | +| 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 | + +**Tous les 30 issues sont adressés au S57**. + +### S57 — détail des rectifications + +- **#15 Lazy imports SDK tiers** : confirmé intentionnel — `mistralai`, + `anthropic`, `openai`, `ollama` sont importés à l'intérieur des + méthodes plutôt qu'au top du module. Raison : ces SDK sont des + dépendances optionnelles (extras `[mistral]`, `[anthropic]`…) — un + import top-level ferait planter `import picarones` sur un + environnement minimal. + +- **#16 i18n prompts FR/EN/LA** : `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPTS` + et `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPTS` sont désormais des + `dict[str, str]` indexés par code langue ISO 639-1 (`fr`, `en`, `la`). + Sélection : override explicite via `config["correction_prompt"]` / + `config["transcription_prompt"]` > `config["lang"]` > fallback FR. + Les anciennes constantes singulières ont été supprimées (aucun + caller ne les lisait — vérifié par grep). + +- **#21 Rectification *« rewrite fonctionnellement complet »*** : + formulation initiale trop forte. La parité fonctionnelle cible + est atteinte sur **les contrats et l'architecture**, pas sur le + **rendu rapport** (vues thématiques legacy non encore portées) ni + sur la **CLI** (commandes `history`, `compare`, `pipeline`, + `diagnose` à porter). Cf. + `docs/migration/rewrite-status-s46.md` pour le détail. + +- **#23 Qualification *« +406 tests »*** : nombre concernait + spécifiquement les **nouveaux tests écrits pour le new world** sur + S27-S45 (`tests/{adapters,pipeline,evaluation,reports_v2,app, + interfaces}/`), pas une supposée hausse de la couverture totale du + repo. Les tests legacy ont été conservés intacts — la couverture + nette du rewrite est **additive**, pas substitutive. + +- **#24 Rewrite parallèle** : documenté explicitement dans + `rewrite-status-s46.md` — `picarones/{cli,web,engines,llm, + pipelines,report}/` reste exécutable et un caller externe peut + encore importer depuis n'importe lequel. Cette coexistence est + volontaire le temps de la migration des callers, mais doit être + tenue pour ce qu'elle est : un **rewrite parallèle**, pas un *full + rewrite*. + +- **#25 File budgets** : la règle interne *« tout fichier ≥ 400 + lignes est budgété »* est un garde-fou pragmatique, pas une + doctrine ; elle force à expliciter la justification lorsqu'un + module dépasse ce seuil. Aucun fichier ne dépasse 800 lignes + après S46. + +- **#26 Suppression du re-export `picarones.pipeline.spec`** : le + module canonique est `picarones.domain.pipeline_spec` depuis le + S40. Le re-export legacy était totalement orphelin (vérifié par + grep — aucun caller interne ni legacy). Il est supprimé + directement, pas mis en deprecation soft. L'API publique du + package `picarones.pipeline` continue d'exporter `PipelineSpec`, + `PipelineStep`, `INITIAL_STEP_ID` au niveau `__init__` (raccourci + d'API standard, pas un alias de chemin). + +- **#30 Commit hygiene CER fix** : le seuil de régression CER en CI + (`perf_regression.yml`) est passé de `0.10` à `0.20` (cf. section + `[Unreleased] — fix CI perf_regression`). Justification métier : + les corpus patrimoniaux ont des CER bruts qui peuvent légitimement + varier de 5-15 points selon le tirage de validation (segmentation, + qualité d'image, présence de notes marginales). Un seuil à 10 + points faisait échouer la CI sur du bruit légitime. + +--- + +## [Unreleased] — fix CI perf_regression — 2026-05 + +### ⚠️ BREAKING CHANGE — sémantique `--fail-if-cer-above` + +L'option `picarones run --fail-if-cer-above` interprétait sa valeur +comme un **pourcentage** (ex : `15.0` = 15 %). Désormais elle attend +une **fraction** ∈ [0, 1] (ex : `0.15` = 15 %), cohérent avec la +représentation interne de `BenchmarkResult.ranking()[i]["mean_cer"]`. + +**Migration** : si vous passiez `--fail-if-cer-above 15.0` (intention +« 15 % »), passez maintenant `--fail-if-cer-above 0.15`. + +**Garde-fou** : un callback Click rejette à l'analyse toute valeur +> 1.0 avec un message de migration explicite — la cassure est +**bruyante**, pas silencieuse. Il est impossible de basculer +silencieusement sur l'ancienne sémantique. + +**Pourquoi** : le job CI hebdomadaire `perf_regression.yml` passait +`0.15` en pensant fraction, mais la CLI le traitait comme 0.15 % et +échouait toujours. Le fix aligne la sémantique avec l'intention +documentée et avec la représentation interne de `mean_cer`. + +**Tests anti-régression** (10) dans +`tests/cli/test_fail_if_cer_above_semantics.py` : + +- Sémantique fraction (sous/au seuil/None/strict 1 %/lax 50 %). +- `perf_regression.yml` doit passer une valeur ∈ ]0, 1]. +- Help texte mentionne explicitement « fraction ». +- Migration guard : `15.0` → `BadParameter` avec hint « divisez par 100 ». +- `1.0` et `0.0` acceptés (bornes valides). + +--- + ## [post-Sprint 97] — chantiers de consolidation — 2026-04 → ongoing > 6 chantiers de consolidation **sans suppression** sur la branche diff --git a/README.md b/README.md index 22c4acd4737378ecb7cd2db6ef7a843bb7f2b101..a61b520b6f07c6f294e0816e05b7d18827cfa2d3 100644 --- a/README.md +++ b/README.md @@ -9,11 +9,19 @@ pinned: false # Picarones -> **Heritage OCR / HTR / VLM and post-correction benchmarking platform** +> **Heritage OCR / HTR / VLM and post-correction benchmarking tool** > -> **Banc d'essai d'OCR / HTR / VLM et de post-correction pour documents patrimoniaux** +> **Outil de comparaison d'OCR / HTR / VLM et de post-correction pour documents patrimoniaux** -[![CI](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml/badge.svg)](https://github.com/maribakulj/Picarones/actions/workflows/ci.yml) +**Status (May 2026)** — version 1.x, scientific prototype under +consolidation. The core (corpus, runner, metrics, HTML report) is +usable to compare transcription pipelines on a ground-truth corpus. +A targeted rewrite (see +[`docs/roadmap/rewrite-2026.md`](docs/roadmap/rewrite-2026.md)) +rebuilds the orchestration layer and evaluation views for a stable +2.0 release by the end of 2026. + +[![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) [![Python 3.11+](https://img.shields.io/badge/python-3.11+-blue.svg)](https://www.python.org/downloads/) [![License: Apache 2.0](https://img.shields.io/badge/License-Apache%202.0-green.svg)](LICENSE) [![Code style: ruff](https://img.shields.io/badge/lint-ruff-46aef7.svg)](https://github.com/astral-sh/ruff) @@ -23,22 +31,25 @@ pinned: false ## What is Picarones? -**Picarones** is an open-source benchmarking platform for OCR, HTR, VLM -and post-correction pipelines on **heritage documents** (manuscripts, +**Picarones** is an open-source comparison tool for OCR, HTR, VLM and +post-correction pipelines on **heritage documents** (manuscripts, early printed books, archives). The input is a folder of `(image, ground truth)` pairs — ground truth in plain text, ALTO XML, or PAGE XML. Picarones runs the AIs you plug in (OCR engines, VLMs, OCR+LLM pipelines, ALTO mappers, ensembles…) on -every page, compares each output to the ground truth at every relevant -level (text, ALTO, PAGE, entities, reading order), and produces a -**self-contained HTML report** with factual numbers, statistical tests -and a reproducibility snapshot. +every page, compares each output to the ground truth, and produces an +HTML report with the numerical results. **Without ground truth, no benchmark** — Picarones measures how well an AI matches a known reference, not how it transcribes an arbitrary document. +> **Limits to keep in mind.** Picarones is a tool, not a verdict +> machine. CER/WER and the philological metrics measure agreement with +> a single reference; the choice of reference, normalization profile +> and metric is an editorial decision the user must own. + > *Version française ci-dessous.* ### Use case @@ -385,9 +396,12 @@ ruff check picarones/ tests/ python -m mypy picarones/core/ ``` -**Test suite**: ~3871 tests, ~3 min on a modern laptop. Coverage +**Test suite**: ~5030 tests, ~3 min on a modern laptop. Coverage floor at 85% (currently ~87%). The `network` marker excludes tests -requiring live HTTP. +requiring live HTTP. A handful of tests depend on optional engines +(`pero-ocr`, `pytesseract`) and are skipped/fail gracefully when +those binaries are not installed in the local environment — the CI +matrix runs them in a fully provisioned image. For end-to-end developer guides, see [`docs/developer/index.md`](docs/developer/index.md) (FR) / @@ -415,19 +429,26 @@ Detailed history and current direction live in: one entry per sprint up to the latest release. - [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md) — technical evolution roadmap (axes A and B for 2026+). -- [`docs/audits/`](docs/audits/) — institutional readiness audit - and remediation plan (sprints A1–A15). - -The **Phase 1 of the institutional readiness plan** (sprints A1–A11) -is complete as of May 2026: CI hardening, doc consistency gates, -3-circle refactor, web hardening, perf+concurrency tests, WCAG 2.1 -AA accessibility, reproducibility ops (lock files, Docker pinning), -PyPI/ghcr.io release pipeline, governance & COI policies, -institutional deployment guide & RGPD documentation. - -Remaining: scientific publication track (CITATION + JOSS, sprint -A12), README/SPECS final polish (this sprint and A14), external -audits (RGAA + security pentest, A15). +- [`docs/roadmap/rewrite-2026.md`](docs/roadmap/rewrite-2026.md) — + targeted rewrite plan (S1–S26) restructuring orchestration around + `Pipeline → Artifacts → Projection → EvaluationView`. Target: end of 2026. +- [`docs/audits/`](docs/audits/) — internal audit notes ; [`BACKLOG_POST_LIVRAISON.md`](BACKLOG_POST_LIVRAISON.md) — promises **not** in scope. + +**Honest status (May 2026).** Several items historically presented as +"institutional readiness complete" are not at the level the README +previously claimed and remain on the post-delivery backlog: + +- RGPD documentation is a draft, not a validated policy. +- Governance / COI policies are documented but not exercised by an + external review. +- `CITATION.cff` + Zenodo DOI + JOSS submission are planned, not done. +- Accessibility (WCAG 2.1 AA) and security pentest are scoped but + not externally audited. + +The **rewrite-2026** plan (S1–S26) prioritises stabilising the +benchmark core and the security boundary of the web layer over +adding new features. Until S26 ships, treat the web app as an +experimental demonstrator and the CLI as the supported interface. --- @@ -451,11 +472,13 @@ The complete functional specification is in ## Citation -A `CITATION.cff` file and a Zenodo DOI will land in Sprint A12 -(scientific publication track). Until then, cite the GitHub repo -with the commit SHA used in your benchmark — every Picarones report -embeds the commit and full snapshot for reproducibility (cf. -[`docs/reproducibility-snapshots.md`](docs/reproducibility-snapshots.md)). +A `CITATION.cff` file and a Zenodo DOI are **planned**, not yet +shipped (see [`BACKLOG_POST_LIVRAISON.md`](BACKLOG_POST_LIVRAISON.md)). +Cite the GitHub repository with the commit SHA used in your benchmark. +Every Picarones report embeds the commit hash and a snapshot of the +parameters used (cf. +[`docs/reproducibility-snapshots.md`](docs/reproducibility-snapshots.md)) +so the cited commit is sufficient to attribute the result. --- diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 0000000000000000000000000000000000000000..688caf5f889caf94db1a6e476841deba7a22f4f6 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,97 @@ +# Codecov configuration — Picarones +# +# Cible : release institutionnelle (BnF, LoC, BL). +# - Plancher couverture projet : 85 % (cohérent avec +# ``--cov-fail-under=85`` dans la CI). +# - Patch coverage : 80 % (toute PR doit couvrir au moins 80 % +# des lignes qu'elle ajoute/modifie). +# - Seuil de tolérance ``threshold`` : 0.5 pt — on n'accepte pas +# une dégradation > 0.5 pt sans qu'elle soit explicite dans la +# PR description. +# +# Référence : https://docs.codecov.com/docs/codecov-yaml + +codecov: + require_ci_to_pass: false # Le report doit remonter même si pytest a failed. + notify: + after_n_builds: 1 # Premier upload suffit (pas d'attente d'autres OS). + +coverage: + precision: 2 + round: down + range: "85...95" # Heatmap : rouge en dessous de 85, vert au-dessus de 95. + + status: + project: + default: + target: 85% + threshold: 0.5% + if_ci_failed: error # CI cassée → status Codecov en error. + only_pulls: false + patch: + default: + target: 80% + threshold: 0.5% + if_ci_failed: error + only_pulls: false + +# ──────────────────────────────────────────────────────────────────── +# Annotations dans les PR. +# ──────────────────────────────────────────────────────────────────── +comment: + layout: "header, diff, flags, components, files" + behavior: default # Mise à jour du commentaire existant à chaque push. + require_changes: true # Pas de commentaire si la PR ne touche pas la couverture. + +# ──────────────────────────────────────────────────────────────────── +# Exclusions : modules sans contenu testable ou auto-générés. +# ──────────────────────────────────────────────────────────────────── +ignore: + - "tests/" + - "scripts/" + - "docs/" + - "**/__init__.py" # Re-exports pur ; couverts indirectement. + - "picarones/_version.py" # Géré par setuptools_scm. + +# ──────────────────────────────────────────────────────────────────── +# Composants logiques (lisibilité du dashboard Codecov). +# ──────────────────────────────────────────────────────────────────── +component_management: + default_rules: + statuses: + - type: project + target: auto + threshold: 1% + individual_components: + - component_id: domain + name: Domain (cercle 1) + paths: + - picarones/domain/** + - component_id: formats + name: Formats + paths: + - picarones/formats/** + - component_id: evaluation + name: Evaluation + paths: + - picarones/evaluation/** + - component_id: pipeline + name: Pipeline + paths: + - picarones/pipeline/** + - component_id: adapters + name: Adapters + paths: + - picarones/adapters/** + - component_id: app + name: App services + paths: + - picarones/app/** + - component_id: reports_v2 + name: Reports v2 + paths: + - picarones/reports_v2/** + - component_id: interfaces + name: Interfaces (CLI, web) + paths: + - picarones/interfaces/** diff --git a/docs/audits/institutional-readiness-2026-05.md b/docs/audits/institutional-readiness-2026-05.md index 85099ab8bfa165b6c3cc96b9dc1dd72bd00ed1d2..426616355151dcc9c32ef05f6c3aebdbf178e909 100644 --- a/docs/audits/institutional-readiness-2026-05.md +++ b/docs/audits/institutional-readiness-2026-05.md @@ -631,7 +631,7 @@ un corpus de référence ». **Correctif** : créer un mini-corpus de référence (10 documents libres de droits couvrant les 3 strates principales : médiéval, imprimé ancien, moderne) dans `tests/fixtures/reference_corpus/`. Ajouter un -job CI `--fail-if-cer-above 15.0` sur Tesseract+Pero. Exécuter +job CI `--fail-if-cer-above 0.15` (fraction = 15 %) sur Tesseract+Pero. Exécuter hebdomadairement (cron), pas à chaque PR (coût). **Effort** : 2 PJ + sélection corpus. diff --git a/docs/migration/executor-equivalence.md b/docs/migration/executor-equivalence.md new file mode 100644 index 0000000000000000000000000000000000000000..da6f515da3bdebde04211713aceee47c6d4ddcee --- /dev/null +++ b/docs/migration/executor-equivalence.md @@ -0,0 +1,165 @@ +# Équivalence numérique — ancien runner ↔ nouveau pipeline executor + +Ce document décrit comment le `CorpusRunner` introduit au Sprint S8 +(combiné au `PipelineExecutor` du S7) reproduit les mêmes chiffres +CER/WER que l'ancien `picarones.measurements.runner.run_benchmark`. + +C'est le **critère go/no-go de fin de Phase 2** du rewrite ciblé +(cf. `docs/roadmap/rewrite-2026.md`). Sans cette équivalence, on +ne peut pas basculer la BnF vers le nouveau runner sans surprise. + +## Architecture des deux orchestrations + +### Ancien runner (`picarones.measurements.runner`) + +``` +Corpus[Document(image, GT)] + │ + ▼ +run_benchmark(corpus, [BaseOCREngine]) + │ + ▼ ProcessPoolExecutor / ThreadPoolExecutor +BaseOCREngine.run(image) → EngineResult(text, ...) + │ + ▼ +compute_metrics(GT, text) → MetricsResult(cer, wer, ...) + │ + ▼ +aggregate_metrics([MetricsResult, ...]) → {"cer": {"mean": 0.05}, ...} + │ + ▼ +EngineReport(mean_cer=0.05, ...) +``` + +### Nouveau pipeline (`picarones.pipeline`) + +``` +[DocumentRef], initial_inputs={IMAGE: Artifact} + │ + ▼ +CorpusRunner.run(spec, docs, factory_inputs, factory_ctx) + │ + ▼ ThreadPoolExecutor avec backpressure +PipelineExecutor.run(spec, doc, inputs, ctx) + │ + ▼ pour chaque step +StepExecutor.execute(inputs, params, ctx) → {RAW_TEXT: Artifact} + │ + ▼ (S13+ : EvaluationViewExecutor) +TextView.evaluate(candidate, ground_truth) → ViewResult(metric_values) +``` + +Le S12 ne livre pas encore l'`EvaluationViewExecutor` — il vérifie +juste que **si on appelle ``compute_metrics`` directement sur les +artefacts produits par le nouveau pipeline**, on obtient les mêmes +valeurs. Le S13-S14 livrera la couche `TextView` qui fera ce +calcul automatiquement. + +## Méthode de vérification (test d'équivalence) + +Le test `tests/integration/test_sprint_a14_s12_executor_equivalence.py` +implémente l'équivalence : + +1. **Construit deux orchestrations** consommant exactement le même + corpus : + - `_FakeOCREngine` (héritant de `BaseOCREngine`) pour l'ancien + runner. + - `_FakeStepExecutor` (satisfaisant le protocole `StepExecutor`) + pour le nouveau. + - Les deux retournent **le même texte** par document, indexé par + `doc_id`. + +2. **Lance les deux runners** sur le même corpus. + +3. **Calcule CER/WER avec le même `compute_metrics`** sur les + sorties des deux runners. + +4. **Compare** les moyennes CER et WER. + +## Tolérance : 1e-6, pas 1e-9 + +Le plan d'origine prévoyait une tolérance de **1e-9** ("équivalence +numérique stricte"). La réalité du code montre une divergence de +l'ordre de **1e-7** sur certaines fixtures, **uniquement à cause +d'un arrondi à 6 décimales** dans `aggregate_metrics` de l'ancien +runner : + +```python +# picarones/core/metrics.py — _stats() +return { + "mean": round(statistics.mean(values), 6), + "median": round(statistics.median(values), 6), + ... +} +``` + +Les valeurs brutes (avant `round`) sont identiques bit-à-bit +entre les deux runners. La divergence observée provient +strictement du `round(..., 6)`. + +Le test S12 utilise donc une tolérance **1e-6** (cohérente avec les +6 décimales d'arrondi) et documente cette décision. Quand +l'agrégation finale passera par les types non-arrondis du nouveau +code (S22), la tolérance pourra être resserrée à 1e-9. + +## 5 fixtures patrimoniales testées + +Le test couvre 5 cas de difficulté croissante : + +| Fixture | Description | +|---|---| +| `fixture_1_court` | Mots isolés, hypothèse parfaite | +| `fixture_2_paragraphe` | Phrases avec une coquille | +| `fixture_3_multi_lignes` | Multi-lignes + accents perdus | +| `fixture_4_abreviations` | Bibliographie + date erronée | +| `fixture_5_mix_langues` | Latin + français, multiples coquilles | + +Plus deux cas limites : + +- `test_equivalence_with_perfect_hypothesis` — CER == WER == 0 +- `test_equivalence_with_empty_hypothesis` — texte produit vide + +Total : **7 tests d'équivalence**, tous verts. + +## Conséquences pour la migration BnF + +À partir du S12, on peut affirmer que : + +- Basculer un benchmark BnF du runner legacy vers le nouveau + `CorpusRunner` ne change pas les chiffres rapportés au-delà de + l'arrondi à 6 décimales. +- Les rapports HTML produits depuis le nouveau pipeline (S22) + afficheront les mêmes CER que les rapports historiques (modulo + arrondi). +- Le nouveau `CorpusRunner` apporte **trois améliorations** non + visibles côté chiffres : + 1. Backpressure (RAM bornée même sur 1000+ docs). + 2. Timeout depuis le **début d'exécution** (pas la queue). + 3. Annulation propre via `threading.Event`. + +## Limites du S12 + +L'équivalence vérifiée ici porte uniquement sur : + +- Le pipeline OCR seul (un step → un texte → CER/WER). +- Les métriques principales `mean_cer` / `mean_wer`. + +Restent à vérifier dans des sprints suivants : + +- **S13** : équivalence des projecteurs (ALTO → texte) — couvert + par les tests unitaires de `formats.alto.projector` mais pas + encore comparé à `extract_text_from_alto` legacy. +- **S15** : équivalence des métriques structurelles (Layout F1, + reading order F1) — non testées en S12 car elles vivent dans + des fichiers `measurements/*.py` non encore migrés. +- **S20** : équivalence des métriques philologiques (MUFI, + abbreviations, etc.) — idem. + +Quand ces sprints ajouteront leurs tests d'équivalence, le critère +"équivalence numérique fin Phase 3 / Phase 4" sera complet. + +## Statut + +- **Fin de Phase 2 (S12)** — équivalence runner OCR ✅ +- **Fin de Phase 3 (S18)** — équivalence views ouverte (S13-S18) +- **Fin de Phase 4 (S22)** — équivalence rapport HTML ouverte diff --git a/docs/migration/rewrite-status-s46.md b/docs/migration/rewrite-status-s46.md new file mode 100644 index 0000000000000000000000000000000000000000..c06e46cb567a51b9f638f5c45fe9e6f2943282a7 --- /dev/null +++ b/docs/migration/rewrite-status-s46.md @@ -0,0 +1,185 @@ +# État du rewrite — Sprints A14-S46 puis S47-S57 (audit + remédiation) + +Ce document synthétise l'état du rewrite du Picarones après les 20 sprints +S27-S46 réalisés sur la directive *« rewrite tout, le plus solide, sans +dette technique »*, puis les 11 sprints S47-S57 d'audit/remédiation des +30 dettes identifiées en revue de fin de rewrite (audit 2026-05). + +## Statut réel — partial rewrite, pas full rewrite (S57, audit #21 + #24) + +Le rewrite est **fonctionnellement complet sur le périmètre des contrats +et de l'architecture cible** (circles propres `domain → formats → +evaluation → pipeline → adapters → app → reports_v2 → interfaces`, +services applicatifs, adapters natifs OCR/LLM/VLM, pipeline planner, +artifact store, web UI native). La formulation initiale *« rewrite +fonctionnellement complet »* était trop forte sur deux dimensions +relevées par l'audit : + +1. **Parité fonctionnelle non encore atteinte côté rendu rapport** : le + legacy `picarones/report/` contient ~22 vues HTML thématiques + (Pareto, narrative, glossary, case-studies, etc.) que `reports_v2/` + ne reproduit pas intégralement. Les vues canoniques (TextView, + AltoView, SearchView) sont en place ; les vues additionnelles seront + portées une à une selon les besoins BnF, pas en bloc. + +2. **Coexistence legacy + new world** : `picarones/{cli,web,engines, + llm,pipelines,report}/` reste en place et exécutable. Un caller + externe peut encore importer depuis n'importe lequel. Cette + coexistence est volontaire (cf. *Critères pour la suppression future + du legacy* plus bas) mais doit être tenue pour ce qu'elle est : un + **rewrite parallèle**, pas un *full rewrite*. Les usages production + sont à migrer caller par caller. + +3. **Tests legacy non migrés** : ~200+ tests legacy valident le + comportement historique (`tests/web/`, `tests/measurements/`, + `tests/cli/_workflows/`, `tests/integration/test_chantier*.py`, + etc.). Ils protègent le legacy contre les régressions le temps + que la migration des callers s'achève ; les supprimer prématurément + perdrait la couverture. + +## Inventaire des modules legacy + +| Module | Statut | Nouvelle implémentation | Action S46 | +|--------|--------|--------------------------|------------| +| `picarones/cli/` | LEGACY | `picarones/interfaces/cli/` (3 commandes) | Conserver — features CLI manquantes | +| `picarones/web/` | LEGACY | `picarones/interfaces/web/` (skeleton + 3 routers + UI) | Conserver — UI riche manquante | +| `picarones/engines/` | LEGACY | `picarones/adapters/ocr/` (5 natifs) | Conserver — feature parité (confidences) | +| `picarones/llm/` | RE-EXPORT | `picarones/adapters/llm/` | Déjà migré (re-export pur) | +| `picarones/pipelines/` | LEGACY | (composition via pipeline DAG natif S6+) | Conserver — pas d'équivalent direct | +| `picarones/report/` | LEGACY | `picarones/reports_v2/{html,csv,json}/` | Conserver — vues thématiques manquantes | + +## Ce qui est DÉFINITIVEMENT migré (S27-S45) + +### Sprints S27-S29 — Fondations architecturales +- `ProjectionEngine` + `EvaluationEngine` séparés (S27) +- `PipelinePlanner` + `ExecutionPlan` (S28) +- `ArtifactStore` avec hash multi-paramètres + persistance filesystem (S29) + +### Sprints S30-S34 — 5 OCR engines natifs (NO SHIM) +- `TesseractAdapter` (S30) +- `PeroOCRAdapter` (S31) +- `MistralOCRAdapter` (S32) +- `GoogleVisionAdapter` (S33) +- `AzureDocIntelAdapter` (S34) + +Tous héritent directement de `BaseOCRAdapter` (S26), pas du legacy +`BaseOCREngine`. Le legacy peut être supprimé une fois les confidences +migrées vers `ConfidenceArtifact` (sprint dédié). + +### Sprints S35-S38 — Web app native (NO SHIM) +- Skeleton FastAPI avec DI (`WebAppState`, `create_app`) — S35 +- Routers corpus + benchmark — S36 +- JobStore SQLite + jobs router — S37 +- UI Jinja2 + static + i18n FR/EN — S38 + +### Sprints S39-S41 — Format YAML + domain cleanup +- RunSpec étendu (`inputs_from`, `preferred_text_output`) — S39 +- `PipelineSpec` migré dans `domain/` — S40 +- `artifacts_index.jsonl` séparé — S41 + +### Sprints S42-S43 — Reports CSV + JSON +- `CsvReportRenderer` — S42 +- `JsonReportRenderer` — S43 + +### Sprints S44-S45 — LLM/VLM nativement intégrés (NO SHIM) +- Les 4 LLM adapters (Anthropic, OpenAI, Mistral, Ollama) ont désormais + un `execute()` natif compatible `StepExecutor` — S44 +- 4 VLM adapters dérivés via MRO multiple — S45 + +## Critères pour la suppression future du legacy + +Pour chaque module legacy à supprimer, il faut : + +1. **Parité fonctionnelle** : tout ce que fait le legacy doit avoir un + équivalent dans le new world. +2. **Migration des tests** : les tests legacy doivent soit migrer vers + le new world, soit être identifiés comme supprimables. +3. **Migration des callers externes** : si des callers externes + importent depuis `picarones.web.app` (par ex. dans le HuggingFace + Space), ils doivent être migrés en amont. +4. **Autorisation utilisateur explicite** : un commit qui supprime + ~4000 lignes de code en production exige une revue formelle. + +## Statistiques globales du rewrite (S1-S57) + +- **Tests** : ~4910 tests, 11 skipped, 0 failed au S46 (vs 4504 au + début du rewrite, S26). Sprint S57 (audit #23) : la formulation + *« +406 nouveaux tests »* concernait spécifiquement les **nouveaux + tests écrits pour le new world** sur S27-S45 (`tests/{adapters, + pipeline,evaluation,reports_v2,app,interfaces}/`) ; elle ne dit + rien d'une supposée hausse de la couverture totale du repo. Les + tests legacy (`tests/{web,cli,engines,measurements,...}/`) ont été + conservés intacts — la couverture nette du rewrite est donc + **additive**, pas substitutive. +- **Lint** : `ruff check picarones/ tests/` clean. +- **File budgets** (audit #25) : la règle interne *« tout fichier + ≥ 400 lignes est budgété »* est un garde-fou pragmatique, pas une + doctrine ; elle force à expliciter la justification lorsqu'un + module dépasse ce seuil (ex. `interfaces/web/app.py` ~480 lignes + — composé de routes/handlers/middlewares groupés par cohérence + fonctionnelle). Aucun fichier ne dépasse 800 lignes après S46. +- **Layer dependencies** : domain → formats → evaluation → pipeline + → adapters → app → reports_v2 → interfaces, vérifié par test + d'architecture. + +## Sprints d'audit/remédiation S47-S57 (audit institutional readiness) + +L'audit *institutional readiness 2026-05* a identifié 30 dettes +techniques résiduelles après le rewrite ciblé. Elles ont été +adressées en 6 vagues (S47-S57) : + +| Vague | Sprint | Issues | Thème | +|-------|--------|--------|-------| +| pré-audit | S47-S48 | #1, #2 | ArtifactStore wired, JobRunner threading | +| A | S49-S51 | #3-#7 | Web security middlewares, confidences sidecar, output paths | +| B | S52-S53 | #8-#11 | AdapterStepError hierarchy, Mistral routing strict, normalize_llm_content path | +| C | S54 | #6 | MRO guard `__init_subclass__` BaseVLMAdapter | +| D | S55 | #14 | Live integration tests `tests/integration/live/` | +| 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. | +| F | S57 | #15, #16, #21, #23, #24, #25, #26, #30 | i18n prompts FR/EN/LA, DeprecationWarning legacy spec.py, doc rectifications | + +**Tous les 30 issues sont adressés au S57**. Les détails sont dans +`docs/audits/remediation-plan-2026-05.md`. + +### Notes spécifiques (S57) + +- **#15 Lazy imports SDK tiers** : les imports `mistralai`, `anthropic`, + `openai`, `ollama` sont **intentionnellement à l'intérieur des + méthodes** (`MistralOCRAdapter._call_chat_vision_api`, etc.) plutôt + qu'au top du module. Raison : ces SDK sont des dépendances + optionnelles (extras `[mistral]`, `[anthropic]`…) — un import top-level + ferait planter `import picarones` sur un environnement minimal. + Le coût (re-exécution de l'import à chaque appel) est négligé par + le cache d'imports Python. +- **#16 i18n prompts FR/EN/LA** : `BaseLLMAdapter.DEFAULT_CORRECTION_PROMPTS` + et `BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPTS` sont des + `dict[str, str]` indexés par code langue. Sélection : override + explicite via `config["correction_prompt"]`/`["transcription_prompt"]` + > `config["lang"]` (fr/en/la) > fallback FR. +- **#26 Suppression du re-export `picarones.pipeline.spec`** : ce + module re-export orphelin (aucun caller interne ni legacy) a été + supprimé directement. Le chemin canonique unique est + `picarones.domain.pipeline_spec`, re-exporté au niveau `__init__` + des packages `picarones.domain` et `picarones.pipeline` (API + publique standard). +- **#30 Commit hygiene CER fix** : la modification du seuil de + régression CER en CI (de 0.10 à 0.20) est documentée dans le + CHANGELOG sous *« CER regression check threshold rationale »* + avec justification métier (corpus patrimoniaux ont des CER bruts + qui peuvent légitimement varier de 5-15 points selon le tirage de + validation). + +## Prochaines étapes possibles (post-rewrite) + +1. **Confidences typées** : créer un `ConfidenceArtifact` typé pour + réutiliser proprement les confidences exposées par chaque OCR + adapter, sans surcharger `BaseOCRAdapter.execute()`. +2. **Vues HTML manquantes** : porter Pareto, Narrative, Glossary du + legacy `report/` vers `reports_v2/html/` une vue à la fois. +3. **CLI complète** : porter les commandes manquantes (`history`, + `compare`, `pipeline`, `diagnose`, etc.) dans + `interfaces/cli/`. +4. **Suppression effective du legacy** : après obtention de la + parité ci-dessus, retirer `picarones/{web,engines,pipelines, + report,cli}/` (en gardant `llm/` re-export pour compatibilité + historique). diff --git a/docs/roadmap/rewrite-2026.md b/docs/roadmap/rewrite-2026.md new file mode 100644 index 0000000000000000000000000000000000000000..b53e2355000016d490c1c02dbd6cbde6ee9dcde0 --- /dev/null +++ b/docs/roadmap/rewrite-2026.md @@ -0,0 +1,185 @@ +# Rewrite ciblé — plan S1 → S26 + +> **Statut** — démarré au Sprint A14-S1 (mai 2026), livraison cible +> **fin 2026** sur la branche `claude/repo-analysis-cukvm` puis fusion +> sur `main` pour livraison BnF. +> +> **Doctrine** : pas de Big Rewrite. Pas non plus de migration douce +> qui laisserait la dette en place. **Rewrite ciblé** : on réécrit +> from scratch les zones cassées (~5–8 k lignes : runner d'orchestration, +> couche web sécurité, gestion d'artefacts) et on **déplace** les zones +> saines (~30–40 k lignes : calculs purs MUFI / philological / +> statistics / etc.) sans toucher à leur logique. + +--- + +## Pourquoi un rewrite ciblé ? + +Trois constats issus de l'audit (`docs/audits/`) et de la conversation +de cadrage de mai 2026 : + +1. **Les promesses du README dépassaient la réalité du code.** Six bugs + P0 vérifiés dans l'audit invalidaient la promesse scientifique + (notamment : `normalization_profile` côté web silencieusement + ignoré, `compact()` qui amputait le JSON exporté, `compute_metrics` + qui retournait `0.0` indistinguable d'un score parfait en cas + d'erreur). +2. **L'architecture à imports magiques.** `import picarones` + déclenche une chaîne d'imports par effet de bord qui charge le + registre de métriques. Une dépendance optionnelle manquante au fond + de la chaîne fait crasher l'import du package entier. +3. **La dette narrative est trop lourde.** ~679 références à + "Sprint N" dans les fichiers Python, qui parasitent la lecture du + code par un nouveau contributeur et empêchent toute prise en main + par un mainteneur extérieur. + +Le rewrite ciblé attaque ces trois problèmes ensemble. + +--- + +## Architecture cible + +À la fin du rewrite, l'arborescence Python sera : + +``` +picarones/ + domain/ # Cercle 1 — types purs (Artifact, PipelineSpec, + # EvaluationSpec, DocumentRef, Provenance) + evaluation/ # Cercle 2 — vues, projecteurs, métriques + views/ + projectors/ + metrics/ + registry.py + pipeline/ # Cercle 2 — exécution + executor.py + cache.py + spec.py + formats/ # Cercle 2 — ALTO, PAGE, normalisation texte + alto/ + pagexml/ + text/ + adapters/ # Cercle 3 — moteurs OCR/LLM/VLM, importers, storage + ocr/ + llm/ + vlm/ + corpus/ + storage/ + app/ # Cercle 4 — services applicatifs + services/ + schemas/ + interfaces/ # Cercle 5 — CLI, web, reports + cli/ + web/ + reports/ + html/ + json/ + csv/ +``` + +Pivot mental : l'objet central n'est plus `Engine + BenchmarkResult`, +c'est `Pipeline → Artifacts → Projection → EvaluationView → Metrics`. + +--- + +## Calendrier (26 semaines) + +### Phase 0 — Stabilisation de l'existant (S1 → S2) + +| Sprint | Objectif | État | +|---|---|---| +| **S1** | Boucher les 6 P0 sur `main` | ✅ Livré (commit `a2bea75`) | +| **S2** | Recadrer le README, env propre, BACKLOG_POST_LIVRAISON | ⏳ En cours | + +À la fin de S2, l'outil actuel reste utilisable pour les tests BnF +pendant que le rewrite avance sur `rewrite-2026`. + +### Phase 1 — Squelette et règles d'architecture (S3 → S6) + +| Sprint | Objectif | +|---|---| +| S3 | Créer les répertoires cibles + tests d'architecture qui interdisent le retour en arrière | +| S4 | Modèle `Artifact` et types fondamentaux dans `domain/` | +| S5 | `EvaluationView`, `EvaluationSpec`, `MetricSpec` typés | +| S6 | `PipelineSpec`, `PipelineStep`, contrats d'exécution | + +Critère go/no-go fin de Phase 1 : les tests d'architecture passent, +la BnF continue à utiliser `main`. + +### Phase 2 — Pipeline executor et migration des calculs (S7 → S12) + +| Sprint | Objectif | +|---|---| +| S7 | Pipeline executor v1 (séquentiel mono-document) | +| S8 | Backpressure + timeout réel + annulation propre | +| S9 | `formats/alto/` et `formats/pagexml/` | +| S10 | Migration des calculs purs vers `evaluation/metrics/` (gros sprint) | +| S11 | Migration des adapters dans `adapters/` | +| S12 | Le nouvel executor reproduit l'ancien runner numériquement | + +Critère go/no-go fin de Phase 2 : équivalence CER/WER vérifiée à +1e-9 près sur 5 fixtures + 1 corpus BnF réel. + +### Phase 3 — Vues d'évaluation (S13 → S18) — cœur de la valeur ajoutée + +| Sprint | Objectif | +|---|---| +| S13 | `EvaluationViewExecutor` et le moteur de vues | +| S14 | `TextView` (vue canonique 1) | +| S15 | `AltoView` (vue canonique 2) | +| S16 | `SearchView` (vue canonique 3) + cohérence inter-vues | +| S17 | Intégration runner + vues + nouveau format de résultat | +| S18 | E2E sur le cas BnF central + recettage interne | + +Critère go/no-go fin de Phase 3 : ton cas d'usage central +(Tesseract texte brut vs OCR+LLM+ALTO remappé vs VLM+ALTO reconstruit) +fonctionne bout-en-bout, lisible, avec rapports de projection +explicites. + +### Phase 4 — Web sandboxée + recettage (S19 → S24) + +| Sprint | Objectif | +|---|---| +| S19 | Couche `app/services/` | +| S20 | Réécriture corpus upload + sandbox ZIP | +| S21 | Nouveau `interfaces/web/` (CSRF on, CSP sans inline) | +| S22 | `interfaces/cli/` + `reports/html/` migration | +| S23 | Recettage BnF complet | +| S24 | Corrections de recettage + documentation finale | + +### Buffer (S25 → S26) + +Imprévus + livraison. Ces deux semaines sont **non négociables**. + +--- + +## Discipline du rewrite + +Quatre invariants permanents, valables pendant les 26 semaines : + +1. **`main` reste livrable.** Le rewrite vit sur `rewrite-2026` / + `claude/repo-analysis-cukvm`. Les P0 vont sur `main`. +2. **Pas de feature nouvelle.** Si l'envie vient, écrire dans + [`BACKLOG_POST_LIVRAISON.md`](../../BACKLOG_POST_LIVRAISON.md) et + passer. +3. **Fin de chaque sprint = un commit qui passe `pytest tests/ -q`.** +4. **Chaque sprint a un livrable démontrable** en 5 minutes. + +Pour le détail à la semaine de chaque sprint (livrables, tests, +définition de "done", risque principal), voir le plan complet livré +en réponse à la question de cadrage du 2026-05-03 dans la session +[`session_011XQZNitg1rCgia8ZD1a2hP`](https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP). + +--- + +## Ce qui n'est *pas* dans le rewrite + +Cf. [`BACKLOG_POST_LIVRAISON.md`](../../BACKLOG_POST_LIVRAISON.md) pour +la liste complète. En résumé : + +- Pas de feature nouvelle (NER cloud, VLM extras, etc.). +- Pas de promesses institutionnelles (RGPD opérationnel, JOSS, COI + exercés). +- Pas de réécriture des calculs purs (MUFI, philological, statistics) + — on les déplace, point. +- Pas de refonte du rapport HTML au-delà de l'intégration des vues + (le rendu visuel reste celui d'aujourd'hui pour ne pas allonger). diff --git a/docs/views/alto-view.md b/docs/views/alto-view.md new file mode 100644 index 0000000000000000000000000000000000000000..7e5a261d3e4530d68d0913690f00a99766b13eae --- /dev/null +++ b/docs/views/alto-view.md @@ -0,0 +1,113 @@ +# AltoView — fidélité documentaire ALTO + +Sprint A14-S15 du rewrite ciblé livre `AltoView`, la deuxième vue +canonique. Elle répond à la question : **"quel pipeline produit +le meilleur ALTO exploitable ?"** + +## Distinct de TextView + +| Aspect | TextView (S14) | AltoView (S15) | +|---|---|---| +| Question | "meilleur texte final ?" | "meilleur ALTO exploitable ?" | +| Types acceptés | RAW_TEXT, CORRECTED_TEXT, ALTO, PAGE, CANONICAL | ALTO_XML uniquement | +| Projection | tout → RAW_TEXT | aucune (compare ALTO direct) | +| Mesure | qualité linguistique | fidélité structurelle | +| Métriques | CER, WER, MER, WIL | alto_validity, line_count_ratio, word_box_coverage | + +Un même pipeline peut être évalué dans les deux vues. Le rapport +HTML (S22) présentera les deux côte-à-côte pour qu'un lecteur +comprenne pourquoi deux pipelines avec le même CER peuvent +produire des ALTO de qualités différentes. + +## Pattern d'omission explicite + +Un pipeline qui ne produit pas d'`ALTO_XML` (exemple : Tesseract +texte brut sans ALTO) ne peut **pas** être évalué dans `AltoView`. +Le caller (typiquement un service applicatif au S19) doit +**omettre** ce pipeline du résultat, plutôt que de lui attribuer +un score factice à 0. + +```python +from picarones.evaluation.views import build_alto_view + +view = build_alto_view() + +pipelines = [ + ("tesseract", ArtifactType.RAW_TEXT), # PAS d'ALTO + ("ocr_llm_alto", ArtifactType.ALTO_XML), # ALTO ✓ + ("vlm_alto", ArtifactType.ALTO_XML), # ALTO ✓ +] + +eligible = [(n, t) for n, t in pipelines if view.accepts(t)] +omitted = [(n, t) for n, t in pipelines if not view.accepts(t)] + +# eligible: [("ocr_llm_alto", ALTO_XML), ("vlm_alto", ALTO_XML)] +# omitted: [("tesseract", RAW_TEXT)] +``` + +Le caller affichera dans le rapport : *"Tesseract n'est pas +évalué dans AltoView (ne produit pas d'ALTO)."* Pas de score +factice à 0 qui ferait passer Tesseract pour un mauvais ALTO, +alors qu'il n'a juste pas pris part à la compétition. + +## Métriques par défaut + +### `alto_validity` + +L'hypothèse a-t-elle une structure ALTO cohérente ? ≥ 1 page ET +≥ 1 bloc ET ≥ 1 ligne. Détecte les ALTO vides, tronqués, ou +produits par un reconstructeur défaillant. + +- 1.0 = structure cohérente +- 0.0 = vide ou tronqué + +### `alto_line_count_ratio` + +Ratio min/max du nombre de lignes : `min(n_hyp, n_ref) / max(n_hyp, +n_ref)` ∈ [0, 1]. 1.0 = même nombre de lignes. + +Permet de détecter un reconstructeur qui invente ou perd des +lignes. Ne dit rien sur l'**alignement spatial** — c'est +`textline_alignment` (post-livraison) qui mesurera cette +dimension. + +### `alto_word_box_coverage` + +Fraction des `AltoString` de l'hypothèse qui ont une `bbox` +définie (HPOS, VPOS, WIDTH, HEIGHT). 1.0 = tous les mots ont +une boîte (cas idéal pour un reconstructeur ALTO). + +Un VLM qui produit du markdown puis le reconstruit en ALTO sans +coordonnées aura un `word_box_coverage` proche de 0. + +## Garde-fou méthodologique + +Le `ViewResult` produit par `AltoView` porte un `warnings` +explicite : + +> Cette vue mesure la fidélité STRUCTURELLE de l'ALTO produit +> (validité, nombre de lignes, bbox). La qualité TEXTUELLE de +> ce qui est dans cet ALTO est mesurée par TextView ; les deux +> doivent être lues ensemble pour juger un pipeline. +> +> Les pipelines qui ne produisent pas d'ALTO sont OMIS de cette +> vue. Aucun score factice n'est attribué à un pipeline absent. + +## Limites assumées + +Reportées à des sprints suivants : + +- **`textline_alignment`** (IoU des bbox de lignes) — exige un + algorithme d'alignement bipartite par bbox. +- **`reading_order_consistency`** (Kendall tau sur les IDs de + lignes) — exige un mapping ID → position. +- **`layout_f1` (ICDAR 2015)** — déjà implémenté dans + `evaluation/metrics/layout.py` (migré au S10) sur des `Region` + génériques ; un wrapper ALTO peut être ajouté plus tard. + +## Statut + +- ✅ Sprint S15 — `AltoView` livré (3 métriques + pattern d'omission) +- ⏳ Sprint S16 — `SearchView` (recherchabilité fuzzy) +- ⏳ Sprint S17 — intégration runner + RunManifest +- ⏳ Sprint S18 — tests E2E sur le cas BnF central diff --git a/docs/views/comparing-views.md b/docs/views/comparing-views.md new file mode 100644 index 0000000000000000000000000000000000000000..8cadc2ed0a1c4c7acc3a56267d225f6d433931e0 --- /dev/null +++ b/docs/views/comparing-views.md @@ -0,0 +1,117 @@ +# Lire les 3 vues canoniques ensemble + +Sprint A14-S16 livre la troisième vue canonique du rewrite ciblé : +`SearchView`. Avec `TextView` (S14) et `AltoView` (S15), on a +maintenant **trois lentilles complémentaires** pour évaluer un +même pipeline. + +## Le tableau des 3 vues + +| Vue | Question | Métriques | Direction | +|---|---|---|---| +| **TextView** (S14) | Quel pipeline produit le meilleur **texte final** ? | CER, WER, MER, WIL | `lower_is_better` (erreurs) | +| **AltoView** (S15) | Quel pipeline produit le meilleur **ALTO exploitable** ? | alto_validity, line_count_ratio, word_box_coverage | `higher_is_better` (qualité) | +| **SearchView** (S16) | Quel pipeline maximise la **recherchabilité plein-texte** ? | searchability_recall, numerical_sequence_preservation | `higher_is_better` (rappel) | + +Aucune des trois vues ne dit toute la vérité sur un pipeline. +**Ensemble, elles racontent l'histoire complète.** + +## Pourquoi les trois vues sont nécessaires + +Un même pipeline peut être **excellent dans une vue et médiocre +dans une autre**. C'est précisément ce qui rend la comparaison +hétérogène utile pour la BnF — un seul score (CER global) +masquerait des informations critiques. + +### Pattern 1 : CER excellent, recherchabilité numérique catastrophique + +Démontré dans le test +`tests/evaluation/test_sprint_a14_s16_views_consistency.py::TestDivergencePattern::test_year_corruption_invisible_to_cer_visible_to_search` : + +- **GT** : *"Charte signée à Paris le 14 juillet 1789 en présence du roi"* +- **Hypothèse** : *"Charte signée à Paris le 14 juillet 1798 en présence du roi"* + +Le LLM de post-correction a "amélioré" la date (1789 → 1798). +Conséquences : + +| Vue | Métrique | Valeur | Lecture | +|---|---|---|---| +| TextView | CER | ~0.03 | Excellent (3 chars sur 58) | +| TextView | WER | ~0.09 | Très bon (1 mot sur 11) | +| SearchView | searchability_recall | ~0.91 | Bon (1798 fuzzy match 1789) | +| SearchView | **numerical_sequence_preservation** | **0.0** | **Catastrophique** | + +Pour un historien qui veut indexer ses chartes par date, ce +pipeline est **inutilisable** — l'année 1789 est silencieusement +réécrite en 1798. Le CER ne le révèle pas. `SearchView` le +révèle. + +### Pattern 2 : Texte parfait, ALTO inexistant + +Un OCR Tesseract qui ne produit que du texte brut : + +| Vue | Statut | Lecture | +|---|---|---| +| TextView | CER = 0.0 | Pipeline parfait pour la lecture | +| SearchView | recall = 1.0 | Pipeline parfait pour l'indexation | +| **AltoView** | **OMIS** | Pipeline non éligible | + +Pour un workflow IIIF / Mirador qui veut surligner les mots dans +l'image, ce pipeline est **inutilisable** — pas de coordonnées. +`AltoView` ne lui attribue pas un score factice à 0 ; le rapport +affiche *"Tesseract texte brut n'est pas évalué dans AltoView +(ne produit pas d'ALTO)"*. + +### Pattern 3 : ALTO valide mais texte hallucinant + +Un VLM avec module ALTO_reconstruction peut produire un ALTO +structurellement parfait (validity=1, lignes correctes, +coordonnées présentes) mais avec du texte inventé : + +| Vue | Métrique | Valeur | Lecture | +|---|---|---|---| +| AltoView | tous | 1.0 | Pipeline parfait structurellement | +| TextView | CER | élevé | Pipeline mauvais textuellement | +| SearchView | recall | bas | Pipeline inutile pour la recherche | + +`AltoView` seul ferait passer ce VLM pour le meilleur pipeline. +Lire les trois vues ensemble révèle le vrai problème. + +## Recommandation de lecture pour le rapport BnF + +Le rapport HTML (S22) présentera les 3 vues côte-à-côte avec +cette grille de lecture : + +1. **Tableau de synthèse** : un tableau par vue, chaque ligne = + un pipeline, chaque colonne = une métrique. Les pipelines + omis sont indiqués explicitement (pas de valeur factice). + +2. **Encart "divergences notables"** : signale automatiquement + les pipelines dont le rang change fortement entre vues + (par exemple "rang 1 en TextView, rang 5 en SearchView"). + C'est un signal pour l'utilisateur d'aller regarder en + détail ce qui se passe. + +3. **Pour chaque vue** : warnings explicites de ce qu'elle + **n'évalue pas** (cf. `ignored_dimensions` dans chaque + `ViewResult`). L'utilisateur ne peut pas conclure + "TextView dit que X est le meilleur" sans avoir vu ce que + `TextView.ignored_dimensions` ne dit PAS. + +## Critères de choix selon l'usage + +| Usage cible | Vue principale | Vues secondaires | +|---|---|---| +| Lecture humaine (édition critique) | TextView | AltoView (si édition diplomatique) | +| Indexation Elastic / Solr / Gallica | SearchView | TextView | +| Réinjection IIIF / Mirador (mots cliquables) | AltoView | TextView | +| Citation académique | TextView + SearchView | AltoView | +| Reproduction d'un fac-similé | AltoView | TextView | + +## Statut + +- ✅ Sprint S14 — `TextView` +- ✅ Sprint S15 — `AltoView` +- ✅ Sprint S16 — `SearchView` + cohérence inter-vues +- ⏳ Sprint S17 — intégration runner + RunManifest +- ⏳ Sprint S18 — tests E2E sur le cas BnF central diff --git a/docs/views/text-view.md b/docs/views/text-view.md new file mode 100644 index 0000000000000000000000000000000000000000..fd026e0da8102b9f18bb5f0b8b5e8fc9f6820cb0 --- /dev/null +++ b/docs/views/text-view.md @@ -0,0 +1,144 @@ +# TextView — première vue canonique + +Sprint A14-S14 du rewrite ciblé livre `TextView`, la première vue +d'évaluation canonique. Elle répond à la question patrimoniale la +plus fréquente : **"quel pipeline produit le meilleur texte +final ?"** + +## Cas d'usage central BnF + +Une bibliothèque numérique veut comparer 3 pipelines hétérogènes +sur le même corpus : + +1. **Tesseract** → texte brut (`RAW_TEXT`) +2. **OCR + LLM + remapping ALTO** → ALTO XML enrichi (`ALTO_XML`) +3. **VLM avec sortie markdown structurée** → `CANONICAL_DOCUMENT` + +Sans `TextView`, comparer ces 3 pipelines est trompeur : ils ne +produisent pas le même type d'artefact. Avec `TextView`, chaque +sortie est **projetée vers du texte plat** avant calcul de +CER/WER, et le rapport documente explicitement ce que la vue +**ignore** (géométrie, structure de blocs, ordre de lecture, IDs, +formatage). + +## API + +```python +from picarones.evaluation.views import build_text_view + +# Vue canonique avec valeurs par défaut +view = build_text_view() + +# Vue spécialisée (par exemple : OCR seul, sans ALTO/PAGE) +from picarones.domain import ArtifactType +view_ocr_only = build_text_view( + candidate_types=frozenset({ + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + }), + metric_names=("cer", "wer"), + normalization_profile="medieval_french", +) +``` + +## Types acceptés (par défaut) + +| Type | Projection | Justification | +|---|---|---| +| `RAW_TEXT` | identité | déjà du texte | +| `CORRECTED_TEXT` | identité | déjà du texte (modifié par un LLM) | +| `ALTO_XML` | `AltoToText` | extraction par ordre de lecture, gestion césure | +| `PAGE_XML` | `PageToText` | extraction depuis `` | +| `CANONICAL_DOCUMENT` | `CanonicalToText` | décode markdown, aplatit JSON canonique | + +## Métriques (par défaut) + +`cer`, `wer`, `mer`, `wil` — toutes typées `(RAW_TEXT, RAW_TEXT)` +puisque la comparaison se fait toujours après projection vers +texte plat. + +## Dimensions explicitement ignorées + +Le `ViewResult` propage dans `ignored_dimensions` les dimensions +que cette vue **ne mesure pas** : + +- `geometry` — coordonnées HPOS/VPOS/WIDTH/HEIGHT des mots +- `block_structure` — découpage en `TextBlock` / `TextRegion` +- `reading_order` — ordre de lecture spatial +- `ids` — identifiants stables des éléments +- `confidence` — scores de confiance par mot +- `formatting` — gras / italique / titre + +Ces dimensions sont éventuellement évaluées par d'autres vues : + +- `geometry`, `block_structure`, `reading_order`, `ids` → + **`AltoView`** (S15) +- `confidence` → vue calibration (existante via S5 metrics) + +## Garde-fou méthodologique + +Chaque `ViewResult` produit par `TextView` porte un `warnings` +explicite : + +> Cette vue compare les sorties textuelles finales après +> projection éventuelle. Les pipelines qui produisent +> ALTO/PAGE/markdown sont projetés vers du texte plat — leurs +> structures spatiale et documentaire ne sont PAS évaluées ici. +> Pour évaluer la qualité ALTO, voir AltoView (S15). + +Ce warning sera affiché en tête du bloc TextView dans le rapport +HTML (S22) pour signaler à un lecteur exactement la portée de la +comparaison. + +## Exemple de `ViewResult` + +```python +ViewResult( + view_name="text_final", + candidate_artifact_id="bnf_doc:vlm:canonical_document", + ground_truth_artifact_id="bnf_doc:gt:raw_text", + metric_values={ + "cer": 0.04, + "wer": 0.12, + "mer": 0.04, + "wil": 0.18, + }, + failed_metrics={}, + projection_report=ProjectionReport( + source_artifact_id="bnf_doc:vlm:canonical_document", + source_type=ArtifactType.CANONICAL_DOCUMENT, + target_type=ArtifactType.RAW_TEXT, + projector_name="canonical_to_text", + lossy=True, + ignored_dimensions=("structure", "formatting", "headers", "links"), + warnings=("Markdown / JSON canonique projeté en texte plat...",), + ), + warnings=( + "Cette vue compare les sorties textuelles finales...", + "Markdown / JSON canonique projeté en texte plat...", + ), + ignored_dimensions=( + "geometry", "block_structure", "reading_order", "ids", + "confidence", "formatting", "structure", "headers", "links", + ), +) +``` + +## Limites assumées + +- **Pas de comparaison fuzzy / search recall** — c'est `SearchView` + (S16). +- **Pas d'évaluation structurelle ALTO** — c'est `AltoView` (S15). +- **`CANONICAL_DOCUMENT` peut perdre beaucoup de structure** ; le + warning du `ProjectionReport` le signale. +- **Pas de pondération inter-pipelines** — chaque pipeline est + évalué indépendamment ; le ranking et l'agrégation sont la + responsabilité du caller (typiquement le rapport HTML S22). + +## Statut + +- ✅ Sprint S14 — `TextView` livré (codé + testé) +- ⏳ Sprint S15 — `AltoView` (fidélité documentaire) +- ⏳ Sprint S16 — `SearchView` (recherchabilité fuzzy) +- ⏳ Sprint S17 — intégration runner + RunManifest +- ⏳ Sprint S18 — tests E2E sur le cas BnF central avec 3 pipelines diff --git a/picarones/adapters/__init__.py b/picarones/adapters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..882218a6c783e06a5d916a3760acd977823014e6 --- /dev/null +++ b/picarones/adapters/__init__.py @@ -0,0 +1,28 @@ +"""Cercle 3 — Adapters. + +Implémentations concrètes des contrats du domain. C'est ici que +vivent les dépendances externes lourdes (pytesseract, pero_ocr, +mistralai, openai, anthropic, google-cloud-vision, datasets, etc.). + +Sous-packages : + +- ``ocr/`` — Tesseract, Pero OCR, Kraken, Mistral OCR, Google + Vision, Azure Doc Intel. Cible Sprint S11. +- ``llm/`` — OpenAI, Anthropic, Mistral, Ollama. Cible S11. +- ``vlm/`` — Qwen-VL, Gemini, Claude vision, etc. À remplir + post-livraison (dans la limite de ce qui justifie une vraie + comparaison avec OCR+LLM). +- ``corpus/`` — local folder, IIIF, Gallica, HTR-United, + HuggingFace Datasets, eScriptorium. Cible S11. +- ``storage/`` — filesystem, SQLite (jobs, history). Cible S20. + +Règles d'import : un adapter peut importer le domain et ses libs +externes. Il ne doit **jamais** importer ``app/`` ou +``interfaces/``. Il n'a aucune logique d'évaluation (un OCR +adapter ne calcule pas le CER — il produit un artefact texte que +``evaluation/`` consommera). +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/adapters/_retry.py b/picarones/adapters/_retry.py new file mode 100644 index 0000000000000000000000000000000000000000..81cf1109a8843df29ce344a79fe82770a93279e7 --- /dev/null +++ b/picarones/adapters/_retry.py @@ -0,0 +1,143 @@ +"""Retry exponentiel partagé par les adapters cloud (OCR + LLM). + +Pour une release institutionnelle (BnF, LoC, BL), un benchmark de +N milliers de documents face à un service cloud (Google Vision, +Azure Document Intelligence, Mistral OCR, Anthropic, OpenAI) doit +absorber les erreurs transitoires (429, 5xx, timeout réseau) sans +faire échouer le doc — sinon les résultats partiels ne sont pas +reproductibles d'un run à l'autre. + +Ce module fournit la politique commune. Il vit au top du package +``adapters/`` (et non sous ``llm/`` ou ``ocr/``) parce qu'il est +consommé par les deux familles indistinctement. + +API +--- +- ``is_retryable(exc)`` : True si l'exception est typique d'un + problème transitoire. +- ``call_with_retry(callable, max_retries, backoff_base, label)`` : + exécute le callable, retry exponentiel jusqu'à ``max_retries`` + tentatives. Lève la dernière exception si épuisé. + +Politique +--------- +- ``max_retries=3`` (4 tentatives au total : 0 + 1 + 2 + 3 retries). +- ``backoff_base=2.0`` → 2s, 4s, 8s entre les retries (16s cumul max). +- Logs WARNING à chaque retry avec contexte. + +Anti-sur-ingénierie +------------------- +- Pas de jitter randomisé : pas indispensable à ce volume ; ajouter + si un caller en a concrètement besoin. +- Pas de circuit breaker : un caller qui voit 100 % d'échec sur 5000 + documents arrête le run lui-même. +""" + +from __future__ import annotations + +import logging +import time +from typing import Callable, TypeVar + +logger = logging.getLogger(__name__) + +DEFAULT_MAX_RETRIES = 3 +DEFAULT_BACKOFF_BASE = 2.0 # secondes : 2, 4, 8 + +T = TypeVar("T") + + +def is_retryable(exc: Exception) -> bool: + """``True`` si l'exception est typique d'un problème transitoire. + + Détection sur trois axes : + + 1. Code HTTP exposé par les SDK cloud (``status_code`` ou + ``http_status``) : 429 (rate limit) et tout 5xx. + 2. Type d'exception réseau : ``TimeoutError``, ``ConnectionError``, + ``URLError`` (urllib). + 3. Heuristique sur le message (fallback pour les SDK qui ne + structurent pas) : présence des codes 429/502/503 ou des + motifs ``rate limit``, ``timeout``, ``connection``. + """ + status = ( + getattr(exc, "status_code", None) + or getattr(exc, "http_status", None) + ) + if status is not None: + return status == 429 or status >= 500 + + exc_name = type(exc).__name__ + if exc_name in ("TimeoutError", "ConnectionError", "URLError"): + return True + + msg = str(exc).lower() + if "rate" in msg and "limit" in msg: + return True + if "timeout" in msg or "connection" in msg: + return True + if "429" in msg or "503" in msg or "502" in msg: + return True + + return False + + +def call_with_retry( + fn: Callable[[], T], + *, + max_retries: int = DEFAULT_MAX_RETRIES, + backoff_base: float = DEFAULT_BACKOFF_BASE, + label: str = "adapter", +) -> T: + """Exécute ``fn`` avec retry exponentiel sur erreurs retryables. + + Parameters + ---------- + fn: + Callable sans argument qui retourne le résultat ou lève. + max_retries: + Nombre de retries après la première tentative. ``0`` = + une seule tentative (pas de retry). + backoff_base: + Base de l'attente exponentielle. Tentative ``i`` → attente + ``backoff_base ** (i + 1)`` secondes avant retry. + label: + Étiquette du caller pour le logging (typiquement + ``self.name`` de l'adapter). + + Returns + ------- + Résultat de ``fn``. + + Raises + ------ + Exception + La dernière exception levée si tous les retries sont + épuisés ou si l'erreur n'est pas retryable. + """ + last_exc: Exception | None = None + for attempt in range(max_retries + 1): + try: + return fn() + except Exception as exc: # noqa: BLE001 + last_exc = exc + if attempt < max_retries and is_retryable(exc): + wait = backoff_base ** (attempt + 1) + logger.warning( + "[%s] erreur retryable (tentative %d/%d, " + "attente %.1fs) : %s", + label, attempt + 1, max_retries + 1, wait, exc, + ) + time.sleep(wait) + else: + break + assert last_exc is not None + raise last_exc + + +__all__ = [ + "DEFAULT_BACKOFF_BASE", + "DEFAULT_MAX_RETRIES", + "call_with_retry", + "is_retryable", +] diff --git a/picarones/adapters/corpus/__init__.py b/picarones/adapters/corpus/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..21d14f72dd6fee7c176b06c760e0a0f81cf2eea4 --- /dev/null +++ b/picarones/adapters/corpus/__init__.py @@ -0,0 +1,16 @@ +"""Adaptateurs corpus — Sprint S11. + +Cible : déplacement de ``picarones.extras.importers.{iiif,gallica, +htr_united,huggingface,escriptorium}``. Un corpus adapter charge +un corpus depuis une source distante (manifeste IIIF, dataset HF, +catalogue HTR-United, eScriptorium, ZIP utilisateur) et retourne +un ``CorpusSpec`` (références aux images + GT par niveau). + +Règle : pas de pré-calcul. Pas d'OCR. Le corpus adapter ne sait +que **nommer et localiser** les paires (image, GT). L'exécution +des moteurs est faite plus tard par le pipeline executor. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc b/picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12fc3c6da3061072176dc0a319ebab6855bbc226 Binary files /dev/null and b/picarones/adapters/corpus/__pycache__/__init__.cpython-311.pyc differ diff --git a/picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc b/picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..662589f000eed9119affec956111209680d84200 Binary files /dev/null and b/picarones/adapters/corpus/__pycache__/_fallback_log.cpython-311.pyc differ diff --git a/picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc b/picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4fb9e3877f774321704acbecbbf784acb15b3331 Binary files /dev/null and b/picarones/adapters/corpus/__pycache__/htr_united.cpython-311.pyc differ diff --git a/picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc b/picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..22de9f88efa28676ab7e248b291c07df9c30c025 Binary files /dev/null and b/picarones/adapters/corpus/__pycache__/huggingface.cpython-311.pyc differ diff --git a/picarones/adapters/corpus/_fallback_log.py b/picarones/adapters/corpus/_fallback_log.py new file mode 100644 index 0000000000000000000000000000000000000000..ac7df34a631eb97f739ee0265446684ce5ecbb5d --- /dev/null +++ b/picarones/adapters/corpus/_fallback_log.py @@ -0,0 +1,98 @@ +"""Journal en mémoire des fallbacks d'importer (Sprint A3, item B-3). + +Quand un importer (HuggingFace, HTR-United, Gallica, eScriptorium…) +bascule en mode dégradé (timeout réseau, JSON mal formé, ZIP corrompu, +catalogue distant indisponible…), il enregistre un incident ici via +:func:`record_fallback`. Le moteur narratif consomme ces incidents via +:func:`consume_fallback_log`, qui **vide** la liste pour qu'un benchmark +suivant ne remonte pas les incidents du précédent. + +Conception volontairement minimale : + +- Pas de persistance disque (les incidents sont contextuels à un run). +- Pas de structure complexe (juste un ``list[dict]`` thread-safe). +- Le runner / le rapport peuvent ignorer la liste sans casser. + +Le détecteur de Fact correspondant (``FactType.IMPORTER_FALLBACK_TRIGGERED``) +est implémenté dans +:mod:`picarones.measurements.narrative.detectors.history`. +""" + +from __future__ import annotations + +import logging +import threading +from typing import Any + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_fallbacks: list[dict[str, Any]] = [] + + +def record_fallback( + importer: str, + operation: str, + error: BaseException | None = None, + *, + extra: dict[str, Any] | None = None, +) -> None: + """Enregistre un incident de mode dégradé. + + Logge également via ``logger.warning`` pour qu'un opérateur voit + l'incident en temps réel sans dépendre du rapport. + + Parameters + ---------- + importer: + Nom court de l'importer (ex : ``"huggingface"``, ``"htr_united"``). + operation: + Description courte de l'opération (ex : ``"yaml_catalogue_parse"``, + ``"image_save"``, ``"hub_search"``). + error: + Exception originelle (utilisée pour le message log et stockée dans + le payload sous forme de chaîne — pas l'objet, pour éviter les + références persistantes). + extra: + Champs additionnels (URL distante, identifiant dataset…) qui peuvent + être utiles à un détecteur de Fact ultérieur. + """ + error_repr = repr(error) if error is not None else None + logger.warning( + "[importers/%s] %s a échoué (mode dégradé) : %s", + importer, + operation, + error_repr, + ) + entry: dict[str, Any] = { + "importer": importer, + "operation": operation, + "error": error_repr, + } + if extra: + entry["extra"] = dict(extra) + with _lock: + _fallbacks.append(entry) + + +def consume_fallback_log() -> list[dict[str, Any]]: + """Retourne ET vide la liste des incidents accumulés. + + Le moteur narratif appelle cette fonction au moment de construire + la synthèse pour transformer chaque incident en ``Fact``.""" + with _lock: + out = list(_fallbacks) + _fallbacks.clear() + return out + + +def peek_fallback_log() -> list[dict[str, Any]]: + """Retourne une copie sans vider — utile pour les tests.""" + with _lock: + return list(_fallbacks) + + +def reset_fallback_log() -> None: + """Vide la liste sans rien retourner — utile pour les fixtures pytest.""" + with _lock: + _fallbacks.clear() diff --git a/picarones/adapters/corpus/htr_united.py b/picarones/adapters/corpus/htr_united.py new file mode 100644 index 0000000000000000000000000000000000000000..d6b71660157876bd99ec065f34dd5ea055a8629e --- /dev/null +++ b/picarones/adapters/corpus/htr_united.py @@ -0,0 +1,473 @@ +"""Import depuis le catalogue HTR-United. + +HTR-United est un catalogue communautaire de vérités terrain HTR/OCR publiées +sur GitHub sous licence ouverte. Les métadonnées sont stockées dans un fichier +YAML (catalogue.yml) sur https://github.com/HTR-United/htr-united. + +Ce module fournit : +- :class:`HTRUnitedCatalogue` — chargement et recherche dans le catalogue +- :func:`fetch_catalogue` — téléchargement du catalogue depuis GitHub +- :func:`import_htr_united_corpus` — téléchargement et import d'un corpus + +Exemple +------- + catalogue = HTRUnitedCatalogue.from_remote() + results = catalogue.search("français médiéval") + corpus = import_htr_united_corpus(results[0], output_dir="./corpus/") +""" + +from __future__ import annotations + +import json +import logging +import re +import urllib.error +import urllib.request +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Catalogue remote URL +# --------------------------------------------------------------------------- + +_CATALOGUE_URL = ( + "https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml" +) +_CATALOGUE_API_URL = ( + "https://api.github.com/repos/HTR-United/htr-united/contents/htr-united.yml" +) + +# Catalogue de démonstration / fallback (hors-ligne) +_DEMO_CATALOGUE: list[dict] = [ + { + "id": "lectaurep-repertoires", + "title": "Lectaurep — Répertoires de notaires parisiens", + "url": "https://github.com/HTR-United/lectaurep-repertoires", + "language": ["French"], + "script": ["Cursiva"], + "century": [17, 18], + "institution": "Archives nationales (France)", + "description": "Transcriptions de répertoires de notaires, XVIIe-XVIIIe siècles.", + "license": "CC-BY 4.0", + "lines": 12400, + "format": "ALTO", + "tags": ["notaires", "Paris", "cursive", "imprimé"], + }, + { + "id": "bvmm-manuscripts", + "title": "BVMM — Manuscrits enluminés", + "url": "https://github.com/HTR-United/bvmm-manuscripts", + "language": ["Latin", "French"], + "script": ["Gothic"], + "century": [13, 14, 15], + "institution": "IRHT", + "description": "Manuscrits médiévaux latins et français, XIIIe-XVe siècles.", + "license": "CC-BY 4.0", + "lines": 8700, + "format": "ALTO", + "tags": ["manuscrits", "latin", "médiéval", "enluminure"], + }, + { + "id": "cremma-medieval", + "title": "CREMMA Médiéval", + "url": "https://github.com/HTR-United/cremma-medieval", + "language": ["French", "Latin"], + "script": ["Gothic", "Humanistica"], + "century": [12, 13, 14, 15], + "institution": "École des chartes / Inria", + "description": "Corpus CREMMA de manuscrits médiévaux français et latins.", + "license": "CC-BY 4.0", + "lines": 6200, + "format": "ALTO", + "tags": ["médiéval", "chartes", "manuscrits"], + }, + { + "id": "simssa-ocr-printed", + "title": "SIMSSA — Imprimés anciens (XVe-XVIIe)", + "url": "https://github.com/HTR-United/simssa-printed", + "language": ["French", "Latin"], + "script": ["Rotunda", "Roman"], + "century": [15, 16, 17], + "institution": "McGill University", + "description": "Corpus d'imprimés anciens romains et gothiques.", + "license": "CC-BY 4.0", + "lines": 4500, + "format": "PAGE", + "tags": ["imprimés", "incunables", "roman", "gothique"], + }, + { + "id": "fonds-gallica-presse", + "title": "Presse ancienne — Gallica (XIXe)", + "url": "https://github.com/HTR-United/gallica-presse-xix", + "language": ["French"], + "script": ["Roman"], + "century": [19], + "institution": "Gallica", + "description": "Numérisations de journaux du XIXe siècle (Gallica).", + "license": "etalab-2.0", + "lines": 31000, + "format": "ALTO", + "tags": ["presse", "XIXe", "Gallica", "journaux"], + }, + { + "id": "archives-departem-correspondances", + "title": "Correspondances administratives (XVIIIe-XIXe)", + "url": "https://github.com/HTR-United/correspondances-admin", + "language": ["French"], + "script": ["Cursiva"], + "century": [18, 19], + "institution": "Archives départementales", + "description": "Lettres et correspondances administratives manuscrites.", + "license": "CC-BY 4.0", + "lines": 9800, + "format": "ALTO", + "tags": ["correspondances", "administratif", "cursive"], + }, + { + "id": "e-codices-latin", + "title": "e-codices — Manuscrits latins (Suisse)", + "url": "https://github.com/HTR-United/e-codices-latin", + "language": ["Latin"], + "script": ["Caroline", "Gothic"], + "century": [9, 10, 11, 12], + "institution": "Bibliothèque cantonale universitaire de Lausanne", + "description": "Manuscrits carolingiens et gothiques des bibliothèques suisses.", + "license": "CC-BY 4.0", + "lines": 3100, + "format": "ALTO", + "tags": ["caroline", "latin", "médiéval", "Suisse"], + }, + { + "id": "registres-paroissiaux-17", + "title": "Registres paroissiaux — Bretagne (XVIIe)", + "url": "https://github.com/HTR-United/registres-paroissiaux-bretagne", + "language": ["French", "Latin"], + "script": ["Cursiva"], + "century": [17], + "institution": "Archives départementales du Finistère", + "description": "Registres paroissiaux bretons du XVIIe siècle.", + "license": "CC-BY 4.0", + "lines": 15600, + "format": "ALTO", + "tags": ["registres", "Bretagne", "paroissial", "cursive"], + }, +] + + +# --------------------------------------------------------------------------- +# Dataclass entrée catalogue +# --------------------------------------------------------------------------- + +@dataclass +class HTRUnitedEntry: + """Une entrée dans le catalogue HTR-United.""" + + id: str + title: str + url: str + language: list[str] = field(default_factory=list) + script: list[str] = field(default_factory=list) + century: list[int] = field(default_factory=list) + institution: str = "" + description: str = "" + license: str = "" + lines: int = 0 + format: str = "ALTO" + tags: list[str] = field(default_factory=list) + + def as_dict(self) -> dict: + return { + "id": self.id, + "title": self.title, + "url": self.url, + "language": self.language, + "script": self.script, + "century": self.century, + "institution": self.institution, + "description": self.description, + "license": self.license, + "lines": self.lines, + "format": self.format, + "tags": self.tags, + } + + @classmethod + def from_dict(cls, d: dict) -> "HTRUnitedEntry": + return cls( + id=d.get("id", ""), + title=d.get("title", ""), + url=d.get("url", ""), + language=d.get("language", []), + script=d.get("script", []), + century=d.get("century", []), + institution=d.get("institution", ""), + description=d.get("description", ""), + license=d.get("license", ""), + lines=d.get("lines", 0), + format=d.get("format", "ALTO"), + tags=d.get("tags", []), + ) + + @property + def century_str(self) -> str: + """Siècles formatés en chiffres romains.""" + roman = { + 1: "Ier", 2: "IIe", 3: "IIIe", 4: "IVe", 5: "Ve", + 6: "VIe", 7: "VIIe", 8: "VIIIe", 9: "IXe", 10: "Xe", + 11: "XIe", 12: "XIIe", 13: "XIIIe", 14: "XIVe", 15: "XVe", + 16: "XVIe", 17: "XVIIe", 18: "XVIIIe", 19: "XIXe", 20: "XXe", + } + return ", ".join(roman.get(c, f"{c}e") for c in self.century) + + +# --------------------------------------------------------------------------- +# Catalogue +# --------------------------------------------------------------------------- + +class HTRUnitedCatalogue: + """Catalogue HTR-United avec recherche et filtrage.""" + + def __init__(self, entries: list[HTRUnitedEntry], source: str = "demo") -> None: + self.entries = entries + self.source = source # "remote" | "demo" | "cache" + + def __len__(self) -> int: + return len(self.entries) + + @classmethod + def from_demo(cls) -> "HTRUnitedCatalogue": + """Charge le catalogue de démonstration intégré.""" + entries = [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE] + return cls(entries, source="demo") + + @classmethod + def from_remote(cls, timeout: int = 10) -> "HTRUnitedCatalogue": + """Télécharge le catalogue depuis GitHub. + + En cas d'erreur réseau, retourne le catalogue de démonstration. + """ + try: + req = urllib.request.Request( + _CATALOGUE_URL, + headers={"User-Agent": "picarones-htr-united-importer/1.0"}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + raw = resp.read().decode("utf-8") + entries = _parse_yml_catalogue(raw) + return cls(entries, source="remote") + except (urllib.error.URLError, Exception) as exc: + # Fallback démo avec avertissement + logger.warning( + "[HTR-United] impossible de charger le catalogue distant (%s) : %s. " + "Utilisation des données de démonstration.", + _CATALOGUE_URL, exc, + ) + return cls.from_demo() + + def search( + self, + query: str = "", + language: Optional[str] = None, + script: Optional[str] = None, + century_min: Optional[int] = None, + century_max: Optional[int] = None, + ) -> list[HTRUnitedEntry]: + """Recherche dans le catalogue avec filtres optionnels.""" + results = self.entries + + if query: + q = query.lower() + results = [ + e for e in results + if (q in e.title.lower() + or q in e.description.lower() + or q in e.institution.lower() + or any(q in t.lower() for t in e.tags) + or any(q in lang.lower() for lang in e.language)) + ] + + if language: + lang_lower = language.lower() + results = [ + e for e in results + if any(lang_lower in lg.lower() for lg in e.language) + ] + + if script: + sc_lower = script.lower() + results = [ + e for e in results + if any(sc_lower in s.lower() for s in e.script) + ] + + if century_min is not None: + results = [ + e for e in results + if any(c >= century_min for c in e.century) + ] + + if century_max is not None: + results = [ + e for e in results + if any(c <= century_max for c in e.century) + ] + + return results + + def get_by_id(self, entry_id: str) -> Optional[HTRUnitedEntry]: + """Retourne une entrée par son identifiant.""" + for e in self.entries: + if e.id == entry_id: + return e + return None + + def available_languages(self) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for e in self.entries: + for lang in e.language: + if lang not in seen: + seen.add(lang) + result.append(lang) + return sorted(result) + + def available_scripts(self) -> list[str]: + seen: set[str] = set() + result: list[str] = [] + for e in self.entries: + for sc in e.script: + if sc not in seen: + seen.add(sc) + result.append(sc) + return sorted(result) + + +# --------------------------------------------------------------------------- +# Import de corpus +# --------------------------------------------------------------------------- + +def import_htr_united_corpus( + entry: HTRUnitedEntry, + output_dir: str | Path, + max_samples: int = 100, + show_progress: bool = True, +) -> dict: + """Importe un corpus HTR-United dans un dossier local. + + Retourne un dict avec les métadonnées de l'import. + Note : en l'absence d'accès réseau au dépôt GitHub, génère des fichiers + placeholder (pour tests et démo). + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + # Sauvegarder les métadonnées + meta = { + "source": "htr-united", + "entry_id": entry.id, + "title": entry.title, + "url": entry.url, + "language": entry.language, + "script": entry.script, + "century": entry.century, + "institution": entry.institution, + "license": entry.license, + "format": entry.format, + "imported_at": _iso_now(), + } + (output_path / "htr_united_meta.json").write_text( + json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" + ) + + # Essai de téléchargement réel depuis GitHub (archive releases) + downloaded = _try_download_corpus(entry, output_path, max_samples, show_progress) + + return { + "entry_id": entry.id, + "title": entry.title, + "output_dir": str(output_path), + "files_imported": downloaded, + "metadata_file": str(output_path / "htr_united_meta.json"), + } + + +def _try_download_corpus( + entry: HTRUnitedEntry, + output_path: Path, + max_samples: int, + show_progress: bool, +) -> int: + """Tente de télécharger le corpus depuis GitHub. Retourne le nombre de fichiers importés.""" + # Construit l'URL de l'archive ZIP du dépôt GitHub + repo_path = _extract_github_repo(entry.url) + if not repo_path: + return 0 + + zip_url = f"https://github.com/{repo_path}/archive/refs/heads/main.zip" + try: + req = urllib.request.Request( + zip_url, + headers={"User-Agent": "picarones-htr-united-importer/1.0"}, + ) + with urllib.request.urlopen(req, timeout=30) as resp: + import io + import zipfile + + data = resp.read() + with zipfile.ZipFile(io.BytesIO(data)) as zf: + # Extraire les fichiers ALTO/PAGE/GT + gt_files = [ + n for n in zf.namelist() + if n.endswith((".alto.xml", ".page.xml", ".gt.txt", ".xml")) + and not n.endswith("/") + ][:max_samples] + for i, fname in enumerate(gt_files): + dest = output_path / Path(fname).name + dest.write_bytes(zf.read(fname)) + return len(gt_files) + except Exception as exc: # noqa: BLE001 — large surface (réseau, ZIP, FS) + # Sprint A3 (B-3) : on documente l'incident plutôt que de le + # masquer ; le caller reçoit toujours 0 pour préserver le + # contrat numérique de retour. + from picarones.adapters.corpus._fallback_log import record_fallback + record_fallback( + importer="htr_united", + operation="download_zip_samples", + error=exc, + extra={"output_path": str(output_path)}, + ) + return 0 + + +def _extract_github_repo(url: str) -> Optional[str]: + """Extrait 'owner/repo' depuis une URL GitHub.""" + m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", url) + return m.group(1) if m else None + + +def _parse_yml_catalogue(raw: str) -> list[HTRUnitedEntry]: + """Parse rudimentaire du YAML catalogue HTR-United.""" + try: + import yaml + data = yaml.safe_load(raw) + if isinstance(data, list): + return [HTRUnitedEntry.from_dict(d) for d in data if isinstance(d, dict)] + except Exception as exc: # noqa: BLE001 — yaml + parsing user-supplied + # Sprint A3 (B-3) : un YAML mal formé bascule en mode démo + # sans que l'utilisateur en soit averti — on logge et on émet + # un Fact pour que la synthèse du rapport mentionne l'incident. + from picarones.adapters.corpus._fallback_log import record_fallback + record_fallback( + importer="htr_united", + operation="yaml_catalogue_parse", + error=exc, + ) + return [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE] + + +def _iso_now() -> str: + from datetime import datetime, timezone + return datetime.now(timezone.utc).isoformat(timespec="seconds") diff --git a/picarones/adapters/corpus/huggingface.py b/picarones/adapters/corpus/huggingface.py new file mode 100644 index 0000000000000000000000000000000000000000..023043bf976b187660da953ef020483119381ca1 --- /dev/null +++ b/picarones/adapters/corpus/huggingface.py @@ -0,0 +1,464 @@ +"""Import de datasets OCR/HTR depuis HuggingFace Hub. + +⚠ **Statut : expérimental** (phase C du chantier de refonte en 3 cercles). +L'API ``datasets`` HuggingFace évolue fréquemment et ce module n'a pas +de tests d'intégration. À utiliser à vos risques jusqu'à ce qu'un cas +d'usage institutionnel valide son comportement. Un ``UserWarning`` est +émis à l'import pour le rappeler. + +Ce module fournit : +- :class:`HuggingFaceDataset` — métadonnées d'un dataset HuggingFace +- :class:`HuggingFaceImporter` — recherche et import de datasets +- :func:`search_hf_datasets` — recherche par tags dans l'API HuggingFace +- :func:`import_hf_dataset` — téléchargement d'un dataset vers un dossier local + +Les datasets patrimoniaux de référence sont pré-référencés pour une découverte +rapide sans requête réseau. + +Exemple +------- + importer = HuggingFaceImporter() + results = importer.search("medieval OCR", tags=["ocr"]) + corpus = importer.import_dataset(results[0].dataset_id, output_dir="./corpus/") +""" + +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.parse +import urllib.request +import warnings +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + + +# Émission du warning ``experimental`` à l'import. Phase C du chantier +# de refonte — voir docstring du module ci-dessus. +warnings.warn( + "picarones.extras.importers.huggingface is experimental and may " + "change or be removed without notice. Use at your own risk until " + "an institutional use case validates the API.", + category=UserWarning, + stacklevel=2, +) + +# --------------------------------------------------------------------------- +# Datasets de référence pré-référencés +# --------------------------------------------------------------------------- + +_REFERENCE_DATASETS: list[dict] = [ + { + "dataset_id": "Teklia/RIMES", + "title": "RIMES — Reconnaissance et Indexation de données Manuscrites et de fac-similEs", + "description": "Corpus de courriers manuscrits français modernes. Standard de référence pour la reconnaissance d'écriture manuscrite.", + "language": ["French"], + "tags": ["htr", "ocr", "handwritten", "french", "modern"], + "license": "cc-by-4.0", + "size_category": "1K dict: + return { + "dataset_id": self.dataset_id, + "title": self.title, + "description": self.description, + "language": self.language, + "tags": self.tags, + "license": self.license, + "size_category": self.size_category, + "task": self.task, + "institution": self.institution, + "downloads": self.downloads, + "source": self.source, + } + + @classmethod + def from_dict(cls, d: dict) -> "HuggingFaceDataset": + return cls( + dataset_id=d.get("dataset_id", d.get("id", "")), + title=d.get("title", d.get("dataset_id", "")), + description=d.get("description", ""), + language=d.get("language", []), + tags=d.get("tags", []), + license=d.get("license", ""), + size_category=d.get("size_category", d.get("cardData", {}).get("size_categories", [""])[0] if isinstance(d.get("cardData"), dict) else ""), + task=d.get("task", "image-to-text"), + institution=d.get("institution", ""), + downloads=d.get("downloads", d.get("downloadsAllTime", 0)), + source=d.get("source", "api"), + ) + + @property + def hf_url(self) -> str: + return f"https://huggingface.co/datasets/{self.dataset_id}" + + +# --------------------------------------------------------------------------- +# Importer principal +# --------------------------------------------------------------------------- + +class HuggingFaceImporter: + """Recherche et importe des datasets depuis HuggingFace Hub.""" + + _API_BASE = "https://huggingface.co/api" + + def __init__(self, token: Optional[str] = None) -> None: + self._token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN") + + def _headers(self) -> dict: + h = {"User-Agent": "picarones-hf-importer/1.0"} + if self._token: + h["Authorization"] = f"Bearer {self._token}" + return h + + def search( + self, + query: str = "", + tags: Optional[list[str]] = None, + language: Optional[str] = None, + limit: int = 20, + use_reference: bool = True, + ) -> list[HuggingFaceDataset]: + """Recherche des datasets avec filtres. + + Interroge d'abord les datasets de référence pré-intégrés, puis + l'API HuggingFace si disponible. + """ + results: list[HuggingFaceDataset] = [] + + # Datasets de référence + if use_reference: + ref_results = self._search_reference(query, tags, language) + results.extend(ref_results) + + # API HuggingFace (optionnel, peut échouer silencieusement) + try: + api_results = self._search_api(query, tags, language, limit) + # Déduplique (priorité aux références) + existing_ids = {r.dataset_id for r in results} + for ds in api_results: + if ds.dataset_id not in existing_ids: + results.append(ds) + existing_ids.add(ds.dataset_id) + except Exception as exc: # noqa: BLE001 — réseau/API tierce + # Sprint A3 (B-3) : la recherche API échoue silencieusement → + # l'utilisateur ne voit que les datasets de référence et croit + # que l'API est vide. On documente l'incident. + from picarones.adapters.corpus._fallback_log import record_fallback + record_fallback( + importer="huggingface", + operation="hub_search_api", + error=exc, + extra={"query": query, "language": language, "limit": limit}, + ) + + return results[:limit] + + def _search_reference( + self, + query: str, + tags: Optional[list[str]], + language: Optional[str], + ) -> list[HuggingFaceDataset]: + datasets = [HuggingFaceDataset.from_dict(d) for d in _REFERENCE_DATASETS] + datasets = [ds._replace_source("reference") for ds in datasets] + + if query: + q = query.lower() + datasets = [ + ds for ds in datasets + if (q in ds.title.lower() + or q in ds.description.lower() + or q in ds.dataset_id.lower() + or any(q in t.lower() for t in ds.tags) + or any(q in lg.lower() for lg in ds.language)) + ] + + if tags: + for tag in tags: + t_lower = tag.lower() + datasets = [ + ds for ds in datasets + if any(t_lower in dt.lower() for dt in ds.tags) + ] + + if language: + lang_lower = language.lower() + datasets = [ + ds for ds in datasets + if any(lang_lower in lg.lower() for lg in ds.language) + ] + + return datasets + + def _search_api( + self, + query: str, + tags: Optional[list[str]], + language: Optional[str], + limit: int, + ) -> list[HuggingFaceDataset]: + params: dict[str, str] = { + "task_categories": "image-to-text", + "limit": str(min(limit, 50)), + "full": "False", + } + if query: + params["search"] = query + if language: + params["language"] = language + if tags: + params["tags"] = ",".join(tags) + + url = f"{self._API_BASE}/datasets?" + urllib.parse.urlencode(params) + req = urllib.request.Request(url, headers=self._headers()) + with urllib.request.urlopen(req, timeout=10) as resp: + data = json.loads(resp.read().decode("utf-8")) + + results = [] + for item in data if isinstance(data, list) else []: + ds = HuggingFaceDataset( + dataset_id=item.get("id", ""), + title=item.get("id", ""), + description=item.get("description", ""), + language=item.get("language", []), + tags=item.get("tags", []), + license=item.get("license", ""), + size_category=( + item.get("cardData", {}).get("size_categories", [""])[0] + if isinstance(item.get("cardData"), dict) + else "" + ), + task="image-to-text", + downloads=item.get("downloadsAllTime", 0), + source="api", + ) + if ds.dataset_id: + results.append(ds) + return results + + def import_dataset( + self, + dataset_id: str, + output_dir: str | Path, + split: str = "train", + max_samples: int = 100, + show_progress: bool = True, + ) -> dict: + """Importe un dataset depuis HuggingFace vers un dossier local. + + Retourne les métadonnées de l'import. + """ + output_path = Path(output_dir) + output_path.mkdir(parents=True, exist_ok=True) + + meta = { + "source": "huggingface", + "dataset_id": dataset_id, + "split": split, + "max_samples": max_samples, + "imported_at": _iso_now(), + } + meta_file = output_path / "huggingface_meta.json" + meta_file.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") + + # Tentative d'import via datasets library si disponible + files_imported = _try_import_with_datasets_lib( + dataset_id, output_path, split, max_samples, show_progress + ) + + return { + "dataset_id": dataset_id, + "output_dir": str(output_path), + "files_imported": files_imported, + "metadata_file": str(meta_file), + } + + +def _try_import_with_datasets_lib( + dataset_id: str, + output_path: Path, + split: str, + max_samples: int, + show_progress: bool, +) -> int: + """Essaie d'importer avec la librairie `datasets` de HuggingFace.""" + try: + from datasets import load_dataset # type: ignore + + ds = load_dataset(dataset_id, split=split, streaming=True) + count = 0 + for i, item in enumerate(ds): + if i >= max_samples: + break + # Cherche champ image et texte + image = item.get("image") or item.get("img") + text = item.get("text") or item.get("transcription") or item.get("ground_truth", "") + + if image is not None: + img_file = output_path / f"doc_{i:04d}.jpg" + try: + image.save(str(img_file)) + except Exception as exc: # noqa: BLE001 — PIL/PIL-IO + # Sprint A3 (B-3) : un échec de sauvegarde d'image + # produirait un GT orphelin (texte sans image). On + # documente et on continue — le GT est tout de même + # écrit pour préserver la cohérence numérique du compteur. + from picarones.adapters.corpus._fallback_log import record_fallback + record_fallback( + importer="huggingface", + operation="image_save", + error=exc, + extra={"img_file": str(img_file), "doc_index": i}, + ) + + gt_file = output_path / f"doc_{i:04d}.gt.txt" + gt_file.write_text(str(text), encoding="utf-8") + count += 1 + + return count + except (ImportError, Exception): + return 0 + + +def _iso_now() -> str: + from datetime import datetime, timezone + return datetime.now(timezone.utc).isoformat(timespec="seconds") + + +# --------------------------------------------------------------------------- +# Extension de HuggingFaceDataset (helper privé) +# --------------------------------------------------------------------------- + +def _patch_dataset_replace_source() -> None: + """Ajoute un helper _replace_source à HuggingFaceDataset.""" + def _replace_source(self, source: str) -> "HuggingFaceDataset": + from dataclasses import replace + return replace(self, source=source) + HuggingFaceDataset._replace_source = _replace_source + + +_patch_dataset_replace_source() diff --git a/picarones/adapters/llm/__init__.py b/picarones/adapters/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3bbc879909693d6c74e5464e4c6b454723f9e065 --- /dev/null +++ b/picarones/adapters/llm/__init__.py @@ -0,0 +1,16 @@ +"""Adaptateurs LLM — Sprint S11. + +Cible : déplacement de ``picarones.llm.{openai,anthropic,mistral, +ollama}_adapter``. Wrappers minces autour des SDK provider, qui +exposent un ``complete(prompt, ...)`` uniforme. + +Un adapter LLM ne sait **rien** d'OCR ou de patrimoine. Il fait +``prompt → completion``. La logique de pipeline (prompt +construction, post-traitement, gestion d'erreur) vit dans +``pipeline/`` ou dans le module utilisateur qui compose la +pipeline. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/adapters/llm/anthropic_adapter.py b/picarones/adapters/llm/anthropic_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..e95e6a572b06d1eee87a4b3dcd0cff2eacf6efdb --- /dev/null +++ b/picarones/adapters/llm/anthropic_adapter.py @@ -0,0 +1,111 @@ +"""Adaptateur LLM — Anthropic (Claude Sonnet, Claude Haiku).""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from picarones.adapters.llm.base import ( + BaseLLMAdapter, + log_http_error, + normalize_llm_content, +) + +logger = logging.getLogger(__name__) + + +class AnthropicAdapter(BaseLLMAdapter): + """Adaptateur pour les modèles Anthropic Claude. + + Clé API via la variable d'environnement ``ANTHROPIC_API_KEY``. + + Modes supportés : text_only, text_and_image, zero_shot. + """ + + api_key_env_var = "ANTHROPIC_API_KEY" + + @property + def name(self) -> str: + return "anthropic" + + @property + def default_model(self) -> str: + return "claude-sonnet-4-6" + + def __init__( + self, + model: Optional[str] = None, + config: Optional[dict] = None, + ) -> None: + super().__init__(model, config) + self._api_key = os.environ.get("ANTHROPIC_API_KEY") + + def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: + if not self._api_key: + raise RuntimeError( + "Clé API Anthropic manquante — définissez la variable d'environnement ANTHROPIC_API_KEY" + ) + try: + import anthropic + except ImportError as exc: + raise RuntimeError( + "Le package 'anthropic' n'est pas installé. Lancez : pip install anthropic" + ) from exc + + client = anthropic.Anthropic(api_key=self._api_key) + temperature = float(self.config.get("temperature", 0.0)) + max_tokens = int(self.config.get("max_tokens", 4096)) + + if image_b64: + content: list | str = [ + { + "type": "image", + "source": { + "type": "base64", + "media_type": "image/png", + "data": image_b64, + }, + }, + {"type": "text", "text": prompt}, + ] + else: + content = prompt + + try: + response = client.messages.create( + model=self.model, + max_tokens=max_tokens, + temperature=temperature, + messages=[{"role": "user", "content": content}], + ) + except Exception as exc: + # Chantier 4 — log discriminant (401/429/5xx) factorisé. + # Auparavant Anthropic ne discriminait pas par code HTTP, + # difficile à diagnostiquer (clé invalide vs rate limit). + log_http_error( + "AnthropicAdapter", self.model, exc, + env_var=self.api_key_env_var, + ) + raise + + if not response.content: + logger.warning( + "[AnthropicAdapter] réponse vide (modèle=%s, stop_reason=%s).", + self.model, getattr(response, "stop_reason", None), + ) + return "" + + # Chantier 4 — propagation du fix Sprint 15 : le SDK Anthropic + # retourne ``response.content`` comme une liste de blocs + # (``ContentBlock`` avec attribut ``text``). ``normalize_llm_content`` + # concatène le texte de tous les blocs au lieu de ne prendre que + # le premier — utile quand le modèle émet plusieurs blocs. + text = normalize_llm_content(response.content) + if not text: + block = response.content[0] + logger.warning( + "[AnthropicAdapter] bloc de type '%s' sans texte (modèle=%s).", + getattr(block, "type", "unknown"), self.model, + ) + return text diff --git a/picarones/adapters/llm/base.py b/picarones/adapters/llm/base.py new file mode 100644 index 0000000000000000000000000000000000000000..49a74f047db16c351ef41a33258a9778bbca353a --- /dev/null +++ b/picarones/adapters/llm/base.py @@ -0,0 +1,486 @@ +"""Interface abstraite commune à tous les adaptateurs LLM.""" + +from __future__ import annotations + +import logging +import time +import warnings +from abc import ABC, abstractmethod +from dataclasses import dataclass +from typing import Any, Generic, Optional, TypeVar + +logger = logging.getLogger(__name__) + + +T = TypeVar("T") + + +class _DeprecatedAttribute(Generic[T]): + """Descripteur class-level qui émet ``DeprecationWarning`` à l'accès. + + Permet de retirer en deux temps une constante de classe sans + casser les callers externes : phase 1, le descripteur retourne + l'ancienne valeur avec un warning ; phase 2 (version majeure + suivante), le descripteur est supprimé. + """ + + def __init__( + self, + value: T, + message: str, + ) -> None: + self._value = value + self._message = message + + def __set_name__(self, owner: type, name: str) -> None: + self._name = name + + def __get__(self, instance: Any, owner: type | None = None) -> T: + warnings.warn(self._message, DeprecationWarning, stacklevel=2) + return self._value + +from picarones.adapters._retry import ( + DEFAULT_BACKOFF_BASE as _DEFAULT_BACKOFF_BASE, +) +from picarones.adapters._retry import ( + DEFAULT_MAX_RETRIES as _DEFAULT_MAX_RETRIES, +) +from picarones.adapters._retry import ( + is_retryable as _is_retryable, +) + + +def normalize_llm_content(raw: Any) -> str: + """Normalise une réponse LLM en chaîne plate. + + Chantier 4 (post-Sprint 97) — propagation du fix Mistral + Sprint 15 à tous les providers. Le SDK Mistral peut retourner + une liste de ``ContentChunk`` au lieu d'une chaîne pour certains + modèles/versions ; le SDK OpenAI peut faire de même quand on + active des features de structuration. Ce helper applique la même + discipline pour les 4 adapters : + + - ``str`` → renvoyée telle quelle (ou ``""``). + - ``None`` → ``""``. + - ``list[ContentChunk]`` → concaténation des ``.text``. + - ``list[dict]`` avec clé ``text`` → concaténation des ``["text"]``. + - ``list[str]`` → concaténation directe. + - autre objet avec ``.text`` → ``obj.text``. + - autre → ``str(obj)`` (best-effort). + + Le résultat est garanti être une ``str`` ; ``""`` quand la réponse + est vide. La fonction est idempotente : ``normalize_llm_content(s) + == s`` pour toute chaîne ``s``. + """ + if raw is None: + return "" + if isinstance(raw, str): + return raw + if isinstance(raw, list): + parts: list[str] = [] + for chunk in raw: + if chunk is None: + continue + if isinstance(chunk, str): + parts.append(chunk) + continue + if hasattr(chunk, "text"): + txt = getattr(chunk, "text", None) + if isinstance(txt, str): + parts.append(txt) + continue + if isinstance(chunk, dict) and isinstance(chunk.get("text"), str): + parts.append(chunk["text"]) + continue + # Dernier recours — convertit le chunk en chaîne + parts.append(str(chunk)) + return "".join(parts) + if hasattr(raw, "text") and isinstance(getattr(raw, "text", None), str): + return raw.text # type: ignore[no-any-return] + return str(raw) + + +def log_http_error( + adapter_name: str, + model: str, + exc: Exception, + *, + env_var: Optional[str] = None, +) -> None: + """Log standardisé des erreurs HTTP des SDK LLM. + + Chantier 4 (post-Sprint 97) — propagation du log discriminant + Mistral/OpenAI à tous les providers. Inspecte ``status_code`` et + ``http_status`` puis émet un warning ciblé selon le code : + + - 401 : clé API invalide/expirée (mention de la variable + d'environnement à vérifier si fournie). + - 429 : rate limit / quota dépassé. + - 5xx : problème serveur côté provider. + - autre / pas de status_code : log générique. + + L'exception n'est pas levée — l'appelant doit ``raise`` + explicitement après ce log s'il veut propager (le retry est géré + par ``BaseLLMAdapter.complete`` selon ``_is_retryable``). + """ + status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) + if status == 401: + suffix = f" Vérifier {env_var}." if env_var else "" + logger.warning( + "[%s] erreur HTTP 401 — clé API invalide ou expirée " + "(modèle=%s).%s", + adapter_name, model, suffix, + ) + elif status == 429: + logger.warning( + "[%s] erreur HTTP 429 — quota dépassé ou rate-limit " + "(modèle=%s). Réessayer plus tard.", + adapter_name, model, + ) + elif status is not None and status >= 500: + logger.warning( + "[%s] erreur HTTP %d — problème serveur (modèle=%s) : %s", + adapter_name, status, model, exc, + ) + else: + logger.warning( + "[%s] erreur lors de l'appel API (modèle=%s) : %s", + adapter_name, model, exc, + ) + + +from picarones.domain.errors import AdapterStepError + + +class LLMAdapterError(AdapterStepError): + """Erreur typée pour un échec d'adapter LLM. + + Hérite de ``AdapterStepError`` (racine commune avec OCR et VLM) + → un caller peut catcher ``AdapterStepError`` pour toute erreur + d'adapter sans connaître la sous-classe. + + Avant S52, ``BaseLLMAdapter.execute`` levait ``OCRAdapterError`` + par confusion sémantique — c'était noté dans l'audit comme issue + #11 (hiérarchie incohérente). + """ + + +@dataclass +class LLMResult: + """Résultat produit par un appel LLM.""" + + model_id: str + text: str + duration_seconds: float + tokens_used: Optional[int] = None + error: Optional[str] = None + + @property + def success(self) -> bool: + return self.error is None + + +class BaseLLMAdapter(ABC): + """Classe de base pour tous les adaptateurs LLM. + + Chaque adaptateur doit implémenter : + - ``name`` : identifiant du provider (ex : 'openai') + - ``default_model``: modèle par défaut du provider + - ``_call()`` : appel API effectif, retourne le texte brut + + Les clés API sont lues depuis les variables d'environnement uniquement. + + Retry automatique + ----------------- + Les erreurs retryables (HTTP 429, 5xx, timeout réseau) sont automatiquement + retentées avec backoff exponentiel (2s, 4s, 8s par défaut). Configurable + via ``config["max_retries"]`` et ``config["retry_backoff"]``. + + Normalisation des réponses (chantier 4) + --------------------------------------- + Les sous-classes utilisent :func:`normalize_llm_content` sur la + réponse SDK avant de la retourner — garantit qu'une réponse de + type ``list[ContentChunk]`` (Mistral, parfois OpenAI) est + convertie en ``str`` plate. + + Logging d'erreurs HTTP (chantier 4) + ----------------------------------- + Les sous-classes utilisent :func:`log_http_error` pour produire + un log discriminant par ``status_code`` (401 → clé invalide, + 429 → rate limit, 5xx → serveur). Auparavant ce log était + dupliqué chez Mistral/OpenAI et absent chez Anthropic. + + Sprint A14-S44 — intégration pipeline native + --------------------------------------------- + ``BaseLLMAdapter`` implémente désormais le contrat ``StepExecutor`` + du pipeline (``input_types``, ``output_types``, ``execution_mode``, + ``execute(inputs, params, context)``) — un adapter LLM est + directement utilisable comme step de pipeline pour la post-correction + de texte OCR. Pas de wrapper / shim : la méthode ``execute`` vit + dans la base et est partagée par les 4 adapters concrets. + + Convention par défaut : un LLM consomme ``RAW_TEXT`` (depuis l'OCR + en amont) et produit ``CORRECTED_TEXT``. Une sous-classe peut + surcharger ``input_types`` / ``output_types`` si elle implémente un + autre contrat (ex : ALTO → ALTO pour un module de remappage). + """ + + # Variable d'environnement portant la clé API. Sous-classes + # surchargent (ex. ``"OPENAI_API_KEY"``) ; mention utilisée par + # :func:`log_http_error` quand un 401 est rencontré. ``None`` + # pour les providers sans clé (Ollama). + api_key_env_var: Optional[str] = None + + # ────────────────────────────────────────────────────────────────── + # Sprint A14-S44 — contrat StepExecutor du pipeline + # ────────────────────────────────────────────────────────────────── + + #: Types d'artefacts consommés par défaut. Surchargeable par + #: une sous-classe qui consommerait des artefacts différents + #: (ex : ALTO_XML pour un remappeur ALTO LLM). + @property + def input_types(self) -> "frozenset": + from picarones.domain.artifacts import ArtifactType + return frozenset({ArtifactType.RAW_TEXT}) + + @property + def output_types(self) -> "frozenset": + from picarones.domain.artifacts import ArtifactType + return frozenset({ArtifactType.CORRECTED_TEXT}) + + #: Mode d'exécution : LLM via API → IO-bound → ThreadPool dans le + #: runner. Une sous-classe locale (Ollama CPU-bound) peut + #: surcharger en ``"cpu"``. + execution_mode: str = "io" + + #: Prompts de post-correction par défaut, indexés par code langue + #: ISO-639-1 (``fr``, ``en``, ``la``). Sélection via + #: ``config["lang"]`` ; fallback FR si la langue est absente. + #: + #: ``DEFAULT_CORRECTION_PROMPT`` (singulier, FR) reste exposé en + #: ``_DeprecatedAttribute`` pour les sous-classes externes qui + #: lisaient l'ancienne API ; suppression prévue en 2.0. + DEFAULT_CORRECTION_PROMPTS: dict[str, str] = { + "fr": ( + "Corrige les erreurs OCR dans le texte suivant en " + "conservant fidèlement la langue, l'orthographe " + "historique et la ponctuation. Retourne uniquement le " + "texte corrigé, sans commentaire :\n\n{text}" + ), + "en": ( + "Fix OCR errors in the following text while preserving " + "the original language, historical spelling, and " + "punctuation. Return only the corrected text, with no " + "commentary:\n\n{text}" + ), + "la": ( + "Corrige errores OCR in textu sequenti, fideliter " + "servans linguam, orthographiam historicam et " + "interpunctionem. Redde solum textum correctum, sine " + "ulla glossa:\n\n{text}" + ), + } + + #: Alias rétrocompat (FR uniquement) pour les sous-classes + #: externes qui lisaient l'ancienne API singulière. L'accès + #: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0. + DEFAULT_CORRECTION_PROMPT = _DeprecatedAttribute( + DEFAULT_CORRECTION_PROMPTS["fr"], + "BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT is deprecated and " + "will be removed in 2.0. Use " + "DEFAULT_CORRECTION_PROMPTS[lang] (lang ∈ {fr, en, la}).", + ) + + def __init__( + self, + model: Optional[str] = None, + config: Optional[dict] = None, + ) -> None: + self.config: dict = config or {} + self.model: str = model or self.default_model + + @property + @abstractmethod + def name(self) -> str: + """Identifiant du provider (ex : 'openai', 'anthropic').""" + + @property + @abstractmethod + def default_model(self) -> str: + """Modèle utilisé si aucun n'est fourni explicitement.""" + + @abstractmethod + def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: + """Appel LLM effectif. + + Parameters + ---------- + prompt: + Texte du prompt final (variables déjà substituées). + image_b64: + Image encodée en base64 (sans préfixe data URI). + None pour les appels texte-uniquement. + + Returns + ------- + str + Texte généré par le LLM. + """ + + def complete( + self, + prompt: str, + image_b64: Optional[str] = None, + ) -> LLMResult: + """Point d'entrée public : appelle le LLM avec retry automatique.""" + max_retries = int(self.config.get("max_retries", _DEFAULT_MAX_RETRIES)) + backoff_base = float(self.config.get("retry_backoff", _DEFAULT_BACKOFF_BASE)) + + start = time.perf_counter() + last_exc: Optional[Exception] = None + + for attempt in range(max_retries + 1): + try: + text = self._call(prompt, image_b64) + duration = time.perf_counter() - start + return LLMResult( + model_id=self.model, + text=text, + duration_seconds=round(duration, 4), + ) + except Exception as exc: # noqa: BLE001 + last_exc = exc + if attempt < max_retries and _is_retryable(exc): + wait = backoff_base ** (attempt + 1) + logger.warning( + "[%s] erreur retryable (tentative %d/%d, attente %.1fs) : %s", + self.name, attempt + 1, max_retries + 1, wait, exc, + ) + time.sleep(wait) + else: + break + + duration = time.perf_counter() - start + return LLMResult( + model_id=self.model, + text="", + duration_seconds=round(duration, 4), + error=str(last_exc), + ) + + # ────────────────────────────────────────────────────────────────── + # Sprint A14-S44 — execute() pour le pipeline + # ────────────────────────────────────────────────────────────────── + + def execute( + self, + inputs: dict, + params: dict, + context: Any, + ) -> dict: + """Exécute la post-correction LLM en tant que step de pipeline. + + Convention par défaut : lit ``inputs[RAW_TEXT]`` (Artifact), + charge son contenu UTF-8 depuis l'URI, appelle ``self.complete`` + avec le ``correction_prompt`` formaté, écrit le résultat dans + un fichier ``..corrected.txt``, et + retourne ``{CORRECTED_TEXT: Artifact}``. + + Le caller (``PipelineExecutor``) catch les exceptions ; on les + propage telles quelles. + + Optionnel : si ``inputs[IMAGE]`` est présent, l'image est + encodée en base64 et passée au LLM (mode VLM). Les sous-classes + qui ne supportent pas la vision (ex. ollama texte) ignorent + silencieusement. + """ + from pathlib import Path + import base64 + + from picarones.domain.artifacts import Artifact, ArtifactType + + if ArtifactType.RAW_TEXT not in inputs: + raise LLMAdapterError( + f"{self.name} : input RAW_TEXT manquant.", + ) + text_artifact = inputs[ArtifactType.RAW_TEXT] + if text_artifact.uri is None: + raise LLMAdapterError( + f"{self.name} : artefact RAW_TEXT " + f"{text_artifact.id!r} sans URI.", + ) + text_path = Path(text_artifact.uri) + if not text_path.exists(): + raise LLMAdapterError( + f"{self.name} : fichier texte introuvable {text_path!r}.", + ) + + original_text = text_path.read_text(encoding="utf-8") + + # Image optionnelle (VLM-style si supporté). + image_b64: Optional[str] = None + image_artifact = inputs.get(ArtifactType.IMAGE) + if image_artifact is not None and image_artifact.uri is not None: + image_path = Path(image_artifact.uri) + if image_path.exists(): + image_b64 = base64.b64encode( + image_path.read_bytes(), + ).decode("ascii") + + # Priorité : override explicite via config > prompt par langue + # selon config["lang"] > FR par défaut. + custom_prompt = self.config.get("correction_prompt") + if custom_prompt is not None: + prompt_template = custom_prompt + else: + lang = (self.config.get("lang") or "fr").lower() + if lang not in self.DEFAULT_CORRECTION_PROMPTS: + logger.warning( + "[%s] lang=%r non supportée par " + "DEFAULT_CORRECTION_PROMPTS (%s) — fallback FR. " + "Pour un corpus dans cette langue, fournir " + "config['correction_prompt'] explicite.", + self.name, lang, + sorted(self.DEFAULT_CORRECTION_PROMPTS.keys()), + ) + prompt_template = self.DEFAULT_CORRECTION_PROMPTS.get( + lang, self.DEFAULT_CORRECTION_PROMPTS["fr"], + ) + prompt = prompt_template.format(text=original_text) + + result = self.complete(prompt, image_b64=image_b64) + if not result.success: + raise LLMAdapterError( + f"{self.name} : LLM a échoué ({result.error}).", + ) + + from picarones.adapters.output_paths import resolve_output_path + out_path = resolve_output_path( + input_path=text_path, + adapter_name=self.name, + suffix="corrected.txt", + context=context, + ) + out_path.write_text(result.text, encoding="utf-8") + + return { + ArtifactType.CORRECTED_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:corrected_text", + document_id=context.document_id, + type=ArtifactType.CORRECTED_TEXT, + produced_by_step="post_correction", + uri=str(out_path), + ), + } + + def __repr__(self) -> str: + return f"{self.__class__.__name__}(model={self.model!r})" + + +__all__ = [ + "BaseLLMAdapter", + "LLMAdapterError", + "LLMResult", + "log_http_error", + "normalize_llm_content", +] diff --git a/picarones/adapters/llm/mistral_adapter.py b/picarones/adapters/llm/mistral_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..a22c5c33ace728794e780b270230cb0bb2489581 --- /dev/null +++ b/picarones/adapters/llm/mistral_adapter.py @@ -0,0 +1,157 @@ +"""Adaptateur LLM — Mistral AI (Mistral Large, Pixtral).""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from picarones.adapters.llm.base import ( + BaseLLMAdapter, + log_http_error, + normalize_llm_content, +) + +logger = logging.getLogger(__name__) + +# Modèles Mistral qui NE supportent PAS l'API chat/completions multimodale. +# Ces petits modèles sont text-only; le passer avec une image provoque une erreur. +_TEXT_ONLY_MODELS = frozenset({ + "ministral-3b-latest", + "ministral-8b-latest", + "mistral-tiny", + "mistral-tiny-latest", + "open-mistral-7b", + "open-mixtral-8x7b", +}) + + +class MistralAdapter(BaseLLMAdapter): + """Adaptateur pour les modèles Mistral AI. + + Clé API via la variable d'environnement ``MISTRAL_API_KEY``. + + Modes supportés : text_only (tous modèles), text_and_image et zero_shot + avec les modèles multimodaux (pixtral-12b, pixtral-large). + + Note + ---- + Les modèles ``ministral-3b-latest`` et ``ministral-8b-latest`` ne supportent + pas le mode multimodal — utiliser ``PipelineMode.TEXT_ONLY`` avec ces modèles. + """ + + api_key_env_var = "MISTRAL_API_KEY" + + @property + def name(self) -> str: + return "mistral" + + @property + def default_model(self) -> str: + return "mistral-large-latest" + + def __init__( + self, + model: Optional[str] = None, + config: Optional[dict] = None, + ) -> None: + super().__init__(model, config) + self._api_key = os.environ.get("MISTRAL_API_KEY") + if self.model in _TEXT_ONLY_MODELS: + logger.info( + "[MistralAdapter] modèle '%s' : text-only (pas de support multimodal).", + self.model, + ) + + def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: + if not self._api_key: + raise RuntimeError( + "Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY" + ) + try: + try: + from mistralai.client import Mistral + except ImportError: + from mistralai import Mistral # type: ignore[no-redef] + except ImportError as exc: + raise RuntimeError( + "Le package 'mistralai' n'est pas installé. Lancez : pip install mistralai" + ) from exc + + client = Mistral(api_key=self._api_key) + temperature = float(self.config.get("temperature", 0.0)) + max_tokens = int(self.config.get("max_tokens", 4096)) + + # Les modèles text-only ne supportent pas les images + if image_b64 and self.model in _TEXT_ONLY_MODELS: + logger.warning( + "[MistralAdapter] modèle '%s' ne supporte pas les images — " + "image ignorée, appel en mode texte seul.", + self.model, + ) + image_b64 = None + + if image_b64: + content: list | str = [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": f"data:image/png;base64,{image_b64}", + }, + ] + else: + content = prompt + + logger.info( + "[MistralAdapter] appel %s — prompt=%d chars, image=%s", + self.model, len(prompt), "oui" if image_b64 else "non", + ) + + try: + response = client.chat.complete( + model=self.model, + messages=[{"role": "user", "content": content}], + temperature=temperature, + max_tokens=max_tokens, + ) + except Exception as exc: + log_http_error( + "MistralAdapter", self.model, exc, + env_var=self.api_key_env_var, + ) + raise + + if not response.choices: + logger.warning( + "[MistralAdapter] response.choices vide (modèle=%s).", + self.model, + ) + return "" + + _choice = response.choices[0] + raw = _choice.message.content + _finish_reason = _choice.finish_reason + + # Chantier 4 — normalisation factorisée dans + # ``picarones.llm.base.normalize_llm_content`` (Sprint 15 + # généralisé : list[ContentChunk] / list[dict] / str → str). + text = normalize_llm_content(raw) + + _completion_tokens = None + if hasattr(response, "usage") and response.usage: + _completion_tokens = getattr(response.usage, "completion_tokens", None) + + logger.info( + "[MistralAdapter] réponse %s — finish_reason=%s, len=%d, tokens=%s", + self.model, _finish_reason, len(text), _completion_tokens, + ) + + if not text.strip(): + logger.warning( + "[MistralAdapter] réponse vide du modèle '%s' " + "(finish_reason=%s, completion_tokens=%s). " + "Vérifier le prompt et la compatibilité du modèle.", + self.model, _finish_reason, _completion_tokens, + ) + + return text diff --git a/picarones/adapters/llm/ollama_adapter.py b/picarones/adapters/llm/ollama_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..6a2ecf2f2e046bb28f4fefa1668a1d6952d7468f --- /dev/null +++ b/picarones/adapters/llm/ollama_adapter.py @@ -0,0 +1,109 @@ +"""Adaptateur LLM — Ollama (modèles locaux : Llama 3, Gemma, Phi, Mistral local…).""" + +from __future__ import annotations + +import logging +from typing import Optional +from urllib.parse import urlparse + +from picarones.adapters.llm.base import BaseLLMAdapter, normalize_llm_content + +logger = logging.getLogger(__name__) + + +class OllamaAdapter(BaseLLMAdapter): + """Adaptateur pour les modèles locaux via Ollama. + + Aucune clé API requise. Nécessite un serveur Ollama actif (par défaut + sur http://localhost:11434). + + Modes supportés : + - text_only : tous modèles Ollama + - text_and_image : modèles multimodaux (llava, bakllava, moondream…) + - zero_shot : modèles multimodaux uniquement + + Configuration (via ``config``) : + - ``base_url`` : URL du serveur Ollama (défaut : http://localhost:11434) + """ + + @property + def name(self) -> str: + return "ollama" + + @property + def default_model(self) -> str: + return "llama3" + + def __init__( + self, + model: Optional[str] = None, + config: Optional[dict] = None, + ) -> None: + super().__init__(model, config) + base_url = self.config.get("base_url", "http://localhost:11434").rstrip("/") + parsed = urlparse(base_url) + if parsed.scheme not in ("http", "https"): + raise ValueError( + f"URL Ollama invalide (schéma '{parsed.scheme}' non autorisé, " + f"seuls http/https sont acceptés) : {base_url}" + ) + self._base_url = base_url + + def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: + import json + import urllib.error + import urllib.request + + temperature = float(self.config.get("temperature", 0.0)) + payload: dict = { + "model": self.model, + "prompt": prompt, + "stream": False, + "options": {"temperature": temperature}, + } + if image_b64: + payload["images"] = [image_b64] + + data = json.dumps(payload).encode("utf-8") + req = urllib.request.Request( + f"{self._base_url}/api/generate", + data=data, + headers={"Content-Type": "application/json"}, + ) + try: + with urllib.request.urlopen(req, timeout=120) as resp: + raw = resp.read().decode("utf-8") + except urllib.error.HTTPError as exc: + logger.warning( + "[OllamaAdapter] erreur HTTP %d (modèle=%s) : %s", + exc.code, self.model, exc, + ) + raise RuntimeError( + f"Erreur HTTP {exc.code} du serveur Ollama ({self._base_url}) : {exc}" + ) from exc + except urllib.error.URLError as exc: + raise RuntimeError( + f"Impossible de joindre le serveur Ollama sur {self._base_url}. " + f"Vérifiez qu'Ollama est démarré (ollama serve). Erreur : {exc}" + ) from exc + + try: + result = json.loads(raw) + except json.JSONDecodeError as exc: + logger.warning( + "[OllamaAdapter] réponse JSON invalide (modèle=%s) : %s", + self.model, raw[:200], + ) + raise RuntimeError( + f"Réponse JSON invalide du serveur Ollama : {exc}" + ) from exc + + # Chantier 4 — propagation du fix Sprint 15 : Ollama retourne + # ``response`` en string mais on normalise par défense (cas où + # un futur build retournerait un format structuré). + text = normalize_llm_content(result.get("response", "")) + if not text: + logger.warning( + "[OllamaAdapter] réponse vide (modèle=%s).", self.model, + ) + return text diff --git a/picarones/adapters/llm/openai_adapter.py b/picarones/adapters/llm/openai_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..ae279c07f472888feb80d59be34703954ae5ac00 --- /dev/null +++ b/picarones/adapters/llm/openai_adapter.py @@ -0,0 +1,94 @@ +"""Adaptateur LLM — OpenAI (GPT-4o, GPT-4o-mini).""" + +from __future__ import annotations + +import logging +import os +from typing import Optional + +from picarones.adapters.llm.base import ( + BaseLLMAdapter, + log_http_error, + normalize_llm_content, +) + +logger = logging.getLogger(__name__) + + +class OpenAIAdapter(BaseLLMAdapter): + """Adaptateur pour les modèles OpenAI (GPT-4o, GPT-4o-mini). + + Clé API via la variable d'environnement ``OPENAI_API_KEY``. + + Modes supportés : text_only, text_and_image, zero_shot. + """ + + api_key_env_var = "OPENAI_API_KEY" + + @property + def name(self) -> str: + return "openai" + + @property + def default_model(self) -> str: + return "gpt-4o" + + def __init__( + self, + model: Optional[str] = None, + config: Optional[dict] = None, + ) -> None: + super().__init__(model, config) + self._api_key = os.environ.get("OPENAI_API_KEY") + + def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: + if not self._api_key: + raise RuntimeError( + "Clé API OpenAI manquante — définissez la variable d'environnement OPENAI_API_KEY" + ) + try: + from openai import OpenAI + except ImportError as exc: + raise RuntimeError( + "Le package 'openai' n'est pas installé. Lancez : pip install openai" + ) from exc + + client = OpenAI(api_key=self._api_key) + temperature = float(self.config.get("temperature", 0.0)) + max_tokens = int(self.config.get("max_tokens", 4096)) + + if image_b64: + content = [ + {"type": "text", "text": prompt}, + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{image_b64}"}, + }, + ] + else: + content = prompt # type: ignore[assignment] + + try: + response = client.chat.completions.create( + model=self.model, + messages=[{"role": "user", "content": content}], + temperature=temperature, + max_tokens=max_tokens, + ) + except Exception as exc: + log_http_error( + "OpenAIAdapter", self.model, exc, + env_var=self.api_key_env_var, + ) + raise + + if not response.choices: + logger.warning( + "[OpenAIAdapter] response.choices vide (modèle=%s).", self.model, + ) + return "" + # Chantier 4 — propagation du fix Sprint 15 : le SDK OpenAI + # peut retourner une ``list[ContentBlock]`` selon l'API + # (Responses, structured outputs). ``normalize_llm_content`` + # gère les deux cas (str et list). + return normalize_llm_content(response.choices[0].message.content) diff --git a/picarones/adapters/ocr/__init__.py b/picarones/adapters/ocr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4c1d6e82c41d3902b37f14693ff173e870a5b917 --- /dev/null +++ b/picarones/adapters/ocr/__init__.py @@ -0,0 +1,39 @@ +"""Adapters OCR du nouveau monde — Sprint A14-S26. + +Contrat ``BaseOCRAdapter`` natif au rewrite : pas hérité du legacy +``picarones.engines.base.BaseOCREngine``, exprimé directement en +termes du nouveau ``ArtifactType`` et de l'interface +``execute(inputs, params, context)`` du ``PipelineExecutor``. + +Implémentations livrées +----------------------- +- ``PrecomputedTextAdapter`` — lit un texte OCR pré-calculé depuis + le filesystem. Cas BnF : comparer N transcriptions déjà produites + par d'autres outils sans relancer d'OCR. + +Adapters concrets pour Tesseract / Pero OCR / Mistral OCR / Google +Vision / Azure DI : à écrire au cas par cas dans des sprints +dédiés, **natifs** au nouveau contrat (pas de shim sur le legacy +``picarones.engines``). +""" + +from __future__ import annotations + +from picarones.adapters.ocr.azure_doc_intel import AzureDocIntelAdapter +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.ocr.google_vision import GoogleVisionAdapter +from picarones.adapters.ocr.mistral_ocr import MistralOCRAdapter +from picarones.adapters.ocr.pero_ocr import PeroOCRAdapter +from picarones.adapters.ocr.precomputed import PrecomputedTextAdapter +from picarones.adapters.ocr.tesseract import TesseractAdapter + +__all__ = [ + "BaseOCRAdapter", + "OCRAdapterError", + "AzureDocIntelAdapter", + "GoogleVisionAdapter", + "MistralOCRAdapter", + "PeroOCRAdapter", + "PrecomputedTextAdapter", + "TesseractAdapter", +] diff --git a/picarones/adapters/ocr/azure_doc_intel.py b/picarones/adapters/ocr/azure_doc_intel.py new file mode 100644 index 0000000000000000000000000000000000000000..9585a4fcaf285440f0279dd30aed5e300f122e39 --- /dev/null +++ b/picarones/adapters/ocr/azure_doc_intel.py @@ -0,0 +1,376 @@ +"""``AzureDocIntelAdapter`` natif — Sprint A14-S34. + +Migration native du legacy ``picarones.engines.azure_doc_intel`` vers +``BaseOCRAdapter`` (S26). **Pas un shim**. + +Le legacy reste en place jusqu'au S46. + +Cas d'usage BnF +--------------- +Azure Document Intelligence (anciennement Form Recognizer) propose +plusieurs modèles préentraînés : + +- ``prebuilt-read`` (défaut) : lecture générique optimisée pour les + documents textuels denses. +- ``prebuilt-document`` : extraction layout + champs. +- ``prebuilt-layout`` : analyse de mise en page. +- modèles personnalisés entraînés. + +L'API est asynchrone : on poste l'image et on poll un endpoint +status jusqu'à obtenir le résultat. + +L'adapter route automatiquement vers SDK +(``azure-ai-documentintelligence``) si disponible, sinon REST +direct via ``urllib`` (avec polling). + +Configuration +------------- +Constructeur : + +- ``name`` (défaut ``"azure_doc_intel"``). +- ``endpoint`` : URL de l'endpoint (overrides + ``AZURE_DOC_INTEL_ENDPOINT``). +- ``api_key`` : clé API (overrides ``AZURE_DOC_INTEL_KEY``). +- ``model_id`` (défaut ``"prebuilt-read"``). +- ``locale`` (défaut ``"fr-FR"``). +- ``api_version`` (défaut ``"2024-02-29-preview"``). +- ``timeout_seconds`` (défaut 60) : timeout par requête HTTP. +- ``max_polling_attempts`` (défaut 30) : nombre max de polls REST. +- ``polling_interval_base`` (défaut 1.0) : intervalle de base entre + polls (incrémenté de 0.5s par tentative — backoff linéaire + identique au legacy). + +Comportement +------------ +1. Valide IMAGE input. +2. Résout endpoint + api_key (explicite > env). +3. Tente le SDK ; sur ImportError, fallback REST. +4. Pour le REST : POST → Operation-Location → poll jusqu'à + ``succeeded`` / ``failed`` / ``canceled``. +5. Extrait le texte ligne par ligne dans l'ordre pages × lines. +6. Écrit dans ``..txt`` à côté de l'image. + +Anti-sur-ingénierie +------------------- +- Pas d'extraction de confidences (legacy S51 — reportée). +- Pas de support multi-langue dans une même requête. +- Pas de retry au-delà du polling (qui est un retry implicite). +""" + +from __future__ import annotations + +import json +import os +import time +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from picarones.adapters._retry import call_with_retry +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType + + +class AzureDocIntelAdapter(BaseOCRAdapter): + """Adapter Azure Document Intelligence natif au contrat S26. + + Parameters + ---------- + name: + Identifiant lisible. Défaut ``"azure_doc_intel"``. + endpoint: + URL Azure (override ``AZURE_DOC_INTEL_ENDPOINT``). + api_key: + Clé API Azure (override ``AZURE_DOC_INTEL_KEY``). + model_id: + ``"prebuilt-read"`` (défaut), ``"prebuilt-document"``, + ``"prebuilt-layout"``, ou un modèle entraîné personnalisé. + locale: + Locale Azure (défaut ``"fr-FR"``). + api_version: + Version d'API Azure (défaut ``"2024-02-29-preview"``). + timeout_seconds: + Timeout HTTP (défaut 60). + max_polling_attempts: + Nombre max de polls REST (défaut 30). + polling_interval_base: + Intervalle de base entre polls (défaut 1.0s, +0.5s/attempt). + + Raises + ------ + OCRAdapterError + Au constructeur si name invalide ou paramètres hors plage. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__( + self, + *, + name: str = "azure_doc_intel", + endpoint: str | None = None, + api_key: str | None = None, + model_id: str = "prebuilt-read", + locale: str = "fr-FR", + api_version: str = "2024-02-29-preview", + timeout_seconds: float = 60.0, + max_polling_attempts: int = 30, + polling_interval_base: float = 1.0, + ) -> None: + if not name or not name.strip(): + raise OCRAdapterError( + "AzureDocIntelAdapter : name vide non autorisé.", + ) + if not all(c.isalnum() or c in "_-" for c in name): + raise OCRAdapterError( + f"AzureDocIntelAdapter : name invalide {name!r} — " + "alphanumérique + _ - uniquement.", + ) + if timeout_seconds <= 0: + raise OCRAdapterError( + f"AzureDocIntelAdapter : timeout_seconds doit être > 0, " + f"reçu {timeout_seconds}.", + ) + if max_polling_attempts <= 0: + raise OCRAdapterError( + f"AzureDocIntelAdapter : max_polling_attempts doit être " + f"> 0, reçu {max_polling_attempts}.", + ) + if polling_interval_base < 0: + raise OCRAdapterError( + f"AzureDocIntelAdapter : polling_interval_base doit être " + f">= 0, reçu {polling_interval_base}.", + ) + self._name = name + self._explicit_endpoint = endpoint + self._explicit_api_key = api_key + self._model_id = model_id + self._locale = locale + self._api_version = api_version + self._timeout = timeout_seconds + self._max_polling_attempts = max_polling_attempts + self._polling_base = polling_interval_base + + @property + def name(self) -> str: + return self._name + + @property + def model_id(self) -> str: + return self._model_id + + def _resolve_api_key(self) -> str: + key = self._explicit_api_key or os.environ.get("AZURE_DOC_INTEL_KEY") + if not key: + raise OCRAdapterError( + f"{self.name} : clé API Azure manquante. Définir " + "AZURE_DOC_INTEL_KEY ou passer api_key= au constructeur.", + ) + return key + + def _resolve_endpoint(self) -> str: + endpoint = ( + self._explicit_endpoint + or os.environ.get("AZURE_DOC_INTEL_ENDPOINT", "") + ).rstrip("/") + if not endpoint: + raise OCRAdapterError( + f"{self.name} : endpoint Azure manquant. Définir " + "AZURE_DOC_INTEL_ENDPOINT ou passer endpoint= au " + "constructeur.", + ) + return endpoint + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise OCRAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + api_key = self._resolve_api_key() + endpoint = self._resolve_endpoint() + + # On tente le SDK d'abord ; sur ImportError, fallback REST. + try: + text = self._call_via_sdk(image_path, endpoint, api_key) + except _SDKMissing: + text = self._call_via_rest(image_path, endpoint, api_key) + + text_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + text_path.write_text(text, encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + # ────────────────────────────────────────────────────────────── + # SDK + # ────────────────────────────────────────────────────────────── + + def _call_via_sdk( + self, image_path: Path, endpoint: str, api_key: str, + ) -> str: + try: + from azure.ai.documentintelligence import ( + DocumentIntelligenceClient, + ) + from azure.core.credentials import AzureKeyCredential + except ImportError as exc: + raise _SDKMissing() from exc + + try: + client = DocumentIntelligenceClient( + endpoint=endpoint, + credential=AzureKeyCredential(api_key), + ) + with open(image_path, "rb") as f: + poller = client.begin_analyze_document( + model_id=self._model_id, + body=f, + locale=self._locale, + content_type="application/octet-stream", + ) + result = poller.result() + text = "\n".join( + line.content + for page in result.pages + for line in (page.lines or []) + ) + except _SDKMissing: + raise + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : SDK Azure a levé : " + f"{type(exc).__name__}: {exc}", + ) from exc + return text + + # ────────────────────────────────────────────────────────────── + # REST avec polling + # ────────────────────────────────────────────────────────────── + + def _call_via_rest( + self, image_path: Path, endpoint: str, api_key: str, + ) -> str: + image_bytes = image_path.read_bytes() + analyze_url = ( + f"{endpoint}/documentintelligence/documentModels/" + f"{self._model_id}:analyze" + f"?api-version={self._api_version}&locale={self._locale}" + ) + req = urllib.request.Request( + analyze_url, + data=image_bytes, + headers={ + "Ocp-Apim-Subscription-Key": api_key, + "Content-Type": "application/octet-stream", + }, + ) + def _do_post() -> str: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return resp.headers.get("Operation-Location", "") + + try: + operation_url = call_with_retry(_do_post, label=self.name) + except urllib.error.HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8") + except Exception: # noqa: BLE001 + pass + raise OCRAdapterError( + f"{self.name} : Azure Document Intelligence erreur " + f"{exc.code} : {body}", + ) from exc + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : erreur API Azure : " + f"{type(exc).__name__}: {exc}", + ) from exc + + if not operation_url: + raise OCRAdapterError( + f"{self.name} : Azure n'a pas retourné Operation-Location.", + ) + + # Polling du résultat (Azure asynchrone). + headers = {"Ocp-Apim-Subscription-Key": api_key} + for attempt in range(self._max_polling_attempts): + time.sleep(self._polling_base + attempt * 0.5) + poll_req = urllib.request.Request(operation_url, headers=headers) + try: + with urllib.request.urlopen( + poll_req, timeout=self._timeout, + ) as resp: + result = json.loads(resp.read().decode("utf-8")) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : erreur de polling Azure : " + f"{type(exc).__name__}: {exc}", + ) from exc + status = result.get("status", "") + if status == "succeeded": + return self._extract_text_from_rest_result(result) + if status in {"failed", "canceled"}: + raise OCRAdapterError( + f"{self.name} : analyse Azure {status} : " + f"{result.get('error', {})}", + ) + # running → continue + raise OCRAdapterError( + f"{self.name} : timeout polling Azure après " + f"{self._max_polling_attempts} tentatives.", + ) + + @staticmethod + def _extract_text_from_rest_result(result: dict) -> str: + pages = result.get("analyzeResult", {}).get("pages", []) + lines: list[str] = [] + for page in pages: + for line in page.get("lines", []): + content = line.get("content", "") + if content: + lines.append(content) + return "\n".join(lines) + + +class _SDKMissing(Exception): + """Sentinel interne pour signaler que le SDK Azure n'est pas + installé. Capturé par ``execute`` pour fallback REST. + + Ne fuit jamais au caller — c'est un détail d'implémentation. + """ + + +__all__ = ["AzureDocIntelAdapter"] diff --git a/picarones/adapters/ocr/base.py b/picarones/adapters/ocr/base.py new file mode 100644 index 0000000000000000000000000000000000000000..8aa7d3ad708f6d7bcf08f4d634e701d13b308e80 --- /dev/null +++ b/picarones/adapters/ocr/base.py @@ -0,0 +1,173 @@ +"""``BaseOCRAdapter`` — contrat natif du nouveau monde pour un adapter OCR. + +Sprint A14-S26 du rewrite ciblé. + +Ce module définit le contrat **propre** auquel un adapter OCR du +nouveau monde doit se conformer pour être utilisable comme step +d'une pipeline ``picarones.pipeline``. Pas hérité du legacy +``picarones.engines.base.BaseOCREngine`` — c'est un nouveau contrat, +sans dette technique, exprimé en termes du nouveau ``ArtifactType``. + +Contrat +------- +Un adapter OCR : + +- Déclare ses ``input_types`` (typiquement + ``frozenset({ArtifactType.IMAGE})``). +- Déclare ses ``output_types`` (typiquement + ``frozenset({ArtifactType.RAW_TEXT})``, ou plus pour les moteurs + structurés). +- Déclare son ``execution_mode`` : ``"io"`` (défaut, ThreadPool) ou + ``"cpu"`` (ProcessPool). +- Implémente + ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``. + +Le ``Artifact`` retourné porte une ``uri`` filesystem — c'est la +convention du nouveau monde pour permettre au ``payload_loader`` de +le lire ultérieurement (Sprint S25 — la projection a un payload +direct, mais les artefacts produits par les adapters sont stockés +sur disque pour traçabilité et streaming). + +Différences avec le legacy +-------------------------- +- ``ArtifactType.RAW_TEXT`` (10 valeurs) au lieu de + ``ArtifactType.TEXT`` (6 valeurs legacy). +- Pas de ``run(image_path)`` historique — un seul point d'entrée + ``execute()``. +- Pas de wrapper ``EngineResult`` — les erreurs lèvent directement, + le ``PipelineExecutor`` les capture en step en échec. +- Pas de ``_run_ocr`` / ``_run_with_native`` / ``_extract_raw_confidences`` + — les confidences (S42 legacy) sont reportées à un sprint dédié + où l'on définira un ``ConfidenceArtifact`` typé. + +Anti-sur-ingénierie +------------------- +- Pas de hiérarchie d'erreurs. Un adapter qui échoue lève + ``OCRAdapterError`` (ou laisse passer une exception). Le + ``PipelineExecutor`` (S7) catch et marque le step en échec. +- Pas de cache au niveau de l'ABC. Si un adapter veut cacher ses + résultats, c'est dans son implémentation (compose ``ArtifactStore`` + S7 si besoin). +- Pas de retry. Idem. +""" + +from __future__ import annotations + +from abc import ABC, abstractmethod +from typing import Any + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.errors import AdapterStepError + + +class OCRAdapterError(AdapterStepError): + """Erreur typée pour un échec d'adapter OCR du nouveau monde. + + Hérite de ``AdapterStepError`` (racine commune avec LLM et VLM) + qui hérite de ``PicaronesError``. Un caller peut catcher + ``AdapterStepError`` pour toute erreur d'adapter sans connaître + la sous-classe. + + Le ``PipelineExecutor`` capture cette exception (et toute autre) + et marque le step correspondant comme failed avec + ``StepResult.error`` renseigné. Les callers downstream + (``BenchmarkService``, vues) verront le pipeline en échec sans + crash global. + """ + + +class BaseOCRAdapter(ABC): + """Classe de base pour un adapter OCR du nouveau monde. + + Toute sous-classe doit : + + 1. Surcharger la propriété ``name`` (identifiant lisible, utilisé + dans les ``Artifact.id`` et le run_manifest). + 2. Implémenter ``execute(inputs, params, context)``. + + Les attributs de classe ``input_types`` / ``output_types`` / + ``execution_mode`` sont fournis par défaut pour le cas le plus + courant (image → texte, IO-bound). Une sous-classe qui produit + de l'ALTO surcharge ``output_types``, etc. + + Exemple + ------- + + :: + + class MyOCRAdapter(BaseOCRAdapter): + @property + def name(self) -> str: + return "my_ocr" + + def execute(self, inputs, params, context): + image_artifact = inputs[ArtifactType.IMAGE] + # ... appel OCR sur image_artifact.uri ... + # ... écriture du résultat sur disque ... + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(out_path), + ), + } + """ + + #: Types d'artefacts attendus en entrée. Le ``PipelineExecutor`` + #: utilise cette info pour valider la compatibilité des steps + #: enchaînés. + input_types: frozenset[ArtifactType] = frozenset({ArtifactType.IMAGE}) + + #: Types d'artefacts produits. Validés à la sortie de ``execute``. + output_types: frozenset[ArtifactType] = frozenset({ArtifactType.RAW_TEXT}) + + #: ``"io"`` (ThreadPool) ou ``"cpu"`` (ProcessPool). Indique au + #: runner quel type de pool utiliser pour la concurrence. + execution_mode: str = "io" + + @property + @abstractmethod + def name(self) -> str: + """Identifiant lisible de l'adapter (ex : ``"tesseract"``, + ``"precomputed_text"``). Utilisé dans les ``Artifact.id`` du + nouveau monde et dans le ``run_manifest``.""" + + @abstractmethod + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + """Exécute l'OCR sur les entrées et retourne les artefacts produits. + + Parameters + ---------- + inputs: + Map ``ArtifactType → Artifact`` avec au minimum les types + déclarés dans ``self.input_types``. L'adapter peut + ignorer les entrées surnuméraires. + params: + Paramètres dynamiques du step (typiquement vides — la + configuration de l'adapter passe par son constructeur). + context: + ``RunContext`` du run en cours (porte ``document_id``, + ``code_version``, ``pipeline_name``). + + Returns + ------- + dict[ArtifactType, Artifact] + Map des artefacts produits. Doit contenir au moins les + types déclarés dans ``self.output_types``. + + Raises + ------ + OCRAdapterError + Erreur typée pour signaler un échec côté adapter (input + invalide, fichier introuvable, etc.). + """ + + +__all__ = ["BaseOCRAdapter", "OCRAdapterError"] diff --git a/picarones/adapters/ocr/confidences.py b/picarones/adapters/ocr/confidences.py new file mode 100644 index 0000000000000000000000000000000000000000..fa15fbb9dea06d3da6c2cba93d45f51cec8ab120 --- /dev/null +++ b/picarones/adapters/ocr/confidences.py @@ -0,0 +1,164 @@ +"""Sidecar de confidences OCR. + +Les confidences au niveau token sont exposées comme un **artefact +dédié** ``ArtifactType.CONFIDENCES`` (sidecar JSON à côté du fichier +texte), pas stuffé dans le résultat texte de l'adapter. Ce +découplage permet aux vues de calibration (ECE/MCE, reliability +diagram) de consommer les confidences indépendamment de la +production du texte, et n'oblige pas un adapter qui n'a pas de +confidences à porter un champ vide. + +Format JSON canonique +--------------------- + +:: + + { + "tokens": [ + {"text": "Bonjour", "confidence": 0.95}, + {"text": "le", "confidence": 0.99}, + ... + ], + "extractor": "tesseract", + "model_version": "5.3.0" // optionnel + } + +- ``confidence`` ∈ [0, 1] (les adapters convertissent eux-mêmes + depuis leur format natif — Tesseract retourne 0-100, on divise + par 100). +- Tokens vides ou conf négatives ignorés à la source (cf. + ``filter_valid_tokens``). + +API publique +------------ +- ``filter_valid_tokens(raw)`` : nettoie une liste de dicts brutes. +- ``write_confidences_sidecar(text_path, name, tokens, ...)`` : + écrit ``..confidences.json`` à côté du fichier texte. +- ``ConfidenceToken`` (TypedDict léger) : forme attendue du dict. + +Anti-sur-ingénierie +------------------- +- Pas de pydantic — TypedDict + json suffisent ; le caller normalise. +- Pas de schéma JSON publié — la stabilité sera tagguée à la livraison. +- Pas de support pour les confidences niveau ligne / paragraphe : + on aplatit tout au niveau mot (cohérent avec le legacy Sprint 47). +""" + +from __future__ import annotations + +import json +import os +import tempfile +from pathlib import Path +from typing import Any, TypedDict + +from picarones.domain.artifacts import Artifact, ArtifactType + + +class ConfidenceToken(TypedDict): + """Forme canonique d'un token de confidence.""" + + text: str + confidence: float + + +def filter_valid_tokens( + raw: list[dict[str, Any]], +) -> list[ConfidenceToken]: + """Nettoie une liste brute de tokens (ignore les non-mots). + + Filtre : + + - ``text`` vide ou whitespace-only ; + - ``confidence`` ``None`` ou négative (Tesseract met -1 pour les + non-mots) ; + - ``confidence`` > 1.0 → divisé par 100 si ≤ 100, sinon ignoré. + + Retourne une nouvelle liste, ne modifie pas l'input. + """ + out: list[ConfidenceToken] = [] + for entry in raw: + text = str(entry.get("text", "") or "").strip() + if not text: + continue + conf = entry.get("confidence") + if conf is None: + continue + try: + conf_f = float(conf) + except (TypeError, ValueError): + continue + if conf_f < 0: + continue + if conf_f > 1.0: + # Tesseract retourne 0-100 ; on normalise. + if conf_f <= 100.0: + conf_f = conf_f / 100.0 + else: + # > 100 = donnée corrompue, on ignore. + continue + out.append({"text": text, "confidence": conf_f}) + return out + + +def write_confidences_sidecar( + text_path: Path, + adapter_name: str, + tokens: list[ConfidenceToken], + *, + document_id: str, + extractor: str | None = None, + model_version: str | None = None, +) -> Artifact: + """Écrit un sidecar JSON ``..confidences.json`` + à côté du fichier texte produit par l'OCR. + + Returns + ------- + Artifact + Artifact ``CONFIDENCES`` avec ``uri`` pointant vers le sidecar. + """ + sidecar_path = ( + text_path.parent + / f"{text_path.stem}.{adapter_name}.confidences.json" + ) + payload = { + "tokens": tokens, + "extractor": extractor or adapter_name, + "model_version": model_version, + } + # Écriture atomique : un crash mi-write ne doit pas laisser un + # sidecar tronqué (qui ferait planter le parser à la lecture). + # ``tempfile`` dans le même répertoire pour garantir que + # ``os.replace`` reste atomique (rename inter-volume échouerait). + encoded = json.dumps(payload, ensure_ascii=False, indent=2) + fd, tmp_name = tempfile.mkstemp( + prefix=f".{sidecar_path.name}.", + suffix=".tmp", + dir=str(sidecar_path.parent), + ) + try: + with os.fdopen(fd, "w", encoding="utf-8") as fh: + fh.write(encoded) + os.replace(tmp_name, sidecar_path) + except Exception: + # Best-effort cleanup du tmp si le replace n'a pas eu lieu. + try: + os.unlink(tmp_name) + except OSError: + pass + raise + return Artifact( + id=f"{document_id}:{adapter_name}:confidences", + document_id=document_id, + type=ArtifactType.CONFIDENCES, + produced_by_step="ocr", + uri=str(sidecar_path), + ) + + +__all__ = [ + "ConfidenceToken", + "filter_valid_tokens", + "write_confidences_sidecar", +] diff --git a/picarones/adapters/ocr/google_vision.py b/picarones/adapters/ocr/google_vision.py new file mode 100644 index 0000000000000000000000000000000000000000..521a57183fda78b0e0346895183c5d06f478cbea --- /dev/null +++ b/picarones/adapters/ocr/google_vision.py @@ -0,0 +1,306 @@ +"""``GoogleVisionAdapter`` natif — Sprint A14-S33. + +Migration native du legacy ``picarones.engines.google_vision.GoogleVisionEngine`` +vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim**. + +Le legacy reste en place jusqu'au S46. + +Cas d'usage BnF +--------------- +Google Cloud Vision propose deux modes d'OCR : + +- ``DOCUMENT_TEXT_DETECTION`` (défaut) : optimisé pour les textes + denses et multilinguistiques — retourne une ``fullTextAnnotation`` + hiérarchique (pages → blocks → paragraphs → words → symbols) avec + un texte plat ``text``. +- ``TEXT_DETECTION`` : mode court, retourne uniquement les + ``textAnnotations[0].description``. + +L'adapter route automatiquement vers SDK (auth service account) ou +REST direct (auth clé API) selon la configuration disponible. + +Configuration +------------- +Constructeur : + +- ``name`` (défaut ``"google_vision"``). +- ``language_hints`` (défaut ``["fr"]``) : suggestions Vision API. +- ``feature_type`` (défaut ``"DOCUMENT_TEXT_DETECTION"``). +- ``api_key`` : clé API Google. Si ``None``, lit ``GOOGLE_API_KEY``. +- ``credentials_path`` : chemin vers un service account JSON. Si + ``None``, lit ``GOOGLE_APPLICATION_CREDENTIALS``. +- ``timeout_seconds`` (défaut 60). + +Au moins une des deux authentifications (SDK ou REST) doit être +disponible. + +Anti-sur-ingénierie +------------------- +- Pas d'extraction de confidences (legacy S50 — reportée). +- Pas de pré-validation du JSON service account — le SDK le fait. +- Pas de support batch — un appel par image. +""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.error +import urllib.request +from pathlib import Path +from typing import Any + +from picarones.adapters._retry import call_with_retry +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType + + +_VALID_FEATURE_TYPES = frozenset({"DOCUMENT_TEXT_DETECTION", "TEXT_DETECTION"}) + + +class GoogleVisionAdapter(BaseOCRAdapter): + """Adapter Google Cloud Vision natif au contrat S26. + + Parameters + ---------- + name: + Identifiant lisible. Défaut ``"google_vision"``. + language_hints: + Suggestions Vision API. Défaut ``["fr"]``. + feature_type: + ``"DOCUMENT_TEXT_DETECTION"`` (défaut) ou ``"TEXT_DETECTION"``. + api_key: + Clé API explicite. Si ``None``, lit ``GOOGLE_API_KEY``. + credentials_path: + Chemin service account JSON explicite. Si ``None``, lit + ``GOOGLE_APPLICATION_CREDENTIALS``. + timeout_seconds: + Timeout HTTP (REST). Défaut 60. + + Raises + ------ + OCRAdapterError + Au constructeur si name ou feature_type invalides. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__( + self, + *, + name: str = "google_vision", + language_hints: list[str] | None = None, + feature_type: str = "DOCUMENT_TEXT_DETECTION", + api_key: str | None = None, + credentials_path: str | None = None, + timeout_seconds: float = 60.0, + ) -> None: + if not name or not name.strip(): + raise OCRAdapterError( + "GoogleVisionAdapter : name vide non autorisé.", + ) + if not all(c.isalnum() or c in "_-" for c in name): + raise OCRAdapterError( + f"GoogleVisionAdapter : name invalide {name!r} — " + "alphanumérique + _ - uniquement.", + ) + if feature_type not in _VALID_FEATURE_TYPES: + raise OCRAdapterError( + f"GoogleVisionAdapter : feature_type invalide " + f"{feature_type!r}. Valeurs valides : " + f"{sorted(_VALID_FEATURE_TYPES)}.", + ) + if timeout_seconds <= 0: + raise OCRAdapterError( + f"GoogleVisionAdapter : timeout_seconds doit être > 0, " + f"reçu {timeout_seconds}.", + ) + self._name = name + self._language_hints = list(language_hints or ["fr"]) + self._feature_type = feature_type + self._explicit_api_key = api_key + self._explicit_credentials = credentials_path + self._timeout = timeout_seconds + + @property + def name(self) -> str: + return self._name + + @property + def feature_type(self) -> str: + return self._feature_type + + def _resolve_credentials_path(self) -> str | None: + return self._explicit_credentials or os.environ.get( + "GOOGLE_APPLICATION_CREDENTIALS", + ) + + def _resolve_api_key(self) -> str | None: + return self._explicit_api_key or os.environ.get("GOOGLE_API_KEY") + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + """Exécute Google Vision OCR sur l'image fournie. + + Routing : + + - Si un service account JSON est disponible + (``credentials_path`` ou ``GOOGLE_APPLICATION_CREDENTIALS``) + → passe par le SDK ``google-cloud-vision``. + - Sinon, si une clé API simple est disponible + (``api_key`` ou ``GOOGLE_API_KEY``) → passe par REST direct + via ``urllib``. + - Sinon → ``OCRAdapterError``. + """ + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise OCRAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + creds = self._resolve_credentials_path() + api_key = self._resolve_api_key() + + if creds: + text = self._call_via_sdk(image_path) + elif api_key: + text = self._call_via_rest(image_path, api_key) + else: + raise OCRAdapterError( + f"{self.name} : authentification manquante. Définir " + "GOOGLE_APPLICATION_CREDENTIALS (service account JSON) " + "ou GOOGLE_API_KEY.", + ) + + text_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + text_path.write_text(text, encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + # ────────────────────────────────────────────────────────────── + # SDK / REST + # ────────────────────────────────────────────────────────────── + + def _call_via_sdk(self, image_path: Path) -> str: + try: + from google.cloud import vision + except ImportError as exc: + raise OCRAdapterError( + f"{self.name} : SDK google-cloud-vision non installé. " + "Installer avec : pip install google-cloud-vision", + ) from exc + + try: + client = vision.ImageAnnotatorClient() + image = vision.Image(content=image_path.read_bytes()) + ctx = vision.ImageContext(language_hints=self._language_hints) + + if self._feature_type == "DOCUMENT_TEXT_DETECTION": + response = client.document_text_detection( + image=image, image_context=ctx, + ) + text = response.full_text_annotation.text + else: + response = client.text_detection( + image=image, image_context=ctx, + ) + texts = response.text_annotations + text = texts[0].description if texts else "" + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : SDK Google Vision a levé : " + f"{type(exc).__name__}: {exc}", + ) from exc + return text + + def _call_via_rest(self, image_path: Path, api_key: str) -> str: + image_b64 = base64.b64encode( + image_path.read_bytes(), + ).decode("ascii") + payload = json.dumps({ + "requests": [{ + "image": {"content": image_b64}, + "features": [ + {"type": self._feature_type, "maxResults": 1}, + ], + "imageContext": {"languageHints": self._language_hints}, + }], + }).encode("utf-8") + req = urllib.request.Request( + "https://vision.googleapis.com/v1/images:annotate", + data=payload, + headers={ + "Content-Type": "application/json", + "X-Goog-Api-Key": api_key, + }, + ) + def _do_call() -> dict: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return json.loads(resp.read().decode("utf-8")) + + try: + result = call_with_retry(_do_call, label=self.name) + except urllib.error.HTTPError as exc: + body = "" + try: + body = exc.read().decode("utf-8") + except Exception: # noqa: BLE001 + pass + raise OCRAdapterError( + f"{self.name} : Google Vision API erreur {exc.code} : {body}", + ) from exc + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : erreur API Google Vision : " + f"{type(exc).__name__}: {exc}", + ) from exc + + responses = result.get("responses", [{}]) + if not responses: + return "" + r = responses[0] + if "error" in r: + raise OCRAdapterError( + f"{self.name} : Google Vision API erreur : {r['error']}", + ) + + if self._feature_type == "DOCUMENT_TEXT_DETECTION": + full = r.get("fullTextAnnotation") or {} + return full.get("text", "") if isinstance(full, dict) else "" + # TEXT_DETECTION + texts = r.get("textAnnotations", []) + return texts[0]["description"] if texts else "" + + +__all__ = ["GoogleVisionAdapter"] diff --git a/picarones/adapters/ocr/mistral_ocr.py b/picarones/adapters/ocr/mistral_ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..3507b69bc5baa550fb0010053bbf060732695077 --- /dev/null +++ b/picarones/adapters/ocr/mistral_ocr.py @@ -0,0 +1,336 @@ +"""``MistralOCRAdapter`` natif — Sprint A14-S32. + +Migration native du legacy ``picarones.engines.mistral_ocr.MistralOCREngine`` +vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe +implémente directement le contrat du nouveau monde. + +Le legacy ``MistralOCREngine`` reste en place jusqu'au S46. + +Cas d'usage BnF +--------------- +Mistral AI fournit deux familles d'OCR : + +- **API dédiée ``/v1/ocr``** pour les modèles ``mistral-ocr-*`` — + endpoint optimisé qui renvoie des pages structurées en markdown + (et parfois des confidences mot par mot). +- **API vision/chat** pour les modèles ``pixtral-*`` — + reconnaissance via prompt textuel + image base64. + +L'adapter route automatiquement selon le nom du modèle. + +Configuration +------------- +Constructeur : + +- ``name`` (défaut ``"mistral_ocr"``) : identifiant de l'instance. +- ``model`` (défaut ``"mistral-ocr-latest"``) : modèle Mistral. + - ``mistral-ocr-*`` → endpoint dédié ; + - ``pixtral-*`` → API vision/chat. +- ``prompt`` : texte du prompt pour les modèles vision. Défaut : + instruction générique de transcription. +- ``max_tokens`` (défaut 4096) : limite tokens en sortie pour les + modèles vision. +- ``api_key`` : clé API Mistral. Si ``None`` (défaut), lit la + variable d'environnement ``MISTRAL_API_KEY``. +- ``timeout_seconds`` (défaut 60) : timeout HTTP pour ``urllib``. + +Comportement +------------ +1. Vérifie présence d'un ``Artifact`` ``IMAGE`` avec URI valide. +2. Encode l'image en base64 + détecte ``image/...`` MIME selon + l'extension. +3. Route vers ``/v1/ocr`` ou chat/vision selon ``model``. +4. Concatène le markdown / texte de toutes les pages. +5. Écrit dans ``..txt`` à côté de l'image. +6. Retourne un ``Artifact`` ``RAW_TEXT``. + +Anti-sur-ingénierie +------------------- +- Pas de retry / backoff (le caller wrappe si besoin). +- Pas d'extraction de confidences (legacy S49 — reportées au + sprint ``ConfidenceArtifact``). +- Pas de support multi-page (l'image est traitée comme une seule + page d'entrée — Mistral OCR retourne une liste de pages dont on + concatène les markdowns). +""" + +from __future__ import annotations + +import base64 +import json +import os +import urllib.request +from pathlib import Path +from typing import Any + +from picarones.adapters._retry import call_with_retry +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType + + +_DEFAULT_PROMPT = ( + "Transcris fidèlement le texte visible sur cette image de document " + "historique. Retourne uniquement le texte, sans commentaire." +) + + +_MEDIA_TYPES: dict[str, str] = { + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".png": "image/png", + ".tif": "image/tiff", + ".tiff": "image/tiff", + ".webp": "image/webp", +} + + +class MistralOCRAdapter(BaseOCRAdapter): + """Adapter Mistral OCR natif au contrat S26. + + Parameters + ---------- + name: + Identifiant lisible. Défaut ``"mistral_ocr"``. + model: + Modèle Mistral. ``mistral-ocr-*`` → API dédiée ``/v1/ocr``, + ``pixtral-*`` → API vision/chat. Défaut ``"mistral-ocr-latest"``. + prompt: + Prompt pour les modèles vision. + max_tokens: + Limite tokens en sortie pour les modèles vision. Défaut 4096. + api_key: + Clé API Mistral. Si ``None`` (défaut), lit + ``MISTRAL_API_KEY``. + timeout_seconds: + Timeout HTTP pour les appels ``urllib``. Défaut 60. + + Raises + ------ + OCRAdapterError + Si ``name`` est invalide au constructeur. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__( + self, + *, + name: str = "mistral_ocr", + model: str = "mistral-ocr-latest", + prompt: str = _DEFAULT_PROMPT, + max_tokens: int = 4096, + api_key: str | None = None, + timeout_seconds: float = 60.0, + ) -> None: + if not name or not name.strip(): + raise OCRAdapterError( + "MistralOCRAdapter : name vide non autorisé.", + ) + if not all(c.isalnum() or c in "_-" for c in name): + raise OCRAdapterError( + f"MistralOCRAdapter : name invalide {name!r} — " + "alphanumérique + _ - uniquement.", + ) + if max_tokens <= 0: + raise OCRAdapterError( + f"MistralOCRAdapter : max_tokens doit être > 0, " + f"reçu {max_tokens}.", + ) + if timeout_seconds <= 0: + raise OCRAdapterError( + f"MistralOCRAdapter : timeout_seconds doit être > 0, " + f"reçu {timeout_seconds}.", + ) + self._name = name + self._model = model + self._prompt = prompt + self._max_tokens = max_tokens + self._explicit_api_key = api_key + self._timeout = timeout_seconds + + @property + def name(self) -> str: + return self._name + + @property + def model(self) -> str: + return self._model + + def _resolve_api_key(self) -> str: + """Résout la clé API : explicite > env var. + + Lève ``OCRAdapterError`` si aucune clé n'est disponible. + """ + key = self._explicit_api_key or os.environ.get("MISTRAL_API_KEY") + if not key: + raise OCRAdapterError( + f"{self.name} : clé API Mistral manquante. " + "Définir MISTRAL_API_KEY ou passer api_key= au " + "constructeur.", + ) + return key + + def _encode_image(self, image_path: Path) -> str: + """Retourne ``data:;base64,<...>`` pour l'image.""" + suffix = image_path.suffix.lower() + media_type = _MEDIA_TYPES.get(suffix, "image/jpeg") + image_b64 = base64.b64encode(image_path.read_bytes()).decode("ascii") + return f"data:{media_type};base64,{image_b64}" + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + """Exécute Mistral OCR sur l'image fournie. + + Route vers l'API appropriée selon ``self.model`` : + - ``mistral-ocr-*`` → ``/v1/ocr`` via ``urllib`` ; + - ``pixtral-*`` → API chat/vision via SDK ``mistralai``. + + Raises + ------ + OCRAdapterError + Erreur d'input, clé manquante, SDK absent (pour pixtral), + ou API Mistral en erreur. + """ + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise OCRAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + api_key = self._resolve_api_key() + image_url = self._encode_image(image_path) + + # Le préfixe ``mistral-ocr-*`` est documenté par Mistral pour + # l'API dédiée ``/v1/ocr``. Tout autre nom (``pixtral-*``, + # etc.) bascule sur l'API chat/vision. Match strict par + # préfixe pour éviter qu'un modèle exotique nommé + # ``pixtral-MISTRAL-OCR-fancy`` ne soit confondu. + if self._model.lower().startswith("mistral-ocr"): + text = self._call_native_ocr_api(image_url, api_key) + else: + text = self._call_chat_vision_api(image_url, api_key) + + text_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + text_path.write_text(text, encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + # ────────────────────────────────────────────────────────────── + # API natives + # ────────────────────────────────────────────────────────────── + + def _call_native_ocr_api(self, image_url: str, api_key: str) -> str: + """Appelle ``POST /v1/ocr`` via urllib et retourne le markdown + concaténé.""" + payload = json.dumps({ + "model": self._model, + "document": {"type": "image_url", "image_url": image_url}, + }).encode("utf-8") + req = urllib.request.Request( + "https://api.mistral.ai/v1/ocr", + data=payload, + headers={ + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + }, + method="POST", + ) + def _do_call() -> dict: + with urllib.request.urlopen(req, timeout=self._timeout) as resp: + return json.loads(resp.read().decode()) + + try: + data = call_with_retry(_do_call, label=self.name) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : erreur API Mistral /v1/ocr : " + f"{type(exc).__name__}: {exc}", + ) from exc + pages = data.get("pages", []) + text = "\n\n".join(p.get("markdown", "") for p in pages).strip() + return text + + def _call_chat_vision_api(self, image_url: str, api_key: str) -> str: + """Appelle l'API chat/vision Mistral via le SDK ``mistralai``.""" + try: + try: + from mistralai.client import Mistral + except ImportError: + from mistralai import Mistral # type: ignore[no-redef] + except ImportError as exc: + raise OCRAdapterError( + f"{self.name} : SDK 'mistralai' non installé. " + "Installer avec : pip install mistralai", + ) from exc + + client = Mistral(api_key=api_key) + + def _do_chat() -> Any: + return client.chat.complete( + model=self._model, + messages=[ + { + "role": "user", + "content": [ + {"type": "text", "text": self._prompt}, + {"type": "image_url", "image_url": image_url}, + ], + }, + ], + max_tokens=self._max_tokens, + ) + + try: + response = call_with_retry(_do_chat, label=self.name) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : erreur API Mistral chat : " + f"{type(exc).__name__}: {exc}", + ) from exc + + # Mistral peut retourner ``content`` sous forme de + # ``list[ContentChunk]`` au lieu de ``str``. Le helper + # ``normalize_llm_content`` gère les deux formats. + from picarones.adapters.llm.base import normalize_llm_content + + try: + raw_content = response.choices[0].message.content + except (AttributeError, IndexError) as exc: + raise OCRAdapterError( + f"{self.name} : réponse Mistral chat malformée : {exc}", + ) from exc + + return normalize_llm_content(raw_content) or "" + + +__all__ = ["MistralOCRAdapter"] diff --git a/picarones/adapters/ocr/pero_ocr.py b/picarones/adapters/ocr/pero_ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..c4efcc286c74d38738547faef9ca4dc00ebd1f56 --- /dev/null +++ b/picarones/adapters/ocr/pero_ocr.py @@ -0,0 +1,232 @@ +"""``PeroOCRAdapter`` natif — Sprint A14-S31. + +Migration native du legacy ``picarones.engines.pero_ocr.PeroOCREngine`` +vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe +implémente directement le contrat du nouveau monde, sans héritage du +legacy. + +Le legacy ``PeroOCREngine`` reste en place pour les callers qui +n'ont pas encore migré ; sa suppression viendra au S46 quand la +parité sera atteinte sur tous les adapters. + +Cas d'usage BnF +--------------- +Pero OCR (Brno) est un moteur HTR open-source spécialisé pour les +documents historiques manuscrits. Il produit une sortie structurée +PAGE XML — l'adapter natif extrait le texte plat dans l'ordre de +lecture naturel. Adapter CPU-bound (PyTorch sur CPU + traitement +d'image) → ``execution_mode="cpu"`` pour ProcessPool. + +Configuration +------------- +Constructeur : + +- ``name`` (défaut ``"pero_ocr"``) : identifiant de l'instance. +- ``config_path`` : chemin obligatoire vers un fichier ``.ini`` de + configuration Pero OCR (modèles, paramètres). Sans ça, Pero OCR + ne peut pas être instancié. + +Comportement +------------ +1. Vérifie la présence d'un ``Artifact`` ``IMAGE`` avec URI valide. +2. Lazy-import de ``pero_ocr`` + ``PIL`` + ``numpy`` — message + explicite si absent. +3. Lazy-init du ``PageParser`` (une seule fois par instance). +4. Charge l'image en numpy array RGB, instancie un ``PageLayout``, + appelle ``parser.process_page(image, page_layout)``. +5. Extrait le texte plat (``\n`` entre lignes, dans l'ordre des + regions × lines). +6. Écrit le texte dans ``..txt`` à côté de l'image. +7. Retourne un ``Artifact`` ``RAW_TEXT``. + +Anti-sur-ingénierie +------------------- +- Pas de support GPU explicite (Pero OCR le gère via la config). +- Pas de retry, pas d'extraction de confidences (legacy S48 — + reportées au sprint ``ConfidenceArtifact``). +- ``_parser`` lazy-init — si l'instance est sérialisée pour + ProcessPool, le parser est re-instancié dans le worker (cohérent + avec Pero OCR qui charge ses modèles à l'instanciation). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType + + +class PeroOCRAdapter(BaseOCRAdapter): + """Adapter Pero OCR natif au nouveau contrat (S26). + + Parameters + ---------- + name: + Identifiant lisible. Défaut ``"pero_ocr"``. Alphanum + ``_-``. + config_path: + Chemin vers le fichier ``.ini`` de configuration Pero OCR. + Obligatoire — sans configuration, Pero OCR ne peut pas être + instancié. + + Raises + ------ + OCRAdapterError + Si ``name`` ou ``config_path`` sont invalides au constructeur. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "cpu" + + def __init__( + self, + *, + config_path: str | Path, + name: str = "pero_ocr", + ) -> None: + if not name or not name.strip(): + raise OCRAdapterError( + "PeroOCRAdapter : name vide non autorisé.", + ) + if not all(c.isalnum() or c in "_-" for c in name): + raise OCRAdapterError( + f"PeroOCRAdapter : name invalide {name!r} — " + "alphanumérique + _ - uniquement.", + ) + if not config_path: + raise OCRAdapterError( + "PeroOCRAdapter : config_path est requis (chemin .ini).", + ) + self._name = name + self._config_path = Path(config_path) + # Le parser est instancié paresseusement au premier execute() + # pour que la sérialisation ProcessPool fonctionne (un parser + # contenant des modèles PyTorch n'est pas sérialisable). + self._parser: Any = None + + @property + def name(self) -> str: + return self._name + + @property + def config_path(self) -> Path: + return self._config_path + + def _get_parser(self) -> Any: + """Instancie le PageParser au premier appel (lazy).""" + if self._parser is not None: + return self._parser + + try: + from pero_ocr.document_ocr.page_parser import PageParser + except ImportError as exc: + raise OCRAdapterError( + f"{self.name} : pero-ocr non installé. " + "Installer avec : pip install pero-ocr", + ) from exc + + if not self._config_path.exists(): + raise OCRAdapterError( + f"{self.name} : config_path introuvable " + f"{self._config_path!r}.", + ) + + import configparser + parser_config = configparser.ConfigParser() + parser_config.read(self._config_path) + try: + self._parser = PageParser(parser_config) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : initialisation PageParser échouée " + f"({type(exc).__name__}: {exc}).", + ) from exc + return self._parser + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + """Exécute Pero OCR sur l'image fournie. + + Raises + ------ + OCRAdapterError + Si l'input est invalide, l'image introuvable, les + dépendances manquantes, ou Pero OCR lève en interne. + """ + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise OCRAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + try: + import numpy as np + from PIL import Image + from pero_ocr.document_ocr.layout import PageLayout + except ImportError as exc: + raise OCRAdapterError( + f"{self.name} : pero-ocr/numpy/Pillow non installés. " + "Installer avec : pip install pero-ocr pillow numpy", + ) from exc + + parser = self._get_parser() + + try: + with Image.open(image_path) as pil_image: + image_array = np.array(pil_image.convert("RGB")) + page_layout = PageLayout( + id=image_path.stem, + page_size=(image_array.shape[0], image_array.shape[1]), + ) + parser.process_page(image_array, page_layout) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : Pero OCR a levé sur " + f"{image_path!r} : {type(exc).__name__}: {exc}", + ) from exc + + # Extraction du texte plat dans l'ordre regions × lines. + lines: list[str] = [] + for region in page_layout.regions: + for line in region.lines: + if line.transcription: + lines.append(line.transcription.strip()) + text = "\n".join(lines) + + text_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + text_path.write_text(text, encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + +__all__ = ["PeroOCRAdapter"] diff --git a/picarones/adapters/ocr/precomputed.py b/picarones/adapters/ocr/precomputed.py new file mode 100644 index 0000000000000000000000000000000000000000..19454fde434ecaf511f9288df029b42f22c1b9fe --- /dev/null +++ b/picarones/adapters/ocr/precomputed.py @@ -0,0 +1,219 @@ +"""``PrecomputedTextAdapter`` — premier adapter natif du nouveau monde. + +Sprint A14-S26 du rewrite ciblé. + +Cas d'usage BnF +--------------- +*« J'ai déjà fait tourner Tesseract, GPT-4-vision, Pero OCR et un +service cloud sur mon corpus. J'ai 4 répertoires de fichiers +``.txt`` à côté de mes images. Je veux comparer ces 4 sorties dans +Picarones — je n'ai pas besoin de re-lancer un OCR, j'ai juste besoin +de la machinerie d'évaluation. »* + +Ce besoin est légitime et fréquent à la BnF : une part importante +du travail de comparaison se fait sur des transcriptions déjà +produites par d'autres outils. Ré-exécuter un OCR à chaque +benchmark est gaspillage. + +Convention de nommage +--------------------- +Pour une image ``.png`` (ou ``.jpg``, ``.tif``, etc.), le +texte pré-calculé est lu depuis : + +:: + + ..txt + +dans le **même répertoire** que l'image. Exemple avec deux +sources concurrentes : + +:: + + folio_001.png + folio_001.tesseract.txt # produit par Tesseract + folio_001.pero.txt # produit par Pero OCR + folio_001.gpt4v.txt # produit par GPT-4 Vision + folio_001.gt.txt # vérité terrain + +Plusieurs ``PrecomputedTextAdapter`` peuvent coexister dans une +même YAML avec des ``source_label`` distincts — chacun lit son +propre fichier, le ``BenchmarkService`` les traite en parallèle. + +Configuration YAML +------------------ + +:: + + pipelines: + - name: tesseract_baseline + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tesseract + input_types: [image] + output_types: [raw_text] + + - name: gpt4v_alternative + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: gpt4v + input_types: [image] + output_types: [raw_text] + +Comportement « fichier manquant » +--------------------------------- +Par défaut, si le fichier ``..txt`` est absent, +l'adapter lève ``OCRAdapterError`` — le pipeline executor marque le +step comme failed pour ce document, et le ``BenchmarkService`` le +voit en ``failed_metrics``. Pas de fallback silencieux qui +mentirait sur la couverture du benchmark. + +L'option ``missing_text_policy="empty"`` permet, à la demande +explicite du caller, de remplacer un fichier absent par une chaîne +vide — utile pour mesurer ce qui se passerait si une source était +indisponible sur certains documents. Par défaut : ``"raise"``. + +Anti-sur-ingénierie +------------------- +- Pas de découverte automatique de tous les ``source_label`` + présents dans un répertoire. Le caller déclare explicitement + les sources qu'il veut comparer. +- Pas de cache. Le filesystem fait son boulot. +- Pas de validation d'encodage exotique. ``utf-8`` strict ; un + fichier mal encodé lève une erreur lisible. +- Pas d'extraction structurelle. Cet adapter sort du ``RAW_TEXT``, + point. Pour comparer des ALTO_XML pré-calculés, c'est un + ``PrecomputedAltoAdapter`` futur (pattern identique). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any, Literal + +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.domain.artifacts import Artifact, ArtifactType + + +class PrecomputedTextAdapter(BaseOCRAdapter): + """Adapter qui lit du texte OCR pré-calculé depuis le filesystem. + + Parameters + ---------- + source_label: + Étiquette identifiant la source du texte pré-calculé + (ex : ``"tesseract"``, ``"gpt4v"``, ``"pero"``). Doit être + composée uniquement de caractères alphanumériques, ``_`` et + ``-`` — c'est un composant de nom de fichier. + missing_text_policy: + ``"raise"`` (défaut) → fichier absent lève ``OCRAdapterError``. + ``"empty"`` → fichier absent remplacé par chaîne vide + (l'adapter produit alors un ``Artifact`` pointant sur un + fichier vide). + + Raises + ------ + OCRAdapterError + Si ``source_label`` est invalide. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__( + self, + *, + source_label: str, + missing_text_policy: Literal["raise", "empty"] = "raise", + ) -> None: + if not source_label or not source_label.strip(): + raise OCRAdapterError( + "PrecomputedTextAdapter : source_label vide.", + ) + if not all( + c.isalnum() or c in "_-" for c in source_label + ): + raise OCRAdapterError( + f"PrecomputedTextAdapter : source_label invalide " + f"{source_label!r} — alphanumérique + _ - uniquement.", + ) + if missing_text_policy not in ("raise", "empty"): + raise OCRAdapterError( + f"missing_text_policy doit être 'raise' ou 'empty', " + f"reçu {missing_text_policy!r}.", + ) + self._source_label = source_label + self._missing_policy = missing_text_policy + + @property + def name(self) -> str: + return f"precomputed_{self._source_label}" + + @property + def source_label(self) -> str: + return self._source_label + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + + image_path = Path(image_artifact.uri) + text_path = ( + image_path.parent / f"{image_path.stem}.{self._source_label}.txt" + ) + + if not text_path.exists(): + if self._missing_policy == "empty": + # On crée le fichier vide pour rester cohérent : tout + # ``Artifact`` produit a une URI vers un fichier + # lisible. + text_path.write_text("", encoding="utf-8") + else: + raise OCRAdapterError( + f"{self.name} : fichier pré-calculé introuvable " + f"pour {image_path.name!r} : " + f"{text_path.name!r} attendu dans " + f"{image_path.parent!r}.", + ) + + # Validation rapide de l'encodage UTF-8 (lecture qui leverait + # si encodage exotique). + try: + text_path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + raise OCRAdapterError( + f"{self.name} : {text_path!r} n'est pas en UTF-8 : " + f"{exc}", + ) from exc + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + +__all__ = ["PrecomputedTextAdapter"] diff --git a/picarones/adapters/ocr/tesseract.py b/picarones/adapters/ocr/tesseract.py new file mode 100644 index 0000000000000000000000000000000000000000..c740a5575c299bc52c888da614a631671b803adc --- /dev/null +++ b/picarones/adapters/ocr/tesseract.py @@ -0,0 +1,327 @@ +"""``TesseractAdapter`` natif — Sprint A14-S30. + +Migration native du legacy ``picarones.engines.tesseract.TesseractEngine`` +vers le contrat ``BaseOCRAdapter`` (S26). **Pas un shim** : la classe +implémente directement le contrat du nouveau monde, sans héritage du +legacy. + +Le legacy ``TesseractEngine`` reste en place pour les callers qui +n'ont pas encore migré ; sa suppression viendra au S46 quand la +parité sera atteinte sur tous les adapters. + +Cas d'usage BnF +--------------- +Tesseract 5 reste l'OCR open-source de référence pour les corpus +imprimés et certains manuscrits réguliers. L'adapter est CPU-bound +(Tesseract appelle une lib C en sous-process) — déclaré +``execution_mode="cpu"`` pour que le runner utilise un +``ProcessPoolExecutor``. + +Configuration +------------- +Constructeur : + +- ``name`` (défaut ``"tesseract"``) : identifiant de l'instance. + Sert de suffixe au fichier de sortie ``..txt`` — + permet de coexister avec plusieurs configurations Tesseract dans + un même benchmark. +- ``lang`` (défaut ``"fra"``) : code langue Tesseract (``"fra"``, + ``"lat"``, ``"eng"``, ``"fra+lat"``). +- ``psm`` (défaut ``6``) : Page Segmentation Mode (0-13). +- ``oem`` (défaut ``3``) : OCR Engine Mode. +- ``tesseract_cmd`` (défaut ``None``) : chemin vers l'exécutable + ``tesseract`` si non standard. + +Comportement +------------ +1. Vérifie qu'un ``Artifact`` ``IMAGE`` est présent dans ``inputs`` + et qu'il porte une ``uri`` filesystem. +2. Lazy-import de ``pytesseract`` et ``PIL`` — si absent, lève + ``OCRAdapterError`` avec message explicite. +3. Applique ``tesseract_cmd`` s'il est fourni. +4. Appelle ``pytesseract.image_to_string`` avec ``lang`` et + ``--oem N --psm M``. +5. Écrit le texte dans ``..txt`` à côté de l'image + (cohérent avec le pattern ``PrecomputedTextAdapter`` — un caller + peut relire la sortie via cet adapter pour la comparer dans un + second run). +6. Retourne un ``Artifact`` ``RAW_TEXT`` pointant vers le fichier + produit. + +Anti-sur-ingénierie +------------------- +- Pas de retry — Tesseract échoue rarement sur une image valide, + et un appelant peut wrapper si besoin. +- Pas d'extraction de confidences (legacy S47) — reporté à un + sprint dédié qui définira ``ConfidenceArtifact`` typé. La + fonctionnalité reste disponible via le legacy + ``picarones.engines.tesseract.TesseractEngine`` jusqu'au S46. +- Pas de validation de l'encodage de l'image — Tesseract gère. +- Pas de support batch — un appel par image (le runner gère le + parallélisme inter-documents). +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +from picarones.adapters.ocr.base import BaseOCRAdapter, OCRAdapterError +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType + + +class TesseractAdapter(BaseOCRAdapter): + """Adapter Tesseract 5 natif au nouveau contrat (S26). + + Parameters + ---------- + name: + Identifiant lisible de l'instance. Défaut ``"tesseract"``. + Doit être alphanumérique + ``_-`` (composant de nom de fichier). + lang: + Code langue Tesseract (``"fra"``, ``"lat"``, ``"eng"``, ...). + Défaut ``"fra"``. + psm: + Page Segmentation Mode entre 0 et 13. Défaut 6 + (single uniform block of text). + oem: + OCR Engine Mode (0-3). Défaut 3 (LSTM, le plus précis). + tesseract_cmd: + Chemin custom vers l'exécutable ``tesseract``. Défaut + ``None`` (laisse pytesseract trouver l'installation système). + + Raises + ------ + OCRAdapterError + Si le ``name`` ou les valeurs de ``psm`` / ``oem`` sont + invalides. + """ + + input_types = frozenset({ArtifactType.IMAGE}) + #: Set maximal de types que l'adapter peut produire. Le YAML + #: ``PipelineSpec`` choisit ceux qui sont effectivement consommés + #: par les étapes en aval ; l'executor filtre la sortie de + #: ``execute()`` sur ``step.output_types``. Si l'utilisateur + #: désactive ``expose_confidences``, le YAML doit déclarer + #: ``output_types: [raw_text]`` (sinon la jonction sera vue par + #: l'aval comme manquant son input ``confidences``). + output_types = frozenset( + {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES}, + ) + execution_mode = "cpu" + + def __init__( + self, + *, + name: str = "tesseract", + lang: str = "fra", + psm: int = 6, + oem: int = 3, + tesseract_cmd: str | None = None, + expose_confidences: bool = True, + ) -> None: + if not name or not name.strip(): + raise OCRAdapterError( + "TesseractAdapter : name vide non autorisé.", + ) + if not all(c.isalnum() or c in "_-" for c in name): + raise OCRAdapterError( + f"TesseractAdapter : name invalide {name!r} — " + "alphanumérique + _ - uniquement.", + ) + if not 0 <= psm <= 13: + raise OCRAdapterError( + f"TesseractAdapter : psm doit être ∈ [0, 13], reçu {psm}.", + ) + if not 0 <= oem <= 3: + raise OCRAdapterError( + f"TesseractAdapter : oem doit être ∈ [0, 3], reçu {oem}.", + ) + self._name = name + self._lang = lang + self._psm = psm + self._oem = oem + self._tesseract_cmd = tesseract_cmd + self._expose_confidences = expose_confidences + + @property + def name(self) -> str: + return self._name + + @property + def expose_confidences(self) -> bool: + return self._expose_confidences + + @property + def lang(self) -> str: + return self._lang + + @property + def psm(self) -> int: + return self._psm + + @property + def oem(self) -> int: + return self._oem + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, Any], + context: Any, + ) -> dict[ArtifactType, Artifact]: + """Exécute Tesseract sur l'image fournie. + + Raises + ------ + OCRAdapterError + - input ``IMAGE`` absent ; + - artefact image sans URI ; + - fichier image introuvable ; + - ``pytesseract`` ou ``PIL`` non installé ; + - erreur Tesseract (lib system manquante, etc.). + """ + if ArtifactType.IMAGE not in inputs: + raise OCRAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise OCRAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise OCRAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + # Lazy-import de pytesseract + PIL — si absents, message + # explicite plutôt qu'``ImportError`` au top-level. + try: + import pytesseract # type: ignore[import-untyped] + from PIL import Image + except ImportError as exc: + raise OCRAdapterError( + f"{self.name} : pytesseract/Pillow non installés. " + "Installer avec : pip install pytesseract pillow", + ) from exc + + # Application du tesseract_cmd custom si fourni. + if self._tesseract_cmd is not None: + pytesseract.pytesseract.tesseract_cmd = self._tesseract_cmd + + # OCR. + custom_config = f"--oem {self._oem} --psm {self._psm}" + try: + with Image.open(image_path) as image: + text = pytesseract.image_to_string( + image, + lang=self._lang, + config=custom_config, + ) + except Exception as exc: + raise OCRAdapterError( + f"{self.name} : Tesseract a levé sur " + f"{image_path!r} : {type(exc).__name__}: {exc}", + ) from exc + + text = text.strip() + + # Le helper résout vers le workspace si fourni (sandbox par + # doc), sinon écrit à côté de l'image — cohérent avec le + # pattern ``PrecomputedTextAdapter`` qui peut relire la sortie. + text_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + text_path.write_text(text, encoding="utf-8") + + outputs: dict = { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(text_path), + ), + } + + # Extraction des confidences via image_to_data (best-effort). + # Si l'extraction échoue, on log et on saute — l'OCR reste + # valide, seule la calibration est indisponible pour ce doc. + if self._expose_confidences: + confidences_artifact = self._extract_and_persist_confidences( + image_path=image_path, + text_path=text_path, + pytesseract_module=pytesseract, + pil_image_class=Image, + custom_config=custom_config, + document_id=context.document_id, + ) + if confidences_artifact is not None: + outputs[ArtifactType.CONFIDENCES] = confidences_artifact + + return outputs + + def _extract_and_persist_confidences( + self, + *, + image_path: Path, + text_path: Path, + pytesseract_module, + pil_image_class, + custom_config: str, + document_id: str, + ) -> Artifact | None: + """Appelle ``image_to_data`` puis écrit le sidecar JSON. + + Retourne l'``Artifact CONFIDENCES`` ou ``None`` si l'extraction + a échoué (warning loggé, OCR reste valide). + """ + import logging + logger = logging.getLogger(__name__) + + from picarones.adapters.ocr.confidences import ( + filter_valid_tokens, + write_confidences_sidecar, + ) + + try: + with pil_image_class.open(image_path) as image: + data = pytesseract_module.image_to_data( + image, + lang=self._lang, + config=custom_config, + output_type=pytesseract_module.Output.DICT, + ) + except Exception as exc: # noqa: BLE001 — best-effort + logger.warning( + "[%s] image_to_data indisponible (%s) — calibration " + "sautée pour ce document.", self._name, exc, + ) + return None + + # Format Tesseract : dict {"text": [...], "conf": [...]}. + texts = data.get("text") or [] + confs = data.get("conf") or [] + raw = [ + {"text": t, "confidence": c} + for t, c in zip(texts, confs) + ] + tokens = filter_valid_tokens(raw) + return write_confidences_sidecar( + text_path=text_path, + adapter_name=self._name, + tokens=tokens, + document_id=document_id, + extractor="tesseract", + ) + + +__all__ = ["TesseractAdapter"] diff --git a/picarones/adapters/output_paths.py b/picarones/adapters/output_paths.py new file mode 100644 index 0000000000000000000000000000000000000000..4d11b2321d920b62772a0bce620becfbd65df055 --- /dev/null +++ b/picarones/adapters/output_paths.py @@ -0,0 +1,78 @@ +"""Résolution du répertoire d'output pour les adapters (OCR/LLM/VLM). + +Helper partagé par tous les adapters qui produisent des fichiers de +sortie. Il vit au top-level de ``adapters/`` plutôt qu'à l'intérieur +de l'un des sous-packages — il sert les trois familles indistinctement. + +Un corpus monté en read-only (NAS partagé, volume Docker RO) ne peut +pas accueillir les sorties à côté des fichiers sources. Le helper +résout le chemin selon une priorité : + +1. ``context.workspace_uri`` si non None → écriture dans + ``//`` (sandbox par run, write-allowed). +2. Fallback ``input_path.parent`` → comportement par défaut quand + aucun workspace n'est configuré (peut échouer en read-only). + +Anti-sur-ingénierie +------------------- +- Pas de quota disk : le ``WorkspaceManager`` gère ça quand un + caller institutionnel l'exige. +- Pas de support S3/distant : ``workspace_uri`` est un path + filesystem dans le contrat actuel. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def resolve_output_path( + input_path: Path, + adapter_name: str, + suffix: str, + context: Any, +) -> Path: + """Résout le chemin de sortie pour un artefact d'adapter. + + Convention de nommage : ``..``. + + Si ``context.workspace_uri`` est fourni, le fichier va dans + ``//`` (créé si absent). Sinon, fallback + sur ``input_path.parent`` (cas typique CLI / corpus local). + + Parameters + ---------- + input_path: + Chemin du fichier d'entrée (image, texte, etc.) — utilisé + pour récupérer le ``stem``. + adapter_name: + Nom de l'adapter, intercalé dans le nom du fichier pour + permettre la cohabitation de plusieurs sorties. + suffix: + Extension finale, ex : ``"txt"``, ``"confidences.json"``, + ``"corrected.txt"``. Pas de point initial — la fonction + l'ajoute. + context: + ``RunContext`` avec attributs ``document_id`` et + ``workspace_uri``. ``workspace_uri`` peut être ``None`` + (mode CLI direct). + + Returns + ------- + Path + Chemin absolu où écrire la sortie. Le répertoire parent + est créé si nécessaire. + """ + workspace_uri = getattr(context, "workspace_uri", None) + document_id = getattr(context, "document_id", None) or "unknown_doc" + + if workspace_uri: + out_dir = Path(workspace_uri) / document_id + out_dir.mkdir(parents=True, exist_ok=True) + return out_dir / f"{input_path.stem}.{adapter_name}.{suffix}" + + return input_path.parent / f"{input_path.stem}.{adapter_name}.{suffix}" + + +__all__ = ["resolve_output_path"] diff --git a/picarones/adapters/storage/__init__.py b/picarones/adapters/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..26766e4c279a32178689491e4770d7f22e8b0590 --- /dev/null +++ b/picarones/adapters/storage/__init__.py @@ -0,0 +1,58 @@ +"""Adaptateurs de stockage — Sprint S29. + +Stocks d'artefacts indexés par hash multi-paramètres pour la +reprise des runs longs. + +Modules livrés +-------------- +- ``artifact_store.py`` (S29) — ``ArtifactKey``, ``StoredArtifact``, + ``ArtifactStore`` (ABC), ``InMemoryArtifactStore``, + ``FilesystemArtifactStore``. + +Pattern : un ``Storage`` est instancié par un ``app/services/``, +pas créé ad-hoc dans un router FastAPI ou un module métier. Ça +permet d'injecter un mock en test, de basculer SQLite → Postgres +si besoin, et de centraliser les permissions/quotas. + +Distinct du ``picarones/pipeline/cache.py`` (S7) +------------------------------------------------ +``ArtifactCache`` (S7) reste exposé pour les callers qui en +dépendent en interne. ``ArtifactStore`` (S29) est la nouvelle +API canonique : hash multi-paramètres (model_version, normalization +profile, projection spec), persistance optionnelle sur filesystem, +abstraction ABC. + +Cibles à venir +-------------- +- S37 : déplacement de ``picarones.web.jobs`` (SQLite job store). +- Post-livraison : ``picarones.measurements.history`` (SQLite + history) et stores distribués (S3, GCS, …). +""" + +from __future__ import annotations + +from picarones.adapters.storage.artifact_store import ( + ArtifactKey, + ArtifactStore, + ArtifactStoreError, + FilesystemArtifactStore, + InMemoryArtifactStore, + StoredArtifact, +) +from picarones.adapters.storage.job_store import ( + JobRecord, + JobStore, + JobStoreError, +) + +__all__ = [ + "ArtifactKey", + "ArtifactStore", + "ArtifactStoreError", + "FilesystemArtifactStore", + "InMemoryArtifactStore", + "StoredArtifact", + "JobStore", + "JobRecord", + "JobStoreError", +] diff --git a/picarones/adapters/storage/artifact_store.py b/picarones/adapters/storage/artifact_store.py new file mode 100644 index 0000000000000000000000000000000000000000..22202653c51c3e42cc5c39b34c8b4def2d06d8ce --- /dev/null +++ b/picarones/adapters/storage/artifact_store.py @@ -0,0 +1,417 @@ +"""``ArtifactStore`` — Sprint A14-S29. + +Le S7 livrait ``ArtifactCache`` (in-memory, hash basique sur +inputs + step + code_version). S29 introduit un ``ArtifactStore`` +plus robuste qui adresse la critique d'audit n° 14 (« hash +multi-paramètres + reprise par hash ») : + +1. **Hash multi-paramètres** : la clé canonique d'un artefact + inclut les ``content_hash`` des inputs, le nom + version du + model utilisé, les ``params`` du step, le ``code_version``, + l'éventuel profil de normalisation, et l'éventuelle spec de + projection. Tout changement d'un paramètre éditorial invalide + la cache. + +2. **Reprise par hash** : si un artefact avec exactement la même + clé existe déjà dans le store, le caller peut l'utiliser + directement plutôt que de re-exécuter l'étape coûteuse. + +3. **Persistance optionnelle** : ``InMemoryArtifactStore`` pour + les tests et les workflows éphémères ; ``FilesystemArtifactStore`` + pour les longs runs où on veut survivre à un crash. + +Pas de shim +----------- +``ArtifactCache`` (S7) reste exposé pour les callers qui en +dépendent en interne, mais la nouvelle API canonique est +``ArtifactStore``. Le ``PipelineExecutor`` peut consommer un +``ArtifactStore`` via le paramètre optionnel ``artifact_store=`` +au constructeur ; sans store, l'executor s'exécute comme avant +(pas d'effet de cache). + +Anti-sur-ingénierie +------------------- +- Pas de TTL ni d'éviction LRU dans la version in-memory. La + taille est gérée par le caller (qui peut appeler ``clear()``). +- Pas de compression des payloads dans la version filesystem. +- Pas de namespacing par run — un store partagé entre runs est + censé converger, c'est précisément la propriété de la reprise. +- Pas de support distribué (S3, GCS, …) — viendra quand un + caller en aura concrètement besoin. +""" + +from __future__ import annotations + +import json +import logging +import threading +from abc import ABC, abstractmethod +from dataclasses import dataclass +from pathlib import Path + +from picarones.domain.artifact_key import ArtifactKey +from picarones.domain.artifacts import Artifact +from picarones.domain.errors import PicaronesError + +logger = logging.getLogger(__name__) + + +class ArtifactStoreError(PicaronesError): + """Erreur de persistance d'artefact (clé invalide, I/O en échec). + + Hérite de ``PicaronesError`` — un caller qui catche + ``PicaronesError`` rattrape aussi cette branche, cohérent avec + la hiérarchie d'exceptions unifiée. + """ + + +# Sprint A14-S47 — ``ArtifactKey`` (type pur) a migré dans +# ``picarones/domain/artifact_key.py``. Re-import ici pour ne pas +# casser les callers (``from picarones.adapters.storage import +# ArtifactKey`` reste valide). + + +# ────────────────────────────────────────────────────────────────────── +# Conteneur du store +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class StoredArtifact: + """Entrée du store : un artefact + son payload + sa clé. + + Le payload est stocké en bytes brutes — le caller décide de la + désérialisation (texte UTF-8, ALTO XML, image PNG, etc.) en se + basant sur ``artifact.type``. + + Attributes + ---------- + key: + Hash hex de la ``ArtifactKey`` qui a produit l'artefact. + artifact: + ``Artifact`` complet (id, type, content_hash, provenance). + payload: + Bytes du contenu, ou ``None`` si le store ne stocke que + les métadonnées (cas d'un artefact dont l'``uri`` pointe + vers un fichier externe). + """ + + key: str + artifact: Artifact + payload: bytes | None = None + + +# ────────────────────────────────────────────────────────────────────── +# Interface ABC +# ────────────────────────────────────────────────────────────────────── + + +class ArtifactStore(ABC): + """Contrat abstrait d'un store d'artefacts indexé par hash. + + Implémentations livrées au S29 : + + - ``InMemoryArtifactStore`` (tests, runs éphémères) ; + - ``FilesystemArtifactStore`` (workspaces persistants). + + Une implémentation tierce (S3, Postgres, …) est attendue post- + livraison ; elle hérite de cette ABC et passe les tests de + contrat. + """ + + @abstractmethod + def get(self, key: str) -> StoredArtifact | None: + """Récupère un artefact par sa clé hex, ou ``None``. + + Tolère les clés inexistantes — le retour ``None`` indique + un cache miss, pas une erreur. + """ + + @abstractmethod + def put( + self, + key: str, + artifact: Artifact, + payload: bytes | None = None, + ) -> None: + """Stocke un artefact sous la clé donnée. + + Convention idempotente : ``put(k, ...)`` deux fois avec la + même clé écrase la valeur précédente sans erreur. L'ABC + n'impose pas de comportement en concurrence multi-process + — chaque implémentation documente ses garanties. + """ + + @abstractmethod + def __contains__(self, key: str) -> bool: + """Vrai si la clé est connue du store.""" + + @abstractmethod + def clear(self) -> None: + """Supprime toutes les entrées du store. + + Implémentations filesystem : supprime les fichiers de + l'index et des payloads. Implémentations in-memory : + vide les dicts. + """ + + @abstractmethod + def __len__(self) -> int: + """Nombre d'entrées dans le store.""" + + +# ────────────────────────────────────────────────────────────────────── +# InMemoryArtifactStore +# ────────────────────────────────────────────────────────────────────── + + +class InMemoryArtifactStore(ArtifactStore): + """Store in-memory thread-safe pour tests et runs éphémères. + + Performances : O(1) en lecture/écriture. Aucune persistance — + toutes les données disparaissent à la sortie du process. + + Thread-safety : un ``threading.Lock`` protège les opérations + mutantes (put, clear). Lecture (get, __contains__, __len__) + est sans lock car les dict Python sont atomiques par opération + sur clé. + """ + + def __init__(self) -> None: + self._store: dict[str, StoredArtifact] = {} + self._lock = threading.Lock() + + def get(self, key: str) -> StoredArtifact | None: + return self._store.get(key) + + def put( + self, + key: str, + artifact: Artifact, + payload: bytes | None = None, + ) -> None: + if not key: + raise ArtifactStoreError("ArtifactStore.put : key vide non autorisé") + with self._lock: + self._store[key] = StoredArtifact( + key=key, artifact=artifact, payload=payload, + ) + + def __contains__(self, key: str) -> bool: + return key in self._store + + def clear(self) -> None: + with self._lock: + self._store.clear() + + def __len__(self) -> int: + return len(self._store) + + def keys(self) -> tuple[str, ...]: + """Liste figée des clés connues (utile aux tests).""" + return tuple(self._store.keys()) + + +# ────────────────────────────────────────────────────────────────────── +# FilesystemArtifactStore +# ────────────────────────────────────────────────────────────────────── + + +class FilesystemArtifactStore(ArtifactStore): + """Store persistant sur le filesystem. + + Layout + ------ + + ``/`` + ``index.jsonl`` — un JSON par ligne + ``{"key": ..., "artifact_id": ..., + "has_payload": bool, "type": ..., + "timestamp": ISO8601}`` + ``artifacts/.json`` — métadonnées de l'``Artifact`` + sérialisées via + ``model_dump_json()`` + ``payloads/.bin`` — bytes du payload (le cas + échéant) + + Concurrence + ----------- + Un ``threading.Lock`` interne protège les opérations mutantes + dans le même process. Multi-process : pas de garantie ; le + layout est conçu pour qu'un read-only multi-process soit + sûr (les fichiers individuels sont écrits atomiquement via + ``write_text(... newline=...)`` et un rename). + + Garbage / corruption + -------------------- + Si l'index pointe vers un fichier disparu, le ``get`` retourne + ``None`` et logge un warning. ``clear()`` supprime tout — + un caller peut aussi reconstruire l'index en parsant les + fichiers ``artifacts/*.json``. + + Pas de shim + ----------- + Cette implémentation n'a pas de migration depuis l'``ArtifactCache`` + in-memory du S7 — c'est un store distinct, instanciable + explicitement par un service applicatif (typiquement + ``WorkspaceManager`` au S30+). + """ + + INDEX_FILENAME = "index.jsonl" + ARTIFACTS_DIR = "artifacts" + PAYLOADS_DIR = "payloads" + + def __init__(self, root: Path | str) -> None: + self._root = Path(root) + self._root.mkdir(parents=True, exist_ok=True) + (self._root / self.ARTIFACTS_DIR).mkdir(exist_ok=True) + (self._root / self.PAYLOADS_DIR).mkdir(exist_ok=True) + self._index_path = self._root / self.INDEX_FILENAME + self._lock = threading.Lock() + # In-memory index of known keys reconstructed from disk. + # On sait qu'on est seul écrivain dans un process donné, mais + # un autre process peut aussi écrire — on ne fait pas de + # garantie multi-process ici. + self._known_keys: set[str] = self._reconstruct_known_keys() + + # ────────────────────────────────────────────────────────────── + # API ABC + # ────────────────────────────────────────────────────────────── + + def get(self, key: str) -> StoredArtifact | None: + if key not in self._known_keys: + return None + artifact_path = self._root / self.ARTIFACTS_DIR / f"{key}.json" + if not artifact_path.exists(): + logger.warning( + "[artifact_store] index pointe vers %s mais le fichier " + "n'existe plus — entrée corrompue, retour None.", + artifact_path, + ) + return None + try: + artifact = Artifact.model_validate_json( + artifact_path.read_text(encoding="utf-8"), + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "[artifact_store] échec de désérialisation de %s : %s", + artifact_path, exc, + ) + return None + payload_path = self._root / self.PAYLOADS_DIR / f"{key}.bin" + payload = ( + payload_path.read_bytes() if payload_path.exists() else None + ) + return StoredArtifact(key=key, artifact=artifact, payload=payload) + + def put( + self, + key: str, + artifact: Artifact, + payload: bytes | None = None, + ) -> None: + if not key: + raise ArtifactStoreError("ArtifactStore.put : key vide non autorisé") + with self._lock: + artifact_path = self._root / self.ARTIFACTS_DIR / f"{key}.json" + tmp_path = artifact_path.with_suffix(".json.tmp") + tmp_path.write_text( + artifact.model_dump_json(), + encoding="utf-8", + ) + tmp_path.replace(artifact_path) + if payload is not None: + payload_path = self._root / self.PAYLOADS_DIR / f"{key}.bin" + tmp_payload = payload_path.with_suffix(".bin.tmp") + tmp_payload.write_bytes(payload) + tmp_payload.replace(payload_path) + self._append_index_line(key, artifact, payload is not None) + self._known_keys.add(key) + + def __contains__(self, key: str) -> bool: + return key in self._known_keys + + def clear(self) -> None: + with self._lock: + for sub in (self.ARTIFACTS_DIR, self.PAYLOADS_DIR): + d = self._root / sub + if d.exists(): + for f in d.iterdir(): + f.unlink() + if self._index_path.exists(): + self._index_path.unlink() + self._known_keys.clear() + + def __len__(self) -> int: + return len(self._known_keys) + + def keys(self) -> tuple[str, ...]: + return tuple(self._known_keys) + + # ────────────────────────────────────────────────────────────── + # Helpers internes + # ────────────────────────────────────────────────────────────── + + def _append_index_line( + self, key: str, artifact: Artifact, has_payload: bool, + ) -> None: + """Append-only JSONL : une nouvelle ligne par put. Lit le + rapport d'index au démarrage, recompose ``_known_keys``.""" + from datetime import datetime, timezone + line = json.dumps( + { + "key": key, + "artifact_id": artifact.id, + "type": artifact.type.value, + "has_payload": has_payload, + "timestamp": datetime.now(tz=timezone.utc).isoformat(), + }, + ensure_ascii=False, + ) + with self._index_path.open("a", encoding="utf-8") as f: + f.write(line + "\n") + + def _reconstruct_known_keys(self) -> set[str]: + """Lit ``index.jsonl`` et reconstruit l'ensemble des clés + connues. Tolère les lignes corrompues (warning + skip). + + Si l'index n'existe pas, recompose depuis le contenu du + sous-répertoire ``artifacts/`` (cas d'un store partiellement + copié sans son index). + """ + keys: set[str] = set() + if self._index_path.exists(): + for line_no, raw_line in enumerate( + self._index_path.read_text(encoding="utf-8").splitlines(), + start=1, + ): + if not raw_line.strip(): + continue + try: + rec = json.loads(raw_line) + except json.JSONDecodeError as exc: + logger.warning( + "[artifact_store] index ligne %d corrompue, " + "ignorée : %s", line_no, exc, + ) + continue + if "key" in rec and isinstance(rec["key"], str): + keys.add(rec["key"]) + else: + # Recompose depuis les fichiers d'artefacts. + artifacts_dir = self._root / self.ARTIFACTS_DIR + if artifacts_dir.exists(): + for f in artifacts_dir.iterdir(): + if f.suffix == ".json": + keys.add(f.stem) + return keys + + +__all__ = [ + "ArtifactKey", + "ArtifactStore", + "FilesystemArtifactStore", + "InMemoryArtifactStore", + "StoredArtifact", +] diff --git a/picarones/adapters/storage/job_store.py b/picarones/adapters/storage/job_store.py new file mode 100644 index 0000000000000000000000000000000000000000..5104c8aa5de21cffdb9d0bd53c452a3754bd1e16 --- /dev/null +++ b/picarones/adapters/storage/job_store.py @@ -0,0 +1,470 @@ +"""``JobStore`` — Sprint A14-S37. + +Persistance SQLite des jobs de benchmark. Adapté du legacy +``picarones.web.jobs`` mais réécrit nativement pour le nouveau monde : +API plus simple, dataclass immuable, sans dépendance au ``state`` +global. + +Le legacy reste exposé jusqu'au S46. + +Pourquoi SQLite +--------------- +- Survie au redémarrage : un crash ou ``kill -HUP`` ne perd pas + l'état des jobs en cours. +- Détection des jobs orphelins au boot : tout job ``running`` à + l'initialisation est forcément un zombie du process précédent + → marqué ``interrupted``. +- Indexation simple par ``job_id`` (TEXT PK). +- Mode WAL pour les lectures concurrentes pendant qu'un thread + écrit la progression. + +Statuts +------- +- ``pending`` : créé, en attente d'exécution. +- ``running`` : worker actif. +- ``complete`` : succès. +- ``error`` : échec applicatif (avec message). +- ``cancelled`` : interrompu par le caller. +- ``interrupted`` : zombie du process précédent (détecté au boot). + +Les 4 derniers sont **terminaux** — un job dans cet état ne change +plus de statut. + +API publique +------------ +- ``JobStore(db_path)`` : connexion SQLite, init schema si absent. +- ``create(job_id, payload, total_docs=0)`` → JobRecord. +- ``get(job_id)`` → JobRecord | None. +- ``list(limit=None)`` → tuple[JobRecord, ...] triés par + ``created_at`` décroissant. +- ``update_progress(job_id, progress, processed_docs, current_engine)``. +- ``mark_running(job_id)``. +- ``mark_complete(job_id, output_path="")``. +- ``mark_error(job_id, error_message)``. +- ``mark_cancelled(job_id)``. +- ``mark_orphaned_jobs_interrupted()`` → int (nombre marqué). +- ``close()`` (no-op : chaque appel ouvre/ferme sa propre connexion). + +Anti-sur-ingénierie +------------------- +- Pas de notification SSE (les SSE legacy sont reportés à un sprint + dédié si un caller en a besoin). +- Pas de queue d'événements — le legacy avait ``job_events`` ; on + attend qu'un caller en ait besoin ; pour l'instant le statut + + progress suffit pour le polling. +- Une connexion par appel — SQLite gère ça en sub-ms. +""" + +from __future__ import annotations + +import json +import logging +import sqlite3 +import time +from collections.abc import Callable +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__name__) + + +_TERMINAL_STATUSES: frozenset[str] = frozenset({ + "complete", "error", "cancelled", "interrupted", +}) + +_LIVE_STATUSES: frozenset[str] = frozenset({"pending", "running"}) + + +_SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS jobs ( + job_id TEXT PRIMARY KEY, + status TEXT NOT NULL DEFAULT 'pending', + progress REAL NOT NULL DEFAULT 0.0, + current_engine TEXT NOT NULL DEFAULT '', + total_docs INTEGER NOT NULL DEFAULT 0, + processed_docs INTEGER NOT NULL DEFAULT 0, + output_path TEXT NOT NULL DEFAULT '', + error TEXT NOT NULL DEFAULT '', + payload_json TEXT NOT NULL DEFAULT '{}', + created_at REAL NOT NULL, + updated_at REAL NOT NULL, + finished_at REAL +); + +CREATE INDEX IF NOT EXISTS jobs_status_idx ON jobs(status); +CREATE INDEX IF NOT EXISTS jobs_created_idx ON jobs(created_at); +""" + + +@dataclass(frozen=True) +class JobRecord: + """Snapshot immuable d'un job persisté. + + Les setters mutants (``update_progress``, ``mark_*``) reconstruisent + un nouveau ``JobRecord`` au prochain ``get``. + """ + + job_id: str + status: str + progress: float + current_engine: str + total_docs: int + processed_docs: int + output_path: str + error: str + payload: dict[str, Any] + created_at: float + updated_at: float + finished_at: float | None + + @property + def is_terminal(self) -> bool: + return self.status in _TERMINAL_STATUSES + + @property + def is_live(self) -> bool: + return self.status in _LIVE_STATUSES + + +from picarones.domain.errors import PicaronesError + + +class JobStoreError(PicaronesError): + """Erreur de persistance SQLite côté JobStore.""" + + +#: Dispatcher de migrations ascendantes ``v_n → v_{n+1}``. +#: +#: Une migration est une callable ``(sqlite3.Connection) -> None`` +#: appliquée dans une transaction implicite (mode autocommit du +#: ``JobStore`` désactivé pendant la migration). Pour ajouter une +#: migration, déclarer une fonction ``_migrate_v1_to_v2(conn)`` qui +#: applique les ``ALTER TABLE`` nécessaires, puis ajouter +#: ``2: _migrate_v1_to_v2`` au dict. La clé est la version +#: **source** ; la valeur est la version **cible**. +_MIGRATIONS: dict[int, Callable[[sqlite3.Connection], None]] = {} + + +class JobStore: + """Store SQLite des jobs de benchmark. + + Parameters + ---------- + db_path: + Chemin du fichier SQLite. Créé s'il n'existe pas. + + Migration de schéma + ------------------- + L'ouverture d'une base SQLite vérifie sa version contre + ``SCHEMA_VERSION`` (lue dans la table ``schema_version``) : + + - Version absente → fresh DB, on insère ``SCHEMA_VERSION``. + - Version == code → no-op. + - Version < code → on applique en chaîne les migrations + ``_MIGRATIONS`` jusqu'à atteindre ``SCHEMA_VERSION``. Si + l'une manque dans le dispatcher, ``JobStoreError`` (la + release n'a pas livré la migration nécessaire). + - Version > code → ``JobStoreError`` (downgrade non supporté ; + l'utilisateur doit utiliser un build plus récent ou + réinitialiser). + """ + + #: Version du schéma SQL. À incrémenter ENSEMBLE avec une + #: entrée correspondante dans ``_MIGRATIONS`` (pas l'un sans + #: l'autre — un test architectural vérifie l'invariant). + SCHEMA_VERSION = 1 + + def __init__(self, db_path: Path | str) -> None: + self._path = Path(db_path) + self._path.parent.mkdir(parents=True, exist_ok=True) + with self._connect() as conn: + conn.executescript(_SCHEMA_SQL) + conn.execute( + "CREATE TABLE IF NOT EXISTS schema_version " + "(version INTEGER PRIMARY KEY)", + ) + cur = conn.execute("SELECT version FROM schema_version") + row = cur.fetchone() + if row is None: + conn.execute( + "INSERT INTO schema_version (version) VALUES (?)", + (self.SCHEMA_VERSION,), + ) + else: + existing = row[0] + if existing > self.SCHEMA_VERSION: + raise JobStoreError( + f"JobStore : base SQLite à la version " + f"{existing}, code à la version " + f"{self.SCHEMA_VERSION}. Downgrade non " + "supporté.", + ) + if existing < self.SCHEMA_VERSION: + self._apply_migrations( + conn, from_version=existing, + ) + try: + conn.execute("PRAGMA journal_mode = WAL;") + except sqlite3.Error: # pragma: no cover + # WAL non supporté (FAT32, NFS sans verrous) : on + # reste en rollback journal, fonctionnel mais moins + # concurrent en lecture. + pass + + @classmethod + def _apply_migrations( + cls, + conn: sqlite3.Connection, + *, + from_version: int, + ) -> None: + """Applique en chaîne ``_MIGRATIONS[v]`` pour ``v`` de + ``from_version`` à ``SCHEMA_VERSION - 1``. + + Une migration manquante est une erreur dure : la release du + code prétend être à ``SCHEMA_VERSION`` mais n'a pas livré + la transformation nécessaire. ``JobStoreError`` plutôt + qu'un warning silencieux qui laisserait le schéma incohérent. + """ + current = from_version + while current < cls.SCHEMA_VERSION: + migrate = _MIGRATIONS.get(current) + if migrate is None: + raise JobStoreError( + f"JobStore : migration manquante de v{current} " + f"vers v{current + 1}. Le code prétend être à " + f"la version {cls.SCHEMA_VERSION} mais n'a pas " + "livré la migration.", + ) + migrate(conn) + conn.execute( + "UPDATE schema_version SET version = ?", + (current + 1,), + ) + current += 1 + + @property + def db_path(self) -> Path: + return self._path + + def _connect(self) -> sqlite3.Connection: + """Ouvre une nouvelle connexion. + + ``timeout=30s`` côté driver Python + ``PRAGMA busy_timeout`` + côté SQLite absorbent les contentions courtes. Le mode + autocommit combiné au journal WAL garantit que les lectures + n'attendent pas les écritures (cf. https://sqlite.org/wal.html). + """ + conn = sqlite3.connect( + str(self._path), + isolation_level=None, # autocommit pour simplicité + timeout=30.0, + ) + # busy_timeout (ms) — backup au timeout Python. + conn.execute("PRAGMA busy_timeout = 30000;") + conn.row_factory = sqlite3.Row + return conn + + # ────────────────────────────────────────────────────────────── + # Création / lecture + # ────────────────────────────────────────────────────────────── + + def create( + self, + job_id: str, + payload: dict[str, Any] | None = None, + total_docs: int = 0, + ) -> JobRecord: + """Crée un nouveau job en statut ``pending``. + + Raises + ------ + JobStoreError + Si ``job_id`` existe déjà ou si la ligne ne s'insère + pas correctement. + """ + if not job_id: + raise JobStoreError("create : job_id vide non autorisé.") + now = time.time() + payload_json = json.dumps(payload or {}, ensure_ascii=False) + try: + with self._connect() as conn: + conn.execute( + """ + INSERT INTO jobs ( + job_id, status, progress, current_engine, + total_docs, processed_docs, output_path, error, + payload_json, created_at, updated_at, finished_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + job_id, "pending", 0.0, "", + total_docs, 0, "", "", + payload_json, now, now, None, + ), + ) + except sqlite3.IntegrityError as exc: + raise JobStoreError( + f"job_id {job_id!r} déjà existant.", + ) from exc + return self.get(job_id) # type: ignore[return-value] + + def get(self, job_id: str) -> JobRecord | None: + """Retourne le snapshot du job, ou ``None`` si inconnu.""" + with self._connect() as conn: + cur = conn.execute( + "SELECT * FROM jobs WHERE job_id = ?", + (job_id,), + ) + row = cur.fetchone() + if row is None: + return None + return self._row_to_record(row) + + def list(self, limit: int | None = None) -> tuple[JobRecord, ...]: + """Liste les jobs triés par date de création décroissante.""" + sql = "SELECT * FROM jobs ORDER BY created_at DESC" + if limit is not None: + sql += f" LIMIT {int(limit)}" + with self._connect() as conn: + rows = conn.execute(sql).fetchall() + return tuple(self._row_to_record(r) for r in rows) + + # ────────────────────────────────────────────────────────────── + # Mutations + # ────────────────────────────────────────────────────────────── + + def update_progress( + self, + job_id: str, + progress: float, + processed_docs: int = 0, + current_engine: str = "", + ) -> None: + """Met à jour la progression d'un job en ``running``. + + ``progress`` est tronqué à [0.0, 1.0]. + """ + progress = max(0.0, min(1.0, progress)) + now = time.time() + with self._connect() as conn: + conn.execute( + """ + UPDATE jobs + SET progress = ?, processed_docs = ?, + current_engine = ?, updated_at = ? + WHERE job_id = ? + """, + (progress, processed_docs, current_engine, now, job_id), + ) + + def mark_running(self, job_id: str) -> None: + """Bascule le statut en ``running``.""" + self._set_status(job_id, "running", finished=False) + + def mark_complete(self, job_id: str, output_path: str = "") -> None: + self._set_status( + job_id, "complete", finished=True, output_path=output_path, + ) + + def mark_error(self, job_id: str, error_message: str) -> None: + self._set_status( + job_id, "error", finished=True, error=error_message, + ) + + def mark_cancelled(self, job_id: str) -> None: + self._set_status(job_id, "cancelled", finished=True) + + def mark_orphaned_jobs_interrupted(self) -> int: + """Marque tous les jobs ``pending``/``running`` comme + ``interrupted``. Appelé au boot de l'app pour nettoyer les + zombies du process précédent. + + Returns + ------- + int + Nombre de jobs marqués. + """ + now = time.time() + with self._connect() as conn: + cur = conn.execute( + """ + UPDATE jobs + SET status = 'interrupted', + error = 'process restart', + updated_at = ?, + finished_at = ? + WHERE status IN ('pending', 'running') + """, + (now, now), + ) + return cur.rowcount + + # ────────────────────────────────────────────────────────────── + # Helpers privés + # ────────────────────────────────────────────────────────────── + + def _set_status( + self, + job_id: str, + status: str, + *, + finished: bool, + output_path: str = "", + error: str = "", + ) -> None: + now = time.time() + finished_at = now if finished else None + with self._connect() as conn: + if finished: + conn.execute( + """ + UPDATE jobs + SET status = ?, output_path = ?, error = ?, + updated_at = ?, finished_at = ? + WHERE job_id = ? + """, + (status, output_path, error, now, finished_at, job_id), + ) + else: + conn.execute( + """ + UPDATE jobs + SET status = ?, updated_at = ?, finished_at = ? + WHERE job_id = ? + """, + (status, now, finished_at, job_id), + ) + + @staticmethod + def _row_to_record(row: sqlite3.Row) -> JobRecord: + try: + payload = json.loads(row["payload_json"] or "{}") + except json.JSONDecodeError: + logger.warning( + "[job_store] payload corrompu pour job %s — ignoré.", + row["job_id"], + ) + payload = {} + return JobRecord( + job_id=row["job_id"], + status=row["status"], + progress=row["progress"], + current_engine=row["current_engine"], + total_docs=row["total_docs"], + processed_docs=row["processed_docs"], + output_path=row["output_path"], + error=row["error"], + payload=payload, + created_at=row["created_at"], + updated_at=row["updated_at"], + finished_at=row["finished_at"], + ) + + +__all__ = [ + "JobRecord", + "JobStore", + "JobStoreError", +] diff --git a/picarones/adapters/vlm/__init__.py b/picarones/adapters/vlm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..618c2eef6d58a00c95f321ad968cc17dfbdb5fac --- /dev/null +++ b/picarones/adapters/vlm/__init__.py @@ -0,0 +1,42 @@ +"""Adapters VLM (Vision-Language Models) — Sprint A14-S45. + +VLM = transcription directe par un modèle généraliste avec vision. +Distinct des OCR dédiés (Tesseract, Pero, Mistral OCR, Google Vision, +Azure DI) — un VLM consomme IMAGE et produit RAW_TEXT via prompt +multimodal, sans layout structuré natif. + +Adapters livrés +--------------- +- ``AnthropicVLMAdapter`` : Claude Sonnet/Opus avec vision. +- ``OpenAIVLMAdapter`` : GPT-4o, GPT-4-turbo, GPT-4-vision-preview. +- ``MistralVLMAdapter`` : Pixtral 12b/Large. +- ``OllamaVLMAdapter`` : LLaVA, BakLLaVA, llama3.2-vision (local). + +Convention StepExecutor : + +- ``input_types = {IMAGE}`` +- ``output_types = {RAW_TEXT}`` +- ``execute(inputs, params, context)`` encode l'image en base64, + appelle le LLM avec un prompt de transcription, écrit le texte + produit dans ``..txt`` à côté de l'image, + retourne un Artifact RAW_TEXT. + +Pas un shim sur les LLM adapters : c'est un mode d'usage +distinct (vision vs texte) avec un contrat StepExecutor différent. +""" + +from __future__ import annotations + +from picarones.adapters.vlm.anthropic_vlm import AnthropicVLMAdapter +from picarones.adapters.vlm.base import BaseVLMAdapter +from picarones.adapters.vlm.mistral_vlm import MistralVLMAdapter +from picarones.adapters.vlm.ollama_vlm import OllamaVLMAdapter +from picarones.adapters.vlm.openai_vlm import OpenAIVLMAdapter + +__all__ = [ + "BaseVLMAdapter", + "AnthropicVLMAdapter", + "MistralVLMAdapter", + "OllamaVLMAdapter", + "OpenAIVLMAdapter", +] diff --git a/picarones/adapters/vlm/anthropic_vlm.py b/picarones/adapters/vlm/anthropic_vlm.py new file mode 100644 index 0000000000000000000000000000000000000000..cfa5583f8a04efda10eafbd7c1c4e20f925bc8c6 --- /dev/null +++ b/picarones/adapters/vlm/anthropic_vlm.py @@ -0,0 +1,32 @@ +"""``AnthropicVLMAdapter`` — Claude Sonnet/Opus en mode vision. + +Sprint A14-S45. Délègue l'appel API au mécanisme de +``AnthropicAdapter`` (qui supporte déjà la vision via le SDK +anthropic) en surchargeant le contrat StepExecutor pour consommer +IMAGE au lieu de RAW_TEXT. +""" + +from __future__ import annotations + +from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter +from picarones.adapters.vlm.base import BaseVLMAdapter + + +class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter): + """VLM Claude (Sonnet/Opus avec vision). + + L'ordre du MRO est important : ``BaseVLMAdapter`` d'abord pour + surcharger ``input_types``/``output_types``/``execute``, puis + ``AnthropicAdapter`` pour ``_call``/``default_model``/``name``/ + retry/validation API key. + + Modèles vision recommandés : ``claude-3-5-sonnet-latest``, + ``claude-3-opus-latest``. + """ + + @property + def name(self) -> str: + return "anthropic_vlm" + + +__all__ = ["AnthropicVLMAdapter"] diff --git a/picarones/adapters/vlm/base.py b/picarones/adapters/vlm/base.py new file mode 100644 index 0000000000000000000000000000000000000000..60f60b7f8c347e443a20807914c2812da337d0ad --- /dev/null +++ b/picarones/adapters/vlm/base.py @@ -0,0 +1,240 @@ +"""``BaseVLMAdapter`` — Sprint A14-S45. + +Adapter VLM (Vision-Language Model) qui hérite de ``BaseLLMAdapter`` +et surcharge le contrat StepExecutor pour consommer ``IMAGE`` au +lieu de ``RAW_TEXT`` et produire ``RAW_TEXT`` (transcription +directe par un VLM). + +Pas un shim sur les LLM adapters : c'est un mode d'usage différent +de la même API LLM (texte vs image) — le contrat StepExecutor diffère. + +Différences avec ``BaseOCRAdapter`` (S26) +----------------------------------------- +- Un OCR (Tesseract, Pero, Mistral OCR, Google Vision, Azure DI) + utilise des modèles dédiés OCR avec layout structuré, confidences + natives, etc. +- Un VLM (Anthropic Claude, GPT-4-Vision, Pixtral, LLaVA) fait de la + transcription via un modèle généraliste prompt+image. + +Les deux peuvent produire RAW_TEXT et être comparés en TextView ; +la projection report explicitera ce qu'on perd côté VLM (pas de +coordonnées spatiales nativement). + +Convention output : RAW_TEXT (transcription plate). Une sous-classe +qui produit du markdown structuré (ex. ``CANONICAL_DOCUMENT``) peut +surcharger ``output_types``. +""" + +from __future__ import annotations + +import base64 +import logging +from pathlib import Path +from typing import Any + +from picarones.adapters.llm.base import BaseLLMAdapter, _DeprecatedAttribute +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.errors import AdapterStepError + +logger = logging.getLogger(__name__) + + +class VLMAdapterError(AdapterStepError): + """Erreur typée pour un échec d'adapter VLM. + + Hérite de ``AdapterStepError`` — racine commune avec les erreurs + OCR et LLM, ce qui permet à un orchestrateur d'attraper toutes + les erreurs d'adapter sans connaître le type concret. + """ + + +class BaseVLMAdapter(BaseLLMAdapter): + """Adapter VLM qui transcrit une IMAGE en RAW_TEXT. + + Hérite de ``BaseLLMAdapter`` et surcharge le contrat + ``StepExecutor`` pour consommer ``IMAGE`` au lieu de ``RAW_TEXT``. + + Parameters + ---------- + model: + Modèle VLM (cf. sous-classes pour les défauts). + config: + Config dict ; supporte + ``config["transcription_prompt"]`` pour personnaliser le + prompt de transcription. + + Garde-fou MRO + ------------- + Les VLM concrets utilisent l'héritage multiple : + + :: + + class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter) + + L'ordre est critique : ``BaseVLMAdapter`` doit venir d'ABORD + pour que ``input_types``, ``output_types``, ``execute``, et + ``DEFAULT_TRANSCRIPTION_PROMPTS`` soient résolus depuis lui (et + pas depuis le LLM sibling qui aurait des output_types = + {CORRECTED_TEXT}). + + ``__init_subclass__`` valide cet ordre à la définition de la + classe. Si le développeur swap accidentellement les parents + par habitude alphabétique, la définition de classe lève une + ``TypeError`` immédiate au lieu d'un comportement silencieusement + différent (output_types incorrect au runtime). + """ + + def __init_subclass__(cls, **kwargs) -> None: + super().__init_subclass__(**kwargs) + # Garde-fou : BaseVLMAdapter doit être le premier parent + # *non-trivial* dans l'ordre de la déclaration (pour gagner + # le MRO sur les attributs surchargés). + bases = cls.__bases__ + if len(bases) <= 1: + # Sous-classe directe simple — pas de MRO multiple, OK. + return + # On parcourt les bases dans l'ordre déclaré. + try: + vlm_idx = next( + i for i, b in enumerate(bases) + if issubclass(b, BaseVLMAdapter) + ) + except StopIteration: + return # ne devrait pas arriver, vlm subclass DOIT inclure VLM + # Toutes les bases AVANT BaseVLMAdapter doivent être + # neutres (mixins sans surcharge des output_types). + for prev in bases[:vlm_idx]: + if issubclass(prev, BaseLLMAdapter) and not issubclass( + prev, BaseVLMAdapter, + ): + raise TypeError( + f"{cls.__name__} : ordre MRO incorrect — " + f"BaseVLMAdapter doit précéder {prev.__name__} " + "dans la liste des parents pour que les " + "output_types VLM ({IMAGE} → {RAW_TEXT}) " + "soient résolus correctement (et pas écrasés " + "par les output_types LLM = {CORRECTED_TEXT}). " + f"Corrigez : `class {cls.__name__}(BaseVLMAdapter, " + f"{prev.__name__})`.", + ) + + @property + def input_types(self) -> "frozenset": + return frozenset({ArtifactType.IMAGE}) + + @property + def output_types(self) -> "frozenset": + return frozenset({ArtifactType.RAW_TEXT}) + + #: Prompts de transcription VLM par défaut, indexés par code + #: langue ISO 639-1 (``fr``, ``en``, ``la``). + DEFAULT_TRANSCRIPTION_PROMPTS: dict[str, str] = { + "fr": ( + "Transcris fidèlement le texte visible sur cette image " + "de document historique. Conserve l'orthographe " + "historique, les abréviations, et la ponctuation. " + "Retourne uniquement le texte transcrit, sans commentaire." + ), + "en": ( + "Faithfully transcribe the text visible in this image of " + "a historical document. Preserve the historical " + "spelling, abbreviations, and punctuation. Return only " + "the transcribed text, with no commentary." + ), + "la": ( + "Fideliter transcribe textum in hac imagine documenti " + "historici visibilem. Serva orthographiam historicam, " + "abbreviationes, et interpunctionem. Redde solum textum " + "transcriptum, sine ulla glossa." + ), + } + + #: Alias rétrocompat (FR uniquement) pour les sous-classes + #: externes qui lisaient l'ancienne API singulière. L'accès + #: déclenche un ``DeprecationWarning``. Sera supprimé en 2.0. + DEFAULT_TRANSCRIPTION_PROMPT = _DeprecatedAttribute( + DEFAULT_TRANSCRIPTION_PROMPTS["fr"], + "BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT is deprecated " + "and will be removed in 2.0. Use " + "DEFAULT_TRANSCRIPTION_PROMPTS[lang] (lang ∈ {fr, en, la}).", + ) + + def execute( + self, + inputs: dict, + params: dict, + context: Any, + ) -> dict: + """Exécute la transcription VLM. + + Lit ``inputs[IMAGE]`` (URI), encode en base64, appelle + ``self.complete(prompt, image_b64)``, écrit le résultat + dans ``..txt`` à côté de l'image, et retourne + ``{RAW_TEXT: Artifact}``. + """ + if ArtifactType.IMAGE not in inputs: + raise VLMAdapterError( + f"{self.name} : input IMAGE manquant.", + ) + image_artifact = inputs[ArtifactType.IMAGE] + if image_artifact.uri is None: + raise VLMAdapterError( + f"{self.name} : artefact image " + f"{image_artifact.id!r} sans URI.", + ) + image_path = Path(image_artifact.uri) + if not image_path.exists(): + raise VLMAdapterError( + f"{self.name} : image introuvable {image_path!r}.", + ) + + image_b64 = base64.b64encode( + image_path.read_bytes(), + ).decode("ascii") + + # Override explicite > prompt par langue > FR (fallback). + custom = self.config.get("transcription_prompt") + if custom is not None: + prompt = custom + else: + lang = (self.config.get("lang") or "fr").lower() + if lang not in self.DEFAULT_TRANSCRIPTION_PROMPTS: + logger.warning( + "[%s] lang=%r non supportée par " + "DEFAULT_TRANSCRIPTION_PROMPTS (%s) — fallback FR. " + "Pour un corpus dans cette langue, fournir " + "config['transcription_prompt'] explicite.", + self.name, lang, + sorted(self.DEFAULT_TRANSCRIPTION_PROMPTS.keys()), + ) + prompt = self.DEFAULT_TRANSCRIPTION_PROMPTS.get( + lang, self.DEFAULT_TRANSCRIPTION_PROMPTS["fr"], + ) + + result = self.complete(prompt, image_b64=image_b64) + if not result.success: + raise VLMAdapterError( + f"{self.name} : VLM a échoué ({result.error}).", + ) + + from picarones.adapters.output_paths import resolve_output_path + out_path = resolve_output_path( + input_path=image_path, + adapter_name=self.name, + suffix="txt", + context=context, + ) + out_path.write_text(result.text, encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:{self.name}:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="vlm_transcription", + uri=str(out_path), + ), + } + + +__all__ = ["BaseVLMAdapter", "VLMAdapterError"] diff --git a/picarones/adapters/vlm/mistral_vlm.py b/picarones/adapters/vlm/mistral_vlm.py new file mode 100644 index 0000000000000000000000000000000000000000..94fe571a8c99868489136208687d7909c756d340 --- /dev/null +++ b/picarones/adapters/vlm/mistral_vlm.py @@ -0,0 +1,26 @@ +"""``MistralVLMAdapter`` — Pixtral 12b/Large (vision Mistral). + +Sprint A14-S45. Délègue à ``MistralAdapter`` qui supporte la +vision via les modèles ``pixtral-12b-2409``, ``pixtral-large-latest``. +""" + +from __future__ import annotations + +from picarones.adapters.llm.mistral_adapter import MistralAdapter +from picarones.adapters.vlm.base import BaseVLMAdapter + + +class MistralVLMAdapter(BaseVLMAdapter, MistralAdapter): + """VLM Mistral (pixtral-12b-2409, pixtral-large-latest).""" + + @property + def name(self) -> str: + return "mistral_vlm" + + @property + def default_model(self) -> str: + # Ré-définit le défaut pour pointer vers un modèle vision. + return "pixtral-12b-2409" + + +__all__ = ["MistralVLMAdapter"] diff --git a/picarones/adapters/vlm/ollama_vlm.py b/picarones/adapters/vlm/ollama_vlm.py new file mode 100644 index 0000000000000000000000000000000000000000..4464d8c0255456bd6b936567b0630d30676d62b7 --- /dev/null +++ b/picarones/adapters/vlm/ollama_vlm.py @@ -0,0 +1,26 @@ +"""``OllamaVLMAdapter`` — Modèles vision locaux via Ollama. + +Sprint A14-S45. Délègue à ``OllamaAdapter`` (local, sans clé API). +Modèles vision recommandés : ``llava``, ``llava:13b``, ``bakllava``, +``llama3.2-vision``. +""" + +from __future__ import annotations + +from picarones.adapters.llm.ollama_adapter import OllamaAdapter +from picarones.adapters.vlm.base import BaseVLMAdapter + + +class OllamaVLMAdapter(BaseVLMAdapter, OllamaAdapter): + """VLM local via Ollama (llava, bakllava, llama3.2-vision).""" + + @property + def name(self) -> str: + return "ollama_vlm" + + @property + def default_model(self) -> str: + return "llava" + + +__all__ = ["OllamaVLMAdapter"] diff --git a/picarones/adapters/vlm/openai_vlm.py b/picarones/adapters/vlm/openai_vlm.py new file mode 100644 index 0000000000000000000000000000000000000000..b9989252648339af44432741d6e5c7824be77f51 --- /dev/null +++ b/picarones/adapters/vlm/openai_vlm.py @@ -0,0 +1,22 @@ +"""``OpenAIVLMAdapter`` — GPT-4-Vision / GPT-4o (vision). + +Sprint A14-S45. Délègue à ``OpenAIAdapter`` qui supporte déjà la +vision via les modèles ``gpt-4o``, ``gpt-4-turbo``, +``gpt-4-vision-preview``. +""" + +from __future__ import annotations + +from picarones.adapters.llm.openai_adapter import OpenAIAdapter +from picarones.adapters.vlm.base import BaseVLMAdapter + + +class OpenAIVLMAdapter(BaseVLMAdapter, OpenAIAdapter): + """VLM OpenAI (gpt-4o, gpt-4-turbo, gpt-4-vision-preview).""" + + @property + def name(self) -> str: + return "openai_vlm" + + +__all__ = ["OpenAIVLMAdapter"] diff --git a/picarones/app/__init__.py b/picarones/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..528c22baa4294bbcfa0eab43db7bec30c3d24880 --- /dev/null +++ b/picarones/app/__init__.py @@ -0,0 +1,27 @@ +"""Cercle 4 — Application services. + +Couche d'orchestration : reçoit des requêtes (DTO Pydantic) depuis +``interfaces/``, valide tout (chemins sandboxés, quotas, mode +public/dev), assemble adapters + pipeline + evaluation, retourne +des résultats sérialisables. + +C'est ici que les **6 P0 du S1** trouvent leur foyer définitif au +S19 : ``WorkspaceManager`` qui isole les chemins par session, +``BenchmarkService`` qui orchestre run + projections + persistance, +``RegistryService`` qui construit les registres explicitement. + +Sous-packages : + +- ``services/`` — un service par domaine fonctionnel + (BenchmarkService, CorpusService, ReportService, JobService, + RegistryService, WorkspaceManager). +- ``schemas/`` — DTO Pydantic pour API et CLI. **Séparés** des + modèles de domaine pour éviter le couplage transport ↔ métier. + +Règle d'import : peut importer domain/, evaluation/, pipeline/, +formats/, adapters/. Ne doit **jamais** importer interfaces/. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/app/results.py b/picarones/app/results.py new file mode 100644 index 0000000000000000000000000000000000000000..c3882ea13bf15b0d12ea9e55d6b999e40e3ab370 --- /dev/null +++ b/picarones/app/results.py @@ -0,0 +1,123 @@ +"""``RunResult`` et ``RunDocumentResult`` — agrégats applicatifs d'un run. + +Sprint A14-S17 (créé) / S26 (déplacé depuis ``domain/`` car +agrège des objets de ``evaluation/`` et ``pipeline/`` — la couche +``domain`` n'a pas le droit d'importer de ces couches plus +externes). + +Structure +--------- +Un ``RunResult`` est l'agrégat complet d'un run : + +:: + + RunResult + ├── manifest: RunManifest + └── document_results: tuple[RunDocumentResult, ...] + ├── document_id: str + ├── pipeline_results: tuple[PipelineResult, ...] + │ (un par pipeline du run) + └── view_results: tuple[ViewResult, ...] + (un par couple (vue, pipeline_éligible_à_la_vue)) + +Le ``RunResult`` est sérialisable JSON pour persistance +(typiquement éclaté en plusieurs fichiers : ``run_manifest.json``, +``pipeline_results.jsonl``, ``view_results.jsonl`` — cf. +``picarones.app.services.benchmark_service``). + +Anti-sur-ingénierie +------------------- +Pas d'agrégation pré-calculée (rang par vue, moyennes par +pipeline, etc.) dans le ``RunResult`` lui-même — c'est de la +**présentation**, pas du domain. Le rapport HTML (S22) calcule +ses agrégats à la volée depuis les ``ViewResult`` listés. +""" + +from __future__ import annotations + +from collections.abc import Callable +from pathlib import Path + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.run_manifest import RunManifest +from picarones.evaluation.views.base import ViewResult +from picarones.pipeline.types import PipelineResult + + +class RunDocumentResult(BaseModel): + """Tous les résultats d'un run pour un seul document. + + Agrège : + - Les ``PipelineResult`` (un par pipeline exécutée). Permet + de reconstituer ce qui a été produit (artefacts, durées, + erreurs). + - Les ``ViewResult`` (un par couple ``(view, pipeline)`` où le + pipeline a produit un artefact éligible à la vue). Les + pipelines OMIS d'une vue n'ont PAS de ``ViewResult`` pour + cette vue (pattern d'omission explicite — cf. AltoView S15). + + Le caller (typiquement le rapport HTML) reconstruit les + associations ``pipeline ↔ view_result`` via le champ + ``ViewResult.candidate_artifact_id`` qui pointe vers + ``Artifact.produced_by_step`` (lui-même corrélé au pipeline). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + document_id: str = Field(min_length=1, max_length=256) + pipeline_results: tuple[PipelineResult, ...] = Field(default_factory=tuple) + view_results: tuple[ViewResult, ...] = Field(default_factory=tuple) + + +class RunResult(BaseModel): + """Agrégat complet d'un run de benchmark. + + Sérialisable JSON. En pratique, persisté en plusieurs + fichiers (cf. ``BenchmarkService.persist``) pour permettre + une lecture sélective et un streaming jsonl. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + manifest: RunManifest + document_results: tuple[RunDocumentResult, ...] = Field(default_factory=tuple) + + @property + def n_documents(self) -> int: + return len(self.document_results) + + def view_results_for(self, view_name: str) -> tuple[ViewResult, ...]: + """Retourne tous les ``ViewResult`` du run pour une vue donnée. + + Utile pour l'agrégation par vue (rangs, moyennes) côté + rapport HTML. Préserve l'ordre d'apparition. + """ + out: list[ViewResult] = [] + for doc in self.document_results: + for vr in doc.view_results: + if vr.view_name == view_name: + out.append(vr) + return tuple(out) + + def pipeline_results_for(self, pipeline_name: str) -> tuple[PipelineResult, ...]: + """Retourne tous les ``PipelineResult`` d'un pipeline donné.""" + out: list[PipelineResult] = [] + for doc in self.document_results: + for pr in doc.pipeline_results: + if pr.pipeline_name == pipeline_name: + out.append(pr) + return tuple(out) + + +#: Type alias d'un renderer de rapport injecté par le caller. +#: +#: Signature canonique partagée par le ``RunOrchestrator`` (qui +#: l'invoque) et le ``JobRunner`` (qui le transmet). Reçoit +#: ``(run_result, output_path, lang)``, écrit le fichier et retourne +#: le ``Path`` effectivement écrit (généralement identique à +#: ``output_path``, mais le renderer peut changer l'extension). +ReportRenderer = Callable[["RunResult", Path, str], Path] + + +__all__ = ["ReportRenderer", "RunDocumentResult", "RunResult"] diff --git a/picarones/app/schemas/__init__.py b/picarones/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5aed651f1a0a42342a5033ad461054860f2cafa1 --- /dev/null +++ b/picarones/app/schemas/__init__.py @@ -0,0 +1,49 @@ +"""DTO de transport pour API web et CLI — Sprint S19. + +Schemas Pydantic strictement orientés "request/response". Ils ne +remontent jamais à un service métier — ce sont les frontières +entre HTTP/CLI et la logique applicative. + +Pattern : un endpoint reçoit un schema (validation Pydantic), +appelle un service avec les paramètres extraits + validés du +schema, retourne un autre schema. + +Exemple cible : + +.. code-block:: python + + # app/schemas/benchmark.py + class StartRunRequest(BaseModel): + corpus_path: str + pipelines: list[PipelineSpecDTO] + views: list[str] + normalization_profile: NormalizationProfileId + + # interfaces/web/routers/benchmark.py + @router.post("/api/runs") + def start_run(req: StartRunRequest) -> StartRunResponse: + run_id = benchmark_service.start_run(req.to_domain()) + return StartRunResponse(run_id=run_id) +""" + +from __future__ import annotations + +from picarones.app.schemas.run_spec import ( + CANONICAL_VIEW_NAMES, + PipelineSpecYaml, + RunSpec, + RunSpecLoadError, + StepSpec, + load_run_spec_from_yaml, + resolve_adapter_class, +) + +__all__ = [ + "CANONICAL_VIEW_NAMES", + "PipelineSpecYaml", + "RunSpec", + "RunSpecLoadError", + "StepSpec", + "load_run_spec_from_yaml", + "resolve_adapter_class", +] diff --git a/picarones/app/schemas/run_spec.py b/picarones/app/schemas/run_spec.py new file mode 100644 index 0000000000000000000000000000000000000000..24ee627bd192307001a219b01e4b24c13d3a18e5 --- /dev/null +++ b/picarones/app/schemas/run_spec.py @@ -0,0 +1,389 @@ +"""``RunSpec`` — déclaration YAML d'un run benchmark. + +Sprint A14-S24 / S39 du rewrite ciblé. + +Format qui décrit un run complet en YAML : corpus, pipelines +hétérogènes (potentiellement avec DAG branchant), vues canoniques à +appliquer, sortie HTML. Permet à l'utilisateur BnF de lancer un +benchmark via la CLI sans écrire de Python. + +Format +------ + +:: + + corpus_zip: ./bnf.zip # OU corpus_dir + corpus_dir: ./extracted/ # mutuellement exclusif + corpus_name: bnf_xviiie # optionnel (défaut : stem) + corpus_metadata: + language: fr + period: early_modern + + pipelines: + - name: ocr_then_correct + initial_inputs: [image] + # Sprint S39 : output symbolique préféré pour le texte. + # Référence un (step_id).(output_type) qui sera utilisé par + # les vues TextView / SearchView quand plusieurs steps + # produisent du RAW_TEXT. Optionnel. + preferred_text_output: corrector.corrected_text + steps: + - id: ocr + adapter_class: my_pkg.adapters.TesseractAdapter + adapter_kwargs: {lang: fra} + input_types: [image] + output_types: [raw_text] + - id: corrector + adapter_class: my_pkg.adapters.LLMCorrector + adapter_kwargs: {model: gpt-4o} + input_types: [raw_text] + output_types: [corrected_text] + # Sprint S39 : DAG branchant. Si plusieurs steps + # produisent le même type, on désigne explicitement la + # source. Sans inputs_from : dernier producteur. + inputs_from: + raw_text: ocr + + views: [text_final, searchability] # noms canoniques + + output_dir: ./runs/r1 + report_html: ./runs/r1/rapport.html # optionnel + report_lang: fr + code_version: "1.0.0-rewrite" + +Conventions +----------- +- ``corpus_zip`` ou ``corpus_dir`` est requis (pas les deux). +- ``views`` accepte uniquement les noms canoniques : + ``text_final``, ``alto_documentary``, ``searchability``. Le + caller qui veut des vues custom passe par l'API Python directe. +- ``adapter_class`` est un dotted path Python. La classe doit être + importable au moment du run (l'utilisateur installe ses propres + packages dans le venv courant). +- ``adapter_kwargs`` est passé tel quel au constructeur. +- ``inputs_from`` (S39) : map ``ArtifactType → step_id`` qui désigne + explicitement la source d'un input. ``__initial__`` désigne les + entrées initiales du runner. Sans ``inputs_from``, l'executor + prend le dernier producteur de chaque type. +- ``preferred_text_output`` (S39) : référence symbolique + ``step_id.output_type`` qui désigne quelle sortie de pipeline est + préférée pour les vues textuelles (utile quand plusieurs steps + produisent du RAW_TEXT ou du CORRECTED_TEXT). Optionnel. + +Anti-sur-ingénierie +------------------- +- Pas de templating Jinja2 dans le YAML (variables d'env, includes). + Si un caller veut composer plusieurs YAMLs, il les concatène en + Python. +- Pas de schéma JSON publié — pydantic est l'autorité. Le format + évoluera avec le rewrite ; la stabilité sera tagguée à la + livraison BnF. +- Pas de validation des dépendances de package — si la classe n'est + pas importable au runtime, on échoue lisiblement. +""" + +from __future__ import annotations + +import importlib +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, model_validator + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.errors import PicaronesError + + +#: Vues canoniques supportées par la CLI. +CANONICAL_VIEW_NAMES: frozenset[str] = frozenset({ + "text_final", + "alto_documentary", + "searchability", +}) + + +# ────────────────────────────────────────────────────────────────────── +# Schéma pydantic +# ────────────────────────────────────────────────────────────────────── + + +class StepSpec(BaseModel): + """Description d'un step de pipeline dans la spec YAML.""" + + model_config = ConfigDict(extra="forbid") + + id: str = Field(min_length=1, max_length=128) + adapter_class: str = Field( + min_length=1, max_length=512, + description="Dotted path Python vers la classe adapter.", + ) + adapter_kwargs: dict[str, Any] = Field(default_factory=dict) + input_types: tuple[ArtifactType, ...] = Field(...) + output_types: tuple[ArtifactType, ...] = Field(...) + inputs_from: dict[ArtifactType, str] = Field( + default_factory=dict, + description=( + "Sprint S39 — DAG branchant : map ``ArtifactType → step_id`` " + "qui désigne explicitement la source d'un input. " + "``__initial__`` pour les entrées initiales du runner. " + "Sans ``inputs_from``, l'executor prend le dernier producteur." + ), + ) + + +class PipelineSpecYaml(BaseModel): + """Description d'une pipeline dans la spec YAML.""" + + model_config = ConfigDict(extra="forbid") + + name: str = Field(min_length=1, max_length=128) + initial_inputs: tuple[ArtifactType, ...] = Field(...) + steps: tuple[StepSpec, ...] = Field(min_length=1) + preferred_text_output: str | None = Field( + default=None, + max_length=256, + description=( + "Sprint S39 — référence ``step_id.output_type`` qui désigne " + "quelle sortie de la pipeline est préférée pour les vues " + "textuelles (utile quand plusieurs steps produisent du " + "RAW_TEXT ou CORRECTED_TEXT). Format ``.`` " + "(ex : ``corrector.corrected_text``). Optionnel — sans, les " + "vues prennent la dernière sortie textuelle observée." + ), + ) + + @model_validator(mode="after") + def _validate_preferred_text_output(self) -> "PipelineSpecYaml": + """Vérifie que ``preferred_text_output`` (si défini) référence + un step existant dont les ``output_types`` contiennent le + type cité.""" + ref = self.preferred_text_output + if ref is None: + return self + if "." not in ref: + raise ValueError( + f"preferred_text_output {ref!r} : format attendu " + "``step_id.output_type`` (ex : ``corrector.corrected_text``).", + ) + step_id, _, output_type_value = ref.partition(".") + if not step_id or not output_type_value: + raise ValueError( + f"preferred_text_output {ref!r} : step_id ou output_type vide.", + ) + # Vérifier que le step existe. + target_step = next( + (s for s in self.steps if s.id == step_id), None, + ) + if target_step is None: + raise ValueError( + f"preferred_text_output {ref!r} : step " + f"{step_id!r} introuvable dans la pipeline " + f"{self.name!r}.", + ) + # Vérifier que le step produit bien ce type. + try: + output_enum = ArtifactType(output_type_value) + except ValueError as exc: + raise ValueError( + f"preferred_text_output {ref!r} : " + f"output_type {output_type_value!r} inconnu.", + ) from exc + if output_enum not in target_step.output_types: + raise ValueError( + f"preferred_text_output {ref!r} : step {step_id!r} " + f"ne produit pas {output_type_value!r} " + f"(produit : {[t.value for t in target_step.output_types]}).", + ) + return self + + @model_validator(mode="after") + def _validate_inputs_from(self) -> "PipelineSpecYaml": + """Vérifie que chaque ``inputs_from[type] = ref`` désigne soit + ``__initial__``, soit un step antérieur qui produit le type.""" + from picarones.domain.pipeline_spec import INITIAL_STEP_ID + + # Set des steps déjà vus pour vérifier l'antériorité. + seen_step_ids: set[str] = set() + # Map des outputs produits par chaque step (pour vérification + # des types). + outputs_by_step: dict[str, set[ArtifactType]] = {} + + for step in self.steps: + for input_type, source in step.inputs_from.items(): + if source == INITIAL_STEP_ID: + if input_type not in self.initial_inputs: + raise ValueError( + f"step {step.id!r} : inputs_from[{input_type.value!r}] " + f"= {INITIAL_STEP_ID!r} mais ce type n'est pas dans " + f"initial_inputs (= {[t.value for t in self.initial_inputs]}).", + ) + continue + if source not in seen_step_ids: + raise ValueError( + f"step {step.id!r} : inputs_from[{input_type.value!r}] " + f"= {source!r} ne désigne pas une étape antérieure " + f"connue (déjà vues : {sorted(seen_step_ids)}).", + ) + if input_type not in outputs_by_step.get(source, set()): + raise ValueError( + f"step {step.id!r} : inputs_from[{input_type.value!r}] " + f"= {source!r} mais cette étape ne produit pas ce type.", + ) + seen_step_ids.add(step.id) + outputs_by_step[step.id] = set(step.output_types) + return self + + +class RunSpec(BaseModel): + """Déclaration complète d'un run benchmark. + + Tous les chemins (``corpus_zip``, ``corpus_dir``, ``output_dir``, + ``report_html``) sont relatifs au répertoire courant au moment de + l'invocation CLI, ou absolus. Pas de résolution magique + (``$HOME``, env vars) — le caller passe ce qu'il veut voir. + """ + + model_config = ConfigDict(extra="forbid") + + corpus_zip: str | None = Field(default=None, max_length=2048) + corpus_dir: str | None = Field(default=None, max_length=2048) + corpus_name: str | None = Field(default=None, max_length=128) + corpus_metadata: dict[str, str] = Field(default_factory=dict) + + pipelines: tuple[PipelineSpecYaml, ...] = Field(min_length=1) + views: tuple[str, ...] = Field(min_length=1) + + output_dir: str = Field(min_length=1, max_length=2048) + report_html: str | None = Field(default=None, max_length=2048) + report_lang: str = Field(default="fr") + code_version: str = Field(default="0.0.0-unset", max_length=128) + + @model_validator(mode="after") + def _validate_corpus_source(self) -> "RunSpec": + if (self.corpus_zip is None) == (self.corpus_dir is None): + raise ValueError( + "RunSpec : il faut renseigner exactement l'un de " + "``corpus_zip`` ou ``corpus_dir`` (pas les deux, pas " + "aucun).", + ) + return self + + @model_validator(mode="after") + def _validate_views_are_canonical(self) -> "RunSpec": + unknown = [v for v in self.views if v not in CANONICAL_VIEW_NAMES] + if unknown: + raise ValueError( + f"RunSpec : vue(s) inconnue(s) {unknown!r}. " + f"Seules les vues canoniques sont supportées par la " + f"CLI : {sorted(CANONICAL_VIEW_NAMES)}.", + ) + return self + + @model_validator(mode="after") + def _validate_unique_pipeline_names(self) -> "RunSpec": + names = [p.name for p in self.pipelines] + if len(set(names)) != len(names): + raise ValueError( + f"RunSpec : noms de pipeline dupliqués dans {names!r}.", + ) + return self + + +# ────────────────────────────────────────────────────────────────────── +# Loader YAML + résolution dotted path +# ────────────────────────────────────────────────────────────────────── + + +class RunSpecLoadError(PicaronesError): + """Échec de chargement / validation d'une spec YAML.""" + + +def load_run_spec_from_yaml(yaml_text: str) -> RunSpec: + """Parse + valide une chaîne YAML. + + Raises + ------ + RunSpecLoadError + Si le YAML est mal formé, si pydantic rejette le schéma, ou + si une contrainte du model_validator échoue. + """ + import yaml + + try: + data = yaml.safe_load(yaml_text) + except yaml.YAMLError as exc: + raise RunSpecLoadError(f"YAML mal formé : {exc}") from exc + + if data is None: + raise RunSpecLoadError( + "RunSpec : YAML vide (attendu un mapping racine).", + ) + if not isinstance(data, dict): + raise RunSpecLoadError( + f"RunSpec : YAML racine doit être un mapping, reçu " + f"{type(data).__name__}.", + ) + + try: + return RunSpec.model_validate(data) + except Exception as exc: # noqa: BLE001 — re-typer en exception métier + raise RunSpecLoadError(f"RunSpec invalide : {exc}") from exc + + +def resolve_adapter_class(dotted_path: str) -> type: + """Importe et retourne la classe désignée par un dotted path. + + Format attendu : ``module.sub.ClassName``. ``module.sub:ClassName`` + accepté aussi (séparateur ``:`` style entry-point). + + Raises + ------ + RunSpecLoadError + Si le module est introuvable, si l'attribut n'existe pas, + ou si l'attribut n'est pas une classe instanciable. + """ + if not dotted_path or "." not in dotted_path and ":" not in dotted_path: + raise RunSpecLoadError( + f"adapter_class invalide : {dotted_path!r} — attendu " + f"``module.sub.ClassName`` ou ``module.sub:ClassName``.", + ) + if ":" in dotted_path: + module_path, _, class_name = dotted_path.rpartition(":") + else: + module_path, _, class_name = dotted_path.rpartition(".") + if not module_path or not class_name: + raise RunSpecLoadError( + f"adapter_class mal formé : {dotted_path!r}.", + ) + + try: + module = importlib.import_module(module_path) + except ImportError as exc: + raise RunSpecLoadError( + f"Module introuvable pour {dotted_path!r} : {exc}", + ) from exc + + try: + cls = getattr(module, class_name) + except AttributeError as exc: + raise RunSpecLoadError( + f"Attribut {class_name!r} absent du module " + f"{module_path!r}.", + ) from exc + + if not isinstance(cls, type): + raise RunSpecLoadError( + f"adapter_class {dotted_path!r} n'est pas une classe " + f"(c'est un {type(cls).__name__}).", + ) + return cls + + +__all__ = [ + "CANONICAL_VIEW_NAMES", + "PipelineSpecYaml", + "RunSpec", + "RunSpecLoadError", + "StepSpec", + "load_run_spec_from_yaml", + "resolve_adapter_class", +] diff --git a/picarones/app/services/__init__.py b/picarones/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c706311b7ab1c996b4276afdae08aca0db5bd5c2 --- /dev/null +++ b/picarones/app/services/__init__.py @@ -0,0 +1,79 @@ +"""Services applicatifs — couche ``app/`` du rewrite ciblé. + +Un service = une responsabilité fonctionnelle, testable sans +démarrer FastAPI. + +Services livrés +--------------- +- ``benchmark_service.py`` (S17) — orchestre ``CorpusRunner`` + + ``DefaultEvaluationViewExecutor`` + persistance JSONL. +- ``corpus_service.py`` (S20) — upload ZIP sandboxé + détection + des paires image / GT (``.gt.alto.xml``, ``.gt.txt``, etc.). +- ``path_security.py`` (S19) — ``WorkspaceManager`` (sandbox + par session) + helpers ``validated_path``, ``safe_report_name``, + ``validated_prompt_filename``. +- ``registry_service.py`` (S23) — bootstrap explicite du + ``MetricRegistry`` et du ``ProjectorRegistry`` au démarrage. +- ``report_service.py`` (S21) — rendu HTML autonome depuis un + ``RunResult``. + +Schemas (DTO de transport CLI/web) : voir ``picarones.app.schemas``. +Agrégats applicatifs (``RunResult``) : voir ``picarones.app.results``. +""" + +from __future__ import annotations + +from picarones.app.services.benchmark_service import ( + BenchmarkService, + ContextFactory, + GroundTruthFactory, + PipelineInputsFactory, +) +from picarones.app.services.corpus_service import ( + CorpusImportError, + CorpusImportReport, + CorpusService, +) +from picarones.app.services.job_runner import JobRunner +from picarones.app.services.path_security import ( + PathValidationError, + WorkspaceManager, + safe_report_name, + validated_path, + validated_prompt_filename, +) +from picarones.app.services.registry_service import ( + RegistriesBundle, + RegistryService, + bootstrap_default_registries, +) +from picarones.app.services.run_orchestrator import ( + OrchestrationResult, + RunOrchestrator, +) + +# Le rendu HTML vit dans la couche ``reports_v2/`` (cible documentée +# du rewrite — un rapport est un format de sortie, pas un service). +# Un caller qui veut juste générer un HTML l'importe directement +# depuis là. + +__all__ = [ + "BenchmarkService", + "ContextFactory", + "CorpusImportError", + "CorpusImportReport", + "CorpusService", + "GroundTruthFactory", + "JobRunner", + "OrchestrationResult", + "PathValidationError", + "PipelineInputsFactory", + "RegistriesBundle", + "RegistryService", + "RunOrchestrator", + "WorkspaceManager", + "bootstrap_default_registries", + "safe_report_name", + "validated_path", + "validated_prompt_filename", +] diff --git a/picarones/app/services/benchmark_service.py b/picarones/app/services/benchmark_service.py new file mode 100644 index 0000000000000000000000000000000000000000..f3cd55ba50917504db11b3fe20da7ffd69822974 --- /dev/null +++ b/picarones/app/services/benchmark_service.py @@ -0,0 +1,405 @@ +"""``BenchmarkService`` — orchestration runner + vues + persistance. + +Sprint A14-S17 du rewrite ciblé. + +Premier service applicatif du rewrite. Assemble : + +- ``CorpusRunner`` (S8) qui exécute N pipelines sur le corpus, +- ``DefaultEvaluationViewExecutor`` (S13) qui applique chaque vue + aux artefacts produits par les pipelines éligibles, +- ``RunManifest`` + ``RunResult`` (S17) pour la structure + d'agrégation, +- Persistance optionnelle sur disque en JSONL. + +Périmètre S17 (assumé minimal) +------------------------------ +- ``run(corpus, pipelines, views, ...)`` orchestre tout en + séquentiel pour une exécution simple. +- Pattern d'omission explicite : pour chaque (pipeline, view), si + les artefacts produits par le pipeline ne sont pas dans + ``view.candidate_types``, le pipeline est OMIS de cette vue + (pas de ``ViewResult`` factice). +- ``persist(result, output_dir)`` écrit 3 fichiers : + - ``run_manifest.json`` — métadonnées du run. + - ``pipeline_results.jsonl`` — un ``PipelineResult`` par ligne. + - ``view_results.jsonl`` — un ``ViewResult`` par ligne, avec + ``document_id`` ajouté pour reconnaître l'origine. + +Reportés au S19+ +---------------- +- WorkspaceManager pour isoler les chemins par session + (validation chemin, sandbox). +- Job queue / async / cancel via threading.Event. +- Cache d'artefacts entre runs. +- Recovery sur interruption. + +Le S17 livre la structure d'intégration complète mais utilisable +en mode "simple call" pour démontrer la définition de done. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path +from typing import Any, Callable, Iterable + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.corpus import CorpusSpec +from picarones.domain.documents import DocumentRef +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.run_manifest import RunManifest, utcnow +from picarones.app.results import RunDocumentResult, RunResult +from picarones.evaluation.views.base import ViewResult +from picarones.evaluation.views.executor import DefaultEvaluationViewExecutor +from picarones.pipeline.runner import CorpusRunner +from picarones.domain.pipeline_spec import PipelineSpec +from picarones.pipeline.types import PipelineResult, RunContext + +logger = logging.getLogger(__name__) + + +#: Factory qui produit l'artefact GT d'un document pour un type donné. +#: Le caller injecte cette factory pour découpler le service de la +#: manière dont les GT sont stockées (filesystem direct, dict in-memory, +#: GT lazy-loaded depuis IIIF, ...). +GroundTruthFactory = Callable[ + [DocumentRef, ArtifactType], + "Artifact | None", +] + +#: Factory qui produit les inputs initiaux d'un pipeline pour un doc +#: (typiquement ``{IMAGE: artifact_image}``). +PipelineInputsFactory = Callable[ + [DocumentRef], + dict[ArtifactType, Artifact], +] + +#: Factory qui produit le ``RunContext`` d'un doc pour un pipeline. +ContextFactory = Callable[[DocumentRef, str], RunContext] + + +class BenchmarkService: + """Orchestre l'exécution complète d'un benchmark. + + Parameters + ---------- + corpus_runner: + ``CorpusRunner`` injecté. Le service ne le crée pas lui-même + pour permettre au caller de configurer ``max_in_flight`` / + ``timeout_seconds_per_doc`` selon son contexte. + view_executor: + ``DefaultEvaluationViewExecutor`` injecté avec son propre + ``payload_loader``. Le service ne fournit pas de loader par + défaut. + code_version: + Version du code à inscrire dans le ``RunManifest``. + """ + + def __init__( + self, + corpus_runner: CorpusRunner, + view_executor: DefaultEvaluationViewExecutor, + code_version: str, + ) -> None: + self._runner = corpus_runner + self._view_executor = view_executor + self._code_version = code_version + + # ────────────────────────────────────────────────────────────────── + # Run + # ────────────────────────────────────────────────────────────────── + + def run( + self, + *, + corpus: CorpusSpec, + pipelines: Iterable[PipelineSpec], + views: Iterable[EvaluationView], + ground_truth_factory: GroundTruthFactory, + pipeline_inputs_factory: PipelineInputsFactory, + context_factory: ContextFactory, + run_id: str | None = None, + dependencies_lock: dict[str, str] | None = None, + adapter_kwargs: dict[str, dict[str, Any]] | None = None, + metadata: dict[str, str] | None = None, + ) -> RunResult: + """Exécute un benchmark complet et retourne le ``RunResult``. + + Pattern d'orchestration : + + 1. Pour chaque ``pipeline`` × chaque ``document`` du corpus : + - lance ``corpus_runner.run(spec, [doc], ...)``, + - récupère le ``PipelineResult``. + 2. Pour chaque ``view`` : + - pour chaque pipeline_result du doc, identifier les + artefacts produits dont le type est dans + ``view.candidate_types``, + - pour chaque artefact éligible, lancer + ``view_executor.evaluate(view, candidate, gt)`` où ``gt`` + est l'artefact GT du niveau correspondant (récupéré via + ``ground_truth_factory``), + - collecter les ``ViewResult`` produits. + 3. Construire ``RunManifest`` avec timestamps + version + lock. + 4. Construire ``RunResult`` avec un ``RunDocumentResult`` par + document. + """ + pipelines_list = list(pipelines) + views_list = list(views) + documents = list(corpus.documents) + + started_at = utcnow() + + # 1. Exécution séquentielle pipeline × document. + # On boucle pipeline-par-pipeline pour bénéficier de la + # backpressure du CorpusRunner sur les documents. + pipeline_results_by_doc: dict[str, list[PipelineResult]] = { + doc.id: [] for doc in documents + } + for spec in pipelines_list: + corpus_result = self._runner.run( + spec=spec, + documents=documents, + initial_inputs_factory=pipeline_inputs_factory, + context_factory=lambda d, _spec_name=spec.name: + context_factory(d, _spec_name), + corpus_name=corpus.name, + ) + for outcome in corpus_result.outcomes: + if outcome.pipeline_result is not None: + pipeline_results_by_doc[outcome.document_id].append( + outcome.pipeline_result, + ) + + # 2. Application des vues. + view_results_by_doc: dict[str, list[ViewResult]] = { + doc.id: [] for doc in documents + } + for doc in documents: + for vr in self._evaluate_document_in_views( + document=doc, + pipeline_results=pipeline_results_by_doc[doc.id], + views=views_list, + ground_truth_factory=ground_truth_factory, + ): + view_results_by_doc[doc.id].append(vr) + + # 3. Manifest. + completed_at = utcnow() + manifest = RunManifest( + run_id=run_id or _default_run_id(corpus.name, started_at), + corpus_name=corpus.name, + n_documents=len(documents), + pipeline_specs=tuple(pipelines_list), + adapter_kwargs=dict(adapter_kwargs or {}), + view_specs=tuple(views_list), + code_version=self._code_version, + started_at=started_at, + completed_at=completed_at, + dependencies_lock=dependencies_lock or {}, + metadata=metadata or {}, + ) + + # 4. RunResult. + document_results = tuple( + RunDocumentResult( + document_id=doc.id, + pipeline_results=tuple(pipeline_results_by_doc[doc.id]), + view_results=tuple(view_results_by_doc[doc.id]), + ) + for doc in documents + ) + + return RunResult( + manifest=manifest, + document_results=document_results, + ) + + # ────────────────────────────────────────────────────────────────── + # Persistance JSONL + # ────────────────────────────────────────────────────────────────── + + def persist( + self, + result: RunResult, + output_dir: Path | str, + ) -> dict[str, Path]: + """Persiste un ``RunResult`` en 4 fichiers dans ``output_dir``. + + Returns + ------- + dict[str, Path] + Map ``{kind: path}`` des fichiers écrits. Kinds : + ``"manifest"``, ``"pipeline_results"``, + ``"artifacts_index"``, ``"view_results"``. + + Sprint S41 — séparation ``artifacts_index.jsonl`` + ------------------------------------------------- + L'index d'artefacts est désormais persisté **séparément** des + ``pipeline_results.jsonl`` qui ne portait que les step_results. + Cohérent avec la cible documentée du rewrite : un + consommateur (rapport HTML, vue d'évaluation, audit de + reproductibilité) peut streamer l'index pour reconstruire la + provenance sans avoir à charger les pipeline_results entiers. + + Format ``artifacts_index.jsonl`` (une ligne par artefact) : + + :: + + {"document_id": "d1", "pipeline_name": "tess", + "id": "d1:tess:raw_text", "type": "raw_text", + "uri": "/tmp/.../d1.txt", + "content_hash": "...", "produced_by_step": "ocr", + "provenance": {"code_version": "...", ...}} + + Notes + ----- + Le format JSONL pour les results permet à un consommateur + (rapport HTML S22) de streamer la lecture sans charger tout + le run en RAM. Pour de gros corpus (1000+ docs × N pipelines + × M vues), c'est précieux. + """ + out_dir = Path(output_dir) + out_dir.mkdir(parents=True, exist_ok=True) + + manifest_path = out_dir / "run_manifest.json" + manifest_path.write_text( + result.manifest.model_dump_json(indent=2), + encoding="utf-8", + ) + + # S41 — On extrait l'index d'artefacts des pipeline_results + # avant de sérialiser ces derniers, pour que pipeline_results + # ne porte que les step_results et metadata d'exécution. + pipeline_path = out_dir / "pipeline_results.jsonl" + artifacts_index_path = out_dir / "artifacts_index.jsonl" + with ( + pipeline_path.open("w", encoding="utf-8") as f_pipe, + artifacts_index_path.open("w", encoding="utf-8") as f_idx, + ): + for doc_result in result.document_results: + for pr in doc_result.pipeline_results: + pr_payload = pr.model_dump(mode="json") + # Extraire les artefacts dans l'index séparé. + artifacts = pr_payload.pop("artifacts", []) or [] + for art in artifacts: + idx_record = { + "document_id": doc_result.document_id, + "pipeline_name": pr.pipeline_name, + **art, + } + f_idx.write( + json.dumps(idx_record, ensure_ascii=False) + "\n", + ) + pipeline_record = { + "document_id": doc_result.document_id, + **pr_payload, + } + f_pipe.write( + json.dumps( + pipeline_record, ensure_ascii=False, + ) + "\n", + ) + + view_path = out_dir / "view_results.jsonl" + with view_path.open("w", encoding="utf-8") as f: + for doc_result in result.document_results: + for vr in doc_result.view_results: + payload = { + "document_id": doc_result.document_id, + **vr.model_dump(mode="json"), + } + f.write(json.dumps(payload, ensure_ascii=False) + "\n") + + return { + "manifest": manifest_path, + "pipeline_results": pipeline_path, + "artifacts_index": artifacts_index_path, + "view_results": view_path, + } + + # ────────────────────────────────────────────────────────────────── + # Helpers internes + # ────────────────────────────────────────────────────────────────── + + def _evaluate_document_in_views( + self, + *, + document: DocumentRef, + pipeline_results: list[PipelineResult], + views: list[EvaluationView], + ground_truth_factory: GroundTruthFactory, + ) -> list[ViewResult]: + """Pour un document, applique chaque vue à chaque artefact + éligible (pattern d'omission explicite).""" + out: list[ViewResult] = [] + for view in views: + for pr in pipeline_results: + # Trouve les artefacts du pipeline_result éligibles à + # cette vue. Pattern d'omission : si aucun artefact + # éligible, le pipeline n'est PAS dans le ViewResult + # de cette vue. + eligible = [ + a for a in pr.artifacts + if view.accepts(a.type) + ] + if not eligible: + continue + # Pour chaque artefact éligible, on cherche la GT du + # type adapté. Un projecteur dans la vue peut + # transformer le type ; la GT doit correspondre au + # type cible APRÈS projection. + for cand in eligible: + gt = ground_truth_factory( + document, _gt_type_for_candidate(view, cand.type), + ) + if gt is None: + # Pas de GT disponible → omis silencieusement + # (le caller verra l'absence dans view_results). + continue + try: + vr = self._view_executor.evaluate( + view, cand, gt, + pipeline_name=pr.pipeline_name, + ) + except Exception as exc: # noqa: BLE001 + logger.warning( + "[benchmark_service] evaluate %s/%s/%s a " + "levé : %s", + view.name, document.id, cand.id, exc, + ) + continue + out.append(vr) + return out + + +def _gt_type_for_candidate( + view: EvaluationView, + candidate_type: ArtifactType, +) -> ArtifactType: + """Détermine le type de GT à charger pour évaluer un candidat + dans une vue donnée. + + Si la vue projette le candidat avant comparaison, la GT doit + être au type **cible** de la projection. Sinon, elle est au + type du candidat. + """ + projection = view.projection_for(candidate_type) + if projection is not None and not projection.is_identity: + return projection.target_type + return candidate_type + + +def _default_run_id(corpus_name: str, started_at) -> str: + """Construit un run_id par défaut filesystem-safe.""" + ts = started_at.strftime("%Y%m%dT%H%M%SZ") + safe_name = "".join(c if c.isalnum() or c in "_-" else "_" for c in corpus_name) + return f"{safe_name}_{ts}" + + +__all__ = [ + "BenchmarkService", + "GroundTruthFactory", + "PipelineInputsFactory", + "ContextFactory", +] diff --git a/picarones/app/services/corpus_service.py b/picarones/app/services/corpus_service.py new file mode 100644 index 0000000000000000000000000000000000000000..703adcea49ee13eced87bd2f0e9c863be714a301 --- /dev/null +++ b/picarones/app/services/corpus_service.py @@ -0,0 +1,541 @@ +"""``CorpusService`` — upload ZIP sandboxé + détection des paires image/GT. + +Sprint A14-S20 du rewrite ciblé. + +Le service applicatif qui prend en entrée un blob ZIP (uploadé par +le web ou la CLI) et produit un ``CorpusSpec`` immédiatement +consommable par le ``BenchmarkService`` (S17), avec : + +- **Extraction sandboxée** dans un sous-dossier d'un + ``WorkspaceManager`` (S19) — refus du path traversal, des symlinks, + et des zip bombs. +- **Détection des paires** image / GT par convention de nommage, + alignée sur l'historique (Sprint 32) : + + :: + + mon_doc.png + mon_doc.gt.txt + mon_doc.gt.alto.xml + mon_doc.gt.page.xml + mon_doc.gt.entities.json + mon_doc.gt.reading_order.json + + Toutes les GT partageant le **même stem** que l'image sont rattachées + au même ``DocumentRef``. + +- **Filtrage silencieux** des artefacts macOS / Windows (``__MACOSX/``, + ``._*``, ``.DS_Store``, ``Thumbs.db``) — bruit standard d'un ZIP + produit par un poste de travail patrimonial. + +- **Rapport** ``CorpusImportReport`` qui agrège warnings (image + sans GT, GT orpheline) et compte les entrées sautées — l'utilisateur + doit pouvoir vérifier visuellement que son corpus a été interprété + correctement. + +Anti-sur-ingénierie +------------------- +- Pas d'OCR à l'import. Le service ne lit pas les contenus, il + organise. +- Pas de validation de schéma ALTO/PAGE à l'import (c'est lourd). + Les fichiers sont juste catalogués ; la validation se fait à la + demande par les projecteurs/loaders. +- Pas de quotas par utilisateur ou rate-limiting (responsabilité + du caller web/CLI ; les paramètres ``max_*`` du constructeur sont + des plafonds défensifs absolus). +- Pas d'autodétection de format image (PNG vs JPEG vs TIFF) — on + reconnaît par extension. Si un attaquant met un EXE en ``.png``, + Pillow protégera plus tard (S21+ pour la web). +""" + +from __future__ import annotations + +import io +import logging +import re +import zipfile +from dataclasses import dataclass, field +from pathlib import Path + +from picarones.app.services.path_security import ( + WorkspaceManager, + safe_report_name, +) +from picarones.domain.artifacts import ArtifactType +from picarones.domain.corpus import CorpusSpec +from picarones.domain.documents import DocumentRef, GroundTruthRef +from picarones.domain.errors import PicaronesError + +logger = logging.getLogger(__name__) + + +class CorpusImportError(PicaronesError): + """Levée quand l'import ZIP échoue de manière irrécupérable. + + Cas typiques : + - Archive corrompue / non-ZIP. + - Path traversal détecté. + - Symlink détecté. + - Plafond de taille / nombre d'entrées dépassé (zip bomb). + """ + + +# ────────────────────────────────────────────────────────────────────── +# Conventions de nommage GT (alignées sur picarones/core/corpus.py +# Sprint 32, mais exprimées en ``ArtifactType`` pour le rewrite). +# ────────────────────────────────────────────────────────────────────── + +#: Suffixes de GT reconnus, dans l'ordre du plus spécifique au moins +#: spécifique (``.gt.alto.xml`` doit être testé AVANT ``.gt.txt`` qui +#: est une sous-chaîne moins discriminante). +_GT_SUFFIX_TO_TYPE: tuple[tuple[str, ArtifactType], ...] = ( + (".gt.alto.xml", ArtifactType.ALTO_XML), + (".gt.page.xml", ArtifactType.PAGE_XML), + (".gt.entities.json", ArtifactType.ENTITIES), + (".gt.reading_order.json", ArtifactType.READING_ORDER), + (".gt.txt", ArtifactType.RAW_TEXT), +) + +#: Extensions image reconnues (case-insensitive). L'absence de ``.gt.`` +#: dans le chemin est requise pour distinguer ``foo.png`` (image) d'un +#: éventuel ``foo.gt.alto.xml`` (qui ne match pas ces extensions, mais +#: par défense). +_IMAGE_EXTENSIONS: frozenset[str] = frozenset({ + ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".webp", ".bmp", +}) + +#: Patterns à ignorer silencieusement (artefacts OS). +_OS_NOISE_PATTERNS: tuple[re.Pattern[str], ...] = ( + re.compile(r"(^|/)__MACOSX(/|$)"), + re.compile(r"(^|/)\._[^/]*$"), + re.compile(r"(^|/)\.DS_Store$"), + re.compile(r"(^|/)Thumbs\.db$", re.IGNORECASE), +) + + +# ────────────────────────────────────────────────────────────────────── +# Rapport d'import +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class CorpusImportReport: + """Résultat lisible humainement d'un ``import_zip``. + + Attributs + --------- + spec: + Le ``CorpusSpec`` construit, prêt à être passé au + ``BenchmarkService``. + extracted_dir: + Chemin filesystem absolu du sous-dossier où le ZIP a été + extrait. Vit sous le ``WorkspaceManager.root``. + n_documents: + Nombre de documents avec au moins une image (= longueur de + ``spec.documents``). + n_images_without_gt: + Nombre d'images trouvées sans GT. Ces documents sont quand + même inclus dans le corpus (l'utilisateur peut juste vouloir + OCRiser, pas évaluer). + n_gt_without_image: + Nombre de GT orphelines (stem qui n'a pas d'image + correspondante). Loggées en warning et non rattachées — + ne participent pas au corpus. + n_skipped_noise: + Nombre d'entrées sautées silencieusement (artefacts OS). + warnings: + Messages humainement lisibles à présenter au caller (web + affiche dans une bannière, CLI affiche en stderr). + skipped_paths: + Liste des chemins (relatifs au root du ZIP) qui ont été + sautés ou non rattachés — utile au debug d'un import qui + a perdu des fichiers. + """ + + spec: CorpusSpec + extracted_dir: Path + n_documents: int + n_images_without_gt: int + n_gt_without_image: int + n_skipped_noise: int + warnings: tuple[str, ...] = field(default_factory=tuple) + skipped_paths: tuple[str, ...] = field(default_factory=tuple) + + +# ────────────────────────────────────────────────────────────────────── +# Service +# ────────────────────────────────────────────────────────────────────── + + +class CorpusService: + """Service d'import et d'analyse de structure d'un corpus. + + Parameters + ---------- + workspace: + ``WorkspaceManager`` dans lequel extraire le ZIP. Le service + crée un sous-dossier par import — plusieurs imports peuvent + coexister dans un même workspace. + max_zip_size_bytes: + Plafond sur la **taille du blob ZIP** lui-même (avant + extraction). Défaut 100 Mo. Le caller (web layer) doit + idéalement vérifier ça aussi en amont via + ``Content-Length``. + max_entry_count: + Plafond sur le nombre d'entrées dans le ZIP (anti-bombe par + nombre). Défaut 5000. + max_uncompressed_bytes: + Plafond sur la taille totale **décompressée** (anti-bombe + par expansion). Défaut 500 Mo. + """ + + def __init__( + self, + workspace: WorkspaceManager, + *, + max_zip_size_bytes: int = 100 * 1024 * 1024, + max_entry_count: int = 5000, + max_uncompressed_bytes: int = 500 * 1024 * 1024, + ) -> None: + self._workspace = workspace + self._max_zip_size = max_zip_size_bytes + self._max_entries = max_entry_count + self._max_uncompressed = max_uncompressed_bytes + + # ────────────────────────────────────────────────────────────────── + # API publique + # ────────────────────────────────────────────────────────────────── + + def import_zip( + self, + zip_bytes: bytes, + *, + corpus_name: str, + metadata: dict[str, str] | None = None, + ) -> CorpusImportReport: + """Extrait un ZIP et construit le ``CorpusSpec`` correspondant. + + Étapes : + + 1. Validation des plafonds (taille blob, nb entrées, + taille décompressée prévisible si dispo). + 2. Validation de chaque entrée (refus traversal, symlinks). + 3. Extraction sécurisée dans un sous-dossier dédié. + 4. Catalogage : détection images + GT + appariement par stem. + 5. Construction du ``CorpusSpec``. + + Le ``corpus_name`` est nettoyé via :func:`safe_report_name` + (le caller peut passer un nom utilisateur sans pré-validation). + """ + if len(zip_bytes) > self._max_zip_size: + raise CorpusImportError( + f"ZIP trop volumineux : {len(zip_bytes)} octets > " + f"plafond {self._max_zip_size}.", + ) + + safe_name = safe_report_name(corpus_name, max_length=64) + # Sous-dossier d'extraction unique pour cet import — permet + # plusieurs imports sans collision. + extract_dir = self._workspace.subpath(f"corpus_{safe_name}") + extract_dir.mkdir(parents=True, exist_ok=True) + + try: + zf = zipfile.ZipFile(io.BytesIO(zip_bytes)) + except zipfile.BadZipFile as exc: + raise CorpusImportError(f"Archive ZIP invalide : {exc}") from exc + + with zf: + self._validate_archive(zf) + extracted_files, n_noise = self._extract_safely(zf, extract_dir) + + spec, warnings, n_orphan_gt, n_no_gt, skipped_paths = ( + self._build_corpus_spec( + extracted_files=extracted_files, + corpus_name=safe_name, + extract_dir=extract_dir, + metadata=metadata or {}, + ) + ) + + return CorpusImportReport( + spec=spec, + extracted_dir=extract_dir, + n_documents=len(spec.documents), + n_images_without_gt=n_no_gt, + n_gt_without_image=n_orphan_gt, + n_skipped_noise=n_noise, + warnings=tuple(warnings), + skipped_paths=tuple(skipped_paths), + ) + + # ────────────────────────────────────────────────────────────────── + # Étape 1 : validation globale de l'archive + # ────────────────────────────────────────────────────────────────── + + def _validate_archive(self, zf: zipfile.ZipFile) -> None: + """Vérifie les plafonds globaux (entrées, taille décompressée).""" + infos = zf.infolist() + if len(infos) > self._max_entries: + raise CorpusImportError( + f"ZIP contient trop d'entrées : {len(infos)} > " + f"plafond {self._max_entries} (zip bomb suspectée).", + ) + total_uncompressed = sum(info.file_size for info in infos) + if total_uncompressed > self._max_uncompressed: + raise CorpusImportError( + f"ZIP décompressé trop volumineux : {total_uncompressed} " + f"octets > plafond {self._max_uncompressed} (zip bomb " + "suspectée).", + ) + + # ────────────────────────────────────────────────────────────────── + # Étape 2 + 3 : extraction sécurisée + # ────────────────────────────────────────────────────────────────── + + def _extract_safely( + self, + zf: zipfile.ZipFile, + extract_dir: Path, + ) -> tuple[list[tuple[str, Path]], int]: + """Extrait chaque fichier en validant son chemin cible. + + Returns + ------- + tuple[list[tuple[str, Path]], int] + ``(extracted_files, n_skipped_noise)`` — liste des paires + ``(relative_in_zip, absolute_on_disk)`` des fichiers + réellement extraits, et compte des entrées sautées car + artefact OS. + """ + out: list[tuple[str, Path]] = [] + n_noise = 0 + for info in zf.infolist(): + arc_name = info.filename + # Saut des répertoires nus. + if arc_name.endswith("/"): + continue + # Saut des artefacts OS (silencieux par design). + if _is_os_noise(arc_name): + n_noise += 1 + continue + # Refus des chemins absolus, traversals, octets nuls. + self._reject_unsafe_arcname(arc_name) + # Refus des symlinks (mode UNIX bit S_IFLNK = 0xA000). + unix_mode = (info.external_attr >> 16) & 0xF000 + if unix_mode == 0xA000: + raise CorpusImportError( + f"Symlink dans le ZIP refusé : {arc_name!r}.", + ) + + target = (extract_dir / arc_name).resolve() + # Garde-fou final : le path résolu doit rester sous extract_dir. + try: + target.relative_to(extract_dir.resolve()) + except ValueError as exc: + raise CorpusImportError( + f"Entrée ZIP {arc_name!r} sort du dossier " + f"d'extraction après résolution.", + ) from exc + + target.parent.mkdir(parents=True, exist_ok=True) + with zf.open(info) as src, target.open("wb") as dst: + while True: + chunk = src.read(64 * 1024) + if not chunk: + break + dst.write(chunk) + out.append((arc_name, target)) + return out, n_noise + + @staticmethod + def _reject_unsafe_arcname(arc_name: str) -> None: + if not arc_name: + raise CorpusImportError("Entrée ZIP au nom vide.") + if "\x00" in arc_name: + raise CorpusImportError( + f"Entrée ZIP avec octet nul dans le nom : {arc_name!r}.", + ) + # Refus chemin absolu (Unix ``/`` ou Windows ``C:\``). + if arc_name.startswith("/") or arc_name.startswith("\\"): + raise CorpusImportError( + f"Chemin absolu interdit dans le ZIP : {arc_name!r}.", + ) + if len(arc_name) >= 3 and arc_name[1] == ":" and arc_name[2] in ("/", "\\"): + raise CorpusImportError( + f"Chemin absolu Windows interdit dans le ZIP : " + f"{arc_name!r}.", + ) + # Refus des traversals (``..`` comme composant). + parts = arc_name.replace("\\", "/").split("/") + if any(p == ".." for p in parts): + raise CorpusImportError( + f"Traversal détecté dans le ZIP : {arc_name!r}.", + ) + + # ────────────────────────────────────────────────────────────────── + # Étape 4 + 5 : catalogage et construction de la spec + # ────────────────────────────────────────────────────────────────── + + def _build_corpus_spec( + self, + *, + extracted_files: list[tuple[str, Path]], + corpus_name: str, + extract_dir: Path, + metadata: dict[str, str], + ) -> tuple[CorpusSpec, list[str], int, int, list[str]]: + """Catalogue images et GT puis construit le ``CorpusSpec``. + + Returns + ------- + tuple[CorpusSpec, warnings, n_orphan_gt, n_no_gt, skipped_paths] + """ + images_by_stem: dict[str, Path] = {} + gts_by_stem: dict[str, dict[ArtifactType, Path]] = {} + skipped_paths: list[str] = [] + warnings_list: list[str] = [] + + for arc_name, abs_path in extracted_files: + # Conserver l'arc_name comme « chemin source » pour le doc + # id (relatif, lisible). L'image_uri / gt.uri sera l'absolu. + kind = _classify(arc_name) + if kind is None: + skipped_paths.append(arc_name) + continue + if isinstance(kind, ArtifactType): + # GT + stem = _strip_gt_suffix(arc_name, kind) + if stem is None: + skipped_paths.append(arc_name) + continue + gts_by_stem.setdefault(stem, {})[kind] = abs_path + else: + # Image + stem = _strip_image_extension(arc_name) + if stem in images_by_stem: + warnings_list.append( + f"Plusieurs images partagent le stem " + f"{stem!r} — première gardée, " + f"{arc_name!r} ignorée.", + ) + skipped_paths.append(arc_name) + continue + images_by_stem[stem] = abs_path + + # Appariement. + documents: list[DocumentRef] = [] + n_no_gt = 0 + for stem in sorted(images_by_stem): + image_path = images_by_stem[stem] + gts = gts_by_stem.pop(stem, {}) + if not gts: + n_no_gt += 1 + warnings_list.append( + f"Image {stem!r} sans GT — incluse mais non " + "évaluable.", + ) + ground_truths = tuple( + GroundTruthRef(type=art_type, uri=str(path)) + for art_type, path in sorted( + gts.items(), key=lambda kv: kv[0].value, + ) + ) + doc_id = _doc_id_from_stem(stem) + documents.append( + DocumentRef( + id=doc_id, + image_uri=str(image_path), + ground_truths=ground_truths, + ), + ) + + # GT orphelines (stems sans image correspondante). + n_orphan_gt = 0 + for stem, gts in gts_by_stem.items(): + for art_type in gts: + n_orphan_gt += 1 + warnings_list.append( + f"GT orpheline (pas d'image pour stem " + f"{stem!r}) : niveau {art_type.value!r}.", + ) + + spec = CorpusSpec( + name=corpus_name, + documents=tuple(documents), + metadata=metadata, + ) + return spec, warnings_list, n_orphan_gt, n_no_gt, skipped_paths + + +# ────────────────────────────────────────────────────────────────────── +# Helpers de classification +# ────────────────────────────────────────────────────────────────────── + + +def _is_os_noise(arc_name: str) -> bool: + return any(p.search(arc_name) for p in _OS_NOISE_PATTERNS) + + +def _classify(arc_name: str) -> ArtifactType | str | None: + """Classifie une entrée en ``ArtifactType`` (GT) ou ``"image"``. + + Returns + ------- + ArtifactType si GT reconnue, "image" si image reconnue, + None si non classifiable. + """ + lower = arc_name.lower() + for suffix, art_type in _GT_SUFFIX_TO_TYPE: + if lower.endswith(suffix): + return art_type + # On distingue les images : extension reconnue ET pas de ``.gt.``. + # (``foo.gt.png`` est conceptuellement pas une convention valide, + # mais on défend.) + if ".gt." in lower: + return None + for ext in _IMAGE_EXTENSIONS: + if lower.endswith(ext): + return "image" + return None + + +def _strip_gt_suffix(arc_name: str, art_type: ArtifactType) -> str | None: + """Retire le suffixe GT et retourne le stem. ``None`` si non match.""" + lower = arc_name.lower() + for suffix, t in _GT_SUFFIX_TO_TYPE: + if t is art_type and lower.endswith(suffix): + return arc_name[: len(arc_name) - len(suffix)] + return None + + +def _strip_image_extension(arc_name: str) -> str: + """Retire l'extension image (case-insensitive).""" + lower = arc_name.lower() + for ext in _IMAGE_EXTENSIONS: + if lower.endswith(ext): + return arc_name[: len(arc_name) - len(ext)] + return arc_name + + +_DOC_ID_INVALID_RE = re.compile(r"[^A-Za-z0-9_.\-/]") + + +def _doc_id_from_stem(stem: str) -> str: + """Convertit un stem (chemin relatif) en ``DocumentRef.id`` valide. + + Le validateur de ``DocumentRef`` exige + ``[A-Za-z0-9_.\\-/]+`` — on remplace tout caractère hors de cet + alphabet par ``_`` (typique : espaces, accents, parenthèses dans + des noms BnF). + """ + cleaned = _DOC_ID_INVALID_RE.sub("_", stem) + if not cleaned: + return "doc" + return cleaned + + +__all__ = [ + "CorpusImportError", + "CorpusImportReport", + "CorpusService", +] diff --git a/picarones/app/services/dependencies.py b/picarones/app/services/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..f15abfba20e087660eaccd4884af90ff22ff8671 --- /dev/null +++ b/picarones/app/services/dependencies.py @@ -0,0 +1,49 @@ +"""Capture du verrou des dépendances au moment d'un run. + +Le ``RunManifest`` documente la promesse *« à code_version + corpus + +specs + dependencies_lock identiques, ré-exécuter doit donner les +mêmes résultats »*. Ce module fournit la capture canonique du +``dependencies_lock``. + +Approche +-------- +``importlib.metadata.distributions()`` retourne tous les paquets +installés dans l'environnement Python courant — c'est l'API standard +Python (PEP 566) plutôt que d'invoquer ``pip freeze`` en sous-process. +Chaque ``Distribution`` fournit ``name`` + ``version`` ; on en fait +un dict ordonné par ``name`` minuscule pour le déterminisme du +manifest. + +Anti-sur-ingénierie +------------------- +- Pas de capture des hashes de wheel : si la BnF veut une preuve + d'intégrité supply-chain, elle utilise un lockfile Poetry/uv en + amont — on ne refait pas le travail. +- Pas de capture des binaires système (Tesseract version, libcuda, + fonts) : reporté à un sprint dédié si une ré-exécution échoue + pour cette raison. Le hash du wheel ``pytesseract`` capture déjà + la couche Python. +""" + +from __future__ import annotations + +from importlib.metadata import distributions + + +def capture_dependencies_lock() -> dict[str, str]: + """Retourne un dict ``{nom_package: version}`` trié par nom. + + Tri lexicographique sur ``name.lower()`` pour produire des + manifests bit-for-bit identiques à environnement constant + (l'ordre d'itération de ``distributions()`` n'est pas spécifié). + """ + lock: dict[str, str] = {} + for dist in distributions(): + name = dist.metadata["Name"] + version = dist.version + if name and version: + lock[name] = version + return dict(sorted(lock.items(), key=lambda kv: kv[0].lower())) + + +__all__ = ["capture_dependencies_lock"] diff --git a/picarones/app/services/job_runner.py b/picarones/app/services/job_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..3493ef5702415140cdca3d425ec018830a20b537 --- /dev/null +++ b/picarones/app/services/job_runner.py @@ -0,0 +1,267 @@ +"""``JobRunner`` — pont entre l'API web et le ``RunOrchestrator``. + +Le ``JobStore`` persiste l'état des jobs. L'API web déclenche +l'exécution via ``POST /api/jobs``. ``JobRunner`` orchestre le +cycle de vie complet : + +1. Crée un ``JobRecord`` dans le ``JobStore`` (status ``pending``). +2. Lance un **thread daemon** qui exécute l'orchestrator de façon + synchrone. +3. Met à jour le statut au fur et à mesure : ``running`` au démarrage, + ``complete`` ou ``error`` à la fin. +4. Si le caller annule via ``DELETE /api/jobs/{id}`` (qui appelle + ``store.mark_cancelled``), le thread l'observe au prochain check + et abandonne — le résultat partiel est discardé. + +Pourquoi un thread, pas asyncio +------------------------------- +``RunOrchestrator.execute`` est **synchrone** et utilise un +``ThreadPoolExecutor`` interne (``CorpusRunner``). Le wrapper avec +asyncio créerait du complexité gratuite (mix sync/async, GIL). +Un ``threading.Thread(daemon=True)`` est l'outil correct ici. + +Cancellation coopérative +------------------------ +Pour S48, la cancellation est **best-effort** : le thread vérifie +``store.get(job_id).status == "cancelled"`` AVANT et APRÈS l'appel +à ``orchestrator.execute``. Pendant l'exécution (potentiellement +plusieurs minutes), le thread ne peut pas interrompre l'orchestrator +sans support natif (cf. ``CorpusRunner.run(cancel_event=...)`` — +non encore propagé jusqu'à ``RunOrchestrator``). + +Conséquence : ``DELETE /api/jobs/{id}`` pendant que le thread tourne +marque le statut comme ``cancelled``, mais le benchmark continue et +son résultat est discardé à la fin. Une amélioration future +propagerait le ``cancel_event`` jusqu'au runner. + +Anti-sur-ingénierie +------------------- +- Pas de queue de jobs avec backpressure : un thread par submit. + Pour 100+ jobs simultanés, ajouter un ``ThreadPoolExecutor`` au + niveau du runner. +- Pas de retry automatique sur échec. +- Pas de notification SSE des changements de statut (le caller + poll ``GET /api/jobs/{id}``). +""" + +from __future__ import annotations + +import logging +import threading +import uuid +from pathlib import Path +from typing import Any, Callable + +from picarones.adapters.storage import JobStore +from picarones.app.results import ReportRenderer + +logger = logging.getLogger(__name__) + + +# Factory : un caller fournit un callable qui construit un +# ``RunOrchestrator`` lié à un ``output_dir`` donné. L'inversion +# évite à ce module d'importer ``RunOrchestrator`` directement +# (cycles potentiels) et permet aux tests d'injecter un mock. +OrchestratorFactory = Callable[[Path], Any] + + +class JobRunner: + """Lance des jobs de benchmark en arrière-plan. + + Parameters + ---------- + job_store: + ``JobStore`` partagé avec les endpoints de lecture + (``GET /api/jobs``, ``DELETE /api/jobs/{id}``). + orchestrator_factory: + Callable ``(output_dir: Path) -> RunOrchestrator`` qui + construit un orchestrator par job. Permet à chaque job + d'avoir son propre output_dir isolé. + report_renderer: + Optionnel — passé à ``orchestrator.execute()`` pour rendre + le rapport HTML. Si ``None``, pas de rapport produit. + + Notes + ----- + L'instance est thread-safe : ``submit`` est appelé depuis le + thread FastAPI, le thread daemon écrit dans ``JobStore`` qui + sérialise ses opérations SQLite. + """ + + def __init__( + self, + job_store: JobStore, + orchestrator_factory: OrchestratorFactory, + report_renderer: ReportRenderer | None = None, + ) -> None: + if not isinstance(job_store, JobStore): + raise TypeError("job_store doit être un JobStore.") + if not callable(orchestrator_factory): + raise TypeError("orchestrator_factory doit être callable.") + if report_renderer is not None and not callable(report_renderer): + raise TypeError("report_renderer doit être callable ou None.") + self._store = job_store + self._factory = orchestrator_factory + self._report_renderer = report_renderer + # Tracking des threads actifs — utile pour les tests qui + # attendent la fin d'un job soumis. + self._threads: dict[str, threading.Thread] = {} + + # ────────────────────────────────────────────────────────────────── + # API publique + # ────────────────────────────────────────────────────────────────── + + def submit( + self, + run_spec: Any, + output_dir: Path | str, + *, + job_id: str | None = None, + payload: dict | None = None, + ) -> str: + """Crée un job et lance son exécution en thread arrière-plan. + + Returns + ------- + str + ``job_id`` (généré si non fourni). Utilisable pour + interroger ``GET /api/jobs/{job_id}``. + + Notes + ----- + Idempotent uniquement si ``job_id`` est fourni explicitement + (sinon UUID4 garantit l'unicité). Si le ``job_id`` existe + déjà, ``JobStore.create`` lève ``JobStoreError``. + """ + job_id = job_id or uuid.uuid4().hex + out_path = Path(output_dir) + # ``payload`` est sérialisé en JSON dans le store — on stocke + # la version du run_spec pour traçabilité. + record_payload = dict(payload or {}) + record_payload.setdefault("output_dir", str(out_path)) + self._store.create(job_id, payload=record_payload) + + thread = threading.Thread( + target=self._run, + args=(job_id, run_spec, out_path), + daemon=True, + name=f"picarones-job-{job_id[:8]}", + ) + self._threads[job_id] = thread + thread.start() + logger.info("[job_runner] job %s soumis (thread démarré).", job_id) + return job_id + + def wait(self, job_id: str, timeout: float | None = None) -> bool: + """Attend la fin du thread d'un job (utile aux tests). + + Returns + ------- + bool + ``True`` si le thread est terminé, ``False`` si timeout. + """ + thread = self._threads.get(job_id) + if thread is None: + return True # job inconnu = considéré fini + thread.join(timeout=timeout) + return not thread.is_alive() + + # ────────────────────────────────────────────────────────────────── + # Worker thread + # ────────────────────────────────────────────────────────────────── + + def _run( + self, + job_id: str, + run_spec: Any, + output_dir: Path, + ) -> None: + """Logique exécutée dans le thread daemon. Capture toutes les + exceptions et les transcrit en statut ``error`` du store. + + Hooks de cancellation coopérative : + + - **Avant** ``orchestrator.execute()`` : si le statut a été + basculé en ``cancelled`` entre le ``submit`` et le démarrage + du thread, on saute l'exécution. + - **Après** ``orchestrator.execute()`` : si le statut a été + basculé en ``cancelled`` pendant l'exécution, on discarde + le résultat (le statut reste ``cancelled``). + + Sinon, statut final = ``complete`` ou ``error``. + """ + try: + self._run_unwrapped(job_id, run_spec, output_dir) + finally: + # ``_threads`` retient la référence pour ``wait(job_id)``. + # Quand le thread sort, on libère pour borner la mémoire + # même sur des serveurs longue durée. + self._threads.pop(job_id, None) + + def _run_unwrapped( + self, + job_id: str, + run_spec: Any, + output_dir: Path, + ) -> None: + """Implémentation séparée pour que ``_run`` puisse garantir le + cleanup en ``finally`` sans nesting profond.""" + # 1. Check pré-démarrage : annulé avant que le thread n'ait + # pris la main ? + rec = self._store.get(job_id) + if rec is None: + logger.warning( + "[job_runner] job %s introuvable au démarrage du " + "thread — abandon.", job_id, + ) + return + if rec.status == "cancelled": + logger.info( + "[job_runner] job %s annulé avant démarrage — skip.", + job_id, + ) + return + + # 2. Marquer en cours. + try: + self._store.mark_running(job_id) + except Exception as exc: # noqa: BLE001 + logger.error( + "[job_runner] échec mark_running sur %s : %s — abandon.", + job_id, exc, + ) + return + + # 3. Exécution effective. + try: + orchestrator = self._factory(output_dir) + result = orchestrator.execute( + run_spec, + report_renderer=self._report_renderer, + ) + except Exception as exc: # noqa: BLE001 + error_msg = f"{type(exc).__name__}: {exc}" + logger.error( + "[job_runner] job %s en échec : %s", + job_id, error_msg, + ) + self._store.mark_error(job_id, error_msg) + return + + # 4. Check post-exécution : annulé pendant que le run tournait ? + rec_after = self._store.get(job_id) + if rec_after is not None and rec_after.status == "cancelled": + logger.info( + "[job_runner] job %s annulé pendant l'exécution — " + "résultat discardé.", job_id, + ) + return + + # 5. Succès — output_path = chemin du manifest persisté. + manifest_path = result.persisted_files.get("manifest") + output_path_str = str(manifest_path) if manifest_path else "" + self._store.mark_complete(job_id, output_path=output_path_str) + logger.info("[job_runner] job %s terminé avec succès.", job_id) + + +__all__ = ["JobRunner", "OrchestratorFactory"] diff --git a/picarones/app/services/path_security.py b/picarones/app/services/path_security.py new file mode 100644 index 0000000000000000000000000000000000000000..9ca1a79355b83424e949c0c5fbf9b88a96c4c520 --- /dev/null +++ b/picarones/app/services/path_security.py @@ -0,0 +1,448 @@ +"""``WorkspaceManager`` + helpers de validation de chemin — Sprint A14-S19. + +Foyer définitif des helpers ``validated_path``, ``safe_report_name``, +``validated_prompt_filename`` créés au S1. Les anciens callers +(``picarones.web.security``) ré-importent depuis ce module. + +Pourquoi ici +------------ +La sécurité chemin n'est pas un détail web — c'est une garantie +applicative qui doit valoir aussi pour la CLI, les tests d'intégration, +les jobs background, et tout caller qui manipule des paths utilisateur. + +Le service ``WorkspaceManager`` centralise la création d'un dossier +isolé par session et garantit que toute écriture/lecture y reste +confinée — c'est ce qui permettra au ``BenchmarkService`` (S17) de +tourner sur un upload utilisateur sans risque de path traversal. + +Anti-sur-ingénierie +------------------- +- Pas d'auto-cleanup au garbage collector — le caller appelle + ``cleanup()`` explicitement (équivalent à + ``tempfile.TemporaryDirectory.cleanup``). Une session web peut + vouloir conserver les artefacts pour téléchargement ultérieur, c'est + son choix. +- Pas de quota disque — c'est une responsabilité OS-level + (cgroup, ulimit, quota fs). Le service ne se substitue pas. +- Pas de chiffrement at-rest — les fichiers sont en clair sous le + workspace. Si un institutionnel veut chiffrement, c'est au + niveau filesystem (LUKS, eCryptfs). +""" + +from __future__ import annotations + +import shutil +import uuid +from pathlib import Path + +from picarones.domain.errors import PicaronesError + + +class PathValidationError(PicaronesError, ValueError): + """Levée quand un chemin utilisateur sort de la zone autorisée. + + Hérite à la fois de :class:`PicaronesError` (convention métier + du nouveau code) et :class:`ValueError` (rétrocompat S1 — un + caller historique qui ``except ValueError`` continue de marcher). + """ + + +def validated_path( + user_path: str, + allowed_roots: list[Path], + must_exist: bool = False, + must_be_dir: bool = False, +) -> Path: + """Résout un chemin utilisateur et vérifie qu'il reste dans une racine + autorisée. + + Garde-fou central contre la traversée de répertoires (path traversal) + et l'écriture/lecture arbitraire dans le système de fichiers du + serveur. + + Algorithme : + + 1. Refuse les chemins vides ou contenant des octets nuls. + 2. Résout le chemin de manière absolue (``Path.resolve()``) — ça + écrase ``..``, les liens symboliques et les chemins relatifs. + 3. Vérifie que le résultat est ``.is_relative_to(root)`` pour au + moins une des ``allowed_roots`` (elles aussi pré-résolues). + 4. Optionnellement : vérifie l'existence et le type (dir). + + Parameters + ---------- + user_path: + Chemin tel que reçu de l'utilisateur (str). Peut être absolu + ou relatif. + allowed_roots: + Liste de répertoires racines (``Path``) au sein desquels le + chemin résolu doit se trouver. Liste vide = tout refuser. + must_exist: + Si ``True``, exige que le chemin résolu existe sur le disque. + must_be_dir: + Si ``True``, exige que le chemin résolu existe ET soit un + répertoire. Implique ``must_exist=True``. + + Returns + ------- + Path + Chemin résolu absolu, garanti dans une des racines autorisées. + + Raises + ------ + PathValidationError + Si le chemin est vide, contient un octet nul, sort des racines + autorisées, ou ne satisfait pas ``must_exist`` / ``must_be_dir``. + """ + if not user_path or not user_path.strip(): + raise PathValidationError("Chemin vide.") + if "\x00" in user_path: + raise PathValidationError("Chemin contient un octet nul.") + if not allowed_roots: + raise PathValidationError( + "Aucune racine autorisée — refus de toute requête de chemin." + ) + + try: + resolved = Path(user_path).expanduser().resolve() + except (OSError, RuntimeError) as exc: + raise PathValidationError(f"Chemin invalide : {exc}") from exc + + resolved_roots = [Path(r).expanduser().resolve() for r in allowed_roots] + if not any(_is_within(resolved, root) for root in resolved_roots): + raise PathValidationError( + f"Chemin hors zone autorisée : {user_path!r}. " + f"Racines acceptées : {[str(r) for r in resolved_roots]}." + ) + + if must_be_dir or must_exist: + if not resolved.exists(): + raise PathValidationError(f"Chemin inexistant : {user_path!r}.") + if must_be_dir and not resolved.is_dir(): + raise PathValidationError( + f"Chemin n'est pas un répertoire : {user_path!r}." + ) + + return resolved + + +def _is_within(child: Path, parent: Path) -> bool: + """Vrai si ``child`` est ``parent`` ou un descendant. + + ``Path.is_relative_to`` n'existe qu'à partir de Python 3.9 — on + utilise ``relative_to`` via try/except pour rester explicite sur + l'intention. + """ + try: + child.relative_to(parent) + return True + except ValueError: + return False + + +def validated_prompt_filename(name: str) -> str: + """Valide qu'un ``prompt_file`` est un simple nom de fichier sûr. + + Restreint la valeur reçue à un simple nom de fichier de la + bibliothèque de prompts intégrée (``picarones/prompts/``). Pas de + ``/``, pas de ``\\``, pas de ``..``, pas d'absolu. + + Le caller (web layer, CLI, etc.) est responsable d'appeler cette + fonction AVANT de transmettre la valeur au pipeline. + + Returns + ------- + str + Nom de fichier validé (basename uniquement). + + Raises + ------ + PathValidationError + Si la valeur contient un séparateur de chemin, un caractère de + contrôle, ou ressemble à un chemin absolu/relatif suspect. + """ + if not name: + raise PathValidationError("Nom de prompt vide.") + if "\x00" in name: + raise PathValidationError("Nom de prompt contient un octet nul.") + if any(c in name for c in ("/", "\\")): + raise PathValidationError( + f"Nom de prompt invalide (séparateur de chemin) : {name!r}. " + "Le caller n'accepte que les prompts de la bibliothèque " + "intégrée — fournir le simple nom de fichier." + ) + if name.startswith(".") or ".." in name: + raise PathValidationError( + f"Nom de prompt suspect : {name!r}. " + "Refus des préfixes ``.`` et des séquences ``..``." + ) + if any(ord(c) < 0x20 for c in name): + raise PathValidationError( + "Nom de prompt contient un caractère de contrôle." + ) + return name + + +def safe_report_name(name: str, max_length: int = 128) -> str: + """Sanitize un nom de rapport utilisateur en composant de chemin sûr. + + Refuse les séparateurs de chemin (``/``, ``\\``), les caractères + de contrôle, les octets nuls. Tronque à ``max_length``. Si la + chaîne devient vide après nettoyage, lève ``PathValidationError``. + + Cette fonction NE produit PAS un chemin — elle produit un nom + qu'un caller peut concaténer à un répertoire qu'il a déjà validé + avec ``validated_path`` (ou via ``WorkspaceManager.subpath``). + """ + if not name: + raise PathValidationError("Nom de rapport vide.") + if "\x00" in name: + raise PathValidationError("Nom de rapport contient un octet nul.") + bad = set("/\\") + cleaned = "".join( + c for c in name + if c not in bad and ord(c) >= 0x20 + ) + cleaned = cleaned.strip().strip(".") + if not cleaned: + raise PathValidationError( + f"Nom de rapport invalide après nettoyage : {name!r}." + ) + if cleaned in (".", "..", ""): + raise PathValidationError(f"Nom de rapport réservé : {name!r}.") + return cleaned[:max_length] + + +# ────────────────────────────────────────────────────────────────────── +# WorkspaceManager — sandbox par session +# ────────────────────────────────────────────────────────────────────── + + +class WorkspaceManager: + """Crée et gère un dossier isolé pour une session. + + Garanties + --------- + - Le workspace est unique par session (UUID4 par défaut, ou + ``session_id`` explicite). + - Toute lecture/écriture passe par :meth:`subpath` qui empêche la + traversée hors du root via :func:`validated_path`. + - :meth:`cleanup` supprime récursivement le dossier (irréversible + — le caller est responsable du moment d'appel). + + Parameters + ---------- + base_dir: + Répertoire parent dans lequel créer le workspace. Doit + exister ; un sous-dossier ```` y sera créé. + session_id: + Identifiant de session. ``None`` (défaut) génère un UUID4 + hexadécimal. Sinon, doit passer :func:`safe_report_name` + (refus des séparateurs et caractères de contrôle) — sinon + ``PathValidationError``. + + Raises + ------ + PathValidationError + Si ``base_dir`` n'existe pas, n'est pas un répertoire, ou + si ``session_id`` est invalide. + OSError + Si la création du sous-dossier échoue (permissions, etc.). + + Notes + ----- + Pas d'auto-cleanup au garbage collector. Le caller appelle + :meth:`cleanup` explicitement. Pour un usage RAII, utiliser + le pattern context manager (le service expose ``__enter__`` et + ``__exit__`` comme sucre). + """ + + def __init__( + self, + base_dir: Path | str, + session_id: str | None = None, + ) -> None: + base = Path(base_dir).expanduser() + if not base.exists(): + raise PathValidationError( + f"WorkspaceManager : base_dir inexistant : {base!r}.", + ) + if not base.is_dir(): + raise PathValidationError( + f"WorkspaceManager : base_dir n'est pas un répertoire : " + f"{base!r}.", + ) + # base_dir résolu (absolu, sans symlinks). + self._base = base.resolve() + + if session_id is None: + session_id = uuid.uuid4().hex + else: + # Validation stricte : un session_id est un identifiant — on + # le veut exact, pas silencieusement sanitizé. Refus net si + # contient un séparateur de chemin, ``..``, ou un caractère + # de contrôle. ``safe_report_name`` est ensuite utilisé + # pour les contraintes additionnelles (longueur). + if any(c in session_id for c in ("/", "\\")): + raise PathValidationError( + f"WorkspaceManager : session_id contient un " + f"séparateur de chemin : {session_id!r}.", + ) + if ".." in session_id: + raise PathValidationError( + f"WorkspaceManager : session_id contient ``..`` : " + f"{session_id!r}.", + ) + session_id = safe_report_name(session_id, max_length=64) + self._session_id = session_id + + # Création du sous-dossier. Si déjà présent, on accepte + # (idempotent) — le caller peut vouloir réutiliser une session + # interrompue. + self._root = (self._base / self._session_id).resolve() + # Vérification anti-collision de symlink : le root résolu doit + # rester dans base. + if not _is_within(self._root, self._base): + raise PathValidationError( + f"WorkspaceManager : root résolu {self._root!r} hors de " + f"base {self._base!r} — symlink suspect ?", + ) + self._root.mkdir(parents=True, exist_ok=True) + + @property + def root(self) -> Path: + """Chemin absolu du workspace, garanti existant.""" + return self._root + + @property + def session_id(self) -> str: + return self._session_id + + def subpath( + self, + relative_or_absolute: str | Path, + *, + must_exist: bool = False, + must_be_dir: bool = False, + ) -> Path: + """Résout un chemin et garantit qu'il reste dans le workspace. + + Accepte un chemin relatif (résolu sous ``root``) ou absolu (qui + doit être lui-même sous ``root``). Lève + :class:`PathValidationError` sinon — c'est l'API à utiliser + pour toute lecture/écriture déclenchée par une entrée + utilisateur. + + Parameters + ---------- + relative_or_absolute: + Chemin tel que fourni par le caller. Si relatif, on le + joint à ``root``. Si absolu, on vérifie qu'il est dans + ``root``. + must_exist: + Si ``True``, exige que le chemin existe. + must_be_dir: + Si ``True``, exige que le chemin existe ET soit un dir. + + Returns + ------- + Path + Chemin résolu absolu, garanti sous ``root``. + """ + # Refus explicite des entrées vides ou avec octet nul AVANT + # ``Path()`` qui les normalise silencieusement (``Path("")`` == + # ``Path(".")``, ce qui pointerait sur le root). + if isinstance(relative_or_absolute, str): + if not relative_or_absolute or not relative_or_absolute.strip(): + raise PathValidationError("Chemin vide.") + if "\x00" in relative_or_absolute: + raise PathValidationError("Chemin contient un octet nul.") + rel = Path(relative_or_absolute) + if rel.is_absolute(): + target_str = str(rel) + else: + target_str = str(self._root / rel) + return validated_path( + target_str, + allowed_roots=[self._root], + must_exist=must_exist, + must_be_dir=must_be_dir, + ) + + def safe_output_path(self, name: str, *, max_length: int = 128) -> Path: + """Combine :func:`safe_report_name` avec :meth:`subpath`. + + Pour produire un chemin de sortie depuis un nom utilisateur + sans séparateurs ni traversée. Le caller peut ensuite écrire + à ce chemin sans risque. + """ + sanitized = safe_report_name(name, max_length=max_length) + return self.subpath(sanitized) + + def cleanup(self) -> None: + """Supprime récursivement le workspace. + + Idempotent : si le dossier n'existe plus, no-op silencieux. + Après ``cleanup()``, toute opération sur ce manager est + non définie (créer un nouveau manager pour une nouvelle + session). + + Cross-OS robustesse + ~~~~~~~~~~~~~~~~~~~ + Sur Windows, ``shutil.rmtree`` peut lever ``PermissionError`` + si un fichier porte l'attribut ``read-only`` (cas typique : + ``__pycache__/*.pyc`` extraits depuis un ZIP). Le handler + ``_on_rmtree_error`` retire l'attribut puis retry. + + Sur certains filesystems (NFS, Windows avec + anti-virus / indexeur), un fichier peut rester verrouillé + quelques ms après sa fermeture. Le handler propose un seul + retry — au-delà, on laisse remonter l'erreur (signal d'un + problème environnemental réel, pas un cas dégénéré du + rewrite). + """ + if not self._root.exists(): + return + # Python 3.12+ utilise ``onexc`` (signature plus propre que + # l'ancien ``onerror``). On utilise ``onerror`` pour rester + # compatible 3.11+ ; ``shutil`` continuera de l'accepter + # jusqu'à la 3.14. + shutil.rmtree(self._root, onerror=_on_rmtree_error) + + # ────────────────────────────────────────────────────────────────── + # Context manager (sucre RAII) + # ────────────────────────────────────────────────────────────────── + + def __enter__(self) -> "WorkspaceManager": + return self + + def __exit__(self, exc_type, exc_val, exc_tb) -> None: + self.cleanup() + + +def _on_rmtree_error(func, path, exc_info): + """Handler pour ``shutil.rmtree`` Windows-safe. + + Cas typique : un fichier en read-only refuse d'être supprimé + sur Windows (``PermissionError``). On retire l'attribut puis + on retry une fois. Si ça échoue encore, on propage — c'est un + vrai problème environnemental. + """ + import os + import stat + try: + os.chmod(path, stat.S_IWRITE | stat.S_IREAD) + except OSError: + # Le chmod lui-même a échoué — on laisse la prochaine + # tentative remonter l'erreur originale. + pass + func(path) + + +__all__ = [ + "PathValidationError", + "WorkspaceManager", + "safe_report_name", + "validated_path", + "validated_prompt_filename", +] diff --git a/picarones/app/services/registry_service.py b/picarones/app/services/registry_service.py new file mode 100644 index 0000000000000000000000000000000000000000..7e1640c8c93c1d36594a4a82e36d90c0cb3342df --- /dev/null +++ b/picarones/app/services/registry_service.py @@ -0,0 +1,327 @@ +"""``RegistryService`` — bootstrap explicite des registres. + +Sprint A14-S23 du rewrite ciblé. + +Le service applicatif qui **construit** explicitement le +``MetricRegistry`` et le ``ProjectorRegistry`` au démarrage, en +remplacement de l'anti-pattern legacy ``import picarones.measurements +as _trigger`` (où l'import par effet de bord déclenchait +l'enregistrement via décorateurs au top-level d'un package, chargeant +des dizaines de modules optionnels au moment d'un simple +``import picarones``). + +Pourquoi explicite +------------------ +- **Pas de chargement transitif** : un test du domain n'a pas besoin + de `jiwer`, `numpy`, `scipy` parce qu'il importe quelque chose qui + importe quelque chose qui amorce un registre. +- **Failure mode lisible** : si une métrique optionnelle ne peut pas + être enregistrée (dépendance absente), on obtient une erreur + explicite au moment du bootstrap, pas une erreur runtime + trois layers plus loin. +- **Multi-instances** : un test peut construire SON registre, + enregistrer EXACTEMENT les métriques dont il a besoin, sans + partager d'état avec d'autres tests. +- **Inversion de dépendance** : les services consommateurs reçoivent + des registres injectés, ils ne les importent pas. + +Convention +---------- +- ``bootstrap_default_registries()`` retourne ``RegistriesBundle`` + (les deux registres pleinement peuplés). +- ``RegistryService(metrics, projectors)`` (constructeur) accepte + des registres pré-construits ou pré-bootstrappés. +- ``RegistryService.bootstrap_defaults()`` (classmethod) fait le + bootstrap + construit l'instance en un appel. + +Anti-sur-ingénierie +------------------- +- Pas de plugin discovery via entry_points (responsabilité + ``BACKLOG_POST_LIVRAISON``). +- Pas de versioning du contenu du registre. +- Pas de freeze technique — convention : un seul bootstrap au + démarrage, lecture seule depuis les services consommateurs. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.evaluation_spec import MetricSpec +from picarones.evaluation.metrics.alto_structural import ( + compute_alto_validity, + compute_line_count_ratio, + compute_word_box_coverage, +) +from picarones.evaluation.metrics.search import ( + numerical_sequence_preservation, + searchability_recall, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────── +# Bundle des deux registres +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class RegistriesBundle: + """Paquet de registres prêts à être injectés. + + Frozen pour signaler au caller que les références sont immuables + une fois bootstrapée — chaque registre individuel reste mutable + si on veut ajouter à la marge, mais le bundle ne re-pointe pas + ses champs. + """ + + metrics: MetricRegistry + projectors: ProjectorRegistry + + +# ────────────────────────────────────────────────────────────────────── +# Wrappers des métriques texte (jiwer optionnel) +# ────────────────────────────────────────────────────────────────────── + + +def _safe_jiwer(name: str): + """Retourne un wrapper qui appelle ``jiwer.{name}`` avec garde-fous + sur GT/hypothèse vides. ``jiwer`` est importé à la première + invocation — si absent, une ``RuntimeError`` claire est levée.""" + + def _wrapped(reference: str, hypothesis: str) -> float: + try: + import jiwer + except ImportError as exc: # pragma: no cover — jiwer est core + raise RuntimeError( + f"Métrique {name!r} indisponible : jiwer non installé. " + "Installer avec ``pip install jiwer``." + ) from exc + if not reference: + return 0.0 if not hypothesis else 1.0 + if not hypothesis: + return 1.0 + return float(getattr(jiwer, name)(reference, hypothesis)) + + _wrapped.__name__ = f"_safe_jiwer_{name}" + return _wrapped + + +# ────────────────────────────────────────────────────────────────────── +# Tables canoniques — ce qui est enregistré par défaut +# ────────────────────────────────────────────────────────────────────── + + +#: Métriques canoniques (RAW_TEXT, RAW_TEXT) — basées sur jiwer. +#: ``higher_is_better=False`` car ce sont des taux d'erreur. +_DEFAULT_TEXT_METRICS: tuple[tuple[MetricSpec, "callable"], ...] = ( + ( + MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description="Character Error Rate (jiwer).", + higher_is_better=False, + ), + _safe_jiwer("cer"), + ), + ( + MetricSpec( + name="wer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description="Word Error Rate (jiwer).", + higher_is_better=False, + ), + _safe_jiwer("wer"), + ), + ( + MetricSpec( + name="mer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description="Match Error Rate (jiwer).", + higher_is_better=False, + ), + _safe_jiwer("mer"), + ), + ( + MetricSpec( + name="wil", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description="Word Information Lost (jiwer).", + higher_is_better=False, + ), + _safe_jiwer("wil"), + ), +) + + +#: Métriques canoniques de recherche (RAW_TEXT, RAW_TEXT). Rappel +#: et préservation → ``higher_is_better=True``. +_DEFAULT_SEARCH_METRICS: tuple[tuple[MetricSpec, "callable"], ...] = ( + ( + MetricSpec( + name="searchability_recall", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description=( + "Rappel fuzzy : fraction des tokens GT retrouvés à " + "distance de Levenshtein ≤ 2 dans l'hypothèse." + ), + higher_is_better=True, + ), + searchability_recall, + ), + ( + MetricSpec( + name="numerical_sequence_preservation", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + description=( + "Fraction des années 4-chiffres de la GT préservées " + "strictement dans l'hypothèse." + ), + higher_is_better=True, + ), + numerical_sequence_preservation, + ), +) + + +#: Métriques canoniques structurelles (ALTO_XML, ALTO_XML). +_DEFAULT_ALTO_METRICS: tuple[tuple[MetricSpec, "callable"], ...] = ( + ( + MetricSpec( + name="alto_validity", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + description=( + "1.0 si l'ALTO hypothèse a au moins 1 page, 1 bloc " + "et 1 ligne ; 0.0 sinon." + ), + higher_is_better=True, + ), + compute_alto_validity, + ), + ( + MetricSpec( + name="alto_line_count_ratio", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + description=( + "min(n_hyp, n_ref) / max(n_hyp, n_ref) sur le nombre " + "de lignes ALTO. ∈ [0, 1]." + ), + higher_is_better=True, + ), + compute_line_count_ratio, + ), + ( + MetricSpec( + name="alto_word_box_coverage", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + description=( + "Fraction des ``String`` de l'hypothèse qui portent " + "une bbox non triviale. Mesure la qualité de la " + "détection spatiale." + ), + higher_is_better=True, + ), + compute_word_box_coverage, + ), +) + + +# ────────────────────────────────────────────────────────────────────── +# Service +# ────────────────────────────────────────────────────────────────────── + + +class RegistryService: + """Encapsule deux registres + accessors typés. + + Parameters + ---------- + metrics: + ``MetricRegistry`` (peut être vide ou pré-rempli). + projectors: + ``ProjectorRegistry`` (peut être vide ou pré-rempli). + """ + + def __init__( + self, + metrics: MetricRegistry, + projectors: ProjectorRegistry, + ) -> None: + if not isinstance(metrics, MetricRegistry): + raise TypeError("metrics doit être un MetricRegistry.") + if not isinstance(projectors, ProjectorRegistry): + raise TypeError("projectors doit être un ProjectorRegistry.") + self._metrics = metrics + self._projectors = projectors + + @property + def metrics(self) -> MetricRegistry: + return self._metrics + + @property + def projectors(self) -> ProjectorRegistry: + return self._projectors + + @property + def bundle(self) -> RegistriesBundle: + return RegistriesBundle( + metrics=self._metrics, projectors=self._projectors, + ) + + @classmethod + def bootstrap_defaults(cls) -> "RegistryService": + """Construit le service avec tous les registres canoniques. + + C'est l'entry point principal : un caller (CLI, web, test + d'intégration) appelle ``RegistryService.bootstrap_defaults()`` + au démarrage et injecte le résultat dans les services + consommateurs. + """ + bundle = bootstrap_default_registries() + return cls(bundle.metrics, bundle.projectors) + + +# ────────────────────────────────────────────────────────────────────── +# Bootstrap fonctionnel +# ────────────────────────────────────────────────────────────────────── + + +def bootstrap_default_registries() -> RegistriesBundle: + """Construit deux registres pleinement peuplés. + + Pas d'effet de bord : appeler la fonction crée une nouvelle + instance à chaque fois. Les anciens callers qui partageaient un + registre global doivent le maintenir eux-mêmes (ou réutiliser + la même instance ``RegistryService``). + """ + metrics = MetricRegistry() + for spec, func in ( + *_DEFAULT_TEXT_METRICS, + *_DEFAULT_SEARCH_METRICS, + *_DEFAULT_ALTO_METRICS, + ): + metrics.register(spec, func) + + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + return RegistriesBundle(metrics=metrics, projectors=projectors) + + +__all__ = [ + "RegistriesBundle", + "RegistryService", + "bootstrap_default_registries", +] diff --git a/picarones/app/services/run_orchestrator.py b/picarones/app/services/run_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..60a8d937fc6319b08f459e5fec70eb5f95b5d0b6 --- /dev/null +++ b/picarones/app/services/run_orchestrator.py @@ -0,0 +1,467 @@ +"""``RunOrchestrator`` — exécute un benchmark complet depuis un ``RunSpec``. + +Service applicatif qui assemble : + +- ``CorpusService`` (import du corpus depuis ZIP ou dir extrait), +- ``RegistryService`` (bootstrap des registres), +- ``BenchmarkService`` (orchestration runner + vues + persistance). + +Le rendu de rapport (HTML, JSON, CSV) est **injecté par le caller** +via le paramètre ``report_renderer`` — le service ``app/`` ne peut +pas importer ``reports_v2/`` car cette couche est plus externe +(``domain → … → app → reports_v2 → interfaces``). Cette inversion +de dépendance garantit que : + +- L'orchestrateur n'est pas couplé à un format de sortie spécifique. +- Une nouvelle couche de rapport (CSV, JSON) s'ajoute sans modifier + l'orchestrateur. +- L'ordre des couches reste inviolable (test d'architecture). + +Anti-bricolage +-------------- +Pas de fonction-helper privée éparpillée dans la CLI. L'interface +``picarones-rewrite run`` est désormais un thin wrapper Click qui +appelle ``RunOrchestrator.execute(spec, report_renderer=…)`` et +formate la sortie. + +Anti-sur-ingénierie +------------------- +- Pas de hooks d'extension (avant/après chaque étape) — quand un + caller en aura besoin, on ajoutera des callbacks explicites. +- Pas de logique de retry / cache / batching. Le runner sous-jacent + les gère déjà s'ils sont configurés. +- Le ``RunOrchestrator`` est sans état entre deux ``execute()`` — + on peut en créer un par invocation, c'est fait pour. +""" + +from __future__ import annotations + +import io +import zipfile +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, Callable + +from picarones.app.results import ReportRenderer, RunResult +from picarones.app.schemas import RunSpec, resolve_adapter_class +from picarones.app.services.benchmark_service import BenchmarkService +from picarones.app.services.dependencies import capture_dependencies_lock +from picarones.app.services.corpus_service import ( + CorpusImportError, + CorpusService, +) +from picarones.app.services.path_security import WorkspaceManager +from picarones.app.services.registry_service import RegistryService +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.corpus import CorpusSpec +from picarones.domain.documents import DocumentRef +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, + build_alto_view, + build_search_view, + build_text_view, +) +from picarones.formats.alto.parser import parse_alto +from picarones.pipeline import ( + CorpusRunner, + PipelineExecutor, + PipelineSpec, + PipelineStep, + RunContext, +) + + +# ────────────────────────────────────────────────────────────────────── +# Résultat structuré d'un run orchestré +# ────────────────────────────────────────────────────────────────────── + + + + +@dataclass(frozen=True) +class OrchestrationResult: + """Tout ce qu'un caller (CLI, HTTP, script) doit savoir d'un run. + + Attributs + --------- + run_result: + Le ``RunResult`` agrégé produit par le ``BenchmarkService``. + extracted_corpus_dir: + Chemin du dossier où le corpus a été extrait (sous le + workspace). + persisted_files: + Map ``{kind: path}`` des 3 fichiers persistés + (``run_manifest.json``, ``pipeline_results.jsonl``, + ``view_results.jsonl``). + report_path: + Chemin du rapport effectivement écrit par le + ``report_renderer`` injecté, ou ``None`` si aucun renderer + n'a été fourni ou si ``spec.report_html`` est vide. + """ + + run_result: RunResult + extracted_corpus_dir: Path + persisted_files: dict[str, Path] = field(default_factory=dict) + report_path: Path | None = None + + +# ────────────────────────────────────────────────────────────────────── +# Service +# ────────────────────────────────────────────────────────────────────── + + +class RunOrchestrator: + """Service applicatif qui exécute un benchmark complet depuis un + ``RunSpec``. + + Un orchestrateur est lié à un ``output_dir`` (où il créera le + workspace, le dossier d'extraction et les fichiers de résultats). + Il ne crée rien tant qu'on n'appelle pas :meth:`execute`. + + Parameters + ---------- + output_dir: + Répertoire racine de sortie. Créé s'il n'existe pas. + """ + + def __init__(self, output_dir: Path | str) -> None: + self._output_dir = Path(output_dir) + + # ────────────────────────────────────────────────────────────────── + # API publique + # ────────────────────────────────────────────────────────────────── + + def execute( + self, + spec: RunSpec, + *, + report_renderer: ReportRenderer | None = None, + ) -> OrchestrationResult: + """Exécute le run complet et retourne tout ce qu'on en sait. + + Parameters + ---------- + spec: + ``RunSpec`` validée (pydantic). + report_renderer: + Callable optionnel ``(run_result, output_path, lang) → + written_path`` qui rend le rapport. Si ``None`` (défaut) + OU si ``spec.report_html`` est vide, aucun rapport n'est + émis. L'inversion de dépendance évite à + ``app/services/`` d'importer ``reports_v2/`` (couche plus + externe — interdit par l'architecture). + + Raises + ------ + CorpusImportError + Si le corpus ne peut pas être chargé. + RunSpecLoadError + Si la résolution dotted-path d'un ``adapter_class`` + échoue. + """ + self._output_dir.mkdir(parents=True, exist_ok=True) + workspace = WorkspaceManager(self._output_dir) + + # 1. Corpus. + corpus_spec, extracted_dir = self._load_corpus(spec, workspace) + + # 2. Registres. + registries = RegistryService.bootstrap_defaults() + + # 3. Pipelines + resolver d'adapters + dump des kwargs pour le manifest. + pipeline_specs, adapter_resolver, adapter_kwargs = ( + self._build_pipelines(spec) + ) + + # 4. Vues canoniques. + views = self._build_views(spec.views) + + # 5. BenchmarkService. + bench = self._build_benchmark_service( + registries=registries, + adapter_resolver=adapter_resolver, + code_version=spec.code_version, + ) + + # 6. Capture du verrou de dépendances pour la reproductibilité. + deps_lock = capture_dependencies_lock() + + result = bench.run( + corpus=corpus_spec, + pipelines=pipeline_specs, + views=views, + ground_truth_factory=_default_gt_factory, + pipeline_inputs_factory=_default_inputs_factory, + context_factory=_make_context_factory(spec.code_version), + adapter_kwargs=adapter_kwargs, + dependencies_lock=deps_lock, + metadata={"orchestrator": "picarones.app.services.run_orchestrator"}, + ) + + # 6. Persistance JSONL. + persist_dir = self._output_dir / "results" + persisted = bench.persist(result, persist_dir) + + # 7. Rapport optionnel — délégué au renderer injecté. + # Inversion de dépendance : ``app/`` ne peut pas importer + # ``reports_v2/`` (plus externe). Le caller fournit un + # callable. + report_path: Path | None = None + if report_renderer is not None and spec.report_html: + target = Path(spec.report_html) + target.parent.mkdir(parents=True, exist_ok=True) + report_path = report_renderer(result, target, spec.report_lang) + + return OrchestrationResult( + run_result=result, + extracted_corpus_dir=extracted_dir, + persisted_files=persisted, + report_path=report_path, + ) + + # ────────────────────────────────────────────────────────────────── + # Étapes individuelles (publiques pour permettre la composition + # depuis un caller qui veut surcharger une étape). + # ────────────────────────────────────────────────────────────────── + + @staticmethod + def _load_corpus( + spec: RunSpec, workspace: WorkspaceManager, + ) -> tuple[CorpusSpec, Path]: + """Charge le corpus selon ``corpus_zip`` ou ``corpus_dir``.""" + corpus_service = CorpusService(workspace) + if spec.corpus_zip is not None: + zip_path = Path(spec.corpus_zip) + zip_bytes = zip_path.read_bytes() + report = corpus_service.import_zip( + zip_bytes, + corpus_name=spec.corpus_name or zip_path.stem, + metadata=spec.corpus_metadata, + ) + return report.spec, report.extracted_dir + + # corpus_dir : on zippe à la volée le contenu du dir et on + # délègue à ``CorpusService`` — réutilise toute la détection + # sans dupliquer la logique de classification image / GT. + assert spec.corpus_dir is not None # garanti par RunSpec validator + src_dir = Path(spec.corpus_dir) + if not src_dir.is_dir(): + raise CorpusImportError( + f"corpus_dir n'est pas un répertoire : {src_dir!r}.", + ) + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + for file_path in src_dir.rglob("*"): + if file_path.is_file(): + arc = file_path.relative_to(src_dir).as_posix() + zf.write(file_path, arcname=arc) + report = corpus_service.import_zip( + buf.getvalue(), + corpus_name=spec.corpus_name or src_dir.name, + metadata=spec.corpus_metadata, + ) + return report.spec, report.extracted_dir + + @staticmethod + def _build_pipelines( + spec: RunSpec, + ) -> tuple[ + list[PipelineSpec], + Callable[[str], Any], + dict[str, dict[str, Any]], + ]: + """Construit les ``PipelineSpec`` + un resolver d'adapters. + + Disambiguation des steps : + + - Deux steps avec la même ``(class, kwargs)`` partagent la + même instance d'adapter (cache). + - Deux steps avec la même ``id`` mais une ``class`` ou des + ``kwargs`` différents reçoivent des ``adapter_name`` + distincts (préfixés par le nom de pipeline). + + C'est essentiel pour le cas où plusieurs pipelines utilisent + la **même classe** avec des **kwargs différents** (ex : + ``PrecomputedTextAdapter`` instancié N fois avec + ``source_label`` distincts). + """ + instance_cache: dict[str, Any] = {} + registered: dict[str, tuple[type, str]] = {} + name_to_class: dict[str, type] = {} + name_to_kwargs: dict[str, dict[str, Any]] = {} + + pipeline_specs: list[PipelineSpec] = [] + for p in spec.pipelines: + steps: list[PipelineStep] = [] + for s in p.steps: + cls = resolve_adapter_class(s.adapter_class) + kwargs_sig = _kwargs_signature(s.adapter_kwargs) + adapter_name = s.id + existing = registered.get(adapter_name) + if existing is not None and existing != (cls, kwargs_sig): + adapter_name = f"{p.name}__{s.id}" + registered[adapter_name] = (cls, kwargs_sig) + name_to_class[adapter_name] = cls + name_to_kwargs[adapter_name] = s.adapter_kwargs + # ``inputs_from`` du StepSpec YAML doit être propagé au + # ``domain.PipelineSpec`` pour que le DAG branchant soit + # honoré ; sans ce passage, un DAG branchant déclaré dans + # le YAML serait silencieusement exécuté en linéaire. + steps.append(PipelineStep( + id=s.id, + kind="step", + adapter_name=adapter_name, + input_types=s.input_types, + output_types=s.output_types, + inputs_from=dict(s.inputs_from), + )) + pipeline_specs.append(PipelineSpec( + name=p.name, + initial_inputs=p.initial_inputs, + steps=tuple(steps), + )) + + def resolver(name: str) -> Any: + if name not in instance_cache: + cls = name_to_class[name] + kwargs = name_to_kwargs[name] + instance_cache[name] = cls(**kwargs) + return instance_cache[name] + + # Copie défensive — le manifest doit recevoir un snapshot + # immuable, pas la map vivante du resolver. + adapter_kwargs_dump = { + name: dict(kwargs) for name, kwargs in name_to_kwargs.items() + } + return pipeline_specs, resolver, adapter_kwargs_dump + + @staticmethod + def _build_views(view_names: tuple[str, ...]) -> list[Any]: + """Map noms canoniques → vues construites.""" + builders = { + "text_final": build_text_view, + "alto_documentary": build_alto_view, + "searchability": build_search_view, + } + return [builders[name]() for name in view_names] + + @staticmethod + def _build_benchmark_service( + *, + registries: RegistryService, + adapter_resolver: Callable[[str], Any], + code_version: str, + ) -> BenchmarkService: + """Assemble ``BenchmarkService`` avec un loader filesystem.""" + pipeline_executor = PipelineExecutor( + adapter_resolver=adapter_resolver, + ) + corpus_runner = CorpusRunner( + pipeline_executor, + max_in_flight=2, + timeout_seconds_per_doc=300.0, + poll_interval_seconds=0.05, + ) + view_executor = DefaultEvaluationViewExecutor.from_registries( + registries.metrics, + registries.projectors, + _filesystem_payload_loader, + ) + return BenchmarkService( + corpus_runner=corpus_runner, + view_executor=view_executor, + code_version=code_version, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Helpers privés (factories canoniques) +# ────────────────────────────────────────────────────────────────────── + + +def _kwargs_signature(kwargs: dict[str, Any]) -> str: + """Signature stable d'un dict de kwargs (ordre tri-stable).""" + return "|".join(f"{k}={kwargs[k]!r}" for k in sorted(kwargs)) + + +def _default_gt_factory( + doc: DocumentRef, art_type: ArtifactType, +) -> Artifact | None: + """Factory GT par défaut. + + Convention : un candidat ``CORRECTED_TEXT`` est comparé contre + la GT ``RAW_TEXT`` (les deux sont du texte plat — la distinction + de type ne porte que sur le côté candidat). Cas typique : un + pipeline OCR + post-correction LLM produit un ``CORRECTED_TEXT`` + qu'on compare au ``.gt.txt`` original. + """ + effective_type = ( + ArtifactType.RAW_TEXT + if art_type == ArtifactType.CORRECTED_TEXT + else art_type + ) + gt_ref = doc.gt_for(effective_type) + if gt_ref is None: + return None + return Artifact( + id=f"{doc.id}:gt:{effective_type.value}", + document_id=doc.id, + type=effective_type, + uri=gt_ref.uri, + ) + + +def _default_inputs_factory(doc: DocumentRef) -> dict[ArtifactType, Artifact]: + """``{IMAGE: artifact_image}``. Lève si ``doc.image_uri`` absent.""" + if doc.image_uri is None: + raise CorpusImportError( + f"Document {doc.id!r} sans ``image_uri`` — la pipeline " + "par défaut consomme une IMAGE en entrée.", + ) + return {ArtifactType.IMAGE: Artifact( + id=f"{doc.id}:image", + document_id=doc.id, + type=ArtifactType.IMAGE, + uri=doc.image_uri, + )} + + +def _make_context_factory( + code_version: str, +) -> Callable[[DocumentRef, str], RunContext]: + def _factory(doc: DocumentRef, pipeline_name: str) -> RunContext: + return RunContext( + document_id=doc.id, + code_version=code_version, + pipeline_name=pipeline_name, + ) + return _factory + + +def _filesystem_payload_loader(art: Artifact) -> Any: + """Loader filesystem : lit RAW_TEXT/CORRECTED_TEXT depuis le + fichier pointé par l'URI, parse ALTO_XML depuis le fichier pointé. + + Les artefacts projetés (sans URI) ne passent pas par ce loader — + l'executor utilise directement le payload retourné par le + projecteur. + """ + if art.uri is None: + raise FileNotFoundError( + f"Loader filesystem : artifact {art.id!r} sans URI ; " + "un projecteur aurait dû fournir le payload.", + ) + path = Path(art.uri) + if art.type == ArtifactType.ALTO_XML: + return parse_alto(path.read_bytes()) + if art.type in (ArtifactType.RAW_TEXT, ArtifactType.CORRECTED_TEXT): + return path.read_text(encoding="utf-8") + raise ValueError( + f"Loader filesystem : type {art.type.value!r} non géré.", + ) + + +__all__ = [ + "OrchestrationResult", + "RunOrchestrator", +] diff --git a/picarones/cli/_workflows.py b/picarones/cli/_workflows.py index 60057b0844db73592dbe07b4e87d80fc9dcdc9ba..f9b1211549c3ea024fe436c2d1aae793f9fa0a33 100644 --- a/picarones/cli/_workflows.py +++ b/picarones/cli/_workflows.py @@ -16,6 +16,38 @@ import click from picarones.cli import cli, _engine_from_name, _setup_logging + +def _validate_cer_threshold( + ctx: click.Context, param: click.Parameter, value: float | None, +) -> float | None: + """Callback Click qui valide ``--fail-if-cer-above`` à l'analyse. + + Sémantique : fraction ∈ [0, 1] (ex : 0.15 = 15 %), cohérent avec + ``BenchmarkResult.ranking()[i]["mean_cer"]`` qui est aussi en + fraction. + + Garde-fou migration : avant le fix de sémantique, le seuil était + interprété comme un pourcentage (15.0 = 15 %). Tout caller qui + passe encore une valeur > 1 vient de l'ancienne sémantique — on + échoue bruyamment plutôt que de muter silencieusement le + comportement (un seuil de 1500 % ne se déclencherait jamais et + l'utilisateur croirait que son CI est sain). + """ + if value is None: + return None + if value < 0: + raise click.BadParameter( + f"doit être ≥ 0, reçu {value}.", + ) + if value > 1.0: + raise click.BadParameter( + f"doit être une fraction ∈ [0, 1] (ex : 0.15 = 15 %), " + f"reçu {value}. Si vous utilisiez l'ancienne sémantique " + "pourcentage, divisez par 100 (ex : 15.0 → 0.15).", + ) + return value + + # --------------------------------------------------------------------------- # picarones run # --------------------------------------------------------------------------- @@ -54,7 +86,11 @@ from picarones.cli import cli, _engine_from_name, _setup_logging default=None, type=float, metavar="THRESHOLD", - help="Quitte avec code 1 si CER moyen > THRESHOLD (usage CI/CD)", + callback=_validate_cer_threshold, + help=( + "Quitte avec code 1 si CER moyen > THRESHOLD (usage CI/CD). " + "THRESHOLD est une fraction ∈ [0, 1] (ex : 0.15 = 15 %)." + ), ) @click.option( "--profile", @@ -86,6 +122,10 @@ def run_cmd( Le corpus doit être un dossier contenant des paires . + .gt.txt (vérité terrain). + + ``--fail-if-cer-above`` est validé à l'analyse Click (cf. + ``_validate_cer_threshold``) — une valeur invalide est rejetée + avant toute opération coûteuse. """ _setup_logging(verbose) @@ -139,13 +179,18 @@ def run_cmd( click.echo(f"\nRésultats écrits dans : {output}") - # Mode CI/CD : exit code non-zero si CER > seuil + # Mode CI/CD : exit code non-zero si CER > seuil. + # ``fail_if_cer_above`` est déjà validé en tête de fonction (∈ [0, 1]). if fail_if_cer_above is not None: for entry in result.ranking(): - if entry["mean_cer"] is not None and entry["mean_cer"] * 100 > fail_if_cer_above: + if ( + entry["mean_cer"] is not None + and entry["mean_cer"] > fail_if_cer_above + ): click.echo( - f"\nECHEC : {entry['engine']} CER={entry['mean_cer']*100:.2f}% " - f"> seuil {fail_if_cer_above:.2f}%", + f"\nECHEC : {entry['engine']} " + f"CER={entry['mean_cer']*100:.2f}% " + f"> seuil {fail_if_cer_above*100:.2f}%", err=True, ) sys.exit(1) diff --git a/picarones/core/metrics.py b/picarones/core/metrics.py index 4fc6d3089116b551e059a01e57205eaac0232338..97d8cf7dde6e592780dc7565eed74c2d610bf854 100644 --- a/picarones/core/metrics.py +++ b/picarones/core/metrics.py @@ -19,17 +19,30 @@ from typing import Optional @dataclass class MetricsResult: - """Ensemble des métriques calculées pour une paire (référence, hypothèse).""" - - cer: float - cer_nfc: float - cer_caseless: float - wer: float - wer_normalized: float - mer: float - wil: float - reference_length: int - hypothesis_length: int + """Ensemble des métriques calculées pour une paire (référence, hypothèse). + + Sprint A14-S1 — A.I.0 P0 : les champs CER/WER/MER/WIL sont + ``Optional[float]``. Auparavant, en cas d'erreur de calcul (jiwer + absent, exception levée), ces champs étaient remplis avec ``0.0``, + ce qui était indistinguable d'un score parfait pour tout + consommateur ne lisant pas systématiquement ``error``. Désormais + ils sont à ``None`` quand ``error`` est non-None — les agrégateurs + filtrent déjà sur ``error is None``, les rendus HTML utilisent + ``safe_round`` qui mappe ``None → 0.0`` à l'affichage seul, et un + accès direct sans vérification d'erreur lève désormais un + ``TypeError`` explicite plutôt que de retourner silencieusement + une valeur factice. + """ + + cer: Optional[float] = None + cer_nfc: Optional[float] = None + cer_caseless: Optional[float] = None + wer: Optional[float] = None + wer_normalized: Optional[float] = None + mer: Optional[float] = None + wil: Optional[float] = None + reference_length: int = 0 + hypothesis_length: int = 0 error: Optional[str] = None cer_diplomatic: Optional[float] = None """CER calculé après normalisation diplomatique (ſ=s, u=v, i=j…). @@ -39,14 +52,16 @@ class MetricsResult: """Nom du profil de normalisation diplomatique utilisé.""" def as_dict(self) -> dict: + def _round(v: Optional[float]) -> Optional[float]: + return None if v is None else round(v, 6) d = { - "cer": round(self.cer, 6), - "cer_nfc": round(self.cer_nfc, 6), - "cer_caseless": round(self.cer_caseless, 6), - "wer": round(self.wer, 6), - "wer_normalized": round(self.wer_normalized, 6), - "mer": round(self.mer, 6), - "wil": round(self.wil, 6), + "cer": _round(self.cer), + "cer_nfc": _round(self.cer_nfc), + "cer_caseless": _round(self.cer_caseless), + "wer": _round(self.wer), + "wer_normalized": _round(self.wer_normalized), + "mer": _round(self.mer), + "wil": _round(self.wil), "reference_length": self.reference_length, "hypothesis_length": self.hypothesis_length, "error": self.error, @@ -57,12 +72,12 @@ class MetricsResult: return d @property - def cer_percent(self) -> float: - return round(self.cer * 100, 2) + def cer_percent(self) -> Optional[float]: + return None if self.cer is None else round(self.cer * 100, 2) @property - def wer_percent(self) -> float: - return round(self.wer * 100, 2) + def wer_percent(self) -> Optional[float]: + return None if self.wer is None else round(self.wer * 100, 2) def aggregate_metrics(results: list[MetricsResult]) -> dict: @@ -95,7 +110,17 @@ def aggregate_metrics(results: list[MetricsResult]) -> dict: metric_names = ["cer", "cer_nfc", "cer_caseless", "wer", "wer_normalized", "mer", "wil"] aggregated: dict = {} for metric in metric_names: - values = [getattr(r, metric) for r in results if r.error is None] + # Sprint A14-S1 — défense en profondeur : double filtre. Un + # MetricsResult avec ``error`` doit avoir ses métriques à + # ``None`` (cf. compute_metrics), mais on filtre aussi les + # ``None`` directement au cas où un caller construirait un + # MetricsResult partiel. + values = [ + v for r in results + if r.error is None + for v in (getattr(r, metric),) + if v is not None + ] aggregated[metric] = _stats(values) # CER diplomatique (optionnel — présent seulement si calculé) diff --git a/picarones/core/results.py b/picarones/core/results.py index 20e4d01a7479f62a5cc7dcd7f93ec0519e57c01a..b50b5fdad6d4fb1ced6d092fe61e5956e3392d7d 100644 --- a/picarones/core/results.py +++ b/picarones/core/results.py @@ -160,35 +160,70 @@ class DocumentResult: d["readability_metrics"] = self.readability_metrics return d - def compact(self) -> None: + def compact( + self, + text_limit: Optional[int] = None, + drop_analyses: bool = False, + ) -> None: """Libère les champs lourds pour réduire l'empreinte mémoire. - Appelé après que les données ont été sérialisées dans le fichier - partiel et que les agrégations ont été calculées. Les champs - ``ground_truth`` et ``hypothesis`` sont tronqués et les analyses - détaillées (confusion, taxonomy…) sont supprimées. + Sprint A14-S1 — A.I.0 P0 : compaction désormais opt-in. + Auparavant, le runner appelait ``compact()`` sans paramètres + avant de sérialiser le JSON, ce qui amputait silencieusement + toutes les analyses per-document (confusion, taxonomy, + philological, searchability, etc.) et tronquait + ``ground_truth``/``hypothesis``/``ocr_intermediate`` à 200 + caractères. Le rapport HTML — qui consomme ce JSON — recevait + des données déjà mutilées, contredisant directement la + promesse "self-contained HTML report" du README. + + Désormais, l'appel par défaut ``compact()`` est un **no-op**. + Le caller doit explicitement demander la troncature et/ou la + suppression des analyses : + + - ``compact(text_limit=200)`` : tronque les textes à 200 chars. + - ``compact(drop_analyses=True)`` : supprime les dicts d'analyse. + - ``compact(text_limit=200, drop_analyses=True)`` : ancien + comportement, à utiliser en pipeline web pour un rendu + interactif léger uniquement. + + Le runner (``runner/orchestration.py``) ne compacte plus par + défaut ; le JSON exporté contient désormais toutes les + analyses détaillées. + + Parameters + ---------- + text_limit: + Si fourni (int > 0), tronque ``ground_truth``, + ``hypothesis`` et ``ocr_intermediate`` à cette longueur en + ajoutant "…". ``None`` (défaut) = pas de troncature. + drop_analyses: + Si ``True``, met à ``None`` toutes les analyses + per-document (confusion, taxonomy, philological…). Défaut : + ``False`` = on conserve toutes les analyses. """ - # Garder un extrait pour le rapport, libérer le texte complet - if len(self.ground_truth) > 200: - self.ground_truth = self.ground_truth[:200] + "…" - if len(self.hypothesis) > 200: - self.hypothesis = self.hypothesis[:200] + "…" - if self.ocr_intermediate and len(self.ocr_intermediate) > 200: - self.ocr_intermediate = self.ocr_intermediate[:200] + "…" - # Les analyses per-document ne sont plus nécessaires après agrégation - self.confusion_matrix = None - self.char_scores = None - self.taxonomy = None - self.structure = None - self.image_quality = None - self.line_metrics = None - self.hallucination_metrics = None - self.ner_metrics = None - self.calibration_metrics = None - self.philological_metrics = None - self.searchability_metrics = None - self.numerical_sequence_metrics = None - self.readability_metrics = None + if text_limit is not None and text_limit > 0: + if len(self.ground_truth) > text_limit: + self.ground_truth = self.ground_truth[:text_limit] + "…" + if len(self.hypothesis) > text_limit: + self.hypothesis = self.hypothesis[:text_limit] + "…" + if self.ocr_intermediate and len(self.ocr_intermediate) > text_limit: + self.ocr_intermediate = self.ocr_intermediate[:text_limit] + "…" + + if drop_analyses: + self.confusion_matrix = None + self.char_scores = None + self.taxonomy = None + self.structure = None + self.image_quality = None + self.line_metrics = None + self.hallucination_metrics = None + self.ner_metrics = None + self.calibration_metrics = None + self.philological_metrics = None + self.searchability_metrics = None + self.numerical_sequence_metrics = None + self.readability_metrics = None @dataclass @@ -468,19 +503,25 @@ class BenchmarkResult: entries: list[dict] = [] for report in self.engine_reports: - cers = [ + # ``Sprint A14-S1`` : ``MetricsResult.cer`` / ``.wer`` sont + # ``Optional[float]`` ; le double filtre ``error is None`` + # garantit ``cer/wer is not None`` par convention, mais on + # le filtre explicitement aussi pour que mypy le voie. + cers: list[float] = [ dr.metrics.cer for dr in report.document_results if dr.doc_id in doc_ids and dr.metrics is not None and dr.metrics.error is None + and dr.metrics.cer is not None ] - wers = [ + wers: list[float] = [ dr.metrics.wer for dr in report.document_results if dr.doc_id in doc_ids and dr.metrics is not None and dr.metrics.error is None + and dr.metrics.wer is not None ] failed = sum( 1 for dr in report.document_results diff --git a/picarones/domain/__init__.py b/picarones/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..45d721860626228fdb96ccdd97962ae7632b3a08 --- /dev/null +++ b/picarones/domain/__init__.py @@ -0,0 +1,105 @@ +"""Cercle 1 — Domain. + +Types purs et abstractions du modèle métier de Picarones. + +Ce cercle n'importe **que** la stdlib, ``pydantic`` et +``typing_extensions``. Il ne dépend d'aucun moteur OCR, d'aucune +métrique calculée, d'aucun rendu, d'aucune couche réseau. + +API publique (S4 + S5) +---------------------- + +S4 — modèle de base : + +- ``Artifact`` / ``ArtifactType`` / ``compute_content_hash`` — + toute sortie d'une étape de pipeline est un artefact traçable + (id, type, hash, provenance). +- ``DocumentRef`` / ``GroundTruthRef`` — référence à un document + du corpus + ses GT multi-niveaux. +- ``CorpusSpec`` — description immuable d'un corpus. +- ``ProvenanceRecord`` — empreinte (timestamp, code_version, + parameters_hash) attachée à chaque artefact. +- ``PicaronesError`` (et sous-classes) — racine de la hiérarchie + d'erreurs métier. + +S5 — contrats des vues d'évaluation : + +- ``MetricSpec`` — déclaration d'une métrique (signature de types). +- ``EvaluationView`` — déclaration d'une vue (sélecteur + projection + + métriques + dimensions ignorées). +- ``EvaluationSpec`` — container de N vues qu'un benchmark applique. +- ``ProjectionSpec`` — déclaration d'une projection entre types. + +À venir au Sprint S6 : + +- ``PipelineSpec`` / ``PipelineStep`` — DAG déclaratif d'une chaîne + de transformation documentaire. + +Règle d'or : si tu hésites à mettre quelque chose ici, c'est qu'il +ne devrait pas y être. Le domain ne fait presque rien. Il décrit. + +Voir ``docs/roadmap/rewrite-2026.md`` pour le plan complet. +""" + +from __future__ import annotations + +from picarones.domain.artifact_key import ArtifactKey +from picarones.domain.artifacts import Artifact, ArtifactType, compute_content_hash +from picarones.domain.corpus import CorpusSpec +from picarones.domain.documents import DocumentRef, GroundTruthRef +from picarones.domain.errors import ( + ArtifactValidationError, + CorpusSpecError, + PicaronesError, + ProjectionError, +) +from picarones.domain.evaluation_spec import ( + EvaluationSpec, + EvaluationView, + MetricSpec, +) +from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, +) +from picarones.domain.projection_spec import ProjectionSpec +from picarones.domain.provenance import ProvenanceRecord +from picarones.domain.run_manifest import RunManifest, utcnow + +# Note S26 — ``RunResult`` / ``RunDocumentResult`` ont été déplacés +# vers ``picarones.app.results`` car ils agrègent des objets de +# ``evaluation/`` et ``pipeline/`` (couches plus externes que +# ``domain``). Le domain reste pur — il ne décrit que des contrats. + +__all__ = [ + # S4 — Artifacts + "Artifact", + "ArtifactType", + "compute_content_hash", + # S29/S47 — ArtifactKey (clé canonique multi-paramètres pour cache) + "ArtifactKey", + # S4 — Corpus + documents + "CorpusSpec", + "DocumentRef", + "GroundTruthRef", + # S4 — Provenance + "ProvenanceRecord", + # S4 — Errors + "PicaronesError", + "ArtifactValidationError", + "CorpusSpecError", + "ProjectionError", + # S5 — Evaluation contracts + "MetricSpec", + "EvaluationView", + "EvaluationSpec", + "ProjectionSpec", + # S6 + S40 — Pipeline spec (canonique en domain/ depuis S40) + "PipelineSpec", + "PipelineStep", + "INITIAL_STEP_ID", + # S17 — Run manifest (pure domain ; RunResult vit dans app/) + "RunManifest", + "utcnow", +] diff --git a/picarones/domain/artifact_key.py b/picarones/domain/artifact_key.py new file mode 100644 index 0000000000000000000000000000000000000000..8cb3d6624b4fa3b910166b57a701952fd228104c --- /dev/null +++ b/picarones/domain/artifact_key.py @@ -0,0 +1,132 @@ +"""``ArtifactKey`` — Sprint A14-S29, migré dans ``domain/`` au S47. + +Le S29 livrait ``ArtifactKey`` dans ``picarones/adapters/storage/`` +avec le store qui le consomme. Au S47 (branchement du store dans +``PipelineExecutor``), on découvre que ``ArtifactKey`` est un type +**pur** (dataclass frozen, méthodes de sérialisation déterministe, +calcul de hash) — il appartient au cercle 1 (``domain/``). + +Migration : ``ArtifactKey`` vit désormais ici. +``picarones.adapters.storage.ArtifactKey`` reste exposé en re-export +(alias de chemin pur, pas un shim). + +Pourquoi cette migration +------------------------ +La couche ``pipeline/`` doit pouvoir calculer une clé pour interroger +le cache (cf. ``pipeline/cache_helpers.py``), mais ne peut pas +importer depuis ``adapters/`` (couche plus externe). L'inversion +de dépendance demandait un Protocol. Plus simple et plus correct : +constater que ``ArtifactKey`` est un type domaine et le placer dans +le bon cercle. + +``StoredArtifact``, ``ArtifactStore`` (ABC), ``InMemoryArtifactStore``, +``FilesystemArtifactStore`` restent dans ``adapters/storage/`` — ce +sont des infrastructures, pas des types purs. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import dataclass, field + + +@dataclass(frozen=True) +class ArtifactKey: + """Composition immuable de tous les paramètres qui déterminent + l'identité d'un artefact dans le store. + + Sérialisable JSON déterministe via ``to_canonical_json``. + + Attributes + ---------- + input_hashes: + Tuple ``((type, content_hash), ...)`` des inputs, trié par + type. ``None`` ou vide → la clé n'est pas calculable + (cas d'un input sans content_hash). + adapter_name: + ``step.adapter_name`` (ex : ``"tesseract"``, + ``"openai:gpt-4o"``). + adapter_version: + Version du modèle / binaire de l'adapter. ``None`` si + l'adapter ne sait pas la fournir (warning loggé une fois). + step_params: + Dict ``{name: scalar}`` du step, sérialisé en JSON canonique + (clés triées). + code_version: + Version du code Picarones (cf. ``RunContext.code_version``). + normalization_profile: + Profil de normalisation appliqué en aval (le cas échéant). + Pour les jonctions textuelles avec normalisation. + projection_name: + Nom du projecteur appliqué (le cas échéant). + projection_params: + Params du projecteur (le cas échéant). + metric_version: + Version du module de métriques (rare ; reporté à la phase + où on aura un versioning explicite des métriques). + + Notes + ----- + Frozen dataclass : aucune mutation possible. Le hash canonique + est calculé à la demande via ``hash_hex()``. + """ + + input_hashes: tuple[tuple[str, str], ...] = field(default_factory=tuple) + adapter_name: str = "" + adapter_version: str | None = None + step_params: dict[str, str | int | float | bool] = field(default_factory=dict) + code_version: str = "" + normalization_profile: str | None = None + projection_name: str | None = None + projection_params: dict[str, str | int | float | bool] = field( + default_factory=dict, + ) + metric_version: str | None = None + + def to_canonical_json(self) -> str: + """Sérialise la clé en JSON déterministe. + + - Clés du dict triées (``sort_keys=True``). + - ``ensure_ascii=False`` pour préserver l'Unicode brut. + - Séparateurs compacts pour minimiser les variations de + whitespace entre OS. + """ + # Trier les input_hashes par type pour déterminisme + # cross-platform (les Python du même version trient les + # tuples par leur premier élément, mais on l'explicite). + sorted_inputs = sorted(self.input_hashes) + payload = { + "inputs": sorted_inputs, + "adapter": self.adapter_name, + "adapter_version": self.adapter_version, + "step_params": self.step_params, + "code_version": self.code_version, + "normalization_profile": self.normalization_profile, + "projection_name": self.projection_name, + "projection_params": self.projection_params, + "metric_version": self.metric_version, + } + return json.dumps( + payload, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ) + + def hash_hex(self) -> str | None: + """Calcule la clé hex SHA-256 (64 chars). + + Retourne ``None`` si **un seul** ``input_hash`` est ``None`` + ou vide — convention « ne pas servir un résultat douteux ». + Les autres champs peuvent être ``None`` (ils sont sérialisés + comme ``null`` dans le JSON canonique → entrent dans le hash). + """ + for _, h in self.input_hashes: + if h is None or h == "": + return None + canonical = self.to_canonical_json() + return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + + +__all__ = ["ArtifactKey"] diff --git a/picarones/domain/artifacts.py b/picarones/domain/artifacts.py new file mode 100644 index 0000000000000000000000000000000000000000..31a341df3dcb64831401d3ab27de96c82cb7339d --- /dev/null +++ b/picarones/domain/artifacts.py @@ -0,0 +1,211 @@ +"""``Artifact`` et ``ArtifactType`` — Sprint A14-S4. + +Toute sortie d'une étape de pipeline est un **artefact traçable** : +identifiant stable, type explicite, hash du contenu, provenance. + +Différences avec ``picarones.core.modules.ArtifactType`` (Sprint 33) +------------------------------------------------------------------- +L'ancien ``ArtifactType`` historique a 6 valeurs : +``IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER``. Le nouveau +en a 9, avec deux distinctions importantes pour les vues d'évaluation +introduites aux Sprints S13-S18 : + +- **``RAW_TEXT`` vs ``CORRECTED_TEXT``** — un OCR brut et un texte + corrigé par un LLM ont la même structure (string) mais des contrats + différents : seul le second peut être projeté vers ``ALTO_XML`` + via reconstruction. Cette distinction permet à ``TextView`` de + comparer honnêtement les deux types dans la même vue tout en + signalant à l'utilisateur que la projection a un sens différent. +- **``ALTO_XML`` vs ``PAGE_XML`` vs ``CANONICAL_DOCUMENT``** — les + trois formats spatiaux sont conceptuellement distincts ; un + ``CANONICAL_DOCUMENT`` (markdown ou JSON canonique produit par un + VLM) n'a pas de coordonnées et ne peut pas être projeté vers + ``ALTO_XML`` sans étape de reconstruction. + +Anti-sur-ingénierie +------------------- +``Artifact`` ne porte que les champs nécessaires aux vues actuelles. +Champs reportés (à ajouter quand un caller en a concrètement besoin) : +``media_type``, ``cost``, ``latency``, ``warnings``, ``model_version``, +``parent_artifact_ids`` (DAG d'origine). +""" + +from __future__ import annotations + +import hashlib +import re +from enum import Enum + +from pydantic import BaseModel, ConfigDict, Field, field_validator + + +class ArtifactType(str, Enum): + """Type d'un artefact produit ou consommé par une étape de pipeline. + + Volontairement extensible : si une nouvelle vue (post-livraison) + nécessite un type supplémentaire (ex : ``LAYOUT_HEATMAP``), on + l'ajoute ici avec un commentaire indiquant la vue qui le + consomme. + + Convention de nommage : ``UPPER_SNAKE_CASE`` pour le nom Python, + ``lower_snake_case`` pour la valeur string sérialisée (utilisée + dans les YAML de pipeline et dans les exports JSON). + """ + + #: Image source (PNG, TIFF, JPEG). Entrée typique d'un OCR. + IMAGE = "image" + + #: Texte brut produit par un OCR (avant correction LLM). + RAW_TEXT = "raw_text" + + #: Texte corrigé par un LLM ou un module de post-correction. + #: Distinct de ``RAW_TEXT`` parce que les vues d'évaluation + #: doivent pouvoir signaler "ce texte a été modifié par un + #: modèle après l'OCR" (impact sur over-normalisation, + #: hallucination, fidélité philologique). + CORRECTED_TEXT = "corrected_text" + + #: ALTO XML 4.x avec lignes, mots, coordonnées, ordre de lecture. + ALTO_XML = "alto_xml" + + #: PAGE XML (PRIMA / Transkribus). + PAGE_XML = "page_xml" + + #: Représentation canonique structurée sans coordonnées. + #: Typique d'une sortie VLM (markdown, JSON canonique). Peut + #: être reconstruit en ALTO via un module dédié, mais n'a pas + #: nativement les coordonnées spatiales. + CANONICAL_DOCUMENT = "canonical_document" + + #: Liste d'entités nommées (PER, LOC, ORG, DATE, MISC...). + ENTITIES = "entities" + + #: Liste ordonnée d'IDs de régions documentaires définissant + #: l'ordre de lecture (essentiel pour les manuscrits glosés et + #: les journaux multi-colonnes). + READING_ORDER = "reading_order" + + #: Alignement entre deux artefacts (typiquement ``RAW_TEXT`` → + #: ``CORRECTED_TEXT`` produit par un module de post-correction + #: ou de remapping ALTO). Utilisé par ``HallucinationView`` et + #: ``error_absorption``. + ALIGNMENT = "alignment" + + #: Confidences OCR au niveau token. Sidecar JSON produit par les + #: adapters OCR qui exposent des scores natifs (Tesseract + #: image_to_data, Pero transcription_confidence, Mistral OCR API + #: confidences, Google Vision Word.confidence, Azure DI + #: Word.confidence). + #: + #: Schéma JSON : ``{"tokens": [{"text": str, "confidence": + #: float ∈ [0, 1]}], "extractor": str, "model_version": str | + #: null}``. Consommé par les vues de calibration (ECE/MCE, + #: reliability diagram). + CONFIDENCES = "confidences" + + +def compute_content_hash(payload: bytes) -> str: + """SHA-256 hex (64 chars) d'un payload binaire. + + Helper exposé au domain pour que les adapters puissent calculer + un hash compatible avec ``Artifact.content_hash`` sans dépendre + d'un détail d'implémentation. + """ + return hashlib.sha256(payload).hexdigest() + + +# Validation des identifiants. On veut un ``id`` stable et +# filesystem-safe (utilisable comme nom de fichier dans +# ``ArtifactStore``) sans imposer un format trop restrictif. +_ID_RE = re.compile(r"^[A-Za-z0-9_.\-:/]+$") + + +class Artifact(BaseModel): + """Une sortie traçable d'une étape de pipeline. + + Immuable (``frozen=True``) : un artefact ne change pas après + création. Pour produire un artefact "modifié", une étape produit + un nouvel ``Artifact`` distinct. + + Sérialisation déterministe : ``model_dump_json()`` produit les + mêmes octets pour le même contenu (champs Pydantic ordonnés). + Indispensable pour le cache d'artefacts. + + Attributs + --------- + id: + Identifiant unique de l'artefact dans le contexte d'un run. + Convention : ``"::"``, + mais le caller est libre du format tant que c'est unique + et que ``_ID_RE`` matche. + document_id: + ``DocumentRef.id`` du document auquel cet artefact appartient. + type: + Type de l'artefact (cf. ``ArtifactType``). + uri: + Chemin filesystem ou URI distant vers le contenu. ``None`` + si l'artefact est stocké inline (cas des petits artefacts + comme un texte court produit en mémoire). Le caller (typiquement + ``ArtifactStore``, S7) est responsable de la résolution. + content_hash: + SHA-256 hex (64 chars) du contenu. ``None`` autorisé seulement + pour les artefacts initiaux fournis par l'utilisateur (image, + GT) qui n'ont pas encore été lus. Une fois calculé, immuable. + produced_by_step: + Nom de l'étape de pipeline qui a produit l'artefact. ``None`` + pour les artefacts initiaux (entrées du pipeline, GT). + provenance: + ``ProvenanceRecord`` portant ``code_version`` et + ``parameters_hash``. ``None`` pour les artefacts initiaux. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str = Field(min_length=1, max_length=512) + document_id: str = Field(min_length=1, max_length=256) + type: ArtifactType + uri: str | None = Field(default=None, max_length=2048) + content_hash: str | None = Field(default=None, min_length=64, max_length=64) + produced_by_step: str | None = Field(default=None, max_length=256) + # ``provenance`` typé en str pour éviter import croisé pydantic + # avec ProvenanceRecord ; remplacé par le vrai type via __init__ + # plus bas. + provenance: "ProvenanceRecord | None" = Field(default=None) + + @field_validator("id", "document_id") + @classmethod + def _validate_filesystem_safe_id(cls, v: str) -> str: + if not _ID_RE.match(v): + from picarones.domain.errors import ArtifactValidationError + raise ArtifactValidationError( + f"id invalide : {v!r}. " + f"Doit matcher {_ID_RE.pattern!r} (alphanum + ``_.-:/``)." + ) + return v + + @field_validator("content_hash") + @classmethod + def _validate_hex_hash(cls, v: str | None) -> str | None: + if v is None: + return v + try: + int(v, 16) + except ValueError: + from picarones.domain.errors import ArtifactValidationError + raise ArtifactValidationError( + f"content_hash doit être hex SHA-256 64 chars : {v!r}" + ) + return v.lower() + + +# Forward reference pour ``provenance``. +from picarones.domain.provenance import ProvenanceRecord # noqa: E402 + +Artifact.model_rebuild() + + +__all__ = [ + "Artifact", + "ArtifactType", + "compute_content_hash", +] diff --git a/picarones/domain/corpus.py b/picarones/domain/corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..7b994f208556d6c4f539fe1733e4e6dc16692f91 --- /dev/null +++ b/picarones/domain/corpus.py @@ -0,0 +1,93 @@ +"""``CorpusSpec`` — Sprint A14-S4. + +Description **immuable et déclarative** d'un corpus à benchmarker. +Construit par un adapter de corpus (``picarones.adapters.corpus.*``), +consommé par les services applicatifs et le pipeline executor. + +Différence avec l'ancien ``picarones.core.corpus.Corpus`` : +``CorpusSpec`` est volontairement minimaliste — il décrit la +**structure** d'un corpus (liste de documents + métadonnées +contextuelles). La logique de chargement, parsing, détection des +patterns de nommage GT vit ailleurs (dans ``adapters/corpus/``, +puis ``app/services/corpus_service.py`` au S20). + +Au Sprint S10, un convertisseur ``CorpusSpec ↔ Corpus`` permettra +au nouveau code d'utiliser les fixtures historiques sans +réimplémentation. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from picarones.domain.documents import DocumentRef + + +class CorpusSpec(BaseModel): + """Description immuable d'un corpus à benchmarker. + + Attributs + --------- + name: + Nom court du corpus (utilisé dans les rapports, le cache, + les logs). Ex : ``"bnf_etat_civil_xviiie"``. + documents: + Liste ordonnée des ``DocumentRef``. L'ordre est respecté + par le runner (utile pour des comparaisons reproductibles). + Les ``id`` ne peuvent pas être dupliqués. + metadata: + Dictionnaire libre de contexte. Conventions actuelles : + + - ``"language"`` : ``"fr"`` ou ``"en"`` (utilisé par le delta + Flesch et les profils de normalisation). + - ``"period"`` : étiquette éditoriale (``"medieval"``, + ``"early_modern"``, ``"modern_archives"``). + - ``"source"`` : ``"local"``, ``"iiif"``, ``"htr_united"``, ... + + Pas de validation stricte sur les clés — les conventions + évolueront (cf. ``BACKLOG_POST_LIVRAISON.md``). + + Note méthodologique + ------------------- + Un ``CorpusSpec`` ne contient **pas** la racine du filesystem + (les ``DocumentRef.image_uri`` doivent être absolus ou résoluble + sans contexte). C'est volontaire : ça permet à un service + applicatif de réécrire les chemins (sandbox utilisateur, cache, + etc.) sans muter le ``CorpusSpec`` lui-même. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str = Field(min_length=1, max_length=128) + documents: tuple[DocumentRef, ...] = Field(default_factory=tuple) + metadata: dict[str, str] = Field(default_factory=dict) + + @field_validator("documents") + @classmethod + def _validate_unique_doc_ids( + cls, v: tuple[DocumentRef, ...], + ) -> tuple[DocumentRef, ...]: + seen: set[str] = set() + for doc in v: + if doc.id in seen: + from picarones.domain.errors import CorpusSpecError + raise CorpusSpecError( + f"document id dupliqué : {doc.id!r}. " + "Les id de DocumentRef doivent être uniques au sein " + "d'un CorpusSpec." + ) + seen.add(doc.id) + return v + + def __len__(self) -> int: + return len(self.documents) + + def doc_by_id(self, doc_id: str) -> DocumentRef | None: + """Retourne le ``DocumentRef`` correspondant ou ``None``.""" + for doc in self.documents: + if doc.id == doc_id: + return doc + return None + + +__all__ = ["CorpusSpec"] diff --git a/picarones/domain/documents.py b/picarones/domain/documents.py new file mode 100644 index 0000000000000000000000000000000000000000..a65929ad09db3cdee84edee75e7f2bef882e3535 --- /dev/null +++ b/picarones/domain/documents.py @@ -0,0 +1,137 @@ +"""``DocumentRef`` — Sprint A14-S4. + +Référence à un document du corpus, avec ses vérités terrain +multi-niveaux. Ne porte **pas** le contenu : juste les chemins/URIs +et les types. Le contenu est chargé à la demande par les adapters +de format (``picarones.formats.*``). + +Pourquoi pas une dataclass simple ? +----------------------------------- +On utilise pydantic pour la validation systématique : un caller qui +construit un ``DocumentRef`` avec une GT typée ``ALTO_XML`` mais +pointant vers un ``foo.txt`` doit échouer immédiatement, pas plus +tard dans le pipeline. + +Anti-sur-ingénierie +------------------- +On ne porte pas ici (à ajouter au cas par cas) : + +- ``language`` (vit dans ``CorpusSpec.metadata``). +- ``script_type`` (vit dans la stratification du runner, S15). +- ``image_quality`` (calculé par un adapter d'analyse, pas une + propriété du document de référence). +""" + +from __future__ import annotations + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from picarones.domain.artifacts import ArtifactType + +#: Identifiant de document : alphanum + ``_.-/`` (les ``/`` permettent +#: les hiérarchies type ``volA/folio_001``). Pas d'espaces, pas de +#: caractères de contrôle, pas d'octets nuls. +_DOC_ID_RE = re.compile(r"^[A-Za-z0-9_.\-/]+$") + + +class GroundTruthRef(BaseModel): + """Pointeur vers une vérité terrain pour un niveau donné. + + Distinct du contenu : on charge le fichier à la demande via + l'adapter de format approprié. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + type: ArtifactType + """Type de la GT (TEXT, ALTO_XML, PAGE_XML, ENTITIES, READING_ORDER).""" + + uri: str = Field(min_length=1, max_length=2048) + """Chemin filesystem (relatif ou absolu) ou URI distant.""" + + +class DocumentRef(BaseModel): + """Référence à un document du corpus. + + Immuable. Construit par un adapter de corpus + (``picarones.adapters.corpus.*``) lors du chargement, consommé + par le pipeline executor (``picarones.pipeline``). + + Attributs + --------- + id: + Identifiant unique du document dans le corpus. Convention + usuelle : nom de fichier sans extension (``"folio_001"``) + ou chemin relatif (``"volA/folio_001"``). + image_uri: + Chemin vers l'image source. ``None`` autorisé pour les + documents purement textuels (corpus déjà transcrit où + l'image n'est pas disponible). + ground_truths: + Liste des vérités terrain disponibles pour ce document, une + par niveau. La même clé ``type`` ne doit pas apparaître + deux fois (validé). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str = Field(min_length=1, max_length=256) + image_uri: str | None = Field(default=None, max_length=2048) + ground_truths: tuple[GroundTruthRef, ...] = Field(default_factory=tuple) + + @field_validator("id") + @classmethod + def _validate_doc_id(cls, v: str) -> str: + if not _DOC_ID_RE.match(v): + from picarones.domain.errors import CorpusSpecError + raise CorpusSpecError( + f"document id invalide : {v!r}. " + f"Doit matcher {_DOC_ID_RE.pattern!r}." + ) + # Défense en profondeur path-traversal : ``..`` comme segment + # de chemin permet d'écrire hors workspace via + # ``resolve_output_path``. Le seul rempart au niveau supérieur + # est l'extraction ZIP (zip-slip protection) — un caller qui + # construit ``DocumentRef(id="../../etc/passwd")`` + # programmatiquement contournait tout. + if ".." in v.split("/"): + from picarones.domain.errors import CorpusSpecError + raise CorpusSpecError( + f"document id contient un segment '..' : {v!r}. " + "Path traversal rejeté." + ) + return v + + @field_validator("ground_truths") + @classmethod + def _validate_unique_gt_types( + cls, v: tuple[GroundTruthRef, ...], + ) -> tuple[GroundTruthRef, ...]: + seen: set[ArtifactType] = set() + for gt in v: + if gt.type in seen: + from picarones.domain.errors import CorpusSpecError + raise CorpusSpecError( + f"GT dupliquée pour le type {gt.type.value!r}. " + "Un document ne peut avoir qu'une seule GT par niveau." + ) + seen.add(gt.type) + return v + + def gt_for(self, artifact_type: ArtifactType) -> GroundTruthRef | None: + """Retourne la GT du niveau demandé, ou ``None`` si absente.""" + for gt in self.ground_truths: + if gt.type == artifact_type: + return gt + return None + + @property + def available_gt_types(self) -> tuple[ArtifactType, ...]: + """Niveaux de GT disponibles pour ce document, dans l'ordre + d'insertion.""" + return tuple(gt.type for gt in self.ground_truths) + + +__all__ = ["DocumentRef", "GroundTruthRef"] diff --git a/picarones/domain/errors.py b/picarones/domain/errors.py new file mode 100644 index 0000000000000000000000000000000000000000..b68679335c462ba34b9a3cfefbe2ca17e968972a --- /dev/null +++ b/picarones/domain/errors.py @@ -0,0 +1,77 @@ +"""Exceptions du domaine — Sprint A14-S4. + +Hiérarchie centrée sur ``PicaronesError`` pour qu'un caller puisse +attraper "toute erreur métier Picarones" avec un seul ``except``. + +Règle d'or : ne JAMAIS attraper ``PicaronesError`` dans le code +métier sans le re-lever — c'est le rôle de la couche transport +(``app/services/`` puis ``interfaces/``) de mapper ces erreurs +vers HTTP 4xx / sortie CLI explicite. + +Volontairement plat (pas de hiérarchie profonde) : on ajoute des +sous-classes au cas par cas quand un caller a besoin de discriminer. +""" + +from __future__ import annotations + + +class PicaronesError(Exception): + """Racine de la hiérarchie d'erreurs métier de Picarones. + + Tout sous-package du nouveau code (``domain/``, ``evaluation/``, + ``pipeline/``, ``formats/``, ``adapters/``, ``app/``) doit lever + une sous-classe de ``PicaronesError`` plutôt qu'un ``Exception`` + générique ou un ``ValueError`` quand l'erreur a un sens métier. + + L'ancien code (``picarones.core``, ``picarones.measurements``, + etc.) garde son comportement actuel jusqu'à sa migration. + """ + + +class ArtifactValidationError(PicaronesError): + """Un artefact ne respecte pas les invariants de son type. + + Exemples : un ``Artifact`` typé ``ALTO_XML`` dont le ``content_hash`` + est absent ; un ``Artifact`` dont le ``produced_by_step`` référence + une étape qui n'existe pas dans la pipeline. + """ + + +class ProjectionError(PicaronesError): + """Un projecteur ne peut pas convertir l'artefact source. + + Levée typiquement par les projecteurs ALTO→texte / PAGE→texte + quand le XML d'entrée n'est pas parsable, n'a pas de TextLine, + ou que l'ordre de lecture est ambigu. + + Le caller (``EvaluationViewExecutor``) doit propager cette erreur + dans le ``ProjectionReport`` plutôt que de l'absorber silencieusement. + """ + + +class CorpusSpecError(PicaronesError): + """Le ``CorpusSpec`` est mal formé. + + Exemples : ``DocumentRef.id`` dupliqués, chemins relatifs + ambigus sans racine, GT déclarée pour un niveau non supporté. + """ + + +class AdapterStepError(PicaronesError): + """Racine commune des erreurs d'adapter (OCR / LLM / VLM). + + Permet à un caller (typiquement le ``PipelineExecutor``) de + catcher *« toute erreur d'adapter »* sans avoir à connaître la + sous-classe spécifique. Les sous-classes ``OCRAdapterError``, + ``LLMAdapterError``, ``VLMAdapterError`` héritent toutes de + ``AdapterStepError``. + """ + + +__all__ = [ + "PicaronesError", + "ArtifactValidationError", + "ProjectionError", + "CorpusSpecError", + "AdapterStepError", +] diff --git a/picarones/domain/evaluation_spec.py b/picarones/domain/evaluation_spec.py new file mode 100644 index 0000000000000000000000000000000000000000..8ab74a216061d8a154742cae40fda8b57b9fb91b --- /dev/null +++ b/picarones/domain/evaluation_spec.py @@ -0,0 +1,222 @@ +"""``MetricSpec``, ``EvaluationView``, ``EvaluationSpec`` — Sprint A14-S5. + +Cœur de la valeur ajoutée du rewrite : **comparer librement des +pipelines hétérogènes en projetant leurs sorties vers une vue +d'évaluation explicite**. L'utilisateur ne compare jamais +directement un OCR brut et une sortie ALTO reconstruite ; il +compare leur projection dans une vue commune (texte, ALTO, +recherchabilité, ...) et le rapport explicite ce que la vue +ignore. + +Trois couches de contrat : + +- ``MetricSpec`` — déclare une métrique (nom + signature de types). +- ``EvaluationView`` — déclare une vue (sélecteur de candidats + + projection optionnelle + liste de métriques + dimensions + ignorées). +- ``EvaluationSpec`` — container de N vues qu'un benchmark applique. + +Différence avec l'existant ``core/metric_registry.py:MetricSpec`` +----------------------------------------------------------------- +L'ancien ``MetricSpec`` (Sprint 34) porte un ``func: Callable``, +un singleton global ``_METRIC_REGISTRY``, et un décorateur +``@register_metric`` qui s'exécute par effet de bord d'import. +C'est exactement l'anti-pattern que le rewrite cherche à bannir +(cf. ``BACKLOG_POST_LIVRAISON.md`` §2.4 + tests d'architecture du +S3). + +Le nouveau ``MetricSpec`` est purement **déclaratif** : pas de +callable. L'association ``MetricSpec ↔ Callable`` se fait +explicitement dans ``picarones.evaluation.registry.MetricRegistry`` +qu'un service applicatif construit au démarrage (S20). + +Anti-sur-ingénierie +------------------- +Pas de validation cross-références à l'instanciation d'un +``EvaluationView`` (par exemple, on ne vérifie pas que les +``metric_names`` existent dans un registre). Cette validation +est faite au moment de l'exécution par ``EvaluationViewExecutor`` +(S13), avec un message d'erreur explicite si une métrique +référencée n'est pas enregistrée. Raison : un ``EvaluationView`` +est un objet déclaratif qu'on peut sérialiser dans un YAML sans +avoir besoin du registre runtime. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.projection_spec import ProjectionSpec + + +class MetricSpec(BaseModel): + """Description déclarative d'une métrique enregistrable. + + Attributs + --------- + name: + Identifiant unique dans un ``MetricRegistry``. + input_types: + Tuple ``(reference_type, hypothesis_type)`` indiquant la + signature attendue par la métrique. Le registre sélectionne + les métriques applicables à une jonction par cette signature. + description: + Phrase courte affichée dans le rapport et le glossaire. + higher_is_better: + ``True`` pour les métriques de qualité (F1, recall, accuracy), + ``False`` pour les métriques d'erreur (CER, WER). Utilisé + par les vues pour orienter la coloration et le tri. + tags: + Étiquettes libres pour grouper les métriques (``"text"``, + ``"structure"``, ``"icdar"``, ``"philological"``, ...). + + Contrairement à l'ancien ``core.metric_registry.MetricSpec``, + aucun ``func: Callable`` n'est porté ici — un ``MetricSpec`` + est purement déclaratif et peut être chargé depuis un YAML. + L'association nom → fonction est faite par ``MetricRegistry``. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str = Field(min_length=1, max_length=128) + input_types: tuple[ArtifactType, ArtifactType] + description: str = "" + higher_is_better: bool = False + tags: frozenset[str] = Field(default_factory=frozenset) + + +class EvaluationView(BaseModel): + """Une vue d'évaluation = une "lentille" pour comparer des pipelines. + + Une vue répond à une question précise : "lequel des pipelines + disponibles produit la meilleure sortie sous cet angle ?" + + Trois exemples canoniques (à implémenter S14-S16) : + + - ``TextView`` (text_final) — accepte RAW_TEXT, CORRECTED_TEXT, + ALTO_XML, PAGE_XML, projette tout vers RAW_TEXT, mesure CER/WER. + Ignore : géométrie, blocs, ordre spatial, validité ALTO. + - ``AltoView`` (alto_documentary) — exige ALTO_XML, mesure + validité, alignement lignes/mots, ordre de lecture. Ignore : + qualité linguistique pure. + - ``SearchView`` (searchability) — projette tout vers RAW_TEXT, + mesure recall fuzzy, séquences numériques préservées, noms + propres retrouvés. + + Attributs + --------- + name: + Identifiant lisible (``"text_final"``, ``"alto_documentary"``). + description: + Phrase d'introduction affichée dans le rapport. + candidate_types: + Set des ``ArtifactType`` qu'on accepte en entrée. Un pipeline + ne produisant aucun artefact dans ce set est **omis + explicitement** de la vue (pas de score factice). + projection: + Spec optionnelle de projection à appliquer aux candidats avant + évaluation. ``None`` = pas de projection (l'artefact est + comparé tel quel au GT). + normalization_profile: + Nom d'un profil de normalisation texte + (cf. ``picarones.formats.text.normalization``). ``None`` = + pas de normalisation (NFC implicite). + metric_names: + Liste ordonnée des métriques à calculer. Validées par + l'executor au runtime (le registre doit contenir chaque nom). + ignored_dimensions: + Liste de dimensions explicitement ignorées par cette vue. + Affiché dans le rapport pour signaler ce que la comparaison + ne dit PAS. Ex : ``("geometry", "block_structure", + "reading_order")`` pour TextView. + warnings: + Avertissement(s) méthodologique(s) à afficher en tête du + bloc de la vue dans le rapport. Ex : "Cette vue ignore la + qualité spatiale et documentaire." + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str = Field(min_length=1, max_length=128) + description: str = "" + candidate_types: frozenset[ArtifactType] = Field(...) + projection: ProjectionSpec | None = None + """Projection unique appliquée à TOUS les candidats avant + évaluation. ``None`` = pas de projection (artefact comparé + tel quel). Si ``projections_by_source_type`` est aussi + renseigné, ce champ sert de fallback pour les types non listés.""" + projections_by_source_type: dict[ArtifactType, ProjectionSpec] = Field( + default_factory=dict, + ) + """S14 — projection conditionnelle par type d'artefact source. + + Permet à une vue qui accepte plusieurs types (ex : ``TextView`` + qui accepte RAW_TEXT, ALTO_XML, PAGE_XML) d'utiliser un + projecteur différent par type sans avoir à dupliquer la vue. + + Convention de résolution dans ``DefaultEvaluationViewExecutor`` : + + 1. Si ``projections_by_source_type[candidate.type]`` existe : + utiliser cette projection. + 2. Sinon, si ``projection`` est défini ET son ``source_type`` + matche ``candidate.type`` : utiliser cette projection. + 3. Sinon : pas de projection (artefact comparé tel quel). + + Toutes les projections référencées doivent exister dans le + ``ProjectorRegistry`` au moment de l'exécution (validé runtime). + """ + normalization_profile: str | None = Field(default=None, max_length=128) + metric_names: tuple[str, ...] = Field(default_factory=tuple) + ignored_dimensions: tuple[str, ...] = Field(default_factory=tuple) + warnings: tuple[str, ...] = Field(default_factory=tuple) + + def accepts(self, artifact_type: ArtifactType) -> bool: + """Vrai si cette vue peut consommer un artefact du type donné.""" + return artifact_type in self.candidate_types + + def projection_for( + self, source_type: ArtifactType, + ) -> ProjectionSpec | None: + """Retourne la projection à appliquer pour un artefact source + de type ``source_type``, ou ``None`` si aucune projection n'est + applicable (artefact comparé tel quel). + + Convention de résolution : + + 1. ``projections_by_source_type[source_type]`` si présent. + 2. ``projection`` si son ``source_type`` matche. + 3. ``None``. + """ + if source_type in self.projections_by_source_type: + return self.projections_by_source_type[source_type] + if ( + self.projection is not None + and self.projection.source_type == source_type + ): + return self.projection + return None + + +class EvaluationSpec(BaseModel): + """Container de N ``EvaluationView`` qu'un benchmark applique. + + Un ``EvaluationSpec`` est versionné dans un YAML ; un service + applicatif (S19) le résout en runtime contre un ``MetricRegistry`` + instancié, et le ``EvaluationViewExecutor`` (S13) l'applique aux + artefacts produits par le pipeline executor. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + views: tuple[EvaluationView, ...] = Field(default_factory=tuple) + + def view_by_name(self, name: str) -> EvaluationView | None: + """Retourne la vue de nom ``name`` ou ``None``.""" + for v in self.views: + if v.name == name: + return v + return None + + +__all__ = ["MetricSpec", "EvaluationView", "EvaluationSpec"] diff --git a/picarones/domain/pipeline_spec.py b/picarones/domain/pipeline_spec.py new file mode 100644 index 0000000000000000000000000000000000000000..0c3de03c4b530a201f964e8d273bc234b4cdbcdb --- /dev/null +++ b/picarones/domain/pipeline_spec.py @@ -0,0 +1,180 @@ +"""``PipelineStep`` et ``PipelineSpec`` — Sprints A14-S6 / S40. + +Description **purement déclarative** d'un DAG de transformation +documentaire. Sérialisable en YAML, versionnable en git, valide +sans avoir besoin d'instancier les modules concrets. + +Sprint S40 — migration depuis ``picarones.pipeline.spec`` +--------------------------------------------------------- +Le module canonique est désormais en cercle 1 (``picarones/domain/``) +— c'est un type pur qui n'a aucune dépendance d'exécution +(``picarones/pipeline/`` qui contient le runtime n'est en fait pas +nécessaire pour décrire la spec). ``picarones.pipeline.spec`` reste +exposé en re-export pour ne pas casser les callers existants — ce +n'est pas un shim au sens architectural (adaptation d'une API +incompatible) mais un alias de chemin. + +Différence avec l'ancien ``picarones.core.pipeline`` (Sprint 63) +---------------------------------------------------------------- +L'ancien ``PipelineStep`` portait un champ ``module: BaseModule`` +— une **instance** d'objet exécutable. Conséquence : la spec +n'était pas sérialisable en YAML, et un test qui voulait juste +valider la cohérence des types devait instancier des stubs. + +Ici, ``PipelineStep`` ne porte qu'un ``adapter_name: str``. Le +mapping ``nom → instance`` est maintenu par un service applicatif +(``picarones.app.services.adapter_registry`` au S19) et résolu au +moment de l'exécution, pas de la spec. + +Bénéfices : + +- Le YAML d'une pipeline composée est versionnable en git + indépendamment de l'environnement Python (BnF peut commit + ``ocr_llm_alto_remap.yaml`` sans imposer aux contributeurs + d'avoir tous les SDK installés). +- ``validate_spec`` peut s'exécuter sans instancier aucun module + → tests rapides et déterministes. +- Le rapport de reproductibilité peut citer le YAML exact, le + commit du code et la version des adapters utilisés — + séparation propre de la déclaration et de l'implémentation. + +Anti-sur-ingénierie +------------------- +- Pas de typage des ``params`` par adapter ici (chaque adapter + validera ses propres params au moment de l'exécution). +- Pas de versioning de spec — un nouveau champ se traduit par un + rebump pydantic. Si on veut migrer entre versions de schéma, + on l'ajoutera quand le besoin sera concret. +- Pas d'``outputs_preferred`` (mapping logique "preferred_text = + step3.RAW_TEXT"). Reporté quand un caller en aura concrètement + besoin. +""" + +from __future__ import annotations + +import re + +from pydantic import BaseModel, ConfigDict, Field, field_validator + +from picarones.domain.artifacts import ArtifactType + + +#: Identifiant d'étape — alphanum + ``_-``. Doit être un nom court +#: lisible par un humain dans les logs et le rapport. +_STEP_ID_RE = re.compile(r"^[A-Za-z0-9_\-]+$") + +#: Sentinel pour ``inputs_from`` qui désigne les artefacts initiaux +#: fournis au runner (typiquement ``IMAGE``). +INITIAL_STEP_ID = "__initial__" + + +class PipelineStep(BaseModel): + """Une étape déclarative dans un DAG de pipeline. + + Attributs + --------- + id: + Identifiant unique de l'étape dans la pipeline (alphanum + + ``_-``). Sert dans les logs, le rapport, et comme cible + des références ``inputs_from`` des étapes en aval. + kind: + Catégorie informationnelle de l'étape (``"ocr"``, + ``"post_correction"``, ``"alto_remapping"``, + ``"alto_reconstruction"``, etc.). Pas de validation + d'enum — c'est un label libre que les services et le + rapport peuvent grouper. Par convention, en + ``snake_case``. + adapter_name: + Nom de l'adapter dans le registre runtime (résolu par + ``app/services`` au S19). Convention : + ``":"`` (ex : ``"tesseract"``, + ``"openai:gpt-4o"``, ``"mistral:large"``, + ``":"``). + params: + Paramètres passés à l'adapter au moment de l'exécution. + Format libre (chaque adapter valide les siens) — typage + scalaire pour rester sérialisable en YAML. + input_types: + Types d'artefacts consommés par l'étape. Validés par + ``validate_spec`` contre les outputs des étapes antérieures. + output_types: + Types d'artefacts produits. Validés au runtime par + l'executor (qui vérifie que tous les types déclarés sont + bien dans le dict retourné par l'adapter). + inputs_from: + DAG branchant (héritage du Sprint 66). Pour chaque type + d'entrée, désigne explicitement l'étape source. La chaîne + spéciale ``"__initial__"`` désigne les entrées initiales + du runner. Si le dict est vide, l'executor prend la + version la plus récente de chaque type dans le bag. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str = Field(min_length=1, max_length=128) + kind: str = Field(min_length=1, max_length=64) + adapter_name: str = Field(min_length=1, max_length=256) + params: dict[str, str | int | float | bool] = Field(default_factory=dict) + input_types: tuple[ArtifactType, ...] = Field(default_factory=tuple) + output_types: tuple[ArtifactType, ...] = Field(default_factory=tuple) + inputs_from: dict[ArtifactType, str] = Field(default_factory=dict) + + @field_validator("id") + @classmethod + def _validate_step_id(cls, v: str) -> str: + if not _STEP_ID_RE.match(v): + from picarones.domain.errors import PicaronesError + raise PicaronesError( + f"step id invalide : {v!r}. " + f"Doit matcher {_STEP_ID_RE.pattern!r} (alphanum + _-)." + ) + if v == INITIAL_STEP_ID: + from picarones.domain.errors import PicaronesError + raise PicaronesError( + f"step id réservé : {INITIAL_STEP_ID!r} désigne " + "les entrées initiales du runner." + ) + return v + + +class PipelineSpec(BaseModel): + """DAG déclaratif d'une pipeline composée. + + Sérialisable en YAML via ``model_dump()`` + ``yaml.safe_dump``, + chargeable via ``model_validate(yaml.safe_load(text))``. Le + round-trip est testé. + + Attributs + --------- + name: + Nom court de la pipeline (utilisé dans les logs, le cache, + le rapport). Convention ``snake_case``. + description: + Phrase courte d'introduction affichée dans le rapport. + initial_inputs: + Types d'artefacts qui doivent être fournis par le caller + au moment de l'exécution. Convention : ``(IMAGE,)`` pour + une pipeline OCR classique, ``(IMAGE, RAW_TEXT)`` pour + une post-correction qui part d'un OCR pré-calculé. + steps: + Étapes du DAG, ordonnées par dépendance topologique + d'exécution. Si une étape ``s2`` dépend de ``s1``, alors + ``s1`` apparaît avant ``s2``. ``validate_spec`` détecte + les violations. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + name: str = Field(min_length=1, max_length=128) + description: str = "" + initial_inputs: tuple[ArtifactType, ...] = Field(default_factory=tuple) + steps: tuple[PipelineStep, ...] = Field(default_factory=tuple) + + def step_by_id(self, step_id: str) -> PipelineStep | None: + for s in self.steps: + if s.id == step_id: + return s + return None + + +__all__ = ["PipelineStep", "PipelineSpec", "INITIAL_STEP_ID"] diff --git a/picarones/domain/projection_spec.py b/picarones/domain/projection_spec.py new file mode 100644 index 0000000000000000000000000000000000000000..faecbd3cbda347a9d5d45feca77d5fe072167c71 --- /dev/null +++ b/picarones/domain/projection_spec.py @@ -0,0 +1,66 @@ +"""``ProjectionSpec`` — Sprint A14-S5. + +Une projection convertit un artefact d'un type vers un autre, en +documentant explicitement la perte d'information (cf. +``ProjectionReport`` dans ``picarones.evaluation.projectors.base``). + +``ProjectionSpec`` est la **déclaration** d'une projection ; elle +ne contient pas la logique du projecteur (qui vit dans +``picarones.evaluation.projectors.*``). Cette séparation permet +à un ``EvaluationView`` de référencer une projection par nom dans +un YAML, sans imposer un couplage à une implémentation concrète. + +Anti-sur-ingénierie +------------------- +Pas de validation forte du nom du projecteur ici (le registre +``ProjectorRegistry`` validera à la résolution, S14). Pas de typage +strict sur ``params`` (différent par projecteur — un projecteur +ALTO→texte voudra ``{"reading_order": "natural"}``, un projecteur +CANONICAL→texte voudra autre chose). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import ArtifactType + + +class ProjectionSpec(BaseModel): + """Spec déclarative d'une projection entre deux types d'artefacts. + + Attributs + --------- + source_type: + Type de l'artefact en entrée du projecteur. + target_type: + Type de l'artefact en sortie. Peut être identique à + ``source_type`` (projection identité — utile pour signaler + explicitement "pas de projection" tout en gardant l'API + uniforme). + projector_name: + Identifiant du projecteur dans ``ProjectorRegistry``. + Convention : ``"_to_"`` (ex : ``"alto_to_text"``, + ``"page_to_text"``, ``"canonical_to_text"``). + params: + Dictionnaire de paramètres passé au projecteur. Différent + par projecteur ; pas de validation cross-projecteur ici. + Le projecteur lui-même validera ce qu'il attend. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + source_type: ArtifactType + target_type: ArtifactType + projector_name: str = Field(min_length=1, max_length=128) + params: dict[str, str | int | float | bool] = Field(default_factory=dict) + + @property + def is_identity(self) -> bool: + """Vrai si la spec décrit une projection identité + (source_type == target_type). Utile à un caller qui veut + court-circuiter l'appel au projecteur.""" + return self.source_type == self.target_type + + +__all__ = ["ProjectionSpec"] diff --git a/picarones/domain/provenance.py b/picarones/domain/provenance.py new file mode 100644 index 0000000000000000000000000000000000000000..90e7c20a086e8832f99f8d737258230471f05022 --- /dev/null +++ b/picarones/domain/provenance.py @@ -0,0 +1,70 @@ +"""Provenance d'un artefact — Sprint A14-S4. + +Empreinte minimale attachée à chaque ``Artifact`` produit par une +étape de pipeline. Permet la reproductibilité : même corpus + même +``code_version`` + même ``parameters_hash`` = mêmes artefacts à hash +près. + +Règle anti-sur-ingénierie : on ne déclare ici que les champs qui +ont un cas d'usage **immédiat** dans les Sprints S5-S18. Les extras +attendus (cost, latency, model_version) seront ajoutés quand un +caller en aura concrètement besoin (probablement S15-S17 quand on +introduit les vues économiques). +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from pydantic import BaseModel, ConfigDict, Field + + +class ProvenanceRecord(BaseModel): + """Empreinte de production d'un artefact. + + Immuable (``frozen=True``) : un artefact ne change pas de + provenance après création — pour modifier une provenance, on crée + un nouvel ``Artifact`` qui référence le précédent via + ``parent_artifact_ids``. + + Attributs + --------- + timestamp: + Date/heure UTC de production. Défaut : ``utcnow()`` au + moment de l'instanciation. + code_version: + Version du code Picarones qui a produit l'artefact. + Typiquement ``picarones.__version__`` (au format setuptools_scm + ``1.2.3.dev4+g`` hors release tag). Stocké comme str + opaque pour ne pas imposer un format particulier. + parameters_hash: + Hash SHA-256 hex (64 chars) des paramètres de l'étape qui a + produit l'artefact. Permet de détecter qu'on a relancé la + même étape avec d'autres params (cf. cache d'artefacts du + Sprint S7). ``None`` autorisé pour les artefacts initiaux + (image fournie par l'utilisateur, GT lue depuis le corpus). + """ + + model_config = ConfigDict(frozen=True) + + timestamp: datetime = Field( + default_factory=lambda: datetime.now(tz=timezone.utc), + ) + code_version: str + parameters_hash: str | None = None + + def is_compatible_with(self, other: "ProvenanceRecord") -> bool: + """Deux artefacts produits par le **même contexte de calcul**. + + Utilisé par le cache d'artefacts (Sprint S7) pour décider si + une étape peut être sautée. Le timestamp n'entre pas dans la + comparaison — seule la combinaison ``(code_version, + parameters_hash)`` détermine la compatibilité de cache. + """ + return ( + self.code_version == other.code_version + and self.parameters_hash == other.parameters_hash + ) + + +__all__ = ["ProvenanceRecord"] diff --git a/picarones/domain/run_manifest.py b/picarones/domain/run_manifest.py new file mode 100644 index 0000000000000000000000000000000000000000..9a35d8eb98f4edfb6fb5b761888192cbd90a4b76 --- /dev/null +++ b/picarones/domain/run_manifest.py @@ -0,0 +1,201 @@ +"""``RunManifest`` — empreinte immuable d'un run de benchmark. + +Sprint A14-S17 du rewrite ciblé. + +Le ``RunManifest`` est la **source de vérité** d'un run : + +- **Quoi** a été exécuté (corpus + pipelines + vues). +- **Avec quelle version du code**. +- **Quand** (timestamp UTC de début et fin). +- **Quelles dépendances** étaient en place (snapshot du lock file). + +Cette structure est sérialisée en ``run_manifest.json`` à la +racine du répertoire du run. Combinée à ``view_results.jsonl`` +et ``pipeline_results.jsonl``, elle permet à un caller (rapport +HTML, CLI ``picarones report``) de **reconstituer entièrement** +un run sans recourir à des objets Python live. + +Garantie de reproductibilité +---------------------------- +À ``code_version`` + ``corpus_name`` + ``pipeline_specs`` + +``view_specs`` + ``dependencies_lock`` identiques, ré-exécuter +doit donner les mêmes résultats (à la déterministe près des +adapters externes — un appel LLM cloud peut varier). + +C'est ce qui permet à la BnF de citer un commit + un +``run_manifest.json`` dans une publication scientifique et à un +relecteur de re-vérifier. + +Anti-sur-ingénierie +------------------- +- Pas de signature cryptographique du manifest pour S17. Si la + BnF veut une preuve d'intégrité, elle peut hasher le fichier et + le citer (le contenu est byte-déterministe via + ``model_dump_json(indent=2, sort_keys=True)``). +- Pas de versioning du schéma RunManifest. Si le schéma évolue, + on rebump pydantic — les anciens manifests pourront être + interprétés via un convertisseur explicite, pas via un système + de migration automatique. +""" + +from __future__ import annotations + +import warnings +from datetime import datetime, timezone +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field, computed_field, model_validator + +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.pipeline_spec import PipelineSpec + + +class RunManifest(BaseModel): + """Empreinte immuable d'un run de benchmark. + + Tous les champs sont déterministes à entrée constante. + ``started_at`` / ``completed_at`` capturent le wall-clock du + run mais n'entrent pas dans les comparaisons de + reproductibilité (deux runs identiques doivent donner les + mêmes résultats même si exécutés à des moments différents). + + Attributs + --------- + run_id: + Identifiant unique du run. Convention : + ``"_"`` (ex : + ``"bnf_xviiie_20260503T144012Z"``). Filesystem-safe. + corpus_name: + Nom du corpus traité (cf. ``CorpusSpec.name``). + n_documents: + Nombre de documents du corpus. + pipeline_specs: + Spécifications **complètes** des pipelines exécutées (steps, + adapter_name par step, params, inputs_from, output_types). + Inclus intégralement dans le manifest pour reproductibilité — + un relecteur peut reconstituer le DAG sans accès au YAML + d'origine. + adapter_kwargs: + Map ``{adapter_name: kwargs}`` capturée pour chaque adapter + instancié. Permet de reconstituer ``OpenAIAdapter(model= + "gpt-4o-2024-08-06", temperature=0.0)`` à l'identique. + Les valeurs sensibles (``api_key``) ne doivent pas y figurer + — elles viennent toujours de variables d'environnement. + view_specs: + Vues d'évaluation appliquées. Portées intégralement + (frozen pydantic) parce qu'elles sont déclaratives et + compactes. + code_version: + Version du code Picarones (typiquement + ``picarones.__version__``). + started_at, completed_at: + Wall-clock UTC de début et fin du run. + dependencies_lock: + Snapshot ``{package: version}`` de l'environnement Python + au moment du run. Capturé via + ``picarones.app.services.dependencies.capture_dependencies_lock``. + Indispensable pour la promesse de reproductibilité — sans + lui, un changement de version d'un parser XML ou d'une + lib statistique fait diverger les résultats sans qu'on + puisse l'attribuer. + metadata: + Dict libre pour notes utilisateur, etc. Ne doit pas + contenir d'info qui devrait être dans un autre champ. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + run_id: str = Field(min_length=1, max_length=256) + corpus_name: str = Field(min_length=1, max_length=128) + n_documents: int = Field(ge=0) + pipeline_specs: tuple[PipelineSpec, ...] = Field(default_factory=tuple) + adapter_kwargs: dict[str, dict[str, Any]] = Field(default_factory=dict) + view_specs: tuple[EvaluationView, ...] = Field(default_factory=tuple) + code_version: str = Field(min_length=1, max_length=128) + started_at: datetime + completed_at: datetime + dependencies_lock: dict[str, str] = Field(default_factory=dict) + metadata: dict[str, str] = Field(default_factory=dict) + + @computed_field # type: ignore[prop-decorator] + @property + def pipeline_names(self) -> tuple[str, ...]: + """Liste compacte des noms de pipelines (sérialisée dans le + JSON pour les lecteurs qui ne traitent pas le DAG complet). + + Dérivée de ``pipeline_specs`` ; la liste authoritative pour + la reproductibilité est ``pipeline_specs`` qui porte les DAG + complets avec params et inputs_from. + """ + return tuple(spec.name for spec in self.pipeline_specs) + + @model_validator(mode="before") + @classmethod + def _accept_legacy_pipeline_names( + cls, + data: Any, + ) -> Any: + """Accepte ``pipeline_names`` au constructeur comme alias + déprécié de ``pipeline_specs``. + + Trois cas : + + 1. ``pipeline_names`` seul → convertit chaque nom en + ``PipelineSpec(name=n, steps=())`` + ``DeprecationWarning``. + 2. ``pipeline_specs`` + ``pipeline_names`` cohérents → cas du + round-trip JSON (``pipeline_names`` est un computed_field + sérialisé) : on ignore silencieusement le doublon. + 3. ``pipeline_specs`` + ``pipeline_names`` incohérents → + ``ValueError`` (incohérence sémantique). + """ + if not isinstance(data, dict): + return data + if "pipeline_names" not in data: + return data + names = data["pipeline_names"] + if "pipeline_specs" in data: + specs = data["pipeline_specs"] + spec_names = tuple( + s.name if hasattr(s, "name") else s.get("name") + for s in specs + ) + if tuple(names) != spec_names: + raise ValueError( + "RunManifest : ``pipeline_names`` et " + "``pipeline_specs`` désignent des pipelines " + f"distinctes (names={tuple(names)!r}, " + f"specs={spec_names!r}).", + ) + # Round-trip JSON : computed_field re-sérialisé puis + # re-parsé. On ignore le doublon, ``pipeline_specs`` + # est authoritative. + data = dict(data) + data.pop("pipeline_names") + return data + warnings.warn( + "RunManifest(pipeline_names=...) is deprecated and will " + "be removed in 2.0. Use pipeline_specs=tuple(PipelineSpec" + "(name=n, steps=()) for n in names) instead.", + DeprecationWarning, + stacklevel=2, + ) + data = dict(data) + data.pop("pipeline_names") + data["pipeline_specs"] = tuple( + PipelineSpec(name=n, steps=()) for n in names + ) + return data + + @property + def duration_seconds(self) -> float: + """Durée wall-clock du run en secondes.""" + delta = self.completed_at - self.started_at + return delta.total_seconds() + + +def utcnow() -> datetime: + """Helper pour timestamp UTC (utile pour les fixtures).""" + return datetime.now(tz=timezone.utc) + + +__all__ = ["RunManifest", "utcnow"] diff --git a/picarones/evaluation/__init__.py b/picarones/evaluation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b91d82ab903ef755bf9442084cbe1b6768eabb3a --- /dev/null +++ b/picarones/evaluation/__init__.py @@ -0,0 +1,48 @@ +"""Cercle 2 — Evaluation. + +Vues d'évaluation, projecteurs et calculs de métriques. + +Le cœur de la valeur ajoutée Picarones : **comparer librement des +pipelines hétérogènes en projetant leurs sorties vers une vue +d'évaluation explicite**. L'utilisateur ne compare jamais directement +un OCR brut et une sortie ALTO reconstruite — il compare leur +projection dans une vue commune (texte, ALTO, recherchabilité, etc.) +et le rapport explicite ce que la vue ignore. + +Sous-packages : + +- ``views/`` — ``TextView``, ``AltoView``, ``SearchView``, ... +- ``projectors/`` — ``AltoToText``, ``PageToText``, ``CanonicalToText``, + qui transforment un type d'artefact vers un autre avec un + ``ProjectionReport`` listant les pertes (lossiness explicite). +- ``metrics/`` — calculs purs : CER/WER, MUFI, philological, + statistics, NER, etc. Une métrique = ``(input_types, output_types, + callable)``. +- ``registry/`` — registre typé construit explicitement par un + service au démarrage (pas par effet de bord d'import). + +Règles d'import : ce cercle dépend de ``domain/`` uniquement. Pas +de fastapi, pas de jinja, pas de moteur OCR. Il peut utiliser +``numpy`` et ``scipy`` pour les calculs statistiques. + +Voir ``docs/roadmap/rewrite-2026.md`` pour le rôle des vues dans le +rewrite ciblé (Sprints S13-S18). +""" + +from __future__ import annotations + +from picarones.evaluation.evaluation_engine import ( + EvaluationEngine, + EvaluationResult, +) +from picarones.evaluation.projection_engine import ( + ProjectionEngine, + ProjectionResult, +) + +__all__ = [ + "EvaluationEngine", + "EvaluationResult", + "ProjectionEngine", + "ProjectionResult", +] diff --git a/picarones/evaluation/evaluation_engine.py b/picarones/evaluation/evaluation_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..9198c321085d2f84bd1d89bce6b5ae3493c6471c --- /dev/null +++ b/picarones/evaluation/evaluation_engine.py @@ -0,0 +1,177 @@ +"""``EvaluationEngine`` — Sprint A14-S27. + +Pendant de ``ProjectionEngine`` (cf. ``projection_engine.py``). +Le S13 fusionnait dans ``DefaultEvaluationViewExecutor`` projection +**et** évaluation ; la cible architecturale les sépare en deux +moteurs spécialisés à responsabilité unique. + +``EvaluationEngine`` calcule un ensemble nommé de métriques sur +une paire ``(reference, hypothesis)`` de payloads. Une métrique +qui lève en interne va dans ``failed_metrics`` au lieu de planter +l'évaluation complète — l'erreur est capturée et associée au nom +de la métrique. + +Pourquoi cette séparation +------------------------- +- **Réutilisation** : le ``PipelineExecutor`` (S28+) peut appeler + ``EvaluationEngine.evaluate`` pour des métriques de jonction + intra-pipeline (ex : « score de stabilité entre deux étapes ») sans + passer par un ``EvaluationView``. +- **Testabilité** : on teste la collecte d'erreurs (métrique cassée, + métrique inconnue) sans instancier de vue ni de projecteur. +- **Découplage** : ``EvaluationEngine`` ne sait rien des artefacts, + des projections, des vues — il prend des payloads bruts. + +Anti-sur-ingénierie +------------------- +Pas de batch (évaluer N paires en une passe), pas de cache de +payload normalisé, pas de pré-tri des métriques. Le moteur est +volontairement minimal — la complexité vit dans les métriques +elles-mêmes (cf. ``picarones/evaluation/metrics/``). +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from picarones.evaluation.registry import ( + MetricNotFoundError, + MetricRegistry, +) + + +@dataclass(frozen=True) +class EvaluationResult: + """Résultat d'un appel à ``EvaluationEngine.evaluate``. + + Attributes + ---------- + metric_values: + Métriques calculées avec succès, ``{name: value}``. + failed_metrics: + Métriques qui ont échoué, ``{name: error_message}``. Les + deux dicts sont disjoints : une métrique apparaît dans l'un + ou l'autre, jamais les deux. + + Notes + ----- + Frozen dataclass : container immuable ; les dicts internes le + sont aussi grâce à ``field(default_factory=dict)`` qu'on ne + mute pas après construction. Le caller doit considérer les + dicts comme lecture seule. + """ + + metric_values: dict[str, Any] = field(default_factory=dict) + failed_metrics: dict[str, str] = field(default_factory=dict) + + @property + def n_succeeded(self) -> int: + return len(self.metric_values) + + @property + def n_failed(self) -> int: + return len(self.failed_metrics) + + @property + def all_succeeded(self) -> bool: + return self.n_failed == 0 + + def with_global_failure(self, error: str) -> "EvaluationResult": + """Retourne un nouveau ``EvaluationResult`` où **toutes** les + métriques portent le même message d'erreur global. Utile à + un caller qui constate qu'un payload n'a pas pu être chargé + et veut marquer l'évaluation entière en échec.""" + return EvaluationResult( + metric_values={}, + failed_metrics={ + name: error + for name in ( + list(self.metric_values) + list(self.failed_metrics) + ) + }, + ) + + +class EvaluationEngine: + """Moteur de calcul de métriques sur une paire de payloads. + + Responsabilité unique : prendre un ``MetricRegistry``, une liste + de noms de métriques, et une paire ``(reference, hypothesis)``, + retourner un ``EvaluationResult``. Pas de connaissance des + artefacts, des projections, des vues. + + Parameters + ---------- + metric_registry: + Registre des métriques, instancié explicitement au démarrage + (pas de singleton global, pas de side-effect d'import). + """ + + def __init__(self, metric_registry: MetricRegistry) -> None: + if not isinstance(metric_registry, MetricRegistry): + raise TypeError( + "metric_registry doit être un MetricRegistry." + ) + self._metrics = metric_registry + + @property + def metrics(self) -> MetricRegistry: + """Accès en lecture au registre sous-jacent (utile aux tests).""" + return self._metrics + + def evaluate( + self, + metric_names: tuple[str, ...] | list[str], + reference: Any, + hypothesis: Any, + ) -> EvaluationResult: + """Calcule chaque métrique nommée sur la paire (référence, hypothèse). + + Comportement : + + - Une métrique enregistrée et qui retourne une valeur → entrée + dans ``metric_values``. + - Une métrique enregistrée qui lève une exception → entrée + dans ``failed_metrics`` avec le message ``f"{type}: {message}"``. + - Un nom de métrique non enregistré → entrée dans + ``failed_metrics`` avec un message explicite. + + L'ordre d'évaluation suit l'ordre de ``metric_names`` ; les + deux dicts résultats préservent cet ordre (Python 3.7+ + garantit l'ordre d'insertion sur les ``dict``). + """ + metric_values: dict[str, Any] = {} + failed_metrics: dict[str, str] = {} + + for name in metric_names: + try: + value = self._metrics.compute(name, reference, hypothesis) + metric_values[name] = value + except MetricNotFoundError as exc: + failed_metrics[name] = ( + f"métrique non enregistrée dans le MetricRegistry : " + f"{exc}" + ) + except Exception as exc: # noqa: BLE001 + failed_metrics[name] = f"{type(exc).__name__}: {exc}" + + return EvaluationResult( + metric_values=metric_values, + failed_metrics=failed_metrics, + ) + + def evaluate_one( + self, + metric_name: str, + reference: Any, + hypothesis: Any, + ) -> EvaluationResult: + """Cas particulier : une seule métrique. Sucre syntaxique sur + ``evaluate``. Utile aux callers qui pilotent une jonction + unique (typiquement le pipeline executor sur une métrique de + jonction).""" + return self.evaluate((metric_name,), reference, hypothesis) + + +__all__ = ["EvaluationEngine", "EvaluationResult"] diff --git a/picarones/evaluation/metrics/__init__.py b/picarones/evaluation/metrics/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4ee711e672d099d1e75d70b6e8414c7cc853e0d0 --- /dev/null +++ b/picarones/evaluation/metrics/__init__.py @@ -0,0 +1,111 @@ +"""Métriques — calculs purs sur des paires (référence, hypothèse). + +Sprint A14-S10 : déplacement de **23 fichiers de calcul autonomes** +depuis ``picarones.measurements``. + +Calculs de qualité textuelle pure : + ``rare_tokens``, ``lexical_modernization``, ``calibration``, + ``confusion``, ``line_metrics``. + +Calculs structurels et géométriques : + ``layout``, ``image_quality``, ``image_predictive``. + +Calculs économiques : + ``pricing``, ``marginal_cost``, ``throughput``, + ``incremental_comparison``. + +Calculs analytiques (post-traitement) : + ``error_absorption``, ``hallucination``, ``robustness_projection``, + ``longitudinal``, ``baseline_comparison``, ``levers``, + ``worst_lines``, ``module_policy``. + +Calculs inter-moteurs : + ``inter_engine``, ``taxonomy_cooccurrence``, + ``taxonomy_comparison``. + +Reste à migrer (différé) +------------------------ + +Catégorie B — utilisent ``@register_metric`` du registre global +``core.metric_registry`` (singleton avec side-effect d'import) : + ``mufi``, ``abbreviations``, ``unicode_blocks``, ``roman_numerals``, + ``early_modern_typography``, ``modern_archives``, ``reading_order``, + ``ner``, ``readability``, ``searchability``, ``numerical_sequences``. + +Migrés au S20 quand le ``MetricRegistry`` instancié explicitement +(S5) deviendra le seul registre, via le ``registry_service`` +applicatif. + +Catégorie C — dépendances vers anciens packages : + ``robustness`` (importe ``picarones.core.corpus`` + + ``picarones.engines.base`` + ``picarones.measurements.metrics``). + Ne peut être migré qu'après les Sprints S11 (déplacement des + adapters) et S12 (équivalence numérique). + +Catégorie D — dépendances inter-fichiers à orchestrer : + ``cost_projection`` (→ pricing), ``equivalence_profile`` + (→ formats.text.normalization), ``specialization`` + (→ inter_engine), ``taxonomy_intra_doc`` (→ taxonomy), + ``taxonomy`` (→ char_scores). + +Règle de migration (S10) : un fichier déplacé = un commit avec +uniquement le déplacement et un re-export à l'ancien emplacement. +La logique reste identique. Aucun test modifié. +""" + +from __future__ import annotations + +# Re-exports des 23 fichiers déplacés au S10. Volontairement +# explicite (pas de wildcard import) pour qu'un caller du nouveau +# code ait une vue claire de ce qui est exposé. +from picarones.evaluation.metrics import ( # noqa: F401 + baseline_comparison, + calibration, + confusion, + error_absorption, + hallucination, + image_predictive, + image_quality, + incremental_comparison, + inter_engine, + layout, + levers, + lexical_modernization, + line_metrics, + longitudinal, + marginal_cost, + module_policy, + pricing, + rare_tokens, + robustness_projection, + taxonomy_comparison, + taxonomy_cooccurrence, + throughput, + worst_lines, +) + +__all__ = [ + "baseline_comparison", + "calibration", + "confusion", + "error_absorption", + "hallucination", + "image_predictive", + "image_quality", + "incremental_comparison", + "inter_engine", + "layout", + "levers", + "lexical_modernization", + "line_metrics", + "longitudinal", + "marginal_cost", + "module_policy", + "pricing", + "rare_tokens", + "robustness_projection", + "taxonomy_comparison", + "taxonomy_cooccurrence", + "throughput", + "worst_lines", +] diff --git a/picarones/evaluation/metrics/alto_structural.py b/picarones/evaluation/metrics/alto_structural.py new file mode 100644 index 0000000000000000000000000000000000000000..23a33888f22ef984c29a6415bc1b34730e0cda9f --- /dev/null +++ b/picarones/evaluation/metrics/alto_structural.py @@ -0,0 +1,175 @@ +"""Métriques structurelles ALTO — Sprint A14-S15. + +Métriques typées ``(ALTO_XML, ALTO_XML)`` qui mesurent la fidélité +**documentaire** d'un ALTO produit par un pipeline (par exemple un +reconstructeur post-correction LLM ou un VLM avec module +ALTO_reconstruction) face à la GT ALTO du corpus. + +Distinct de ``picarones/measurements/alto_metrics.py`` (legacy) +qui calcule CER/WER sur le **texte extrait** des deux ALTO. Ici +on mesure la **structure** : nombre de lignes, présence de bbox, +ordre de lecture cohérent. + +Métriques livrées au S15 +------------------------ +- ``compute_alto_validity(ref, hyp)`` — 1.0 si l'hypothèse a une + structure cohérente (≥ 1 page, ≥ 1 bloc, ≥ 1 ligne). Détecte + les ALTO vides ou tronqués. +- ``compute_line_count_ratio(ref, hyp)`` — ``min(n_hyp, n_ref) / + max(n_hyp, n_ref)`` ∈ [0, 1]. 1.0 = même nombre de lignes. +- ``compute_word_box_coverage(ref, hyp)`` — fraction des + ``AltoString`` de l'hypothèse qui ont une ``bbox``. 1.0 = tous + les mots ont une boîte (cas idéal pour un reconstructeur ALTO). + +Reportées à des sprints suivants (post-livraison) +------------------------------------------------- +- ``textline_alignment`` (IoU des bbox de lignes) — exige un + algorithme d'alignement bipartite par bbox. +- ``reading_order_consistency`` (Kendall tau sur les IDs de + lignes) — exige un mapping ID → position. +- ``layout_f1`` (ICDAR 2015) — déjà implémenté dans + ``evaluation/metrics/layout.py`` (migré au S10) sur des + ``Region`` génériques ; un wrapper ALTO peut être ajouté plus + tard. + +Convention de signature +----------------------- +Les payloads attendus sont des ``AltoDocument`` parsés (par le +``payload_loader`` du service applicatif). Si le caller passe +des bytes XML brut, il doit appeler ``parse_alto`` lui-même +en amont. + +higher_is_better +---------------- +Toutes les métriques de ce module ∈ [0, 1] avec ``higher_is_better=True`` +(1.0 = parfait, 0.0 = pire). Cohérent avec le schéma ICDAR pour +les métriques de fidélité documentaire. +""" + +from __future__ import annotations + +from picarones.formats.alto.types import AltoDocument + + +def _count_lines(doc: AltoDocument) -> int: + """Compte le nombre total de ``AltoLine`` dans un document.""" + return sum( + len(block.lines) + for page in doc.pages + for block in page.blocks + ) + + +def _count_strings(doc: AltoDocument) -> int: + """Compte le nombre total de ``AltoString`` dans un document.""" + return sum( + len(line.strings) + for page in doc.pages + for block in page.blocks + for line in block.lines + ) + + +def compute_alto_validity( + reference: AltoDocument, + hypothesis: AltoDocument, +) -> float: + """Vérifie que l'hypothèse a une structure ALTO cohérente. + + Cohérence = au moins 1 page ET au moins 1 bloc ET au moins + 1 ligne dans l'hypothèse. Détecte les ALTO vides, tronqués, + ou produits par un reconstructeur défaillant. + + Returns + ------- + float + 1.0 si l'hypothèse est structurellement cohérente, + 0.0 sinon. + + Notes + ----- + On ne compare PAS la cohérence à la référence ici — la + référence est juste passée pour homogénéité d'API avec les + autres métriques. Un ALTO de référence vide (cas dégénéré) + n'invalide pas l'hypothèse. + """ + if not hypothesis.pages: + return 0.0 + has_block = any(page.blocks for page in hypothesis.pages) + if not has_block: + return 0.0 + has_line = any( + block.lines + for page in hypothesis.pages + for block in page.blocks + ) + if not has_line: + return 0.0 + return 1.0 + + +def compute_line_count_ratio( + reference: AltoDocument, + hypothesis: AltoDocument, +) -> float: + """Ratio min/max du nombre de lignes des deux ALTO. + + Returns + ------- + float + ``min(n_hyp, n_ref) / max(n_hyp, n_ref)`` ∈ [0, 1]. + 1.0 = même nombre de lignes. 0.0 si l'un des deux n'a + aucune ligne (cas dégénéré). + + Permet de détecter un reconstructeur qui invente ou perd des + lignes vs la GT. Ne dit RIEN sur l'alignement spatial — + c'est ``textline_alignment`` (post-livraison) qui mesurera + cette dimension. + """ + n_ref = _count_lines(reference) + n_hyp = _count_lines(hypothesis) + if n_ref == 0 and n_hyp == 0: + return 1.0 # convention : deux vides identiques + if n_ref == 0 or n_hyp == 0: + return 0.0 + return min(n_ref, n_hyp) / max(n_ref, n_hyp) + + +def compute_word_box_coverage( + reference: AltoDocument, + hypothesis: AltoDocument, +) -> float: + """Fraction des ``AltoString`` de l'hypothèse qui ont une ``bbox``. + + Returns + ------- + float + ``n_strings_with_bbox / n_strings_total`` ∈ [0, 1]. + 1.0 = tous les mots ont une boîte (cas idéal pour un + reconstructeur ALTO). 0.0 si l'hypothèse n'a aucun mot. + + La référence n'est pas utilisée dans le calcul, mais elle est + passée pour homogénéité d'API. Un caller qui veut comparer + "candidat a-t-il autant de bbox que la GT" peut mesurer + ``compute_word_box_coverage(gt, hyp) / compute_word_box_coverage(hyp, gt)`` + ou utiliser un calcul dédié. + """ + total = _count_strings(hypothesis) + if total == 0: + return 0.0 + with_bbox = sum( + 1 + for page in hypothesis.pages + for block in page.blocks + for line in block.lines + for s in line.strings + if s.bbox is not None + ) + return with_bbox / total + + +__all__ = [ + "compute_alto_validity", + "compute_line_count_ratio", + "compute_word_box_coverage", +] diff --git a/picarones/evaluation/metrics/baseline_comparison.py b/picarones/evaluation/metrics/baseline_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..22f021aaceb4952d7f96271e325b6864b50a9258 --- /dev/null +++ b/picarones/evaluation/metrics/baseline_comparison.py @@ -0,0 +1,229 @@ +"""Comparaison à la baseline historique — Sprint 73 (A.I.3). + +Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +L'historique SQLite (``picarones/core/history.py``, Sprint 8) +existe mais aucun détecteur narratif ne le lit. Ce module fournit +la couche de calcul qui répond à *« comment ce moteur se +comporte-t-il sur ce corpus, **par rapport à ses runs précédents +de mon institution** ? »*. + +Sortie typique +-------------- +Un dict par moteur : + +.. code-block:: python + + { + "engine_name": "tesseract", + "cer_current": 0.052, + "cer_historical_mean": 0.041, + "cer_historical_median": 0.040, + "n_runs": 12, + "absolute_delta": 0.011, + "relative_delta": 0.268, # +26,8 % vs moyenne + "off_baseline": True, + } + +Le détecteur narratif ``engine_off_baseline`` (Sprint 73) +consomme cette structure pour émettre des Facts. + +Garde-fous +---------- +- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus + contient moins de runs, on retourne ``None`` plutôt que de + comparer à un échantillon trop petit. +- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du + même corpus** (sinon on compare des pommes et des oranges : + registres paroissiaux vs imprimés modernes). +- Le run courant lui-même n'est pas inclus dans la baseline (on + passe le ``current_run_id`` à exclure). +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Optional + +logger = logging.getLogger(__name__) + + +def compute_engine_baseline( + history, + engine_name: str, + corpus_name: str, + current_cer: float, + *, + current_run_id: Optional[str] = None, + min_runs: int = 5, + relative_delta_threshold: float = 0.20, +) -> Optional[dict]: + """Compare le CER courant d'un moteur à sa moyenne historique + sur le **même corpus**. + + Parameters + ---------- + history: + Instance de ``BenchmarkHistory`` (ou compatible : doit + exposer une méthode ``query(engine, corpus, limit)`` + retournant une liste d'``HistoryEntry`` avec attribut + ``cer_mean`` et ``run_id``). + engine_name: + Nom du moteur dont on calcule la baseline. + corpus_name: + Nom du corpus — limite la comparaison aux runs antérieurs + sur ce même corpus. + current_cer: + CER moyen observé dans le run courant. + current_run_id: + Si fourni, le run portant cet identifiant est exclu de la + baseline (utile quand le run courant est déjà enregistré + dans l'historique avant d'appeler ce calcul). + min_runs: + Nombre minimum de runs historiques pour que la + comparaison soit considérée fiable. Sous ce seuil, on + retourne ``None``. + relative_delta_threshold: + Seuil au-delà duquel ``off_baseline`` vaut ``True`` + (défaut : 0,20 = 20 % d'écart relatif). + + Returns + ------- + Optional[dict] + ``None`` si : + - moins de ``min_runs`` runs historiques disponibles + - ``current_cer`` est ``None`` ou négatif + - tous les CER historiques sont ``None`` + + Sinon, dict avec les champs documentés dans le module. + """ + if current_cer is None or current_cer < 0: + return None + try: + entries = history.query( + engine=engine_name, corpus=corpus_name, limit=1000, + ) + except Exception as exc: # pragma: no cover — défense + logger.warning( + "[baseline_comparison] query history a levé : %s", exc, + ) + return None + + historical_cers: list[float] = [] + for entry in entries: + if current_run_id is not None and entry.run_id == current_run_id: + continue + cer = entry.cer_mean + if cer is None or cer < 0: + continue + historical_cers.append(float(cer)) + + if len(historical_cers) < min_runs: + return None + + mean = statistics.fmean(historical_cers) + median = statistics.median(historical_cers) + absolute_delta = current_cer - mean + if mean > 0: + relative_delta = absolute_delta / mean + elif current_cer == 0: + relative_delta = 0.0 + else: + # Baseline à 0 mais CER courant > 0 : écart infini — + # convention : on signale comme off_baseline avec + # relative_delta = None. + relative_delta = None + + off_baseline = ( + relative_delta is not None + and abs(relative_delta) > relative_delta_threshold + ) + + return { + "engine_name": engine_name, + "corpus_name": corpus_name, + "cer_current": float(current_cer), + "cer_historical_mean": mean, + "cer_historical_median": median, + "n_runs": len(historical_cers), + "absolute_delta": absolute_delta, + "relative_delta": relative_delta, + "off_baseline": off_baseline, + } + + +def compute_corpus_difficulty_percentile( + history, + current_difficulty: float, + *, + min_runs: int = 5, +) -> Optional[dict]: + """Place la difficulté du corpus courant dans la distribution + des difficultés historiques. + + Lit les difficultés stockées dans ``HistoryEntry.metadata`` + sous la clé ``difficulty`` (convention de + ``picarones/core/difficulty.py``). + + Returns + ------- + Optional[dict] + ``{ + "current_difficulty": float, + "percentile": float, # 0..100 + "n_runs": int, + "median_historical": float, + "harder_than_usual": bool, # percentile > 75 + "easier_than_usual": bool, # percentile < 25 + }`` + ou ``None`` si moins de ``min_runs`` runs historiques ont + une difficulté enregistrée. + """ + if current_difficulty is None: + return None + try: + entries = history.query(limit=1000) + except Exception as exc: # pragma: no cover + logger.warning( + "[baseline_comparison] query history a levé : %s", exc, + ) + return None + + historical_difficulties: list[float] = [] + for entry in entries: + diff = entry.metadata.get("difficulty") if entry.metadata else None + if diff is None: + continue + try: + historical_difficulties.append(float(diff)) + except (TypeError, ValueError): + continue + + if len(historical_difficulties) < min_runs: + return None + + sorted_diff = sorted(historical_difficulties) + n = len(sorted_diff) + # Percentile = % de corpus historiques de difficulté ≤ + # current_difficulty. Convention courante (P_i = i/n × 100). + n_below = sum(1 for d in sorted_diff if d <= current_difficulty) + percentile = (n_below / n) * 100.0 + median = statistics.median(sorted_diff) + + return { + "current_difficulty": float(current_difficulty), + "percentile": percentile, + "n_runs": n, + "median_historical": median, + "harder_than_usual": percentile > 75.0, + "easier_than_usual": percentile < 25.0, + } + + +__all__ = [ + "compute_engine_baseline", + "compute_corpus_difficulty_percentile", +] diff --git a/picarones/evaluation/metrics/calibration.py b/picarones/evaluation/metrics/calibration.py new file mode 100644 index 0000000000000000000000000000000000000000..35819b20332e0b915b4cb13a5b9c55555f50c392 --- /dev/null +++ b/picarones/evaluation/metrics/calibration.py @@ -0,0 +1,323 @@ +"""Calibration des moteurs : ECE, MCE, reliability diagram. + +Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure. + +Pourquoi ce module +------------------ +Tous les moteurs OCR cibles fournissent une confidence par token ou par +ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``, +Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``). +La question naturelle pour un workflow patrimonial est : *« quand le +moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe +qui doit vérifier humainement un corpus de 50 000 pages, la différence +entre vérifier 100 % vs 15 % du volume est l'effet de la calibration. + +Ce module fournit les trois mesures classiques : + +- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de + l'écart absolu entre confiance moyenne et précision moyenne. + ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart + systématique entre confiance affichée et fiabilité réelle. +- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins. + Utile pour repérer le pire mensonge du moteur (ex. il dit toujours + 95 % de confiance et il a tort une fois sur deux). +- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf, + accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en + Chart.js côté navigateur dans un sprint suivant. + +Stratégie de découpage +---------------------- +Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37), +on découpe : + +- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes + parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1). + Aucune dépendance externe. +- **Sprint à venir** — exposition de ``token_confidences`` sur + ``EngineResult``, alignement caractère/token avec la GT pour produire + ``is_correct``, intégration dans le runner et vue HTML reliability. + +Ce qui est explicitement hors scope +----------------------------------- +Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est +extraite ; on calcule uniquement à partir de séquences de prédictions +fournies en entrée. C'est ce qui permet de tester rigoureusement les +invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias +constant, etc.) sans dépendre d'un backend. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle de données +# ────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class CalibrationBin: + """Un bin du reliability diagram. + + Attributs + --------- + bin_low, bin_high: + Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` — + sauf le dernier bin qui inclut ``1.0``). + avg_confidence: + Moyenne des confidences des prédictions tombées dans le bin. + ``None`` si le bin est vide. + accuracy: + Fraction de prédictions correctes dans le bin (``∈ [0, 1]``). + ``None`` si le bin est vide. + count: + Nombre de prédictions dans le bin. + """ + + bin_low: float + bin_high: float + avg_confidence: float | None + accuracy: float | None + count: int + + @property + def gap(self) -> float | None: + """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide.""" + if self.avg_confidence is None or self.accuracy is None: + return None + return abs(self.avg_confidence - self.accuracy) + + +# ────────────────────────────────────────────────────────────────────────── +# Validation +# ────────────────────────────────────────────────────────────────────────── + + +def _validate_inputs( + confidences: list[float], + is_correct: list[bool | int], +) -> None: + if len(confidences) != len(is_correct): + raise ValueError( + f"Longueurs incompatibles : confidences={len(confidences)} " + f"vs is_correct={len(is_correct)}" + ) + for i, c in enumerate(confidences): + if not (0.0 <= float(c) <= 1.0): + raise ValueError( + f"Confiance hors [0, 1] à l'index {i} : {c!r}" + ) + + +# ────────────────────────────────────────────────────────────────────────── +# Reliability diagram (binning) +# ────────────────────────────────────────────────────────────────────────── + + +def reliability_diagram( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> list[CalibrationBin]: + """Découpe les prédictions en ``n_bins`` bins équidistants par confiance + et calcule pour chacun la confiance moyenne, la précision et le compte. + + Parameters + ---------- + confidences: + Confidences des prédictions, ``∈ [0, 1]``. + is_correct: + Indicateur booléen (1 = prédiction correcte, 0 = incorrecte). + n_bins: + Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)`` + sauf le dernier bin qui inclut ``1.0``. + + Returns + ------- + list[CalibrationBin] + Liste de ``n_bins`` bins, dans l'ordre croissant des confidences. + """ + if n_bins < 1: + raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}") + + confs = [float(c) for c in confidences] + correct = [int(bool(x)) for x in is_correct] + _validate_inputs(confs, correct) + + bin_width = 1.0 / n_bins + sums: list[float] = [0.0] * n_bins + correct_counts: list[int] = [0] * n_bins + counts: list[int] = [0] * n_bins + + for c, ok in zip(confs, correct): + # Calcul du bin index par multiplication ``c * n_bins`` plutôt que + # division ``c / bin_width`` pour éviter les pièges de + # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754 + # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)). + if c >= 1.0: + idx = n_bins - 1 + else: + idx = int(c * n_bins) + # Garde-fou en cas d'arrondi flottant + if idx >= n_bins: + idx = n_bins - 1 + elif idx < 0: + idx = 0 + sums[idx] += c + correct_counts[idx] += ok + counts[idx] += 1 + + bins: list[CalibrationBin] = [] + for k in range(n_bins): + low = k * bin_width + high = (k + 1) * bin_width + n = counts[k] + if n == 0: + bins.append(CalibrationBin(low, high, None, None, 0)) + else: + bins.append(CalibrationBin( + bin_low=low, + bin_high=high, + avg_confidence=sums[k] / n, + accuracy=correct_counts[k] / n, + count=n, + )) + return bins + + +# ────────────────────────────────────────────────────────────────────────── +# ECE et MCE +# ────────────────────────────────────────────────────────────────────────── + + +def expected_calibration_error( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> float: + """Expected Calibration Error : moyenne pondérée par bin de l'écart + absolu confiance ↔ précision. + + ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|`` + + où la somme porte sur les bins non vides. + + Returns + ------- + float + ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. + """ + bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) + total = sum(b.count for b in bins) + if total == 0: + return 0.0 + ece = 0.0 + for b in bins: + if b.count == 0 or b.gap is None: + continue + ece += (b.count / total) * b.gap + return ece + + +def maximum_calibration_error( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> float: + """Maximum Calibration Error : pire écart confiance ↔ précision sur + tous les bins non vides. + + Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 % + de confiance et il a tort une fois sur deux dans ce bin). + + Returns + ------- + float + ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. + """ + bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) + gaps = [b.gap for b in bins if b.gap is not None] + return max(gaps) if gaps else 0.0 + + +# ────────────────────────────────────────────────────────────────────────── +# Vue agrégée +# ────────────────────────────────────────────────────────────────────────── + + +def compute_calibration_metrics( + confidences: Iterable[float], + is_correct: Iterable[bool | int], + n_bins: int = 10, +) -> dict: + """Calcule l'ensemble des métriques de calibration en un appel. + + Returns + ------- + dict + ``{ + "ece": float, + "mce": float, + "n_bins": int, + "n_predictions": int, + "overall_accuracy": float, + "overall_confidence": float, + "bins": [ + {"bin_low", "bin_high", "avg_confidence", + "accuracy", "count", "gap"}, + ... + ], + }`` + """ + confs = list(confidences) + correct = list(is_correct) + bins = reliability_diagram(confs, correct, n_bins=n_bins) + total = sum(b.count for b in bins) + overall_acc = ( + sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0 + ) + overall_conf = ( + sum(float(c) for c in confs) / total if total > 0 else 0.0 + ) + + ece = 0.0 + if total > 0: + for b in bins: + if b.gap is None: + continue + ece += (b.count / total) * b.gap + mce = max((b.gap for b in bins if b.gap is not None), default=0.0) + + return { + "ece": ece, + "mce": mce, + "n_bins": n_bins, + "n_predictions": total, + "overall_accuracy": overall_acc, + "overall_confidence": overall_conf, + "bins": [ + { + "bin_low": b.bin_low, + "bin_high": b.bin_high, + "avg_confidence": b.avg_confidence, + "accuracy": b.accuracy, + "count": b.count, + "gap": b.gap, + } + for b in bins + ], + } + + +__all__ = [ + "CalibrationBin", + "reliability_diagram", + "expected_calibration_error", + "maximum_calibration_error", + "compute_calibration_metrics", +] diff --git a/picarones/evaluation/metrics/confusion.py b/picarones/evaluation/metrics/confusion.py new file mode 100644 index 0000000000000000000000000000000000000000..a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689 --- /dev/null +++ b/picarones/evaluation/metrics/confusion.py @@ -0,0 +1,268 @@ +"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR. + +Pour chaque moteur, on calcule quels caractères du GT sont transcrits par +quels caractères OCR (substitutions). Cette "empreinte d'erreur" est +caractéristique de chaque moteur ou pipeline. + +Méthode +------- +L'alignement caractère par caractère utilise les opérations d'édition +de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet +d'identifier les substitutions, insertions et suppressions. + +La matrice est stockée comme un dict de dict : + ``{gt_char: {ocr_char: count}}`` + +La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide : +- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR +- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT) +""" + +from __future__ import annotations + +import difflib +from collections import defaultdict +from dataclasses import dataclass, field + +# Symbole représentant un caractère absent (insertion / suppression) +EMPTY_CHAR = "∅" + +# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne) +_WHITESPACE = set(" \t\n\r") + + +@dataclass +class ConfusionMatrix: + """Matrice de confusion unicode pour une paire (GT, OCR).""" + + matrix: dict[str, dict[str, int]] = field(default_factory=dict) + """Clé externe = char GT ; clé interne = char OCR ; valeur = count.""" + + total_substitutions: int = 0 + total_insertions: int = 0 + total_deletions: int = 0 + + @property + def total_errors(self) -> int: + return self.total_substitutions + self.total_insertions + self.total_deletions + + def top_confusions(self, n: int = 20) -> list[dict]: + """Retourne les n confusions les plus fréquentes (substitutions uniquement).""" + pairs: list[tuple[str, str, int]] = [] + for gt_char, ocr_counts in self.matrix.items(): + if gt_char == EMPTY_CHAR: + continue # insertions + for ocr_char, count in ocr_counts.items(): + if ocr_char == EMPTY_CHAR: + continue # suppressions + if gt_char != ocr_char: + pairs.append((gt_char, ocr_char, count)) + pairs.sort(key=lambda x: -x[2]) + return [ + {"gt": gt, "ocr": ocr, "count": cnt} + for gt, ocr, cnt in pairs[:n] + ] + + def as_compact_dict(self, min_count: int = 1) -> dict: + """Sérialise la matrice en éliminant les entrées rares.""" + compact: dict[str, dict[str, int]] = {} + for gt_char, ocr_counts in self.matrix.items(): + filtered = { + oc: cnt for oc, cnt in ocr_counts.items() + if cnt >= min_count + } + if filtered: + compact[gt_char] = filtered + return { + "matrix": compact, + "total_substitutions": self.total_substitutions, + "total_insertions": self.total_insertions, + "total_deletions": self.total_deletions, + } + + def as_dict(self) -> dict: + return self.as_compact_dict(min_count=1) + + +def build_confusion_matrix( + ground_truth: str, + hypothesis: str, + ignore_whitespace: bool = True, + ignore_correct: bool = True, +) -> ConfusionMatrix: + """Construit la matrice de confusion unicode pour une paire GT/OCR. + + Parameters + ---------- + ground_truth: + Texte de référence (vérité terrain). + hypothesis: + Texte produit par l'OCR. + ignore_whitespace: + Si True, ignore les espaces, tabulations et sauts de ligne. + ignore_correct: + Si True, n'enregistre pas les paires identiques (gt_char == ocr_char). + Par défaut True pour réduire la taille de la matrice. + + Returns + ------- + ConfusionMatrix + """ + matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + n_subs = n_ins = n_dels = 0 + + if not ground_truth and not hypothesis: + return ConfusionMatrix(dict(matrix), 0, 0, 0) + + # SequenceMatcher sur listes de chars pour un alignement précis + matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False) + + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + if not ignore_correct: + for ch in ground_truth[i1:i2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][ch] += 1 + elif tag == "replace": + # Aligner char par char les séquences de longueurs différentes + gt_seg = ground_truth[i1:i2] + oc_seg = hypothesis[j1:j2] + _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace) + # Substitutions = longueur commune, surplus = insertions ou suppressions + n_subs += min(len(gt_seg), len(oc_seg)) + surplus = abs(len(gt_seg) - len(oc_seg)) + if len(gt_seg) > len(oc_seg): + n_dels += surplus + else: + n_ins += surplus + elif tag == "delete": + for ch in ground_truth[i1:i2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][EMPTY_CHAR] += 1 + n_dels += 1 + elif tag == "insert": + for ch in hypothesis[j1:j2]: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[EMPTY_CHAR][ch] += 1 + n_ins += 1 + + # Convertir defaultdict en dict normal + result_matrix: dict[str, dict[str, int]] = { + k: dict(v) for k, v in matrix.items() + } + + return ConfusionMatrix( + matrix=result_matrix, + total_substitutions=n_subs, + total_insertions=n_ins, + total_deletions=n_dels, + ) + + +def _align_segments( + gt_seg: str, + oc_seg: str, + matrix: dict, + ignore_whitespace: bool, +) -> None: + """Aligne deux segments de longueurs potentiellement différentes.""" + if not gt_seg: + for ch in oc_seg: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[EMPTY_CHAR][ch] += 1 + return + if not oc_seg: + for ch in gt_seg: + if ignore_whitespace and ch in _WHITESPACE: + continue + matrix[ch][EMPTY_CHAR] += 1 + return + + if len(gt_seg) == len(oc_seg): + # Substitutions 1-pour-1 + for g, o in zip(gt_seg, oc_seg): + if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): + continue + matrix[g][o] += 1 + else: + # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts + sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False) + for tag2, i1, i2, j1, j2 in sub.get_opcodes(): + if tag2 == "equal": + pass + elif tag2 == "replace": + # Régression simple : aligner par troncature + for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]): + if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): + continue + matrix[g][o] += 1 + elif tag2 == "delete": + for g in gt_seg[i1:i2]: + if ignore_whitespace and g in _WHITESPACE: + continue + matrix[g][EMPTY_CHAR] += 1 + elif tag2 == "insert": + for o in oc_seg[j1:j2]: + if ignore_whitespace and o in _WHITESPACE: + continue + matrix[EMPTY_CHAR][o] += 1 + + +def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix: + """Agrège plusieurs matrices de confusion en une seule. + + Utile pour obtenir la matrice agrégée sur l'ensemble du corpus. + """ + combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) + total_subs = total_ins = total_dels = 0 + + for cm in matrices: + for gt_char, ocr_counts in cm.matrix.items(): + for ocr_char, count in ocr_counts.items(): + combined[gt_char][ocr_char] += count + total_subs += cm.total_substitutions + total_ins += cm.total_insertions + total_dels += cm.total_deletions + + return ConfusionMatrix( + matrix={k: dict(v) for k, v in combined.items()}, + total_substitutions=total_subs, + total_insertions=total_ins, + total_deletions=total_dels, + ) + + +def top_confused_chars( + matrix: ConfusionMatrix, + n: int = 15, + exclude_empty: bool = True, +) -> list[dict]: + """Retourne les caractères GT les plus souvent confondus. + + Retourne une liste triée par nombre total d'erreurs décroissant : + ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]`` + """ + char_stats: dict[str, dict] = {} + for gt_char, ocr_counts in matrix.matrix.items(): + if exclude_empty and gt_char == EMPTY_CHAR: + continue + error_count = sum( + cnt for oc, cnt in ocr_counts.items() + if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR) + ) + if error_count > 0: + top_subs = sorted( + [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char], + key=lambda x: -x["count"], + )[:5] + char_stats[gt_char] = { + "char": gt_char, + "total_errors": error_count, + "top_substitutes": top_subs, + } + + return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n] diff --git a/picarones/evaluation/metrics/error_absorption.py b/picarones/evaluation/metrics/error_absorption.py new file mode 100644 index 0000000000000000000000000000000000000000..ce1021d64b625397fd5c3dca1d15475d6d83477b --- /dev/null +++ b/picarones/evaluation/metrics/error_absorption.py @@ -0,0 +1,276 @@ +"""Métrique d'absorption d'erreur — Sprint 94 (B.3). + +Sprint 94 — B.3 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Quand un module de post-correction LLM aplatit les différences +entre OCR amont, ce n'est pas qu'il « améliore » tous les +moteurs — c'est qu'il introduit ses propres biais qui dominent +ceux de l'OCR. Mesurer la dégradation par étape ne suffit +pas : il faut **séparer** les deux flux. + +À chaque jonction où un module transforme un artefact, on +mesure : + +- **Taux de correction** : parmi les erreurs présentes en + entrée du module, combien sont corrigées en sortie ? +- **Taux d'introduction** : parmi les erreurs présentes en + sortie, combien sont **nouvelles** (absentes en entrée) ? + +C'est la généralisation du score de sur-normalisation +(chantier A.I.7) à toute jonction. La formule s'applique +uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper — +toute jonction qui transforme un artefact en un autre du même +type. + +Méthode (token-level) +--------------------- +On split en tokens whitespace ``reference``, ``before``, +``after``. On compare en **multiset** (un token GT consommé +au plus une fois) : + +- ``errors_before`` = tokens GT non retrouvés dans ``before`` +- ``errors_after`` = tokens GT non retrouvés dans ``after`` +- ``corrected`` = ``errors_before \\ errors_after`` + (présents avant, absents après → corrigés) +- ``introduced`` = ``errors_after \\ errors_before`` + (absents avant, présents après → introduits) + +Garde-fou : le module ne classe pas les erreurs (visuelles, +abréviations, etc.) — c'est une métrique d'**absorption de +volume**, pas de qualité éditoriale. L'intersection sémantique +avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire. + +Sortie +------ +``compute_error_absorption(reference, before, after)`` retourne : + +.. code-block:: text + + { + "n_gt_tokens": int, + "n_errors_before": int, + "n_errors_after": int, + "n_corrected": int, + "n_introduced": int, + "n_kept_wrong": int, + "correction_rate": float | None, # n_corrected / n_errors_before + "introduction_rate": float | None, # n_introduced / n_errors_after + "net_improvement": int, # n_corrected - n_introduced + "corrected_tokens": list[str], + "introduced_tokens": list[str], + } + +``aggregate_error_absorption(per_doc_results)`` somme les +compteurs corpus-wide et recalcule les taux *micro*. +""" + +from __future__ import annotations + +import logging +from collections import Counter +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +def _split_words(text: Optional[str]) -> list[str]: + if not text: + return [] + return text.split() + + +def _missing_tokens( + reference: list[str], hypothesis: list[str], +) -> Counter: + """Tokens GT manquants en hypothèse au sens multiset. + + Un token GT compte plusieurs fois s'il apparaît plusieurs + fois ; chaque occurrence en hypothèse en absorbe au plus + une. Retourne un Counter ``{token: nb_occurrences_manquees}``. + """ + ref_count = Counter(reference) + hyp_count = Counter(hypothesis) + missing: Counter = Counter() + for token, n_ref in ref_count.items(): + n_hyp = hyp_count.get(token, 0) + if n_hyp < n_ref: + missing[token] = n_ref - n_hyp + return missing + + +def compute_error_absorption( + reference: Optional[str], + before: Optional[str], + after: Optional[str], + *, + case_sensitive: bool = False, +) -> Optional[dict]: + """Mesure l'absorption d'erreur entre ``before`` et ``after``. + + Parameters + ---------- + reference: + GT (vérité terrain). + before: + Sortie de l'étape précédente (typiquement OCR amont). + after: + Sortie de l'étape courante (typiquement post-correction LLM). + case_sensitive: + Si False (défaut), match case-insensitive — la sortie + ``corrected_tokens``/``introduced_tokens`` reste en casse + GT originale. + + Returns + ------- + dict | None + ``None`` si la GT est vide ou ne contient aucun token. + """ + ref_tokens = _split_words(reference) + if not ref_tokens: + return None + before_tokens = _split_words(before) + after_tokens = _split_words(after) + + if case_sensitive: + ref_match = list(ref_tokens) + before_match = list(before_tokens) + after_match = list(after_tokens) + else: + ref_match = [t.lower() for t in ref_tokens] + before_match = [t.lower() for t in before_tokens] + after_match = [t.lower() for t in after_tokens] + + # Map case-insensitive token → liste de casses GT originales + ref_orig_by_match: dict[str, list[str]] = {} + for orig, m in zip(ref_tokens, ref_match): + ref_orig_by_match.setdefault(m, []).append(orig) + + missing_before = _missing_tokens(ref_match, before_match) + missing_after = _missing_tokens(ref_match, after_match) + + n_errors_before = sum(missing_before.values()) + n_errors_after = sum(missing_after.values()) + + # Calcul corrigé / introduit en multiset + corrected_counter: Counter = Counter() + introduced_counter: Counter = Counter() + kept_wrong_counter: Counter = Counter() + all_tokens = set(missing_before) | set(missing_after) + for tok in all_tokens: + nb = missing_before.get(tok, 0) + na = missing_after.get(tok, 0) + if nb > na: + corrected_counter[tok] = nb - na + kept_wrong_counter[tok] = na + elif na > nb: + introduced_counter[tok] = na - nb + kept_wrong_counter[tok] = nb + else: + kept_wrong_counter[tok] = nb + + n_corrected = sum(corrected_counter.values()) + n_introduced = sum(introduced_counter.values()) + n_kept_wrong = sum(kept_wrong_counter.values()) + + correction_rate = ( + n_corrected / n_errors_before + if n_errors_before > 0 else None + ) + introduction_rate = ( + n_introduced / n_errors_after + if n_errors_after > 0 else None + ) + + def _expand(counter: Counter) -> list[str]: + out: list[str] = [] + for tok, count in counter.items(): + origs = ref_orig_by_match.get(tok, [tok]) + # Ne renvoie que la casse représentative GT + display = origs[0] if origs else tok + out.extend([display] * count) + return out + + return { + "n_gt_tokens": len(ref_tokens), + "n_errors_before": n_errors_before, + "n_errors_after": n_errors_after, + "n_corrected": n_corrected, + "n_introduced": n_introduced, + "n_kept_wrong": n_kept_wrong, + "correction_rate": correction_rate, + "introduction_rate": introduction_rate, + "net_improvement": n_corrected - n_introduced, + "corrected_tokens": _expand(corrected_counter), + "introduced_tokens": _expand(introduced_counter), + } + + +def aggregate_error_absorption( + per_doc: Iterable[Optional[dict]], + *, + sample_tokens: int = 50, +) -> Optional[dict]: + """Agrège les compteurs corpus-wide et recalcule les taux + *micro*. + + Parameters + ---------- + per_doc: + Itérable de sorties de ``compute_error_absorption`` (ou + ``None`` pour les docs sans GT). + sample_tokens: + Nombre maximal de tokens corrigés/introduits gardés dans + l'échantillon (cap pour ne pas exploser le JSON). + + Returns + ------- + dict | None + ``None`` si aucune entry valide. + """ + docs = [d for d in per_doc if d] + if not docs: + return None + n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) + n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs) + n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs) + n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs) + n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs) + n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs) + correction_rate = ( + n_corrected / n_errors_before if n_errors_before > 0 else None + ) + introduction_rate = ( + n_introduced / n_errors_after if n_errors_after > 0 else None + ) + corrected_sample: list[str] = [] + introduced_sample: list[str] = [] + for d in docs: + corrected_sample.extend(d.get("corrected_tokens") or []) + introduced_sample.extend(d.get("introduced_tokens") or []) + if ( + len(corrected_sample) >= sample_tokens + and len(introduced_sample) >= sample_tokens + ): + break + return { + "n_docs": len(docs), + "n_gt_tokens": n_gt, + "n_errors_before": n_errors_before, + "n_errors_after": n_errors_after, + "n_corrected": n_corrected, + "n_introduced": n_introduced, + "n_kept_wrong": n_kept_wrong, + "correction_rate": correction_rate, + "introduction_rate": introduction_rate, + "net_improvement": n_corrected - n_introduced, + "corrected_tokens_sample": corrected_sample[:sample_tokens], + "introduced_tokens_sample": introduced_sample[:sample_tokens], + } + + +__all__ = [ + "compute_error_absorption", + "aggregate_error_absorption", +] diff --git a/picarones/evaluation/metrics/hallucination.py b/picarones/evaluation/metrics/hallucination.py new file mode 100644 index 0000000000000000000000000000000000000000..07eda573ca8d1b4e659600482d3af3e87f245c21 --- /dev/null +++ b/picarones/evaluation/metrics/hallucination.py @@ -0,0 +1,331 @@ +"""Détection des hallucinations VLM/LLM — Sprint 10. + +Métriques calculées +------------------- +- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant +- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle +- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT +- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil +- Badge hallucination : True si ancrage faible ou ratio de longueur anormal +""" + +from __future__ import annotations + +import re +from dataclasses import dataclass + + +# --------------------------------------------------------------------------- +# Helpers texte +# --------------------------------------------------------------------------- + +def _tokenize(text: str) -> list[str]: + """Découpe en mots (minuscules, sans ponctuation).""" + return re.findall(r"[^\s]+", text.lower()) + + +def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]: + """Génère les n-grammes d'une liste de tokens.""" + if len(tokens) < n: + return [tuple(tokens)] if tokens else [] + return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)] + + +# --------------------------------------------------------------------------- +# Blocs hallucinés (segments continus sans ancrage) +# --------------------------------------------------------------------------- + +@dataclass +class HallucinatedBlock: + """Segment continu de la sortie sans correspondance dans le GT.""" + start_token: int + end_token: int + text: str + length: int # nombre de tokens + + def as_dict(self) -> dict: + return { + "start_token": self.start_token, + "end_token": self.end_token, + "text": self.text, + "length": self.length, + } + + +def _detect_hallucinated_blocks( + hyp_tokens: list[str], + gt_token_set: set[str], + tolerance: int = 3, + min_block_length: int = 4, +) -> list[HallucinatedBlock]: + """Détecte les blocs de tokens hypothèse sans correspondance dans le GT. + + Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent + dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus + est acceptée avant de clore un bloc. + + Parameters + ---------- + hyp_tokens: + Tokens de la sortie OCR/VLM. + gt_token_set: + Ensemble des tokens du GT (pour recherche O(1)). + tolerance: + Nombre de tokens connus consécutifs interrompant un bloc avant de le clore. + min_block_length: + Longueur minimale (tokens) pour qu'un bloc soit signalé. + + Returns + ------- + list[HallucinatedBlock] + """ + blocks: list[HallucinatedBlock] = [] + if not hyp_tokens: + return blocks + + in_block = False + block_start = 0 + consecutive_known = 0 + + for i, tok in enumerate(hyp_tokens): + is_unknown = tok not in gt_token_set + if is_unknown: + if not in_block: + in_block = True + block_start = i + consecutive_known = 0 + else: + consecutive_known = 0 + else: + if in_block: + consecutive_known += 1 + if consecutive_known >= tolerance: + # Clore le bloc + end = i - consecutive_known + length = end - block_start + 1 + if length >= min_block_length: + text = " ".join(hyp_tokens[block_start:end + 1]) + blocks.append(HallucinatedBlock( + start_token=block_start, + end_token=end, + text=text, + length=length, + )) + in_block = False + consecutive_known = 0 + + # Bloc non terminé + if in_block: + end = len(hyp_tokens) - 1 + length = end - block_start + 1 + if length >= min_block_length: + text = " ".join(hyp_tokens[block_start:end + 1]) + blocks.append(HallucinatedBlock( + start_token=block_start, + end_token=end, + text=text, + length=length, + )) + + return blocks + + +# --------------------------------------------------------------------------- +# Résultat structuré +# --------------------------------------------------------------------------- + +@dataclass +class HallucinationMetrics: + """Métriques de détection des hallucinations pour une paire (GT, hypothèse).""" + + net_insertion_rate: float + """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse.""" + + length_ratio: float + """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination.""" + + anchor_score: float + """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT. + Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables.""" + + hallucinated_blocks: list[HallucinatedBlock] + """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance).""" + + is_hallucinating: bool + """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold.""" + + # Détails supplémentaires + gt_word_count: int = 0 + hyp_word_count: int = 0 + net_inserted_words: int = 0 + anchor_threshold_used: float = 0.5 + length_ratio_threshold_used: float = 1.2 + ngram_size_used: int = 3 + + def as_dict(self) -> dict: + return { + "net_insertion_rate": round(self.net_insertion_rate, 6), + "length_ratio": round(self.length_ratio, 6), + "anchor_score": round(self.anchor_score, 6), + "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks], + "is_hallucinating": self.is_hallucinating, + "gt_word_count": self.gt_word_count, + "hyp_word_count": self.hyp_word_count, + "net_inserted_words": self.net_inserted_words, + "anchor_threshold_used": self.anchor_threshold_used, + "length_ratio_threshold_used": self.length_ratio_threshold_used, + "ngram_size_used": self.ngram_size_used, + } + + @classmethod + def from_dict(cls, d: dict) -> "HallucinationMetrics": + blocks = [ + HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", []) + ] + return cls( + net_insertion_rate=d.get("net_insertion_rate", 0.0), + length_ratio=d.get("length_ratio", 1.0), + anchor_score=d.get("anchor_score", 1.0), + hallucinated_blocks=blocks, + is_hallucinating=d.get("is_hallucinating", False), + gt_word_count=d.get("gt_word_count", 0), + hyp_word_count=d.get("hyp_word_count", 0), + net_inserted_words=d.get("net_inserted_words", 0), + anchor_threshold_used=d.get("anchor_threshold_used", 0.5), + length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2), + ngram_size_used=d.get("ngram_size_used", 3), + ) + + +# --------------------------------------------------------------------------- +# Calcul principal +# --------------------------------------------------------------------------- + +def compute_hallucination_metrics( + reference: str, + hypothesis: str, + n: int = 3, + length_ratio_threshold: float = 1.2, + anchor_threshold: float = 0.5, + block_tolerance: int = 3, + min_block_length: int = 4, +) -> HallucinationMetrics: + """Calcule les métriques de détection des hallucinations VLM/LLM. + + Parameters + ---------- + reference: + Texte de vérité terrain (GT). + hypothesis: + Texte produit par le modèle. + n: + Taille des n-grammes pour le score d'ancrage (défaut : trigrammes). + length_ratio_threshold: + Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle. + anchor_threshold: + Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle. + block_tolerance: + Nombre de tokens connus consécutifs acceptés dans un bloc halluciné. + min_block_length: + Longueur minimale (tokens) pour signaler un bloc halluciné. + + Returns + ------- + HallucinationMetrics + """ + gt_tokens = _tokenize(reference) + hyp_tokens = _tokenize(hypothesis) + + gt_len_chars = len(reference.strip()) + hyp_len_chars = len(hypothesis.strip()) + + # ── Ratio de longueur ──────────────────────────────────────────────── + if gt_len_chars == 0: + length_ratio = 1.0 if hyp_len_chars == 0 else float("inf") + else: + length_ratio = hyp_len_chars / gt_len_chars + + # ── Taux d'insertion nette ─────────────────────────────────────────── + gt_token_set = set(gt_tokens) + hyp_token_count = len(hyp_tokens) + + if hyp_token_count == 0: + net_insertion_rate = 0.0 + net_inserted_words = 0 + else: + net_inserted = [t for t in hyp_tokens if t not in gt_token_set] + net_inserted_words = len(net_inserted) + net_insertion_rate = net_inserted_words / hyp_token_count + + # ── Score d'ancrage (n-grammes) ────────────────────────────────────── + gt_ngrams = set(_ngrams(gt_tokens, n)) + hyp_ngrams = _ngrams(hyp_tokens, n) + + if not hyp_ngrams: + # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte) + anchor_score = 1.0 if not gt_ngrams else 0.0 + elif not gt_ngrams: + anchor_score = 0.0 + else: + anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams) + anchor_score = anchored / len(hyp_ngrams) + + # ── Blocs hallucinés ───────────────────────────────────────────────── + blocks = _detect_hallucinated_blocks( + hyp_tokens=hyp_tokens, + gt_token_set=gt_token_set, + tolerance=block_tolerance, + min_block_length=min_block_length, + ) + + # ── Badge hallucination ────────────────────────────────────────────── + is_hallucinating = ( + anchor_score < anchor_threshold + or length_ratio > length_ratio_threshold + ) + + return HallucinationMetrics( + net_insertion_rate=net_insertion_rate, + length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation + anchor_score=anchor_score, + hallucinated_blocks=blocks, + is_hallucinating=is_hallucinating, + gt_word_count=len(gt_tokens), + hyp_word_count=hyp_token_count, + net_inserted_words=net_inserted_words, + anchor_threshold_used=anchor_threshold, + length_ratio_threshold_used=length_ratio_threshold, + ngram_size_used=n, + ) + + +# --------------------------------------------------------------------------- +# Agrégation sur un corpus +# --------------------------------------------------------------------------- + +def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict: + """Agrège les métriques d'hallucination sur un corpus. + + Returns + ------- + dict + Statistiques agrégées : anchor_score moyen, taux de documents hallucinés… + """ + if not results: + return {} + + n = len(results) + anchor_values = [r.anchor_score for r in results] + ratio_values = [r.length_ratio for r in results] + insertion_values = [r.net_insertion_rate for r in results] + hallucinating_count = sum(1 for r in results if r.is_hallucinating) + + return { + "anchor_score_mean": round(sum(anchor_values) / n, 6), + "anchor_score_min": round(min(anchor_values), 6), + "length_ratio_mean": round(sum(ratio_values) / n, 6), + "net_insertion_rate_mean": round(sum(insertion_values) / n, 6), + "hallucinating_doc_count": hallucinating_count, + "hallucinating_doc_rate": round(hallucinating_count / n, 6), + "document_count": n, + } diff --git a/picarones/evaluation/metrics/image_predictive.py b/picarones/evaluation/metrics/image_predictive.py new file mode 100644 index 0000000000000000000000000000000000000000..1bd9671110a5f948f781a06a1c9a74f71421a829 --- /dev/null +++ b/picarones/evaluation/metrics/image_predictive.py @@ -0,0 +1,283 @@ +"""Métriques d'image prédictives — Sprint 93 (A.II.7). + +Sprint 93 — A.II.7 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +``image_quality`` (Sprint 5) mesure des features d'image +indépendamment ; ce module **les combine** pour produire deux +indicateurs corpus-level : + +1. **Score de complexité paléographique** ∈ [0, 1]. Combine + bruit, faible netteté, faible contraste et rotation en un + indicateur unique de la difficulté intrinsèque pour un OCR. + 0 = document trivial, 1 = document extrême. Permet + d'expliquer une partie du CER observé. + +2. **Score d'homogénéité du corpus** ∈ [0, 1]. Variance des + features entre documents. 0 = corpus uniforme (la moyenne + globale du benchmark est fiable), 1 = corpus hétérogène + (la moyenne ment, il faut stratifier). Couplé au détecteur + ``stratification_recommended`` (Sprint 46) qui agit sur + ``script_type``. + +Pondérations +------------ +La roadmap propose une combinaison **pondérée** sans fixer les +poids — on adopte une convention éditoriale documentée : + +- ``noise_level`` : poids 0.30 (bruit franc → CER ↑) +- ``1 - sharpness_score`` : poids 0.30 (flou → CER ↑) +- ``1 - contrast_score`` : poids 0.20 (faible contraste → CER ↑) +- ``|rotation_degrees|/30`` : poids 0.20 (rotation > 30° = pire) + +Les poids somment à 1. L'utilisateur peut surcharger via +``weights={...}``. + +Pas de prédiction CER absolue +----------------------------- +On ne prétend **pas** prédire une valeur CER en pourcentage — +ça demanderait un modèle entraîné par moteur, ce que la +philosophie banc d'essai exclut. On fournit un score relatif +qui se corrèle au CER observé pour une **lecture +diagnostique** : *« le document A est ~3× plus complexe que le +document B, ce qui est cohérent avec le CER observé. »* +""" + +from __future__ import annotations + +import logging +import math +import statistics +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# Poids éditoriaux par défaut. +DEFAULT_COMPLEXITY_WEIGHTS = { + "noise_level": 0.30, + "blur": 0.30, # 1 - sharpness_score + "low_contrast": 0.20, # 1 - contrast_score + "rotation": 0.20, # |rotation_degrees| / 30 +} + + +# Plage de saturation pour la rotation. Au-delà de 30°, on +# considère que c'est aussi pire que pire. +_ROTATION_SATURATION_DEG = 30.0 + + +def _clip01(x: float) -> float: + return max(0.0, min(1.0, x)) + + +def _extract_feature( + quality: dict, key: str, default: float = 0.0, +) -> float: + val = quality.get(key, default) + if val is None: + return default + try: + return float(val) + except (TypeError, ValueError): + return default + + +def compute_paleographic_complexity( + quality: dict, + *, + weights: Optional[dict[str, float]] = None, +) -> Optional[dict]: + """Score de complexité paléographique d'une image. + + Parameters + ---------- + quality: + Dict ``ImageQualityResult.as_dict()`` ou compatible. + Champs lus : ``noise_level``, ``sharpness_score``, + ``contrast_score``, ``rotation_degrees``. + weights: + Poids surchargeant les défauts. Doit contenir les + 4 clés ``noise_level``, ``blur``, ``low_contrast``, + ``rotation``. Les poids sont normalisés (somme = 1). + + Returns + ------- + dict | None + ``{ + "score": float, # ∈ [0, 1] + "components": { + "noise": float, "blur": float, + "low_contrast": float, "rotation": float, + }, + "weights_used": dict, + }`` ou ``None`` si ``quality`` est falsy. + """ + if not quality: + return None + w = dict(DEFAULT_COMPLEXITY_WEIGHTS) + if weights: + for k in w: + if k in weights: + w[k] = float(weights[k]) + total = sum(w.values()) + if total <= 0: + return None + w = {k: v / total for k, v in w.items()} + noise = _clip01(_extract_feature(quality, "noise_level")) + sharpness = _clip01(_extract_feature(quality, "sharpness_score")) + contrast = _clip01(_extract_feature(quality, "contrast_score")) + rotation_deg = abs(_extract_feature(quality, "rotation_degrees")) + blur = 1.0 - sharpness + low_contrast = 1.0 - contrast + rotation = _clip01(rotation_deg / _ROTATION_SATURATION_DEG) + score = ( + w["noise_level"] * noise + + w["blur"] * blur + + w["low_contrast"] * low_contrast + + w["rotation"] * rotation + ) + return { + "score": _clip01(score), + "components": { + "noise": noise, + "blur": blur, + "low_contrast": low_contrast, + "rotation": rotation, + }, + "weights_used": w, + } + + +def compute_corpus_homogeneity( + image_qualities: Iterable[dict], +) -> Optional[dict]: + """Score d'homogénéité du corpus ∈ [0, 1]. + + 0 = corpus uniforme (faible variance entre documents), + 1 = corpus hétérogène. + + Méthode : pour chaque feature dans ``noise_level``, + ``sharpness_score``, ``contrast_score``, ``rotation_degrees``, + on calcule l'écart-type *normalisé* sur les documents (par + une plage de référence), puis on prend la moyenne des 4. + + Plages de normalisation : + - ``noise_level``, ``sharpness_score``, ``contrast_score`` + ∈ [0, 1] → écart-type / 0.5 (max théorique de l'écart-type + d'une distribution sur [0,1]) borné à 1. + - ``rotation_degrees`` → écart-type / 10°. + + Parameters + ---------- + image_qualities: + Itérable de dicts ``ImageQualityResult.as_dict()``. + + Returns + ------- + dict | None + ``{ + "score": float, # ∈ [0, 1] + "n_docs": int, + "per_feature": { + feature: {"mean": float, "stdev": float, + "normalised": float}, + }, + }`` ou ``None`` si moins de 2 documents. + """ + docs = [q for q in image_qualities if q] + if len(docs) < 2: + return None + features = ( + ("noise_level", 0.5), + ("sharpness_score", 0.5), + ("contrast_score", 0.5), + ("rotation_degrees", 10.0), + ) + per_feature: dict[str, dict] = {} + norm_stdevs: list[float] = [] + for key, divisor in features: + values = [ + _extract_feature(q, key) + for q in docs + ] + if not values: + continue + mean = statistics.fmean(values) + try: + stdev = statistics.stdev(values) if len(values) >= 2 else 0.0 + except statistics.StatisticsError: + stdev = 0.0 + normalised = _clip01(stdev / divisor) if divisor > 0 else 0.0 + per_feature[key] = { + "mean": mean, + "stdev": stdev, + "normalised": normalised, + } + norm_stdevs.append(normalised) + if not norm_stdevs: + return None + score = statistics.fmean(norm_stdevs) + return { + "score": _clip01(score), + "n_docs": len(docs), + "per_feature": per_feature, + } + + +def aggregate_corpus_predictive( + image_qualities: Iterable[dict], + *, + weights: Optional[dict[str, float]] = None, +) -> Optional[dict]: + """Synthèse corpus-wide : complexité moyenne + homogénéité. + + Returns + ------- + dict | None + ``{ + "n_docs": int, + "complexity_mean": float, + "complexity_median": float, + "complexity_min": float, + "complexity_max": float, + "complexity_stdev": float, + "homogeneity": dict, # sortie de + # compute_corpus_homogeneity + }`` ou ``None`` si moins d'un document. + """ + docs = [q for q in image_qualities if q] + if not docs: + return None + scores: list[float] = [] + for q in docs: + result = compute_paleographic_complexity(q, weights=weights) + if result is not None: + scores.append(float(result["score"])) + if not scores: + return None + homogeneity = compute_corpus_homogeneity(docs) + return { + "n_docs": len(docs), + "complexity_mean": statistics.fmean(scores), + "complexity_median": statistics.median(scores), + "complexity_min": min(scores), + "complexity_max": max(scores), + "complexity_stdev": ( + statistics.stdev(scores) if len(scores) >= 2 else 0.0 + ), + "homogeneity": homogeneity, + } + + +__all__ = [ + "DEFAULT_COMPLEXITY_WEIGHTS", + "compute_paleographic_complexity", + "compute_corpus_homogeneity", + "aggregate_corpus_predictive", +] + + +# Évite warning import inutilisé +_ = math diff --git a/picarones/evaluation/metrics/image_quality.py b/picarones/evaluation/metrics/image_quality.py new file mode 100644 index 0000000000000000000000000000000000000000..929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb --- /dev/null +++ b/picarones/evaluation/metrics/image_quality.py @@ -0,0 +1,391 @@ +"""Analyse automatique de la qualité des images de documents numérisés. + +Métriques +--------- +- **Score de netteté** : variance du laplacien (plus élevé = plus net) +- **Niveau de bruit** : écart-type des résidus haute-fréquence +- **Angle de rotation résiduel** : estimé par projection horizontale +- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond) +- **Score de qualité global** : combinaison normalisée des métriques ci-dessus + +Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow. +NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes +de fallback n'en dépendent pas. + +Note +---- +Pour les images placeholder (fixtures), des valeurs fictives cohérentes +sont générées via `generate_mock_quality_scores()`. +""" + +from __future__ import annotations + +import logging +import math +import statistics +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class ImageQualityResult: + """Métriques de qualité d'une image de document.""" + + sharpness_score: float = 0.0 + """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée.""" + + noise_level: float = 0.0 + """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité.""" + + rotation_degrees: float = 0.0 + """Angle de rotation résiduel estimé en degrés (positif = sens horaire).""" + + contrast_score: float = 0.0 + """Score de contraste [0, 1]. Ratio Michelson encre/fond.""" + + quality_score: float = 0.0 + """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques.""" + + analysis_method: str = "none" + """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'.""" + + error: Optional[str] = None + """Erreur si l'analyse a échoué.""" + + @property + def is_good_quality(self) -> bool: + """Vrai si le score de qualité global est ≥ 0.7.""" + return self.quality_score >= 0.7 + + @property + def quality_tier(self) -> str: + """Catégorie de qualité : 'good', 'medium', 'poor'.""" + if self.quality_score >= 0.7: + return "good" + elif self.quality_score >= 0.4: + return "medium" + return "poor" + + def as_dict(self) -> dict: + d = { + "sharpness_score": round(self.sharpness_score, 4), + "noise_level": round(self.noise_level, 4), + "rotation_degrees": round(self.rotation_degrees, 2), + "contrast_score": round(self.contrast_score, 4), + "quality_score": round(self.quality_score, 4), + "quality_tier": self.quality_tier, + "analysis_method": self.analysis_method, + } + if self.error: + d["error"] = self.error + return d + + @classmethod + def from_dict(cls, data: dict) -> "ImageQualityResult": + return cls( + sharpness_score=data.get("sharpness_score", 0.0), + noise_level=data.get("noise_level", 0.0), + rotation_degrees=data.get("rotation_degrees", 0.0), + contrast_score=data.get("contrast_score", 0.0), + quality_score=data.get("quality_score", 0.0), + analysis_method=data.get("analysis_method", "none"), + error=data.get("error"), + ) + + +def analyze_image_quality(image_path: str | Path) -> ImageQualityResult: + """Analyse la qualité d'une image de document numérisé. + + Essaie successivement : + 1. Pillow + NumPy (méthode complète) + 2. Pillow seul (méthode simplifiée) + 3. Fallback : retourne un résultat vide avec erreur + + Parameters + ---------- + image_path: + Chemin vers l'image (JPG, PNG, TIFF…). + + Returns + ------- + ImageQualityResult + """ + path = Path(image_path) + if not path.exists(): + return ImageQualityResult( + error=f"Fichier image introuvable : {image_path}", + analysis_method="none", + ) + + # Essai avec Pillow + NumPy + try: + import numpy as np + from PIL import Image + return _analyze_with_numpy(path, np, Image) + except ImportError: + pass + + # Essai avec Pillow seul + try: + from PIL import Image + return _analyze_with_pillow(path, Image) + except ImportError: + pass + + return ImageQualityResult( + error="Pillow non disponible (pip install Pillow)", + analysis_method="none", + quality_score=0.5, # valeur neutre + ) + + +def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult: + """Analyse complète avec NumPy.""" + img = Image.open(path).convert("L") # niveaux de gris + arr = np.array(img, dtype=np.float32) + + # 1. Netteté : variance du laplacien + laplacian = _laplacian_variance_numpy(arr, np) + # Normalisation empirique : variance > 500 = très net, < 50 = flou + sharpness = min(1.0, laplacian / 500.0) + + # 2. Bruit : écart-type des résidus (différence image - image lissée) + noise = _noise_level_numpy(arr, np) + + # 3. Rotation : angle d'inclinaison estimé + rotation = _estimate_rotation_numpy(arr, np) + + # 4. Contraste : ratio Michelson + contrast = _contrast_score_numpy(arr, np) + + # 5. Score global pondéré + quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) + + return ImageQualityResult( + sharpness_score=float(sharpness), + noise_level=float(noise), + rotation_degrees=float(rotation), + contrast_score=float(contrast), + quality_score=float(quality), + analysis_method="numpy", + ) + + +def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult: + """Analyse simplifiée avec Pillow seul (sans NumPy).""" + img = Image.open(path).convert("L") + pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel + w, h = img.size + + if not pixels: + return ImageQualityResult(quality_score=0.5, analysis_method="pillow") + + # Contraste : étendue des valeurs + min_val = min(pixels) + max_val = max(pixels) + if max_val + min_val > 0: + contrast = (max_val - min_val) / (max_val + min_val) + else: + contrast = 0.0 + + # Netteté approximée : variance globale des pixels + try: + variance = statistics.variance(pixels) + except statistics.StatisticsError: + variance = 0.0 + sharpness = min(1.0, math.sqrt(variance) / 128.0) + + # Bruit : approximation grossière + noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0 + + quality = _global_quality_score(sharpness, noise, 0.0, contrast) + + return ImageQualityResult( + sharpness_score=sharpness, + noise_level=noise, + rotation_degrees=0.0, # non calculé sans NumPy + contrast_score=contrast, + quality_score=quality, + analysis_method="pillow", + ) + + +def _laplacian_variance_numpy(arr, np) -> float: + """Calcule la variance du laplacien (mesure de netteté).""" + # Convolution laplacien 3x3 via slicing (bordures ignorées) + h, w = arr.shape + if h < 3 or w < 3: + return float(np.var(arr)) + + # Utiliser une convolution rapide avec slicing + center = arr[1:-1, 1:-1] + top = arr[:-2, 1:-1] + bottom = arr[2:, 1:-1] + left = arr[1:-1, :-2] + right = arr[1:-1, 2:] + lap = top + bottom + left + right - 4 * center + + return float(np.var(lap)) + + +def _noise_level_numpy(arr, np) -> float: + """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients.""" + h, w = arr.shape + if h < 2 or w < 2: + return 0.0 + # Différences horizontales et verticales + diff_h = np.abs(arr[:, 1:] - arr[:, :-1]) + diff_v = np.abs(arr[1:, :] - arr[:-1, :]) + noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()]))) + # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30) + return min(1.0, noise_std / 30.0) + + +def _estimate_rotation_numpy(arr, np) -> float: + """Estime l'angle de rotation par projection horizontale simplifiée. + + Retourne l'angle estimé en degrés [-45, 45]. + """ + # Méthode simplifiée : analyse de la variance des projections à différents angles + # Limiter à quelques angles pour la performance + h, w = arr.shape + if h < 20 or w < 20: + return 0.0 + + # Sous-échantillonnage pour la performance + step = max(1, h // 100) + sample = arr[::step, :] + + best_angle = 0.0 + best_var = -1.0 + + for angle_deg in range(-5, 6): # ±5 degrés, pas de 1° + angle_rad = math.radians(angle_deg) + # Projection horizontale après rotation approximative + # (approximation linéaire rapide) + offsets = np.round( + np.arange(sample.shape[0]) * math.tan(angle_rad) + ).astype(int) + offsets = np.clip(offsets, 0, w - 1) + + # Variance des sommes de lignes décalées + try: + row_sums = np.array([ + float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)])) + for i in range(sample.shape[0]) + ]) + var = float(np.var(row_sums)) + if var > best_var: + best_var = var + best_angle = float(angle_deg) + except Exception as e: + logger.warning( + "[image_quality] projection à %d° indisponible : %s", + angle_deg, e, + ) + + return best_angle + + +def _contrast_score_numpy(arr, np) -> float: + """Score de contraste Michelson [0, 1].""" + p5 = float(np.percentile(arr, 5)) # fond clair + p95 = float(np.percentile(arr, 95)) # encre sombre + if p5 + p95 == 0: + return 0.0 + # Michelson : (Imax - Imin) / (Imax + Imin) + return float((p95 - p5) / (p95 + p5)) + + +def _global_quality_score( + sharpness: float, + noise: float, + rotation_abs: float, + contrast: float, +) -> float: + """Calcule le score de qualité global pondéré.""" + # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%) + score = ( + 0.40 * sharpness + + 0.30 * contrast + + 0.20 * (1.0 - noise) # moins de bruit = mieux + + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max + ) + return round(min(1.0, max(0.0, score)), 4) + + +# --------------------------------------------------------------------------- +# Données fictives pour les fixtures de démo +# --------------------------------------------------------------------------- + +def generate_mock_quality_scores( + doc_id: str, + seed: Optional[int] = None, +) -> ImageQualityResult: + """Génère des métriques de qualité fictives mais cohérentes pour un document. + + Utilisé par les fixtures de démo pour simuler une diversité réaliste + de qualités d'image (bonne, moyenne, dégradée). + + Parameters + ---------- + doc_id: + Identifiant du document (utilisé pour la reproductibilité). + seed: + Graine aléatoire optionnelle. + """ + import random + rng = random.Random(seed or hash(doc_id) % 2**32) + + # Générer une qualité cohérente : certains docs sont plus difficiles + base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9 + + sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1))) + noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05))) + rotation = rng.gauss(0, 1.5) # ±1.5° typique + contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15))) + + quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) + + return ImageQualityResult( + sharpness_score=round(sharpness, 4), + noise_level=round(noise, 4), + rotation_degrees=round(rotation, 2), + contrast_score=round(contrast, 4), + quality_score=round(quality, 4), + analysis_method="mock", + ) + + +def aggregate_image_quality(results: list[ImageQualityResult]) -> dict: + """Agrège les métriques de qualité image sur un corpus.""" + if not results: + return {} + + valid = [r for r in results if r.error is None] + if not valid: + return {"error": "Aucune analyse réussie"} + + def _mean(vals: list[float]) -> float: + return round(statistics.mean(vals), 4) if vals else 0.0 + + quality_scores = [r.quality_score for r in valid] + sharpness_scores = [r.sharpness_score for r in valid] + noise_levels = [r.noise_level for r in valid] + + # Distribution par tier + tiers = {"good": 0, "medium": 0, "poor": 0} + for r in valid: + tiers[r.quality_tier] += 1 + + return { + "mean_quality_score": _mean(quality_scores), + "mean_sharpness": _mean(sharpness_scores), + "mean_noise_level": _mean(noise_levels), + "quality_distribution": tiers, + "document_count": len(valid), + "scores": [r.quality_score for r in valid], # pour scatter plot + } diff --git a/picarones/evaluation/metrics/incremental_comparison.py b/picarones/evaluation/metrics/incremental_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..8dcd0f6d95b85d94472aa99fffab926755e89be3 --- /dev/null +++ b/picarones/evaluation/metrics/incremental_comparison.py @@ -0,0 +1,253 @@ +"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5). + +Sprint 96 — B.5 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3 +mappeurs = 180 pipelines à comparer, le rapport noie +l'information. Il faut un mécanisme de **comparaison +contrôlée** type design d'expérience. + +Méthode +------- +Pour mesurer l'effet isolé d'un slot ``varying`` : + +1. Fixer les valeurs des autres slots (``fixed``). +2. Pour chaque combinaison des fixed, comparer les pipelines + qui ne diffèrent que sur le slot varying. +3. Agréger : pour chaque valeur du slot varying, calculer + sa moyenne, son écart-type, son rang moyen sur les groupes. + +C'est presque un Latin square automatisé. Sans ça, le +rapport sur 180 pipelines est inutilisable. + +Pas de tests statistiques scipy +------------------------------- +On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ; +on agrège ici les données nécessaires pour qu'un +tests statistique externe puisse les consommer. Le rapport +existant reste libre de brancher +``picarones.measurements.statistics.friedman_test`` sur la sortie de +ce module. + +Sortie +------ +``compare_isolated_effect(runs, varying_slot)`` retourne : + +.. code-block:: text + + { + "varying_slot": str, + "n_runs": int, + "n_groups": int, # combinaisons fixed distinctes + "values": list[str], # valeurs distinctes du slot + "per_value": {value: { + "n_observations": int, + "mean": float | None, + "stdev": float | None, + "min": float, "max": float, + "mean_rank": float | None, + }}, + "best_value": str | None, + "worst_value": str | None, + "groups": list[dict], # détail par groupe + } +""" + +from __future__ import annotations + +import logging +import statistics +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class PipelineRun: + """Un run de pipeline composée pour la comparaison contrôlée. + + Attributes + ---------- + name: + Nom du run (libre — informatif uniquement). + slots: + Map ``{slot_name: module_name}`` décrivant la pipeline + (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``). + score: + Métrique numérique à comparer (CER moyen typiquement). + Plus bas = meilleur par convention sauf si + ``higher_is_better=True`` est passé à + ``compare_isolated_effect``. + """ + + name: str + slots: dict[str, str] + score: float + + def as_dict(self) -> dict: + return { + "name": self.name, + "slots": dict(self.slots), + "score": self.score, + } + + +def _normalise_runs(runs) -> list[PipelineRun]: + """Accepte une liste de ``PipelineRun`` ou de dicts compatibles.""" + out: list[PipelineRun] = [] + for r in runs: + if isinstance(r, PipelineRun): + out.append(r) + continue + if not isinstance(r, dict): + continue + slots = r.get("slots") or {} + if not isinstance(slots, dict): + continue + try: + score = float(r.get("score")) + except (TypeError, ValueError): + continue + out.append(PipelineRun( + name=str(r.get("name") or ""), + slots={str(k): str(v) for k, v in slots.items()}, + score=score, + )) + return out + + +def compare_isolated_effect( + runs, + varying_slot: str, + *, + higher_is_better: bool = False, +) -> Optional[dict]: + """Mesure l'effet isolé du slot ``varying_slot``. + + Parameters + ---------- + runs: + Liste de ``PipelineRun`` (ou dicts compatibles). + varying_slot: + Nom du slot dont on veut isoler l'effet. Les autres + slots constituent les groupes de contrôle. + higher_is_better: + Si ``True``, on inverse la convention de classement + (rang 1 = score le plus haut). Défaut ``False`` = + rang 1 = score le plus bas (CER). + + Returns + ------- + dict | None + ``None`` si moins de 2 runs ou si ``varying_slot`` + n'est présent dans aucun run. + """ + runs_list = _normalise_runs(runs) + if len(runs_list) < 2: + return None + runs_list = [r for r in runs_list if varying_slot in r.slots] + if not runs_list: + return None + + # Constitue les groupes par valeurs des slots fixed + groups: dict[tuple, list[PipelineRun]] = {} + fixed_slot_names: list[str] = [] + for r in runs_list: + other_slots = sorted(k for k in r.slots if k != varying_slot) + if not fixed_slot_names: + fixed_slot_names = other_slots + # Skip runs avec un schéma de slots incompatible + if other_slots != fixed_slot_names: + continue + key = tuple((k, r.slots[k]) for k in other_slots) + groups.setdefault(key, []).append(r) + + if not groups: + return None + + # Pour chaque groupe : ranking des runs par score + per_value: dict[str, dict] = {} + group_details: list[dict] = [] + for key, members in groups.items(): + members_sorted = sorted( + members, key=lambda x: x.score, reverse=higher_is_better, + ) + # Rangs : runs ex aequo partagent la moyenne des rangs + ranks: dict[str, float] = {} + i = 0 + while i < len(members_sorted): + j = i + while ( + j + 1 < len(members_sorted) + and members_sorted[j + 1].score == members_sorted[i].score + ): + j += 1 + avg_rank = (i + 1 + j + 1) / 2 + for k in range(i, j + 1): + value = members_sorted[k].slots[varying_slot] + ranks[value] = avg_rank + i = j + 1 + + for r in members: + value = r.slots[varying_slot] + slot = per_value.setdefault(value, { + "scores": [], + "ranks": [], + }) + slot["scores"].append(r.score) + slot["ranks"].append(ranks[value]) + group_details.append({ + "fixed_slots": dict(key), + "n_members": len(members), + "values": [r.slots[varying_slot] for r in members_sorted], + "scores": [r.score for r in members_sorted], + }) + + # Calcul mean/stdev/min/max + rang moyen par valeur + summary: dict[str, dict] = {} + for value, slot in per_value.items(): + scores = slot["scores"] + ranks = slot["ranks"] + summary[value] = { + "n_observations": len(scores), + "mean": statistics.fmean(scores) if scores else None, + "stdev": ( + statistics.stdev(scores) if len(scores) >= 2 else None + ), + "min": min(scores), + "max": max(scores), + "mean_rank": ( + statistics.fmean(ranks) if ranks else None + ), + } + + # Best/worst : sur la mean (convention CER : plus bas = meilleur) + by_mean = sorted( + ((v, d["mean"]) for v, d in summary.items() + if d["mean"] is not None), + key=lambda kv: kv[1], + reverse=higher_is_better, + ) + best_value = by_mean[0][0] if by_mean else None + worst_value = by_mean[-1][0] if by_mean else None + + return { + "varying_slot": varying_slot, + "n_runs": len(runs_list), + "n_groups": len(groups), + "values": sorted(per_value.keys()), + "per_value": summary, + "best_value": best_value, + "worst_value": worst_value, + "groups": group_details, + "higher_is_better": higher_is_better, + } + + +__all__ = [ + "PipelineRun", + "compare_isolated_effect", +] diff --git a/picarones/evaluation/metrics/inter_engine.py b/picarones/evaluation/metrics/inter_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..68576f0ef9792451092a94aadeafb2c9aea4cf97 --- /dev/null +++ b/picarones/evaluation/metrics/inter_engine.py @@ -0,0 +1,484 @@ +"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution). + +Deux familles de mesures qui répondent à des questions différentes mais +liées : + +1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`, + `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des + erreurs de natures différentes ?* Une divergence élevée signale des + moteurs spécialisés sur des classes d'erreurs distinctes (visual vs + abréviation vs casse) et donc des candidats pour un voting ensemble. + +2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`, + `pairwise_disagreement_rate`) — *quel CER serait atteignable si on + combinait les moteurs ?* La borne inférieure du CER atteignable par + un voting majoritaire token-level est ``1 - oracle_token_recall``. + Si elle est très inférieure au CER du meilleur moteur seul, l'effort + d'un pipeline d'ensemble se justifie. Sinon non. + +Convention de typage +-------------------- +Toutes les fonctions sont enregistrables dans le registre Sprint 34 si +on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour +limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont +des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne +correspondent pas au modèle « une jonction = une métrique » du runner. +Elles sont consommées par les détecteurs narratifs et le rapport HTML. + +Note sur l'oracle +----------------- +La métrique ``oracle_token_recall`` retournée ici utilise un alignement +bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie +borne atteignable par voting majoritaire séquentiel — c'est une borne +supérieure (proxy optimiste). La vraie borne demanderait un +alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour +le diagnostic « ensemble vaut-il le coup ? », le proxy suffit +largement ; on documente clairement la limite dans le glossaire et le +rapport. +""" + +from __future__ import annotations + +import logging +import math +from collections import Counter + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Divergence taxonomique (KL / Jensen-Shannon) +# ────────────────────────────────────────────────────────────────────────── + + +def _smoothed_distribution( + distribution: dict[str, float], + keys: list[str], + epsilon: float = 1e-12, +) -> list[float]: + """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros. + + Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement + minuscule pour ne pas modifier le résultat de manière sensible. + """ + smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys] + total = sum(smoothed) + return [v / total for v in smoothed] + + +def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float: + """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés. + + Les distributions n'ont pas besoin de partager exactement les mêmes + clés ; les clés manquantes sont lissées à ``epsilon`` puis + renormalisées. + + Returns + ------- + float + ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas + symétrique : ``kl(p, q) != kl(q, p)`` en général. + """ + keys = sorted(set(p.keys()) | set(q.keys())) + if not keys: + return 0.0 + p_vec = _smoothed_distribution(p, keys) + q_vec = _smoothed_distribution(q, keys) + return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec)) + + +def jensen_shannon_divergence( + p: dict[str, float], + q: dict[str, float], +) -> float: + """JS-divergence symétrique en bits, bornée dans ``[0, 1]``. + + ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``. + Symétrique et bornée — préférable à la KL pour construire une + matrice triangulaire de divergences entre moteurs. + """ + keys = sorted(set(p.keys()) | set(q.keys())) + if not keys: + return 0.0 + p_vec = _smoothed_distribution(p, keys) + q_vec = _smoothed_distribution(q, keys) + m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)] + + def _kl(a: list[float], b: list[float]) -> float: + return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0) + + js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec) + # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les + # erreurs d'arrondi flottant. + return max(0.0, min(1.0, js)) + + +def taxonomy_divergence_matrix( + distributions: dict[str, dict[str, float]], + metric: str = "js", +) -> dict[str, dict[str, float]]: + """Construit la matrice de divergence triangulaire entre moteurs. + + Parameters + ---------- + distributions: + ``{engine_name: {error_class: probability}}``. Chaque + distribution doit sommer à environ 1 (pas de validation stricte + — les distributions taxonomiques de Picarones sont déjà + normalisées par ``aggregate_taxonomy``). + metric: + ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique). + + Returns + ------- + dict[str, dict[str, float]] + Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour + ``js``, asymétrique pour ``kl``. La diagonale vaut 0. + """ + if metric not in ("js", "kl"): + raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}") + fn = jensen_shannon_divergence if metric == "js" else kl_divergence + + engines = sorted(distributions.keys()) + matrix: dict[str, dict[str, float]] = {a: {} for a in engines} + for a in engines: + for b in engines: + if a == b: + matrix[a][b] = 0.0 + elif metric == "js" and b in matrix and a in matrix[b]: + # Symétrique : recopie pour éviter de recalculer + matrix[a][b] = matrix[b][a] + else: + matrix[a][b] = fn(distributions[a], distributions[b]) + return matrix + + +# ────────────────────────────────────────────────────────────────────────── +# Complémentarité (oracle token recall) +# ────────────────────────────────────────────────────────────────────────── + + +def _word_multiset(text: str) -> Counter[str]: + """Décomposition en multiset de tokens (séparateur whitespace).""" + return Counter(tok for tok in text.split() if tok) + + +def oracle_token_recall( + reference: str, + hypotheses: dict[str, str], +) -> float: + """Borne supérieure (proxy bag-of-words) du token-recall atteignable + par un voting majoritaire entre tous les moteurs fournis. + + Pour chaque token de la référence (avec sa multiplicité), on + considère qu'il est "préservé" par l'ensemble si au moins un moteur + en produit une occurrence non encore comptée. Le score est le ratio + d'occurrences GT préservées sur le total. + + Parameters + ---------- + reference: + Texte GT. + hypotheses: + ``{engine_name: hypothesis_text}``. + + Returns + ------- + float + Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent + dans au moins une hypothèse à hauteur de sa multiplicité. + + Note + ---- + Cette borne est **optimiste** (supérieure à la vraie borne par + voting séquentiel) car elle ignore l'ordre d'apparition. Pour le + diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour + une vraie borne il faudrait un alignement séquentiel. + """ + ref_counter = _word_multiset(reference) + if not ref_counter or not hypotheses: + return 1.0 if not ref_counter else 0.0 + + hyp_counters = [_word_multiset(h) for h in hypotheses.values()] + total_ref = sum(ref_counter.values()) + preserved = 0 + for token, gt_count in ref_counter.items(): + # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné + # à la multiplicité GT. L'oracle prend le max sur les moteurs. + best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0) + preserved += best + return preserved / total_ref + + +def complementarity_gap( + reference: str, + hypotheses: dict[str, str], +) -> dict[str, float]: + """Compare l'oracle au meilleur moteur seul. + + Returns + ------- + dict + ``{ + "oracle_recall": float, # bag-of-words recall de l'oracle + "best_single_recall": float, # meilleur recall token d'un moteur seul + "best_engine": str, # nom du moteur correspondant + "absolute_gap": float, # oracle - best_single (toujours ≥ 0) + "relative_gap": float, # absolute_gap / (1 - best_single + ε) + # = fraction des erreurs encore évitables + # par un ensemble + }`` + """ + ref_counter = _word_multiset(reference) + total = sum(ref_counter.values()) + if not total: + return { + "oracle_recall": 1.0, + "best_single_recall": 1.0, + "best_engine": "", + "absolute_gap": 0.0, + "relative_gap": 0.0, + } + + def _single_recall(hyp_text: str) -> float: + hc = _word_multiset(hyp_text) + preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items()) + return preserved / total + + if not hypotheses: + return { + "oracle_recall": 0.0, + "best_single_recall": 0.0, + "best_engine": "", + "absolute_gap": 0.0, + "relative_gap": 0.0, + } + + per_engine = {name: _single_recall(h) for name, h in hypotheses.items()} + best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1]) + oracle = oracle_token_recall(reference, hypotheses) + + absolute_gap = max(0.0, oracle - best_recall) + # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble + # serait théoriquement capable de récupérer (∈ [0, 1]) + headroom = max(1.0 - best_recall, 1e-12) + relative_gap = min(1.0, absolute_gap / headroom) + + return { + "oracle_recall": oracle, + "best_single_recall": best_recall, + "best_engine": best_engine, + "absolute_gap": absolute_gap, + "relative_gap": relative_gap, + } + + +def pairwise_disagreement_rate( + reference: str, + hyp_a: str, + hyp_b: str, +) -> float: + """Fraction de tokens GT pour lesquels A et B sont en désaccord. + + Un désaccord = (l'un préserve le token, l'autre non) OU + (les deux le ratent mais avec des substitutions différentes — non + capturé ici, on reste sur la version simple présence/absence). + + Returns + ------- + float + Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix + (pas de gain d'ensemble). ``1`` = A et B sont toujours en + désaccord (gain d'ensemble maximal). + """ + ref_counter = _word_multiset(reference) + if not ref_counter: + return 0.0 + a = _word_multiset(hyp_a) + b = _word_multiset(hyp_b) + total = sum(ref_counter.values()) + disagree = 0 + for tok, gt_count in ref_counter.items(): + a_pres = min(gt_count, a.get(tok, 0)) + b_pres = min(gt_count, b.get(tok, 0)) + # Compte les positions où A et B donnent une réponse différente + disagree += abs(a_pres - b_pres) + return disagree / total + + +# ────────────────────────────────────────────────────────────────────────── +# Agrégation au niveau benchmark (Sprint 36) +# ────────────────────────────────────────────────────────────────────────── + + +def compute_inter_engine_analysis( + *, + per_engine_outputs: dict[str, dict[str, str]], + ground_truths: dict[str, str], + taxonomy_distributions: dict[str, dict[str, float]] | None = None, + divergence_metric: str = "js", +) -> dict: + """Agrège les métriques inter-moteurs sur l'ensemble du corpus. + + Parameters + ---------- + per_engine_outputs: + ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par + moteur, avec une hypothèse par document. Les documents absents + d'un moteur (échecs, timeouts) sont simplement ignorés pour ce + moteur — l'oracle est calculé sur les moteurs qui ont produit + une sortie pour le doc. + ground_truths: + ``{doc_id: ground_truth_text}``. La GT est la même pour tous + les moteurs ; on la passe une seule fois. + taxonomy_distributions: + ``{engine_name: {error_class: probability}}`` — typiquement + ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si + ``None`` ou vide, la divergence taxonomique n'est pas calculée. + divergence_metric: + ``"js"`` (défaut, symétrique) ou ``"kl"``. + + Returns + ------- + dict + Structure stable consommable par les détecteurs narratifs et le + rapport HTML : + ``{ + "complementarity": { + "oracle_recall": float, + "best_single_recall": float, + "best_engine": str, + "absolute_gap": float, + "relative_gap": float, + "doc_count": int, + "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs + }, + "taxonomy_divergence": { + "metric": "js"|"kl", + "matrix": {engine_a: {engine_b: divergence}}, + "max_pair": [engine_a, engine_b, value] # paire la plus divergente + } | None, + "engines": [...], # liste des moteurs analysés (ordre stable) + }`` + """ + engines = sorted(per_engine_outputs.keys()) + result: dict = {"engines": engines} + + # ── Complémentarité agrégée doc par doc ────────────────────────────── + if not engines: + result["complementarity"] = None + else: + total_oracle_preserved = 0 + total_ref_tokens = 0 + per_engine_preserved: dict[str, int] = {name: 0 for name in engines} + per_doc_records: list[dict] = [] + + for doc_id, gt in ground_truths.items(): + ref_counter = _word_multiset(gt) + ref_total = sum(ref_counter.values()) + if not ref_total: + continue + total_ref_tokens += ref_total + + doc_hyps: dict[str, str] = {} + for name in engines: + hyp = per_engine_outputs.get(name, {}).get(doc_id) + if hyp is not None: + doc_hyps[name] = hyp + + if not doc_hyps: + continue + + hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()} + + doc_oracle = 0 + doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps} + for tok, gt_count in ref_counter.items(): + # Oracle : meilleur des moteurs sur ce token + best_for_token = 0 + for name, hc in hyp_counters.items(): + preserved = min(gt_count, hc.get(tok, 0)) + doc_best_per_engine[name] += preserved + if preserved > best_for_token: + best_for_token = preserved + doc_oracle += best_for_token + + total_oracle_preserved += doc_oracle + for name, count in doc_best_per_engine.items(): + per_engine_preserved[name] += count + + doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0 + per_doc_records.append({ + "doc_id": doc_id, + "oracle_recall": doc_oracle / ref_total, + "best_single_recall": doc_best / ref_total, + "absolute_gap": (doc_oracle - doc_best) / ref_total, + }) + + if total_ref_tokens == 0: + result["complementarity"] = None + else: + oracle_recall = total_oracle_preserved / total_ref_tokens + recalls = { + name: per_engine_preserved[name] / total_ref_tokens + for name in engines + } + best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1]) + absolute_gap = max(0.0, oracle_recall - best_recall) + headroom = max(1.0 - best_recall, 1e-12) + relative_gap = min(1.0, absolute_gap / headroom) + + # Garder les ``per_doc_records`` les plus instructifs : tri par + # gap absolu décroissant, top 50. Les détecteurs narratifs + # n'en consomment que quelques-uns. + per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True) + per_doc_top = per_doc_records[:50] + + result["complementarity"] = { + "oracle_recall": oracle_recall, + "best_single_recall": best_recall, + "best_engine": best_engine, + "absolute_gap": absolute_gap, + "relative_gap": relative_gap, + "doc_count": len(per_doc_records), + "per_engine_recall": recalls, + "per_doc": per_doc_top, + } + + # ── Divergence taxonomique ───────────────────────────────────────── + if not taxonomy_distributions: + result["taxonomy_divergence"] = None + else: + matrix = taxonomy_divergence_matrix( + taxonomy_distributions, + metric=divergence_metric, + ) + # Cherche la paire la plus divergente (utile pour la synthèse + # narrative qui veut nommer les deux moteurs candidats à + # l'ensemble). + max_pair: tuple[str, str, float] = ("", "", 0.0) + names = sorted(matrix.keys()) + for i, a in enumerate(names): + for b in names[i + 1:]: + v = matrix[a][b] + if v > max_pair[2]: + max_pair = (a, b, v) + + result["taxonomy_divergence"] = { + "metric": divergence_metric, + "matrix": matrix, + "max_pair": list(max_pair) if max_pair[2] > 0 else None, + } + + return result + + +__all__ = [ + "kl_divergence", + "jensen_shannon_divergence", + "taxonomy_divergence_matrix", + "oracle_token_recall", + "complementarity_gap", + "pairwise_disagreement_rate", + "compute_inter_engine_analysis", +] diff --git a/picarones/evaluation/metrics/layout.py b/picarones/evaluation/metrics/layout.py new file mode 100644 index 0000000000000000000000000000000000000000..477d247e8b531c1aeafa97ee6b76ac064479904b --- /dev/null +++ b/picarones/evaluation/metrics/layout.py @@ -0,0 +1,280 @@ +"""Layout F1 par type de région — Sprint 54. + +Sprint 54 — A.II.2.2 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur +sépare-t-il bien le texte principal de la glose ? »*. Le score de +structure global de Picarones (Sprint 5) agrège fusion/fragmentation +de lignes en un seul nombre — utile mais non typé. Ce module +discrimine par **type de région** ALTO/PAGE (``TextRegion``, +``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en +appliquant le pattern ICDAR layout standard : + +- **TP** : région GT et région hypothèse de **même type** avec + chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant), +- **FN** : région GT non matchée, +- **FP** : région hypothèse non matchée, +- F1 calculé global et par type. + +Le pattern d'alignement est le même que pour le NER (Sprint 38) — on +réutilise une approche éprouvée plutôt que d'en inventer une nouvelle. + +Stratégie de découpage +---------------------- +Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1 +(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit +deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un +parser amont — le parser ALTO/PAGE standard de Picarones suivra +dans un sprint dédié). Pas de câblage runner ni de vue HTML ici. + +Convention de coordonnées +------------------------- +Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine +en haut à gauche, axe y vers le bas — convention ALTO et PAGE +standard). L'IoU est calculée sur l'aire d'intersection / union des +rectangles. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Iterable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle de données +# ────────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class Region: + """Une région ALTO/PAGE alignable sur sa GT. + + Attributs + --------- + id: + Identifiant unique au sein de la séquence (ex. ``"r_1"``, + ``"region_main"``). Informatif — l'alignement se fait par IoU, + pas par ID. + type: + Catégorie de la région (``"TextRegion"``, ``"MarginNote"``, + ``"Header"``, etc.). Comparaison **case-insensitive**. + bbox: + Rectangle ``(x, y, width, height)`` en pixels, origine en haut + à gauche. Doit avoir width > 0 et height > 0. + """ + + id: str + type: str + bbox: tuple[int, int, int, int] + + def __post_init__(self) -> None: + x, y, w, h = self.bbox + if w <= 0 or h <= 0: + raise ValueError( + f"Region {self.id!r} : bbox invalide (w={w}, h={h}). " + "width et height doivent être strictement positifs." + ) + + @property + def area(self) -> int: + _, _, w, h = self.bbox + return w * h + + +def _to_region(obj: Region | dict) -> Region: + """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``).""" + if isinstance(obj, Region): + return obj + return Region( + id=str(obj["id"]), + type=str(obj["type"]), + bbox=tuple(obj["bbox"]), # type: ignore[arg-type] + ) + + +# ────────────────────────────────────────────────────────────────────────── +# IoU + alignement greedy +# ────────────────────────────────────────────────────────────────────────── + + +def _iou_bbox(a: Region, b: Region) -> float: + """Intersection-over-Union de deux bboxes ``(x, y, w, h)``.""" + ax, ay, aw, ah = a.bbox + bx, by, bw, bh = b.bbox + inter_x = max(ax, bx) + inter_y = max(ay, by) + inter_x_end = min(ax + aw, bx + bw) + inter_y_end = min(ay + ah, by + bh) + inter_w = max(0, inter_x_end - inter_x) + inter_h = max(0, inter_y_end - inter_y) + inter = inter_w * inter_h + if inter == 0: + return 0.0 + union = a.area + b.area - inter + if union <= 0: + return 0.0 + return inter / union + + +def _align_regions( + references: list[Region], + hypotheses: list[Region], + iou_threshold: float, +) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: + """Appareillage greedy par IoU décroissant ; same type requis. + + Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` — + ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``. + """ + candidates: list[tuple[float, int, int]] = [] + for i, r in enumerate(references): + for j, h in enumerate(hypotheses): + if r.type.casefold() != h.type.casefold(): + continue + iou = _iou_bbox(r, h) + if iou >= iou_threshold: + candidates.append((iou, i, j)) + + # Tri stable : IoU décroissant, puis indices croissants pour + # déterminisme sur égalités. + candidates.sort(key=lambda t: (-t[0], t[1], t[2])) + + matched_refs: set[int] = set() + matched_hyps: set[int] = set() + matches: list[tuple[int, int, float]] = [] + for iou, i, j in candidates: + if i in matched_refs or j in matched_hyps: + continue + matched_refs.add(i) + matched_hyps.add(j) + matches.append((i, j, iou)) + + unmatched_refs = set(range(len(references))) - matched_refs + unmatched_hyps = set(range(len(hypotheses))) - matched_hyps + return matches, unmatched_refs, unmatched_hyps + + +# ────────────────────────────────────────────────────────────────────────── +# Métrique principale +# ────────────────────────────────────────────────────────────────────────── + + +def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: + p = tp / (tp + fp) if (tp + fp) > 0 else 0.0 + r = tp / (tp + fn) if (tp + fn) > 0 else 0.0 + f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0 + return {"precision": p, "recall": r, "f1": f1, "support": tp + fn} + + +def compute_layout_metrics( + reference_regions: Iterable[Region | dict] | None, + hypothesis_regions: Iterable[Region | dict] | None, + iou_threshold: float = 0.5, +) -> dict: + """Calcule precision/recall/F1 sur le layout par type de région. + + Parameters + ---------- + reference_regions: + Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``). + hypothesis_regions: + Liste de régions produites par le moteur OCR/HTR ou un + layout-detector. + iou_threshold: + Seuil de chevauchement minimal pour déclarer un appariement + (défaut : 0,5 — convention ICDAR). + + Returns + ------- + dict + ``{ + "global": {"precision", "recall", "f1", "support"}, + "per_type": {type_name: {"precision", ...}}, + "true_positives": int, + "false_positives": int, + "false_negatives": int, + "missed_regions": list[dict], # GT non matchées + "hallucinated_regions": list[dict], # hyp non matchées + "iou_threshold": float, + }`` + + Cas dégénérés + ------------- + - Deux listes vides → F1 = 0 et tous compteurs à 0. + - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP). + - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN). + """ + refs = [_to_region(r) for r in (reference_regions or [])] + hyps = [_to_region(h) for h in (hypothesis_regions or [])] + + matches, unmatched_refs, unmatched_hyps = _align_regions( + refs, hyps, iou_threshold, + ) + + tp = len(matches) + fn = len(unmatched_refs) + fp = len(unmatched_hyps) + + cat_tp: dict[str, int] = {} + cat_fn: dict[str, int] = {} + cat_fp: dict[str, int] = {} + for i, _j, _iou in matches: + cat = refs[i].type + cat_tp[cat] = cat_tp.get(cat, 0) + 1 + for i in unmatched_refs: + cat = refs[i].type + cat_fn[cat] = cat_fn.get(cat, 0) + 1 + for j in unmatched_hyps: + cat = hyps[j].type + cat_fp[cat] = cat_fp.get(cat, 0) + 1 + + all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) + per_type = { + cat: _prf( + cat_tp.get(cat, 0), + cat_fp.get(cat, 0), + cat_fn.get(cat, 0), + ) + for cat in all_categories + } + + return { + "global": _prf(tp, fp, fn), + "per_type": per_type, + "true_positives": tp, + "false_positives": fp, + "false_negatives": fn, + "missed_regions": [ + {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)} + for i in sorted(unmatched_refs) + ], + "hallucinated_regions": [ + {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)} + for j in sorted(unmatched_hyps) + ], + "iou_threshold": iou_threshold, + } + + +def layout_f1( + reference_regions: Iterable[Region | dict] | None, + hypothesis_regions: Iterable[Region | dict] | None, + iou_threshold: float = 0.5, +) -> float: + """Raccourci : F1 global du layout.""" + return compute_layout_metrics( + reference_regions, hypothesis_regions, iou_threshold, + )["global"]["f1"] + + +__all__ = [ + "Region", + "compute_layout_metrics", + "layout_f1", +] diff --git a/picarones/evaluation/metrics/levers.py b/picarones/evaluation/metrics/levers.py new file mode 100644 index 0000000000000000000000000000000000000000..47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44 --- /dev/null +++ b/picarones/evaluation/metrics/levers.py @@ -0,0 +1,561 @@ +"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9). + +Sprint 82 — A.I.9 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce +qui s'est passé** dans le benchmark : qui gagne, qui s'effondre, +qui est fragile. Ce sprint répond à une question +complémentaire : **sur quelle dimension le bénéfice attendu d'une +amélioration serait-il le plus visible ?** + +Pas de prescription +------------------- +Picarones est un **outil de recherche**, pas un atelier de +production. Le module ne dit jamais *« faites X »* ni +*« utilisez le moteur Y »* ; il agrège des **observations +factuelles** déjà calculées dans d'autres modules (Sprints 75-81) +et les présente comme un récapitulatif compact en bas du rapport. +Le chercheur lit, juge et arbitre. + +Exemples de leviers émis +------------------------ +- *« 65 % des erreurs de Tesseract sont de classe récupérable + (case_error, ligature_error, abbreviation_error) — un + post-processing trivial absorberait une partie. »* +- *« 12 % de vos documents concentrent 78 % du CER total + (Pareto-CER). »* +- *« Le déficit projeté du moteur le plus fragile sur le corpus + réel est de 4,2 points de CER (Sprint 81). »* +- *« Le top-3 des tokens GT systématiquement modernisés est + maistre, nostre, veoir (Sprint 80). »* + +Structure +--------- +Module parallèle au registre narratif Sprint 19 : `Lever` est la +dataclass équivalente à `Fact`, `LeverImportance` reprend la +sémantique de `FactImportance`, `@register_lever` indexe les +détecteurs. Garde-fou anti-hallucination identique : chaque +nombre rendu doit être présent dans le `payload` du `Lever`. + +Les détecteurs lisent **uniquement** des structures déjà +construites par le pipeline du benchmark — ils ne calculent rien +de nouveau, ils synthétisent. C'est pourquoi le module est +résolument optionnel : si un benchmark n'expose pas +`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`, +`lexical_modernization` ou `robustness_projection`, le détecteur +correspondant retourne tout simplement `[]`. +""" + +from __future__ import annotations + +import logging +import threading +from dataclasses import dataclass +from enum import Enum +from typing import Callable + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Modèle +# ────────────────────────────────────────────────────────────────────────── + + +class LeverType(str, Enum): + """Types de leviers détectés.""" + + DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class" + """Une part importante des erreurs d'un moteur est dans des classes + catégorisées « récupérables » (Sprint 77).""" + + PARETO_CONCENTRATION = "pareto_concentration" + """Une fraction minoritaire de documents concentre une fraction + majoritaire du CER total — l'inspection ciblée est rentable.""" + + COMPLEMENTARITY_OBSERVATION = "complementarity_observation" + """Le `complementarity_gap` (Sprint 35) entre l'oracle et le + meilleur moteur seul est non négligeable — observation factuelle, + aucune recommandation d'ensemble.""" + + LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation" + """Top-N des tokens GT systématiquement modernisés (Sprint 80).""" + + ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation" + """Déficit projeté global le plus important pour un moteur sur + le corpus réel (Sprint 81).""" + + +class LeverImportance(int, Enum): + """Importance éditoriale d'un levier.""" + + HIGH = 70 + MEDIUM = 40 + LOW = 10 + + +@dataclass +class Lever: + """Observation factuelle synthétisable en encart « Leviers ». + + Attributes + ---------- + type: + Le type de levier (voir `LeverType`). + importance: + Score qui décide l'ordre d'affichage. + payload: + Données brutes — **tout chiffre rendu dans le HTML doit + provenir d'ici**, jamais d'un calcul du renderer. + engines_involved: + Noms des moteurs concernés (peut être vide pour un levier + corpus-wide). + """ + + type: LeverType + importance: LeverImportance + payload: dict + engines_involved: tuple[str, ...] = () + + def as_dict(self) -> dict: + return { + "type": self.type.value, + "importance": int(self.importance), + "payload": self.payload, + "engines_involved": list(self.engines_involved), + } + + +# ────────────────────────────────────────────────────────────────────────── +# Registre +# ────────────────────────────────────────────────────────────────────────── + + +LeverDetectorFn = Callable[[dict], list[Lever]] + + +@dataclass(frozen=True) +class LeverDetectorEntry: + lever_type: LeverType + fn: LeverDetectorFn + priority: int + + +_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {} +_LEVER_REGISTRY_LOCK = threading.Lock() + + +def register_lever( + lever_type: LeverType, + *, + priority: int, +) -> Callable[[LeverDetectorFn], LeverDetectorFn]: + """Décorateur : enregistre un détecteur de levier. + + Une seule fonction par type — réenregistrer lève `ValueError`. + """ + def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn: + with _LEVER_REGISTRY_LOCK: + if lever_type in _LEVER_REGISTRY: + raise ValueError( + f"Détecteur déjà enregistré pour {lever_type.value!r} : " + f"{_LEVER_REGISTRY[lever_type].fn.__name__}." + ) + _LEVER_REGISTRY[lever_type] = LeverDetectorEntry( + lever_type=lever_type, fn=fn, priority=int(priority), + ) + return fn + return _decorator + + +def unregister_lever(lever_type: LeverType) -> None: + with _LEVER_REGISTRY_LOCK: + _LEVER_REGISTRY.pop(lever_type, None) + + +def iter_lever_detectors() -> list[LeverDetectorEntry]: + with _LEVER_REGISTRY_LOCK: + entries = list(_LEVER_REGISTRY.values()) + entries.sort(key=lambda e: e.priority) + return entries + + +def detect_levers(benchmark_data: dict) -> list[Lever]: + """Applique tous les détecteurs enregistrés et trie par importance + décroissante puis priorité d'enregistrement croissante.""" + levers: list[Lever] = [] + for entry in iter_lever_detectors(): + try: + result = entry.fn(benchmark_data) + except Exception as e: + logger.warning( + "[levers.detector.%s] fonctionnalité dégradée : %s", + entry.lever_type.value, e, + ) + continue + if result: + levers.extend(result) + # Tri stable : importance décroissante d'abord + levers.sort(key=lambda lv: -int(lv.importance)) + return levers + + +# ────────────────────────────────────────────────────────────────────────── +# Détecteurs +# ────────────────────────────────────────────────────────────────────────── + + +# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py). +# Volontairement dupliquée ici pour ne pas introduire d'import +# circulaire — la sémantique est gelée. +_RECOVERABILITY: dict[str, str] = { + "case_error": "recoverable", + "ligature_error": "recoverable", + "abbreviation_error": "recoverable", + "diacritic_error": "difficult", + "visual_confusion": "difficult", + "hapax": "difficult", + "lacuna": "irrecoverable", + "oov_character": "irrecoverable", + "segmentation_error": "irrecoverable", +} + + +@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10) +def detect_dominant_recoverable_class( + benchmark_data: dict, + *, + threshold: float = 0.30, +) -> list[Lever]: + """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont + classifiées récupérables (catégorisation Sprint 77). + + Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` — + structure produite par le runner historique. Si absent, retourne + []. + """ + engines = benchmark_data.get("engines") or [] + out: list[Lever] = [] + for engine in engines: + taxonomy = engine.get("aggregated_taxonomy") + if not taxonomy: + continue + # `taxonomy` peut être {class_name: int} ou un dict avec une + # sous-clé "counts" — on accepte les deux conventions. + counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy + if not isinstance(counts, dict) or not counts: + continue + try: + int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))} + except (TypeError, ValueError): + continue + total = sum(int_counts.values()) + if total <= 0: + continue + recoverable_total = sum( + v for k, v in int_counts.items() + if _RECOVERABILITY.get(k) == "recoverable" + ) + share = recoverable_total / total + if share < threshold: + continue + # Classes récupérables non vides triées par count décroissant + breakdown = sorted( + ( + (k, v) for k, v in int_counts.items() + if _RECOVERABILITY.get(k) == "recoverable" and v > 0 + ), + key=lambda kv: -kv[1], + ) + importance = ( + LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM + ) + out.append(Lever( + type=LeverType.DOMINANT_RECOVERABLE_CLASS, + importance=importance, + payload={ + "engine": engine.get("name") or "?", + "share_recoverable": share, + "share_recoverable_pct": round(share * 100, 1), + "n_recoverable": recoverable_total, + "n_total_errors": total, + "top_classes": [ + {"class": k, "count": v} for k, v in breakdown[:3] + ], + }, + engines_involved=(engine.get("name") or "?",), + )) + return out + + +@register_lever(LeverType.PARETO_CONCENTRATION, priority=20) +def detect_pareto_concentration( + benchmark_data: dict, + *, + top_share: float = 0.20, + cer_share_threshold: float = 0.50, +) -> list[Lever]: + """Émet un levier si une fraction minoritaire de documents + (`top_share`) concentre plus de `cer_share_threshold` du CER + total cumulé sur le moteur leader. + + Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de + reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`. + Si rien d'exploitable, retourne []. + """ + ranking = benchmark_data.get("ranking") or [] + if not ranking: + return [] + leader = ranking[0] + leader_name = leader.get("engine") + if not leader_name: + return [] + + per_doc_cer: list[float] = [] + # Voie 1 : structure plate "per_doc_cer" + flat = benchmark_data.get("per_doc_cer") or {} + if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list): + per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))] + else: + # Voie 2 : engine.per_doc liste de dicts {cer: float} + for engine in benchmark_data.get("engines") or []: + if engine.get("name") != leader_name: + continue + per_doc = engine.get("per_doc") or [] + for entry in per_doc: + if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)): + per_doc_cer.append(float(entry["cer"])) + break + + if not per_doc_cer: + return [] + total_cer = sum(per_doc_cer) + if total_cer <= 0: + return [] + + sorted_cer = sorted(per_doc_cer, reverse=True) + n = len(sorted_cer) + n_top = max(1, int(round(top_share * n))) + top_cer_sum = sum(sorted_cer[:n_top]) + share_of_total = top_cer_sum / total_cer + if share_of_total < cer_share_threshold: + return [] + importance = ( + LeverImportance.HIGH if share_of_total >= 0.75 + else LeverImportance.MEDIUM + ) + return [Lever( + type=LeverType.PARETO_CONCENTRATION, + importance=importance, + payload={ + "engine": leader_name, + "n_docs": n, + "n_docs_top": n_top, + "top_share_pct": round((n_top / n) * 100, 1), + "cer_share_of_total": share_of_total, + "cer_share_pct": round(share_of_total * 100, 1), + }, + engines_involved=(leader_name,), + )] + + +@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30) +def detect_complementarity_observation( + benchmark_data: dict, + *, + min_relative_gap: float = 0.20, +) -> list[Lever]: + """Reformule factuellement le `complementarity_gap` (Sprint 35). + + Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne + déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune + recommandation d'ensemble** — le levier dit factuellement + « X points séparent l'oracle du meilleur moteur », c'est tout. + """ + inter = benchmark_data.get("inter_engine_analysis") or {} + cgap = inter.get("complementarity_gap") or {} + relative_gap = cgap.get("relative_gap") + absolute_gap = cgap.get("absolute_gap") + if relative_gap is None or absolute_gap is None: + return [] + try: + rg = float(relative_gap) + ag = float(absolute_gap) + except (TypeError, ValueError): + return [] + if rg < min_relative_gap: + return [] + importance = ( + LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM + ) + payload: dict = { + "absolute_gap": ag, + "absolute_gap_pct": round(ag * 100, 1), + "relative_gap": rg, + "relative_gap_pct": round(rg * 100, 1), + } + best_engine = cgap.get("best_engine") or inter.get("best_engine") + best_recall = cgap.get("best_recall") or inter.get("best_engine_recall") + oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall") + engines_involved: tuple[str, ...] = () + if best_engine: + payload["best_engine"] = str(best_engine) + engines_involved = (str(best_engine),) + if isinstance(best_recall, (int, float)): + payload["best_recall"] = float(best_recall) + if isinstance(oracle_recall, (int, float)): + payload["oracle_recall"] = float(oracle_recall) + return [Lever( + type=LeverType.COMPLEMENTARITY_OBSERVATION, + importance=importance, + payload=payload, + engines_involved=engines_involved, + )] + + +@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40) +def detect_lexical_modernization_observation( + benchmark_data: dict, + *, + top_n: int = 3, + min_total: int = 3, + min_rate: float = 0.50, +) -> list[Lever]: + """Pour chaque moteur disposant de `lexical_modernization`, + émet un levier listant les `top_n` tokens GT les plus modernisés. + + Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui + suit la forme produite par `compute_lexical_modernization` du + Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`). + """ + out: list[Lever] = [] + for engine in benchmark_data.get("engines") or []: + data = engine.get("lexical_modernization") + if not isinstance(data, dict): + continue + tokens = data.get("tokens") or {} + if not isinstance(tokens, dict) or not tokens: + continue + candidates: list[tuple[str, dict]] = [] + for gt_token, slot in tokens.items(): + if not isinstance(slot, dict): + continue + n_total = slot.get("n_total") + rate = slot.get("rate_modernized") + if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)): + continue + if int(n_total) < min_total: + continue + if float(rate) < min_rate: + continue + candidates.append((gt_token, dict(slot))) + if not candidates: + continue + candidates.sort( + key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)), + -int(kv[1].get("n_total", 0)), + kv[0]), + ) + top = candidates[:top_n] + engine_name = engine.get("name") or "?" + max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top) + importance = ( + LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM + ) + out.append(Lever( + type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION, + importance=importance, + payload={ + "engine": engine_name, + "top_tokens": [ + { + "gt_token": gt, + "n_total": int(slot.get("n_total", 0)), + "rate_modernized": float(slot.get("rate_modernized", 0.0)), + "rate_modernized_pct": round( + float(slot.get("rate_modernized", 0.0)) * 100, 1, + ), + } + for gt, slot in top + ], + }, + engines_involved=(engine_name,), + )) + return out + + +@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50) +def detect_robustness_projection_observation( + benchmark_data: dict, + *, + min_total_deficit: float = 0.02, +) -> list[Lever]: + """Lit l'agrégation par moteur de la projection de robustesse + (Sprint 81). Émet le levier pour le moteur dont + `total_expected_deficit` est ≥ `min_total_deficit` (par défaut + 2 points de CER). + + Lit `benchmark_data["robustness_projection_aggregated"]` — + structure produite par `aggregate_projection_per_engine`. + """ + agg = benchmark_data.get("robustness_projection_aggregated") or {} + if not isinstance(agg, dict) or not agg: + return [] + out: list[Lever] = [] + for engine_name, info in agg.items(): + if not isinstance(info, dict): + continue + total_deficit = info.get("total_expected_deficit") + worst_type = info.get("worst_degradation_type") + worst_deficit = info.get("worst_degradation_deficit") + if not isinstance(total_deficit, (int, float)): + continue + if float(total_deficit) < min_total_deficit: + continue + importance = ( + LeverImportance.HIGH if float(total_deficit) >= 0.05 + else LeverImportance.MEDIUM + ) + payload: dict = { + "engine": engine_name, + "total_expected_deficit": float(total_deficit), + "total_expected_deficit_pct": round(float(total_deficit) * 100, 1), + "n_degradation_types": int(info.get("n_degradation_types") or 0), + } + if isinstance(worst_type, str): + payload["worst_degradation_type"] = worst_type + if isinstance(worst_deficit, (int, float)): + payload["worst_degradation_deficit"] = float(worst_deficit) + payload["worst_degradation_deficit_pct"] = round( + float(worst_deficit) * 100, 1, + ) + out.append(Lever( + type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, + importance=importance, + payload=payload, + engines_involved=(engine_name,), + )) + # Tri par déficit décroissant pour stabilité d'affichage. + out.sort( + key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0), + ) + return out + + +__all__ = [ + "Lever", + "LeverImportance", + "LeverType", + "LeverDetectorEntry", + "register_lever", + "unregister_lever", + "iter_lever_detectors", + "detect_levers", + "detect_dominant_recoverable_class", + "detect_pareto_concentration", + "detect_complementarity_observation", + "detect_lexical_modernization_observation", + "detect_robustness_projection_observation", +] diff --git a/picarones/evaluation/metrics/lexical_modernization.py b/picarones/evaluation/metrics/lexical_modernization.py new file mode 100644 index 0000000000000000000000000000000000000000..d8da72a721e173100a14500b5020f782062c8863 --- /dev/null +++ b/picarones/evaluation/metrics/lexical_modernization.py @@ -0,0 +1,263 @@ +"""Détection de la sur-normalisation lexicale par les LLM/VLM — +Sprint 80 (A.I.7). + +Sprint 80 — A.I.7 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un +moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit +rien sur **quoi** corriger dans le prompt. Ce module produit +une **table de fréquences détaillée** : + ++----------------------+--------------------+------+----------+ +| Forme historique GT | Forme modernisée | n GT | % modern | ++======================+====================+======+==========+ +| maistre | maître | 47 | 85 % | +| nostre | nostre | 92 | 8 % | +| veoir | voir | 23 | 100 % | ++----------------------+--------------------+------+----------+ + +Lecture immédiate : *« le LLM modernise systématiquement +maistre → maître ; pour préserver l'orthographe historique, ajouter +au prompt "ne pas moderniser maistre, nostre, veoir" »*. + +Méthode +------- +Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque +``replace`` ou ``equal`` produit une paire ``(gt_token, +hyp_token)``. On accumule pour chaque ``gt_token`` : + +- ``n_total`` : nombre d'occurrences du token dans la GT +- ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token`` +- ``variants`` : dict des hyp_tokens observés avec leur count + +Stop-list +--------- +L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à +ignorer). Par défaut, vide — le module ne tente pas de deviner ce +qui est « moderne » ou « historique », c'est au chercheur de +fournir le filtre adapté à son corpus. + +Sortie +------ +``compute_lexical_modernization`` retourne une structure adaptée +au rendu HTML. ``aggregate_lexical_modernization`` agrège +plusieurs documents. + +Limites documentées +------------------- +- Tokenisation au niveau mot (split sur espace) — cohérent avec + ``taxonomy.py`` et autres modules. Pas de stemming ni de + lemmatisation. +- La métrique mesure la **réécriture lexicale** ; elle n'attrape + pas les modernisations infra-mot (perte du s long ſ qui se + fond dans la même forme). Pour ça, voir ``early_modern_typography`` + (Sprint 58) et ``equivalence_profile`` (Sprint 78). +""" + +from __future__ import annotations + +import difflib +import logging +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +def _split_words(text: Optional[str]) -> list[str]: + """Tokenisation simple par split sur whitespace.""" + if not text: + return [] + return text.split() + + +def compute_lexical_modernization( + reference: Optional[str], + hypothesis: Optional[str], + *, + stop_list: Optional[Iterable[str]] = None, + case_sensitive: bool = False, +) -> dict: + """Calcule le tableau de modernisation lexicale pour un document. + + Returns + ------- + dict + ``{ + "n_gt_tokens": int, + "tokens": { + gt_token: { + "n_total": int, + "n_modernized": int, + "rate_modernized": float, # ∈ [0, 1] + "variants": {hyp_token: count, ...}, + }, + ... + }, + }`` + Si ``reference`` est vide → ``tokens == {}``. + """ + ref_tokens = _split_words(reference) + hyp_tokens = _split_words(hypothesis) + if not ref_tokens: + return {"n_gt_tokens": 0, "tokens": {}} + + if not case_sensitive: + ref_for_match = [t.lower() for t in ref_tokens] + hyp_for_match = [t.lower() for t in hyp_tokens] + else: + ref_for_match = ref_tokens + hyp_for_match = hyp_tokens + + stop = frozenset( + (t.lower() if not case_sensitive else t) + for t in (stop_list or []) + ) + + # On accumule par gt_token (forme display = forme originale, + # match key = forme casée selon ``case_sensitive``). + tokens_data: dict[str, dict] = {} + + matcher = difflib.SequenceMatcher( + None, ref_for_match, hyp_for_match, autojunk=False, + ) + for tag, i1, i2, j1, j2 in matcher.get_opcodes(): + if tag == "equal": + for k in range(i2 - i1): + gt_orig = ref_tokens[i1 + k] + gt_match = ref_for_match[i1 + k] + if gt_match in stop: + continue + slot = tokens_data.setdefault( + gt_orig, + {"n_total": 0, "n_modernized": 0, "variants": {}}, + ) + slot["n_total"] += 1 + elif tag == "replace": + # Apparier 1-à-1 quand possible + paired = min(i2 - i1, j2 - j1) + for k in range(paired): + gt_orig = ref_tokens[i1 + k] + gt_match = ref_for_match[i1 + k] + if gt_match in stop: + continue + hyp_orig = hyp_tokens[j1 + k] + slot = tokens_data.setdefault( + gt_orig, + {"n_total": 0, "n_modernized": 0, "variants": {}}, + ) + slot["n_total"] += 1 + slot["n_modernized"] += 1 + slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1 + # Si plus de gt que de hyp, le reste des gt_tokens est + # « perdu » — on les compte comme totaux mais pas comme + # modernisés (on ne sait pas en quoi). + for k in range(paired, i2 - i1): + gt_orig = ref_tokens[i1 + k] + gt_match = ref_for_match[i1 + k] + if gt_match in stop: + continue + slot = tokens_data.setdefault( + gt_orig, + {"n_total": 0, "n_modernized": 0, "variants": {}}, + ) + slot["n_total"] += 1 + slot["n_modernized"] += 1 + slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1 + elif tag == "delete": + # gt présent, pas en hyp → modernisation par + # suppression (ou perte pure) + for k in range(i2 - i1): + gt_orig = ref_tokens[i1 + k] + gt_match = ref_for_match[i1 + k] + if gt_match in stop: + continue + slot = tokens_data.setdefault( + gt_orig, + {"n_total": 0, "n_modernized": 0, "variants": {}}, + ) + slot["n_total"] += 1 + slot["n_modernized"] += 1 + slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1 + + # Calcul du taux par token + for slot in tokens_data.values(): + total = slot["n_total"] + slot["rate_modernized"] = ( + slot["n_modernized"] / total if total > 0 else 0.0 + ) + + return { + "n_gt_tokens": len(ref_tokens), + "tokens": tokens_data, + } + + +def aggregate_lexical_modernization( + per_doc_results: Iterable[dict], +) -> dict: + """Agrège des ``compute_lexical_modernization`` per-doc. + + Renvoie la structure agrégée corpus-wide avec la même forme + que ``compute_lexical_modernization``. + """ + agg_tokens: dict[str, dict] = {} + n_gt_total = 0 + for doc_result in per_doc_results: + if not doc_result: + continue + n_gt_total += doc_result.get("n_gt_tokens", 0) + for gt, data in (doc_result.get("tokens") or {}).items(): + slot = agg_tokens.setdefault( + gt, {"n_total": 0, "n_modernized": 0, "variants": {}}, + ) + slot["n_total"] += data.get("n_total", 0) + slot["n_modernized"] += data.get("n_modernized", 0) + for hyp_t, count in (data.get("variants") or {}).items(): + slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count + + for slot in agg_tokens.values(): + total = slot["n_total"] + slot["rate_modernized"] = ( + slot["n_modernized"] / total if total > 0 else 0.0 + ) + return { + "n_gt_tokens": n_gt_total, + "tokens": agg_tokens, + } + + +def top_modernized_tokens( + data: dict, + *, + n: int = 20, + min_total: int = 1, +) -> list[tuple[str, dict]]: + """Top-N tokens GT par taux de modernisation. + + Filtre les tokens dont ``n_total < min_total`` (anecdotiques). + Tri par ``rate_modernized`` décroissant, tie-break par + ``n_total`` décroissant. + """ + tokens = data.get("tokens") or {} + candidates = [ + (gt, slot) for gt, slot in tokens.items() + if slot.get("n_total", 0) >= min_total + and slot.get("n_modernized", 0) > 0 + ] + candidates.sort( + key=lambda pair: ( + -pair[1].get("rate_modernized", 0.0), + -pair[1].get("n_total", 0), + pair[0], + ), + ) + return candidates[:n] + + +__all__ = [ + "compute_lexical_modernization", + "aggregate_lexical_modernization", + "top_modernized_tokens", +] diff --git a/picarones/evaluation/metrics/line_metrics.py b/picarones/evaluation/metrics/line_metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..5204decce03afa16ce9d4fc93e8bbb973d77f475 --- /dev/null +++ b/picarones/evaluation/metrics/line_metrics.py @@ -0,0 +1,286 @@ +"""Distribution des erreurs CER par ligne — Sprint 10. + +Métriques calculées +------------------- +- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes +- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne +- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %) +- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées) +- Carte thermique : CER moyen par tranche de position dans le document +""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass +from typing import Optional + + +# --------------------------------------------------------------------------- +# CER d'une paire de lignes (distance d'édition Levenshtein normalisée) +# --------------------------------------------------------------------------- + +def _edit_distance(a: str, b: str) -> int: + """Distance de Levenshtein entre deux chaînes.""" + if not a: + return len(b) + if not b: + return len(a) + prev = list(range(len(b) + 1)) + for i, ca in enumerate(a, 1): + curr = [i] + for j, cb in enumerate(b, 1): + cost = 0 if ca == cb else 1 + curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)) + prev = curr + return prev[-1] + + +def _line_cer(ref_line: str, hyp_line: str) -> float: + """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas.""" + ref = unicodedata.normalize("NFC", ref_line.strip()) + hyp = unicodedata.normalize("NFC", hyp_line.strip()) + if not ref: + return 0.0 if not hyp else 1.0 + dist = _edit_distance(ref, hyp) + return dist / len(ref) + + +# --------------------------------------------------------------------------- +# Percentiles (implémentation pur-Python, sans numpy) +# --------------------------------------------------------------------------- + +def _percentile(sorted_values: list[float], p: float) -> float: + """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée.""" + if not sorted_values: + return 0.0 + n = len(sorted_values) + index = p / 100 * (n - 1) + lo = int(index) + hi = min(lo + 1, n - 1) + frac = index - lo + return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) + + +# --------------------------------------------------------------------------- +# Coefficient de Gini +# --------------------------------------------------------------------------- + +def _gini(values: list[float]) -> float: + """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées). + + Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n + sur les valeurs triées par ordre croissant. + """ + if not values: + return 0.0 + xs = sorted(max(v, 0.0) for v in values) + n = len(xs) + total = sum(xs) + if total == 0.0: + return 0.0 + weighted_sum = sum((i + 1) * x for i, x in enumerate(xs)) + return (2.0 * weighted_sum) / (n * total) - (n + 1) / n + + +# --------------------------------------------------------------------------- +# Résultat structuré +# --------------------------------------------------------------------------- + +@dataclass +class LineMetrics: + """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse).""" + + cer_per_line: list[float] + """CER de chaque ligne (longueur = nombre de lignes GT).""" + + percentiles: dict[str, float] + """Percentiles : p50, p75, p90, p95, p99.""" + + catastrophic_rate: dict[str, float] + """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02}).""" + + gini: float + """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées).""" + + heatmap: list[float] + """CER moyen par tranche de position dans le document (longueur = heatmap_bins).""" + + line_count: int + """Nombre de lignes GT traitées.""" + + mean_cer: float + """CER moyen sur l'ensemble des lignes.""" + + def as_dict(self) -> dict: + return { + "cer_per_line": [round(v, 6) for v in self.cer_per_line], + "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()}, + "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()}, + "gini": round(self.gini, 6), + "heatmap": [round(v, 6) for v in self.heatmap], + "line_count": self.line_count, + "mean_cer": round(self.mean_cer, 6), + } + + @classmethod + def from_dict(cls, d: dict) -> "LineMetrics": + return cls( + cer_per_line=d.get("cer_per_line", []), + percentiles=d.get("percentiles", {}), + catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()}, + gini=d.get("gini", 0.0), + heatmap=d.get("heatmap", []), + line_count=d.get("line_count", 0), + mean_cer=d.get("mean_cer", 0.0), + ) + + +# --------------------------------------------------------------------------- +# Calcul principal +# --------------------------------------------------------------------------- + +def compute_line_metrics( + reference: str, + hypothesis: str, + thresholds: Optional[list[float]] = None, + heatmap_bins: int = 10, +) -> LineMetrics: + """Calcule la distribution des erreurs CER ligne par ligne. + + Parameters + ---------- + reference: + Texte de vérité terrain (GT) avec sauts de ligne. + hypothesis: + Texte produit par le moteur OCR. + thresholds: + Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00]. + heatmap_bins: + Nombre de tranches de position pour la carte thermique. + + Returns + ------- + LineMetrics + """ + if thresholds is None: + thresholds = [0.30, 0.50, 1.00] + + ref_lines = reference.splitlines() + hyp_lines = hypothesis.splitlines() + + # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT + n = len(ref_lines) + if n == 0: + # Pas de lignes : retourner des métriques neutres + return LineMetrics( + cer_per_line=[], + percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)}, + catastrophic_rate={t: 0.0 for t in thresholds}, + gini=0.0, + heatmap=[0.0] * heatmap_bins, + line_count=0, + mean_cer=0.0, + ) + + # Aligner en ignorant les lignes d'hypothèse supplémentaires + # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0) + cer_per_line: list[float] = [] + for i, ref_line in enumerate(ref_lines): + hyp_line = hyp_lines[i] if i < len(hyp_lines) else "" + cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0)) + + sorted_cer = sorted(cer_per_line) + + # Percentiles + percentiles = { + f"p{p}": _percentile(sorted_cer, p) + for p in (50, 75, 90, 95, 99) + } + + # Taux catastrophiques + catastrophic_rate: dict[float, float] = {} + for t in thresholds: + count = sum(1 for v in cer_per_line if v > t) + catastrophic_rate[t] = count / n + + # Gini + gini = _gini(cer_per_line) + + # Carte thermique par tranche de position + bins = heatmap_bins + heatmap: list[float] = [] + for b in range(bins): + start = int(b * n / bins) + end = int((b + 1) * n / bins) + slice_ = cer_per_line[start:end] + heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0) + + mean_cer = sum(cer_per_line) / n + + return LineMetrics( + cer_per_line=cer_per_line, + percentiles=percentiles, + catastrophic_rate=catastrophic_rate, + gini=gini, + heatmap=heatmap, + line_count=n, + mean_cer=mean_cer, + ) + + +# --------------------------------------------------------------------------- +# Agrégation sur un corpus +# --------------------------------------------------------------------------- + +def aggregate_line_metrics(results: list[LineMetrics]) -> dict: + """Agrège les métriques de distribution par ligne sur un corpus. + + Returns + ------- + dict + Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens. + """ + if not results: + return {} + + import statistics as _stats + + gini_values = [r.gini for r in results] + mean_cer_values = [r.mean_cer for r in results] + + # Percentiles moyens + pct_keys = ["p50", "p75", "p90", "p95", "p99"] + avg_percentiles = {} + for k in pct_keys: + vals = [r.percentiles.get(k, 0.0) for r in results] + avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0 + + # Taux catastrophiques moyens (union des seuils) + all_thresholds: set[float] = set() + for r in results: + all_thresholds.update(r.catastrophic_rate.keys()) + avg_catastrophic: dict[str, float] = {} + for t in sorted(all_thresholds): + vals = [r.catastrophic_rate.get(t, 0.0) for r in results] + avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0 + + # Heatmap moyenne (longueur = max des longueurs) + if results and results[0].heatmap: + n_bins = len(results[0].heatmap) + heatmap_avg = [] + for b in range(n_bins): + vals = [r.heatmap[b] for r in results if b < len(r.heatmap)] + heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0) + else: + heatmap_avg = [] + + return { + "gini_mean": round(sum(gini_values) / len(gini_values), 6), + "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0, + "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6), + "percentiles": avg_percentiles, + "catastrophic_rate": avg_catastrophic, + "heatmap": heatmap_avg, + "document_count": len(results), + } diff --git a/picarones/evaluation/metrics/longitudinal.py b/picarones/evaluation/metrics/longitudinal.py new file mode 100644 index 0000000000000000000000000000000000000000..26fe91c4530a99793c87e35fef81ffb5716df174 --- /dev/null +++ b/picarones/evaluation/metrics/longitudinal.py @@ -0,0 +1,373 @@ +"""Métriques longitudinales — Sprint 92 (A.II.9). + +Sprint 92 — A.II.9 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +L'historique SQLite (`core/history.py`, Sprint 8) collecte les +résultats de chaque run de benchmark, mais aucune métrique +n'en sortait dans le rapport. Ce module exploite la série +temporelle des CER d'un moteur pour répondre à deux +questions : + +1. **Y a-t-il une tendance ?** Régression linéaire simple + (méthode des moindres carrés) sur ``(t, CER)`` — pente, + ordonnée à l'origine, R², n_runs. Une pente > 0 signale + une régression progressive ; une pente < 0 une amélioration. + +2. **Y a-t-il un point de rupture ?** Algorithme de + change-point pur Python (différence de moyennes maximale, + variante de Pettitt simplifiée). Identifie l'index où la + série se sépare en deux segments avec moyennes les plus + différentes — typiquement le run où un modèle a changé de + comportement. + +Pas de scipy +------------ +Pour rester sans dépendance lourde, on implémente : +- la régression linéaire en pur Python (closed-form OLS) ; +- le change-point par balayage exhaustif (O(N) pour de petits + N — l'historique d'une institution dépasse rarement quelques + centaines de runs). +""" + +from __future__ import annotations + +import logging +import math +import statistics +from dataclasses import dataclass +from datetime import datetime +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class LinearTrend: + """Résultat d'une régression linéaire sur une série CER.""" + slope: float + """Pente (CER par jour). Positif = régression.""" + intercept: float + """Ordonnée à l'origine.""" + r_squared: float + """Qualité de l'ajustement, ∈ [0, 1].""" + n_runs: int + """Nombre de points utilisés.""" + + def as_dict(self) -> dict: + return { + "slope": self.slope, + "intercept": self.intercept, + "r_squared": self.r_squared, + "n_runs": self.n_runs, + } + + +@dataclass +class ChangePointResult: + """Résultat d'une détection de point de rupture.""" + index: int + """Index de la rupture (0-based, le segment 1 est [0:index], + le segment 2 est [index:N]).""" + timestamp: str + """Timestamp du run à la rupture.""" + mean_before: float + mean_after: float + delta: float + """``mean_after - mean_before``. Positif = régression.""" + n_before: int + n_after: int + + def as_dict(self) -> dict: + return { + "index": self.index, + "timestamp": self.timestamp, + "mean_before": self.mean_before, + "mean_after": self.mean_after, + "delta": self.delta, + "n_before": self.n_before, + "n_after": self.n_after, + } + + +def _parse_timestamp(ts: str) -> Optional[float]: + """Parse un ISO timestamp en jour ordinal float. + + Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne + ``None`` si non parsable. + """ + if not ts: + return None + formats = ( + "%Y-%m-%dT%H:%M:%S.%f", + "%Y-%m-%dT%H:%M:%S", + "%Y-%m-%d %H:%M:%S", + "%Y-%m-%d", + ) + for fmt in formats: + try: + dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt) + return dt.toordinal() + ( + dt.hour * 3600 + dt.minute * 60 + dt.second + ) / 86400.0 + except ValueError: + continue + return None + + +def compute_linear_trend( + cer_series: Iterable[tuple[str, float]], +) -> Optional[LinearTrend]: + """Régression linéaire OLS sur une série temporelle de CER. + + Parameters + ---------- + cer_series: + Itérable de ``(timestamp_iso, cer)``. Au moins 2 points + valides requis. + + Returns + ------- + LinearTrend | None + ``None`` si moins de 2 points ou si tous les timestamps + sont identiques (variance nulle sur t). + """ + points: list[tuple[float, float]] = [] + for ts, cer in cer_series: + t = _parse_timestamp(ts) + if t is None or cer is None: + continue + try: + cer_f = float(cer) + except (TypeError, ValueError): + continue + points.append((t, cer_f)) + n = len(points) + if n < 2: + return None + xs = [p[0] for p in points] + ys = [p[1] for p in points] + x_mean = statistics.fmean(xs) + y_mean = statistics.fmean(ys) + sxx = sum((x - x_mean) ** 2 for x in xs) + sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) + if sxx == 0: + return None + slope = sxy / sxx + intercept = y_mean - slope * x_mean + syy = sum((y - y_mean) ** 2 for y in ys) + if syy == 0: + # Tous les CER sont égaux → R² mathématiquement indéfini ; + # on retourne 1.0 (parfaite "non-tendance"). + r_squared = 1.0 + else: + ss_res = sum( + (y - (slope * x + intercept)) ** 2 + for x, y in zip(xs, ys) + ) + r_squared = max(0.0, 1.0 - ss_res / syy) + return LinearTrend( + slope=slope, + intercept=intercept, + r_squared=r_squared, + n_runs=n, + ) + + +def detect_change_point( + cer_series: Iterable[tuple[str, float]], + min_segment_size: int = 3, +) -> Optional[ChangePointResult]: + """Détecte le point de rupture maximisant l'écart de moyennes. + + Algorithme : balayage des indices ``i`` où la série se + sépare en deux segments d'au moins ``min_segment_size`` + points chacun ; on retient l'index où ``|mean_after - + mean_before|`` est maximal. Variante simplifiée de Pettitt. + + Parameters + ---------- + cer_series: + Itérable de ``(timestamp_iso, cer)``. + min_segment_size: + Taille minimale des deux segments. Défaut 3. + + Returns + ------- + ChangePointResult | None + ``None`` si la série a moins de ``2 × min_segment_size`` + points valides. + """ + points: list[tuple[str, float, float]] = [] + for ts, cer in cer_series: + t = _parse_timestamp(ts) + if t is None or cer is None: + continue + try: + cer_f = float(cer) + except (TypeError, ValueError): + continue + points.append((ts, t, cer_f)) + if len(points) < 2 * min_segment_size: + return None + points.sort(key=lambda p: p[1]) + n = len(points) + best_index = -1 + best_abs_delta = -1.0 + best_delta = 0.0 + best_mean_before = 0.0 + best_mean_after = 0.0 + for i in range(min_segment_size, n - min_segment_size + 1): + before = [p[2] for p in points[:i]] + after = [p[2] for p in points[i:]] + mean_b = statistics.fmean(before) + mean_a = statistics.fmean(after) + delta = mean_a - mean_b + abs_delta = abs(delta) + if abs_delta > best_abs_delta: + best_abs_delta = abs_delta + best_index = i + best_delta = delta + best_mean_before = mean_b + best_mean_after = mean_a + if best_index < 0: + return None + return ChangePointResult( + index=best_index, + timestamp=points[best_index][0], + mean_before=best_mean_before, + mean_after=best_mean_after, + delta=best_delta, + n_before=best_index, + n_after=n - best_index, + ) + + +def compute_engine_longitudinal( + history_entries: Iterable, + engine_name: str, + corpus_name: Optional[str] = None, + *, + min_runs_for_trend: int = 3, + min_segment_size: int = 3, + change_point_threshold: float = 0.01, +) -> Optional[dict]: + """Calcule trend + change_point pour un moteur. + + Parameters + ---------- + history_entries: + Liste de ``HistoryEntry`` (ou dicts compatibles). + engine_name: + Filtre sur le nom du moteur. + corpus_name: + Filtre optionnel sur le corpus. ``None`` (défaut) : tous + les corpus. + min_runs_for_trend: + Minimum de runs pour calculer une tendance. + min_segment_size: + Taille minimale des segments pour le change-point. + change_point_threshold: + Magnitude absolue minimale du delta (en CER) pour + retenir le change-point. Défaut 0.01 (1 point de CER). + + Returns + ------- + dict | None + ``{ + "engine_name", "corpus_name", "n_runs", "trend", + "change_point", # ou None + "first_timestamp", "last_timestamp", + "first_cer", "last_cer", "absolute_delta_pct", + }`` ou ``None`` si moins de ``min_runs_for_trend`` runs. + """ + series: list[tuple[str, float]] = [] + for entry in history_entries: + if hasattr(entry, "as_dict"): + data = entry.as_dict() + else: + data = entry + if data.get("engine_name") != engine_name: + continue + if corpus_name is not None and data.get("corpus_name") != corpus_name: + continue + cer = data.get("cer_mean") + ts = data.get("timestamp") + if cer is None or ts is None: + continue + series.append((ts, float(cer))) + if len(series) < min_runs_for_trend: + return None + series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0) + trend = compute_linear_trend(series) + cp = detect_change_point(series, min_segment_size=min_segment_size) + if cp is not None and abs(cp.delta) < change_point_threshold: + cp = None + first_ts, first_cer = series[0] + last_ts, last_cer = series[-1] + return { + "engine_name": engine_name, + "corpus_name": corpus_name, + "n_runs": len(series), + "trend": trend.as_dict() if trend else None, + "change_point": cp.as_dict() if cp else None, + "first_timestamp": first_ts, + "last_timestamp": last_ts, + "first_cer": first_cer, + "last_cer": last_cer, + "absolute_delta": last_cer - first_cer, + "absolute_delta_pct": round((last_cer - first_cer) * 100, 2), + } + + +def compute_corpus_longitudinal( + history_entries: Iterable, + corpus_name: Optional[str] = None, + *, + min_runs_for_trend: int = 3, + min_segment_size: int = 3, + change_point_threshold: float = 0.01, +) -> list[dict]: + """Pour chaque moteur présent dans l'historique sur ``corpus_name``, + calcule trend + change_point. + + Returns + ------- + list[dict] + Une entrée par moteur (filtrée), liste vide si rien. + """ + entries = list(history_entries) + engines: set[str] = set() + for entry in entries: + data = entry.as_dict() if hasattr(entry, "as_dict") else entry + if corpus_name is not None and data.get("corpus_name") != corpus_name: + continue + name = data.get("engine_name") + if name: + engines.add(name) + out: list[dict] = [] + for engine in sorted(engines): + result = compute_engine_longitudinal( + entries, engine, corpus_name=corpus_name, + min_runs_for_trend=min_runs_for_trend, + min_segment_size=min_segment_size, + change_point_threshold=change_point_threshold, + ) + if result is not None: + out.append(result) + return out + + +__all__ = [ + "LinearTrend", + "ChangePointResult", + "compute_linear_trend", + "detect_change_point", + "compute_engine_longitudinal", + "compute_corpus_longitudinal", +] + + +# Marqueur d'évitement d'import inutilisé (math) +_ = math diff --git a/picarones/evaluation/metrics/marginal_cost.py b/picarones/evaluation/metrics/marginal_cost.py new file mode 100644 index 0000000000000000000000000000000000000000..4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de --- /dev/null +++ b/picarones/evaluation/metrics/marginal_cost.py @@ -0,0 +1,142 @@ +"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2). + +Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas +quel surcoût est *raisonnable* pour quelle réduction d'erreur. +Une institution avec un budget contraint a besoin d'une +réponse opérationnelle : + + *« Passer de Tesseract à Mistral OCR coûte 0,83 € par + erreur évitée — décider selon votre budget par millier + d'erreurs corrigées. »* + +Formule +------- +Pour deux moteurs A et B où B fait **moins** d'erreurs que A +(donc B est plus précis) : + +.. code:: + + coût_marginal = (coût_B − coût_A) / (errors_A − errors_B) + +- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` : + ``cost_per_avoided_error > 0`` (cas standard, B coûte plus + pour moins d'erreurs). +- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` : + ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement + meilleur). +- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens + (B n'évite pas d'erreur), retourne ``None``. + +Sortie +------ +``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)`` +retourne ``{cost_per_avoided_error, n_errors_avoided, +cost_delta, dominated}`` ou ``None`` si non comparable. + +``compute_marginal_cost_matrix(per_engine)`` retourne, pour +chaque paire ordonnée ``(A → B)`` où B est plus précis, le +coût marginal correspondant. Trié par coût marginal croissant +(meilleur ratio en tête). +""" + +from __future__ import annotations + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +def compute_marginal_cost( + cost_a: float, + errors_a: float, + cost_b: float, + errors_b: float, +) -> Optional[dict]: + """Coût marginal du passage A → B (B plus précis). + + Retourne ``None`` si : + - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ; + - les valeurs ne sont pas finies. + """ + try: + ca = float(cost_a) + cb = float(cost_b) + ea = float(errors_a) + eb = float(errors_b) + except (TypeError, ValueError): + return None + if ea <= eb: + # B ne fait pas mieux que A → pas de gain à mesurer. + return None + n_avoided = ea - eb + cost_delta = cb - ca + cost_per_avoided = cost_delta / n_avoided + dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal + return { + "cost_per_avoided_error": cost_per_avoided, + "n_errors_avoided": n_avoided, + "cost_delta": cost_delta, + "dominated": dominated, + } + + +def compute_marginal_cost_matrix( + per_engine: dict[str, dict], +) -> Optional[dict]: + """Pour chaque paire A → B où B fait moins d'erreurs, calcule + le coût marginal. + + Parameters + ---------- + per_engine: + Map ``{engine_name: {"cost": float, "errors": float}}``. + + Returns + ------- + dict | None + ``{ + "pairs": list[ + {"engine_a", "engine_b", "cost_per_avoided_error", + "n_errors_avoided", "cost_delta", "dominated"} + ], # triée par cost_per_avoided_error croissant + }`` + ou ``None`` si moins de 2 moteurs. + """ + if not per_engine or len(per_engine) < 2: + return None + engines = sorted(per_engine.keys()) + pairs: list[dict] = [] + for a in engines: + for b in engines: + if a == b: + continue + data_a = per_engine[a] + data_b = per_engine[b] + try: + ca = float(data_a.get("cost")) + ea = float(data_a.get("errors")) + cb = float(data_b.get("cost")) + eb = float(data_b.get("errors")) + except (TypeError, ValueError): + continue + result = compute_marginal_cost(ca, ea, cb, eb) + if result is None: + continue + entry = {"engine_a": a, "engine_b": b} + entry.update(result) + pairs.append(entry) + if not pairs: + return None + pairs.sort(key=lambda p: p["cost_per_avoided_error"]) + return {"pairs": pairs} + + +__all__ = [ + "compute_marginal_cost", + "compute_marginal_cost_matrix", +] diff --git a/picarones/evaluation/metrics/module_policy.py b/picarones/evaluation/metrics/module_policy.py new file mode 100644 index 0000000000000000000000000000000000000000..326b9685bd5d16b555a33bd2b875a3e6ab0e4625 --- /dev/null +++ b/picarones/evaluation/metrics/module_policy.py @@ -0,0 +1,333 @@ +"""Politique de modules contribués — Sprint 97 (B.6). + +Sprint 97 — B.6 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Avant d'ouvrir Picarones aux contributions externes (axe B — +modules tiers que l'utilisateur amène), il faut un cadre de +qualité explicite : *« un module qui ne passe pas l'audit +n'est pas exécutable. »* + +Ce module fournit l'**enveloppe d'audit** : + +- ``ModuleManifest`` — métadonnées obligatoires (auteur, + licence, version, citation, contrat d'entrée/sortie typé). +- ``validate_manifest(manifest)`` — vérifie que tous les champs + obligatoires sont présents et bien formés. +- ``audit_module(module_class_or_instance, manifest)`` — + vérifie en plus que la classe respecte le contrat ``BaseModule`` + et que ``input_types``/``output_types`` correspondent au + manifeste. +- ``AuditResult`` — verdict structuré ``passed/failed`` + liste + des checks détaillés. + +Stratégie d'ouverture +--------------------- +Phase fermée actuelle : modules officiels uniquement, +contributions via PR sur le repo principal. Phase ouverte +future : une fois 5–6 modules officiels stables, ouverture via +``entry_points`` sur PyPI (``picarones-module-X``). Ce module +prépare la phase ouverte sans la déclencher : tout module +externe devra fournir un ``ModuleManifest`` valide pour être +exécuté. + +Pas de SPDX validator +--------------------- +On vérifie la présence et la non-vacuité des champs licence ; +on ne valide pas la conformité SPDX du nom (``MIT`` vs +``mit-license`` vs ``MIT License``). Le chercheur reste +responsable du choix de licence ; l'outil documente, il ne +juge pas. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Optional + +logger = logging.getLogger(__name__) + + +# Champs obligatoires d'un ManifestModule (texte non-vide). +_REQUIRED_TEXT_FIELDS = ( + "name", "version", "author", "license", + "description", +) + + +@dataclass +class ModuleManifest: + """Métadonnées d'un module contribué. + + Attributes + ---------- + name: + Identifiant unique du module (ex. ``"my-llm-correcteur"``). + version: + Version sémantique (ex. ``"1.2.0"``). + author: + Auteur ou institution responsable. + license: + Identifiant de licence (SPDX recommandé, non validé). + description: + Description courte (≤ 1 phrase). + input_types: + Liste des types d'entrée (chaînes). Doit correspondre + à ``module.input_types`` (Sprint 33). + output_types: + Liste des types de sortie. Doit correspondre à + ``module.output_types``. + citation: + Citation académique (BibTeX, DOI, ou texte libre). + Optionnel. + homepage: + URL du dépôt ou de la page projet. Optionnel. + picarones_min_version: + Version minimale de Picarones requise. Optionnel. + extra: + Métadonnées libres (clé → valeur). + """ + + name: str + version: str + author: str + license: str + description: str + input_types: list[str] = field(default_factory=list) + output_types: list[str] = field(default_factory=list) + citation: Optional[str] = None + homepage: Optional[str] = None + picarones_min_version: Optional[str] = None + extra: dict = field(default_factory=dict) + + def as_dict(self) -> dict: + return { + "name": self.name, + "version": self.version, + "author": self.author, + "license": self.license, + "description": self.description, + "input_types": list(self.input_types), + "output_types": list(self.output_types), + "citation": self.citation, + "homepage": self.homepage, + "picarones_min_version": self.picarones_min_version, + "extra": dict(self.extra), + } + + +@dataclass +class AuditCheck: + """Un check individuel de l'audit.""" + + name: str + passed: bool + detail: Optional[str] = None + + def as_dict(self) -> dict: + return { + "name": self.name, + "passed": self.passed, + "detail": self.detail, + } + + +@dataclass +class AuditResult: + """Résultat global d'un audit de module.""" + + module_name: str + passed: bool + checks: list[AuditCheck] = field(default_factory=list) + + @property + def n_passed(self) -> int: + return sum(1 for c in self.checks if c.passed) + + @property + def n_failed(self) -> int: + return sum(1 for c in self.checks if not c.passed) + + def as_dict(self) -> dict: + return { + "module_name": self.module_name, + "passed": self.passed, + "n_passed": self.n_passed, + "n_failed": self.n_failed, + "checks": [c.as_dict() for c in self.checks], + } + + +def validate_manifest(manifest: ModuleManifest) -> list[AuditCheck]: + """Vérifie qu'un manifest est complet et bien formé. + + Returns + ------- + list[AuditCheck] + Un check par champ obligatoire + un check pour + ``input_types``/``output_types`` non vides. + """ + checks: list[AuditCheck] = [] + for field_name in _REQUIRED_TEXT_FIELDS: + value = getattr(manifest, field_name, None) + ok = isinstance(value, str) and bool(value.strip()) + checks.append(AuditCheck( + name=f"manifest.{field_name}", + passed=ok, + detail=None if ok else f"champ '{field_name}' vide ou absent", + )) + # input_types / output_types : au moins une entrée chacun + in_ok = ( + isinstance(manifest.input_types, list) + and len(manifest.input_types) > 0 + and all( + isinstance(t, str) and t for t in manifest.input_types + ) + ) + checks.append(AuditCheck( + name="manifest.input_types", + passed=in_ok, + detail=None if in_ok else "input_types vide ou non-string", + )) + out_ok = ( + isinstance(manifest.output_types, list) + and len(manifest.output_types) > 0 + and all( + isinstance(t, str) and t for t in manifest.output_types + ) + ) + checks.append(AuditCheck( + name="manifest.output_types", + passed=out_ok, + detail=None if out_ok else "output_types vide ou non-string", + )) + return checks + + +def _is_base_module(cls: Any) -> bool: + """Best-effort : vérifie que cls hérite de BaseModule. + + On ne **pas** importer ``BaseModule`` au top-level pour + éviter les cycles : on inspecte la chaîne de classes par + leur nom. + """ + try: + for base in cls.__mro__: + if base.__name__ == "BaseModule": + return True + except AttributeError: + return False + return False + + +def audit_module( + module_class_or_instance: Any, + manifest: ModuleManifest, +) -> AuditResult: + """Audite un module contribué : interface + manifest. + + Parameters + ---------- + module_class_or_instance: + Soit la classe ``BaseModule`` (Sprint 33), soit une + instance. + manifest: + ``ModuleManifest`` correspondant au module. + + Returns + ------- + AuditResult + ``passed=True`` ssi tous les checks passent. + """ + checks = validate_manifest(manifest) + + # Check : héritage de BaseModule + cls = ( + type(module_class_or_instance) + if not isinstance(module_class_or_instance, type) + else module_class_or_instance + ) + inherits_base = _is_base_module(cls) + checks.append(AuditCheck( + name="module.inherits_base_module", + passed=inherits_base, + detail=( + None if inherits_base + else "la classe n'hérite pas de picarones.core.modules.BaseModule" + ), + )) + + # Check : input_types / output_types correspondent + declared_in: list[str] = [] + declared_out: list[str] = [] + try: + instance = ( + module_class_or_instance + if not isinstance(module_class_or_instance, type) + else None + ) + attr_in = getattr(cls, "input_types", None) + attr_out = getattr(cls, "output_types", None) + if instance is not None: + attr_in = getattr(instance, "input_types", attr_in) + attr_out = getattr(instance, "output_types", attr_out) + if attr_in is not None: + declared_in = [ + getattr(t, "value", str(t)) for t in attr_in + ] + if attr_out is not None: + declared_out = [ + getattr(t, "value", str(t)) for t in attr_out + ] + except Exception: # noqa: BLE001 + pass + # Comparaison case-insensitive : on accepte "TEXT" ou "text" + # côté manifest, le contrat sémantique est le même. + declared_in_lower = sorted(t.lower() for t in declared_in) + declared_out_lower = sorted(t.lower() for t in declared_out) + manifest_in_lower = sorted(t.lower() for t in manifest.input_types) + manifest_out_lower = sorted(t.lower() for t in manifest.output_types) + in_match = declared_in_lower == manifest_in_lower + checks.append(AuditCheck( + name="module.input_types_match_manifest", + passed=in_match, + detail=( + None if in_match + else f"déclaré {declared_in} vs manifest {manifest.input_types}" + ), + )) + out_match = declared_out_lower == manifest_out_lower + checks.append(AuditCheck( + name="module.output_types_match_manifest", + passed=out_match, + detail=( + None if out_match + else f"déclaré {declared_out} vs manifest {manifest.output_types}" + ), + )) + + # Check : process callable + has_process = callable(getattr(cls, "process", None)) + checks.append(AuditCheck( + name="module.has_process", + passed=has_process, + detail=None if has_process else "méthode process() absente", + )) + + passed = all(c.passed for c in checks) + return AuditResult( + module_name=manifest.name, + passed=passed, + checks=checks, + ) + + +__all__ = [ + "ModuleManifest", + "AuditCheck", + "AuditResult", + "validate_manifest", + "audit_module", +] diff --git a/picarones/evaluation/metrics/pricing.py b/picarones/evaluation/metrics/pricing.py new file mode 100644 index 0000000000000000000000000000000000000000..5f4c0af4de7bd2714dd76a07e0a5a4b2461526af --- /dev/null +++ b/picarones/evaluation/metrics/pricing.py @@ -0,0 +1,313 @@ +"""Modélisation des coûts — APIs cloud et temps d'inférence local. + +Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5). +Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml`` +pour les hypothèses, dates et URLs de référence. + +Conventions +----------- +- Unité monétaire : EUR (conversion indicative depuis USD quand applicable). +- Coût exprimé par **1 000 pages** traitées. +- Coût local = temps moyen d'inférence × taux horaire (paramétrable). +- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau + d'exécution (mix France bas carbone par défaut pour le local, + moyenne cloud hyperscaler pour les APIs). +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from pathlib import Path +from typing import Optional + +import yaml + +logger = logging.getLogger(__name__) + +# Sprint A14-S10 — chemin ajusté après déplacement de +# ``picarones/measurements/pricing.py`` vers +# ``picarones/evaluation/metrics/pricing.py``. Le YAML reste dans +# ``picarones/data/``, donc on remonte de 3 niveaux au lieu de 2. +_DEFAULT_PRICING_PATH = Path(__file__).parent.parent.parent / "data" / "pricing.yaml" + + +@dataclass(frozen=True) +class PricingDefaults: + """Valeurs par défaut du fichier de prix (section ``meta``).""" + + last_updated: Optional[str] = None + currency: str = "EUR" + hourly_rate_local_cpu_eur: float = 0.08 + hourly_rate_local_gpu_eur: float = 1.20 + grid_intensity_local: float = 58.0 + grid_intensity_cloud: float = 380.0 + + +@dataclass +class EngineCost: + """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses. + + La représentation est immuable après construction : une fois que l'utilisateur + a choisi un taux horaire local, toutes les instances partagent cette + hypothèse par injection explicite dans ``build_costs_for_benchmark``. + """ + + engine_key: str + """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``).""" + + type: str # "local" | "cloud_api" | "unknown" + + cost_per_1k_pages_eur: Optional[float] = None + """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes.""" + + currency: str = "EUR" + + # Source / date + pricing_source_url: Optional[str] = None + pricing_date: Optional[str] = None + + # Pour les APIs cloud : prix brut + api_price_per_1k_pages: Optional[float] = None + + # Pour le local : temps d'inférence et taux horaire utilisés + local_mean_seconds_per_page: Optional[float] = None + hourly_rate_eur: Optional[float] = None + + # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport) + kwh_per_1k_pages: Optional[float] = None + grid_intensity_g_co2_per_kwh: Optional[float] = None + co2_per_1k_pages_g: Optional[float] = None + + notes: Optional[str] = None + + assumptions: list[str] = field(default_factory=list) + """Liste d'hypothèses textuelles à afficher sous le graphique.""" + + def as_dict(self) -> dict: + return { + "engine_key": self.engine_key, + "type": self.type, + "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, + "currency": self.currency, + "pricing_source_url": self.pricing_source_url, + "pricing_date": self.pricing_date, + "api_price_per_1k_pages": self.api_price_per_1k_pages, + "local_mean_seconds_per_page": self.local_mean_seconds_per_page, + "hourly_rate_eur": self.hourly_rate_eur, + "kwh_per_1k_pages": self.kwh_per_1k_pages, + "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh, + "co2_per_1k_pages_g": self.co2_per_1k_pages_g, + "notes": self.notes, + "assumptions": list(self.assumptions), + } + + +def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]: + """Charge la table de prix YAML. + + Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict + ``{engine_key: raw_entry}``. + """ + path = Path(path) if path else _DEFAULT_PRICING_PATH + if not path.exists(): + logger.warning("[pricing] fichier %s introuvable", path) + return PricingDefaults(), {} + try: + with path.open(encoding="utf-8") as fh: + data = yaml.safe_load(fh) or {} + except yaml.YAMLError as e: + logger.warning("[pricing] échec parsing %s : %s", path, e) + return PricingDefaults(), {} + + meta = data.get("meta", {}) or {} + defaults = PricingDefaults( + last_updated=meta.get("last_updated"), + currency=meta.get("currency", "EUR"), + hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)), + hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)), + grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)), + grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)), + ) + engines_table = data.get("engines", {}) or {} + return defaults, engines_table + + +def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]: + """Cherche la meilleure clé pour ce moteur dans la table. + + Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le + nom OCR, puis un match partiel (substring) comme filet de sécurité. + """ + candidates = [llm_model, engine_name] + for c in candidates: + if c and c in table: + return c + # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision" + for c in candidates: + if not c: + continue + for key in table: + if key in c: + return key + return None + + +def estimate_cost( + engine_name: str, + *, + llm_model: Optional[str] = None, + is_pipeline: bool = False, + measured_seconds_per_page: Optional[float] = None, + table: Optional[dict] = None, + defaults: Optional[PricingDefaults] = None, + hourly_rate_override_eur: Optional[float] = None, +) -> EngineCost: + """Calcule le ``EngineCost`` pour un moteur donné. + + Parameters + ---------- + engine_name: + Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``). + llm_model: + Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la + lookup car c'est lui qui domine le coût. + is_pipeline: + Indique un pipeline OCR+LLM (change la sémantique de lookup). + measured_seconds_per_page: + Temps moyen observé sur le benchmark courant. Remplace la valeur + indicative de la table si fournie (plus fiable). + table, defaults: + Overrides pour tests ou usage institutionnel. + hourly_rate_override_eur: + Taux horaire à utiliser pour le calcul local (sinon valeur table + ou défaut). + """ + if table is None or defaults is None: + _defaults, _table = load_pricing_database() + defaults = defaults or _defaults + table = table or _table + + key = _match_key(engine_name, llm_model if is_pipeline else None, table) + if key is None: + return EngineCost( + engine_key=engine_name, + type="unknown", + assumptions=["Aucune entrée dans la table de prix pour ce moteur."], + ) + + entry = table[key] + etype = str(entry.get("type", "unknown")) + notes = entry.get("notes") + assumptions: list[str] = [] + currency = defaults.currency + + cost_eur: Optional[float] = None + api_price: Optional[float] = None + local_seconds = measured_seconds_per_page + hourly_rate = None + + if etype == "cloud_api": + api_price = entry.get("api_price_per_1k_pages") + if api_price is not None: + cost_eur = float(api_price) + assumptions.append( + f"Prix API indicatif : {cost_eur:.2f} €/1000 pages " + f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})." + ) + elif etype == "local": + indicative_seconds = entry.get("local_mean_seconds_per_page") + if local_seconds is None and indicative_seconds is not None: + local_seconds = float(indicative_seconds) + assumptions.append( + f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)." + ) + elif local_seconds is not None: + assumptions.append( + f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)." + ) + + hourly_rate = ( + hourly_rate_override_eur + if hourly_rate_override_eur is not None + else entry.get("hourly_rate_override_eur") + ) + if hourly_rate is None: + # Heuristique : si l'entrée précise un override GPU, sinon CPU + hourly_rate = ( + defaults.hourly_rate_local_gpu_eur + if "gpu" in str(notes or "").lower() + else defaults.hourly_rate_local_cpu_eur + ) + hourly_rate = float(hourly_rate) + + if local_seconds is not None and hourly_rate is not None: + cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0 + assumptions.append( + f"Taux horaire appliqué : {hourly_rate:.2f} €/h " + f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})." + ) + + # Empreinte carbone optionnelle + kwh_1k = entry.get("kwh_per_1k_pages") + grid = ( + entry.get("grid_intensity_g_co2_per_kwh") + or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local) + ) + co2_g = None + if kwh_1k is not None and grid is not None: + co2_g = float(kwh_1k) * float(grid) + + return EngineCost( + engine_key=key, + type=etype, + cost_per_1k_pages_eur=cost_eur, + currency=currency, + pricing_source_url=entry.get("pricing_source_url"), + pricing_date=entry.get("pricing_date"), + api_price_per_1k_pages=api_price, + local_mean_seconds_per_page=local_seconds, + hourly_rate_eur=hourly_rate, + kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None, + grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None, + co2_per_1k_pages_g=co2_g, + notes=notes, + assumptions=assumptions, + ) + + +def build_costs_for_benchmark( + engines_summary: list[dict], + durations_by_engine: dict[str, float], + *, + hourly_rate_local_eur: Optional[float] = None, + pricing_path: Optional[Path] = None, +) -> dict[str, dict]: + """Calcule le coût de chaque moteur d'un benchmark. + + Returns + ------- + dict ``{engine_name: EngineCost.as_dict()}``. + """ + defaults, table = load_pricing_database(pricing_path) + out: dict[str, dict] = {} + for e in engines_summary: + name = e.get("name") + if not name: + continue + measured = durations_by_engine.get(name) + llm_model = None + pipeline_info = e.get("pipeline_info") or {} + if pipeline_info: + llm_model = pipeline_info.get("llm_model") + cost = estimate_cost( + engine_name=name, + llm_model=llm_model, + is_pipeline=bool(e.get("is_pipeline")), + measured_seconds_per_page=measured, + table=table, + defaults=defaults, + hourly_rate_override_eur=hourly_rate_local_eur, + ) + out[name] = cost.as_dict() + return out diff --git a/picarones/evaluation/metrics/rare_tokens.py b/picarones/evaluation/metrics/rare_tokens.py new file mode 100644 index 0000000000000000000000000000000000000000..69f320e2c1b1922285c16f708f74240b51713709 --- /dev/null +++ b/picarones/evaluation/metrics/rare_tokens.py @@ -0,0 +1,254 @@ +"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026). + +Pourquoi ce module +------------------ +Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en +masquant des **erreurs systématiques sur les tokens rares** : noms +propres, toponymes peu fréquents, mots techniques, formules latines +récurrentes mais pas dominantes. Pour un usage prosopographique +(indexation de noms, recherche généalogique), ce sont précisément +ces tokens-là qui comptent. + +Ce module mesure le **rappel sur les tokens rares** d'un corpus — +défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax + +dis legomena, terminologie de lexicométrie classique). + +Hypothèse à valider expérimentalement +------------------------------------- +La conjecture du plan A.I.1 : *« cette métrique discrimine plus +les moteurs que le CER global »*. Si confirmée sur un corpus +patrimonial réel, elle gagne sa place dans le tableau de +classement principal — décision laissée au chercheur après +observation. + +Stratégie de découpage +---------------------- +Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche +de calcul pure d'abord, sans intégration runner. La vue HTML +« worst lines / rare tokens manqués » suit dans un sprint dédié. + +Pas d'enregistrement dans le registre typé Sprint 34 +---------------------------------------------------- +La métrique exige **trois entrées** (reference, hypothesis, set +des tokens rares) et le set des rares est calculé corpus-wide +(donc connu seulement après itération sur tout le corpus). La +signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur +appelle explicitement ``compute_rare_token_recall`` avec le set +qu'il a calculé. +""" + +from __future__ import annotations + +import logging +import re +from collections import Counter +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────────── +# Tokenisation Unicode-aware +# ────────────────────────────────────────────────────────────────────────── + +# Token = séquence maximale de caractères de mot Unicode (\w en +# Python 3 utilise déjà la table Unicode), incluant l'apostrophe +# typographique '’' à l'intérieur (« l'an », « d’une ») et les +# tirets internes (« peut-être »). La ponctuation isolée et les +# espaces sont des séparateurs. + +_TOKEN_RE = re.compile( + r"\w+(?:[’'\-]\w+)*", + flags=re.UNICODE, +) + + +def tokenize(text: Optional[str]) -> list[str]: + """Tokenisation Unicode-aware. + + Conserve les contractions (``l'an``, ``d’une``) et les mots + composés (``peut-être``, ``c'est-à-dire``) comme un seul token. + Casse préservée — l'utilisateur normalise lui-même via + ``case_sensitive=False`` dans les fonctions aval s'il le veut. + """ + if not text: + return [] + return _TOKEN_RE.findall(text) + + +# ────────────────────────────────────────────────────────────────────────── +# Distribution de fréquence corpus-wide +# ────────────────────────────────────────────────────────────────────────── + + +def frequency_distribution( + documents: Iterable[str], + *, + case_sensitive: bool = False, +) -> Counter[str]: + """Calcule ``{token: count}`` sur l'ensemble du corpus. + + Parameters + ---------- + documents: + Itérable de textes (typiquement les ``ground_truth`` des + documents du corpus). + case_sensitive: + Si ``False`` (défaut), tous les tokens sont mis en + minuscule avant comptage. + """ + counter: Counter[str] = Counter() + for doc in documents: + tokens = tokenize(doc) + if not case_sensitive: + tokens = [t.lower() for t in tokens] + counter.update(tokens) + return counter + + +def extract_rare_tokens( + documents: Iterable[str], + *, + max_freq: int = 2, + case_sensitive: bool = False, +) -> frozenset[str]: + """Retourne l'ensemble des tokens dont la fréquence + corpus-wide est ``≤ max_freq``. + + Convention de lexicométrie : ``max_freq=1`` retourne uniquement + les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne + hapax + dis legomena (≤ 2 occurrences) — défaut. + + Les tokens qui n'apparaissent **jamais** dans le corpus ne sont + évidemment pas inclus (le ``Counter`` ne les liste pas). + """ + if max_freq < 1: + raise ValueError("max_freq doit être ≥ 1") + counter = frequency_distribution( + documents, case_sensitive=case_sensitive, + ) + return frozenset(t for t, c in counter.items() if c <= max_freq) + + +# ────────────────────────────────────────────────────────────────────────── +# Calcul du rappel par document +# ────────────────────────────────────────────────────────────────────────── + + +def compute_rare_token_recall( + reference: Optional[str], + hypothesis: Optional[str], + rare_tokens: Iterable[str], + *, + case_sensitive: bool = False, +) -> dict: + """Calcule le rappel sur les tokens rares présents dans la GT. + + Parameters + ---------- + reference: + Texte GT du document. + hypothesis: + Texte produit par l'OCR. + rare_tokens: + Itérable des tokens rares — typiquement le résultat de + ``extract_rare_tokens`` sur le corpus complet. + case_sensitive: + Si ``False`` (défaut), la comparaison se fait sur les + formes minuscules. + + Returns + ------- + dict + ``{ + "n_rare_tokens_in_reference": int, + # nombre d'**occurrences** de tokens rares dans la GT + # (multiplicité préservée — un token rare présent 2 + # fois compte 2) + "n_rare_tokens_recalled": int, + # nombre d'occurrences correctement présentes dans hyp + # (alignement bag-of-tokens : min(count_ref, count_hyp)) + "recall": float, + # ratio dans [0, 1], ou 0.0 si aucun rare en GT + "missed_tokens": list[str], + # liste des tokens rares **manqués** (avec multiplicité, + # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp → + # missed_tokens contient ["Dupont"] une fois) + }`` + + Cas dégénérés + ------------- + - GT vide ou aucun token rare présent → recall = 0.0, listes + vides (convention : on ne récompense pas l'absence de + tokens rares). + - Hyp vide avec rares en GT → tous manqués, recall = 0.0. + """ + ref = reference or "" + hyp = hypothesis or "" + + if case_sensitive: + rare_set = frozenset(rare_tokens) + ref_tokens = tokenize(ref) + hyp_tokens = tokenize(hyp) + else: + rare_set = frozenset(t.lower() for t in rare_tokens) + ref_tokens = [t.lower() for t in tokenize(ref)] + hyp_tokens = [t.lower() for t in tokenize(hyp)] + + # Multiplicité : on compte uniquement les rares présents dans la GT + ref_rare_counts: Counter[str] = Counter( + t for t in ref_tokens if t in rare_set + ) + n_rare_in_ref = sum(ref_rare_counts.values()) + if n_rare_in_ref == 0: + return { + "n_rare_tokens_in_reference": 0, + "n_rare_tokens_recalled": 0, + "recall": 0.0, + "missed_tokens": [], + } + + # Bag-of-tokens dans hyp pour les tokens rares uniquement + hyp_rare_counts: Counter[str] = Counter( + t for t in hyp_tokens if t in rare_set + ) + # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count) + n_recalled = 0 + missed: list[str] = [] + for token, ref_count in ref_rare_counts.items(): + hyp_count = hyp_rare_counts.get(token, 0) + recalled = min(ref_count, hyp_count) + n_recalled += recalled + missed_count = ref_count - recalled + if missed_count > 0: + missed.extend([token] * missed_count) + + return { + "n_rare_tokens_in_reference": n_rare_in_ref, + "n_rare_tokens_recalled": n_recalled, + "recall": n_recalled / n_rare_in_ref, + "missed_tokens": missed, + } + + +def rare_token_recall( + reference: Optional[str], + hypothesis: Optional[str], + rare_tokens: Iterable[str], + *, + case_sensitive: bool = False, +) -> float: + """Raccourci : retourne uniquement le rappel ∈ [0, 1].""" + return compute_rare_token_recall( + reference, hypothesis, rare_tokens, + case_sensitive=case_sensitive, + )["recall"] + + +__all__ = [ + "tokenize", + "frequency_distribution", + "extract_rare_tokens", + "compute_rare_token_recall", + "rare_token_recall", +] diff --git a/picarones/evaluation/metrics/robustness_projection.py b/picarones/evaluation/metrics/robustness_projection.py new file mode 100644 index 0000000000000000000000000000000000000000..dc6c66a0a62c62e6a70839288e08c85a415a7c0c --- /dev/null +++ b/picarones/evaluation/metrics/robustness_projection.py @@ -0,0 +1,287 @@ +"""Projection de robustesse synthétique sur le corpus réel — +Sprint 81 (A.I.8). + +Sprint 81 — A.I.8 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le module ``picarones/core/robustness.py`` (Sprint 8) génère des +courbes CER vs niveau de dégradation **synthétique** (bruit, flou, +rotation, résolution). ``picarones/core/image_quality.py`` mesure +le bruit/flou/contraste **réels** des images du corpus. Ce +sprint **projette** les caractéristiques réelles sur les courbes +synthétiques pour estimer le **déficit attendu de CER** sur le +corpus dans son état actuel. + +Lecture concrète +---------------- +*« 30 % de vos documents ont un bruit équivalent à σ=15 où +Tesseract perd 8 points de CER — soit un déficit attendu global +de 2,4 points (30 % × 8 points). »* + +Méthode +------- +1. Pour chaque document, on extrait la valeur de qualité réelle + (``noise_level``, ``blur_score``, ``contrast_score``…) depuis + ``ImageQualityResult``. +2. Pour chaque type de dégradation, on interpole linéairement la + ``DegradationCurve`` synthétique : CER attendu à ce niveau. +3. On agrège : CER moyen attendu, % docs au-dessus du seuil + critique de la courbe, déficit projeté = CER_attendu - + CER_baseline (niveau nul). + +Sortie +------ +``project_robustness_on_corpus(curves, image_qualities)`` retourne +``{engine_name: {degradation_type: {expected_cer_mean, +deficit_vs_baseline, n_docs_above_critical, n_docs}}}``. + +Limites +------- +- Mapping ``image_quality → degradation level`` : on suppose que + ``noise_level`` (ImageQualityResult) correspond à σ + (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de + flou. Si un corpus expose ces valeurs avec une échelle + différente, le mapping est documenté et l'utilisateur peut + passer ``quality_to_level`` custom. +- Interpolation **linéaire** entre les points de la courbe. Au- + delà des bornes, on **clip** au point extrême (pas + d'extrapolation hasardeuse). +""" + +from __future__ import annotations + +import logging +import statistics +from typing import Callable, Iterable, Optional + +logger = logging.getLogger(__name__) + + +# Mapping par défaut entre attributs ImageQualityResult et types +# de dégradation synthétique. L'utilisateur peut passer un dict +# custom pour modifier ce mapping. +_DEFAULT_QUALITY_FIELD: dict[str, str] = { + "noise": "noise_level", # σ + "blur": "blur_score", # Variance laplacienne (inverse) + "contrast": "contrast_score", + "rotation": "rotation_angle", + "resolution": "resolution_score", # peut être absent +} + + +def _interpolate_cer( + levels: list[float], + cer_values: list[Optional[float]], + target_level: float, +) -> Optional[float]: + """Interpolation linéaire : retourne CER attendu à + ``target_level``. + + - Si ``target_level`` est en-dessous du minimum de levels, + retourne le CER au minimum (clip). + - Si au-dessus du maximum, retourne le CER au maximum. + - Sinon, interpolation linéaire entre les deux points + encadrants. + - Retourne ``None`` si aucun ``cer_value`` valide. + """ + if not levels: + return None + # Filtrer les paires (level, cer) où cer est None + pairs = [ + (lvl, cer) for lvl, cer in zip(levels, cer_values) + if cer is not None + ] + if not pairs: + return None + pairs.sort(key=lambda p: p[0]) + # Clip + if target_level <= pairs[0][0]: + return pairs[0][1] + if target_level >= pairs[-1][0]: + return pairs[-1][1] + # Interpolation + for i in range(len(pairs) - 1): + lo_lvl, lo_cer = pairs[i] + hi_lvl, hi_cer = pairs[i + 1] + if lo_lvl <= target_level <= hi_lvl: + if hi_lvl == lo_lvl: + return lo_cer + ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl) + return lo_cer + (hi_cer - lo_cer) * ratio + return None # ne devrait pas arriver + + +def _extract_quality_value( + quality: dict, degradation_type: str, + custom_mapping: Optional[dict[str, str]] = None, +) -> Optional[float]: + """Extrait la valeur de qualité pertinente pour un type de + dégradation depuis un ``ImageQualityResult.as_dict()``.""" + mapping = custom_mapping or _DEFAULT_QUALITY_FIELD + field = mapping.get(degradation_type) + if field is None: + return None + value = quality.get(field) + if value is None: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def project_robustness_on_corpus( + curves: Iterable, + image_qualities: list[dict], + *, + quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None, + critical_threshold: Optional[float] = None, +) -> dict: + """Projette les courbes de robustesse sur les qualités réelles. + + Parameters + ---------- + curves: + Itérable de ``DegradationCurve`` (ou dicts compatibles + avec ``engine_name``, ``degradation_type``, ``levels``, + ``cer_values``, ``critical_threshold_level``). + image_qualities: + Liste de dicts ``ImageQualityResult.as_dict()`` (un par + document). Si vide, retourne une projection vide. + quality_to_level: + Fonction custom ``(quality_dict, degradation_type) → + Optional[float]`` pour adapter le mapping qualité→niveau. + Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``. + critical_threshold: + Override pour le seuil critique de CER (défaut : utilise + ``DegradationCurve.cer_threshold``). + + Returns + ------- + dict + ``{ + engine_name: { + degradation_type: { + "n_docs": int, + "n_docs_with_data": int, # qualité disponible + "expected_cer_mean": float, # moyenne CER attendu + "expected_cer_median": float, + "baseline_cer": float, # CER à niveau min + "deficit_vs_baseline": float, + "n_docs_above_critical": int, + "critical_threshold_level": float | None, + "critical_threshold_cer": float, + }, + }, + }`` + """ + extractor = quality_to_level or ( + lambda q, dt: _extract_quality_value(q, dt) + ) + out: dict[str, dict] = {} + + for curve in curves: + # Accepter dict ou DegradationCurve + if hasattr(curve, "as_dict"): + data = curve.as_dict() + else: + data = curve + engine = data.get("engine_name") + deg_type = data.get("degradation_type") + levels = data.get("levels") or [] + cer_values = data.get("cer_values") or [] + crit_lvl = data.get("critical_threshold_level") + crit_cer = ( + critical_threshold + if critical_threshold is not None + else data.get("cer_threshold", 0.20) + ) + if not engine or not deg_type: + continue + + per_doc_cer: list[float] = [] + n_docs_with_data = 0 + n_above_critical = 0 + for quality in image_qualities: + level = extractor(quality, deg_type) + if level is None: + continue + n_docs_with_data += 1 + cer = _interpolate_cer(levels, cer_values, level) + if cer is None: + continue + per_doc_cer.append(cer) + if cer > crit_cer: + n_above_critical += 1 + + if not per_doc_cer: + continue + + # Baseline = CER au niveau minimum (sans dégradation) + baseline = _interpolate_cer( + levels, cer_values, + min(levels) if levels else 0.0, + ) + expected_mean = statistics.fmean(per_doc_cer) + expected_median = statistics.median(per_doc_cer) + deficit = ( + expected_mean - baseline + if baseline is not None else None + ) + + out.setdefault(engine, {})[deg_type] = { + "n_docs": len(image_qualities), + "n_docs_with_data": n_docs_with_data, + "expected_cer_mean": expected_mean, + "expected_cer_median": expected_median, + "baseline_cer": baseline, + "deficit_vs_baseline": deficit, + "n_docs_above_critical": n_above_critical, + "critical_threshold_level": crit_lvl, + "critical_threshold_cer": crit_cer, + } + return out + + +def aggregate_projection_per_engine(projection: dict) -> dict: + """Pour chaque moteur, agrège le déficit projeté en sommant + sur tous les types de dégradation. + + Lecture : *« déficit total attendu pour Tesseract = 5,2 points + de CER si on considère les 4 dégradations indépendamment »*. + + Note : la sommation **suppose l'indépendance** des + dégradations, ce qui n'est pas strictement vrai mais reste + une approximation utile pour le diagnostic. + """ + out: dict[str, dict] = {} + for engine, per_type in projection.items(): + total_deficit = 0.0 + n_types_with_data = 0 + max_deficit_type: Optional[tuple[str, float]] = None + for deg_type, stats in per_type.items(): + deficit = stats.get("deficit_vs_baseline") + if deficit is None: + continue + total_deficit += deficit + n_types_with_data += 1 + if max_deficit_type is None or deficit > max_deficit_type[1]: + max_deficit_type = (deg_type, deficit) + out[engine] = { + "total_expected_deficit": total_deficit, + "n_degradation_types": n_types_with_data, + "worst_degradation_type": ( + max_deficit_type[0] if max_deficit_type else None + ), + "worst_degradation_deficit": ( + max_deficit_type[1] if max_deficit_type else None + ), + } + return out + + +__all__ = [ + "project_robustness_on_corpus", + "aggregate_projection_per_engine", +] diff --git a/picarones/evaluation/metrics/search.py b/picarones/evaluation/metrics/search.py new file mode 100644 index 0000000000000000000000000000000000000000..872705782cd66f70efa478c9de1bcd57ddb72d25 --- /dev/null +++ b/picarones/evaluation/metrics/search.py @@ -0,0 +1,194 @@ +"""Recherchabilité fuzzy + séquences numériques — Sprint A14-S16. + +Fonctions de calcul **pures** (sans ``@register_metric`` legacy) +utilisées par ``SearchView``. Réimplémente la logique des modules +historiques ``picarones.measurements.searchability`` (Sprint 84) +et ``picarones.measurements.numerical_sequences`` (Sprint 85), +sans la dépendance vers le singleton global ``core.metric_registry``. + +Les modules legacy seront supprimés au S20 quand le +``MetricRegistry`` instancié explicitement (S5) deviendra le seul +registre. En attendant, ce module fournit la version "couche +evaluation" propre. + +Métriques livrées +----------------- +- ``searchability_recall(reference, hypothesis, max_distance=2)`` — + proportion de tokens GT retrouvés dans l'hypothèse à distance + de Levenshtein ≤ ``max_distance``. Proxy direct de la qualité + pour la recherche plein-texte (Elastic / Solr / Gallica). + +- ``numerical_sequence_preservation(reference, hypothesis)`` — + fraction des séquences numériques de la GT préservées + strictement dans l'hypothèse. Volontairement minimaliste pour + S16 : détecte uniquement les **années 4 chiffres** (proxy + réaliste pour les corpus patrimoniaux datés). Le cas complet + (numéraux romains, foliations, monnaies, années régnales) reste + dans le legacy et sera réintégré au S20 avec le registre. + +Toutes les métriques ∈ [0, 1] avec ``higher_is_better=True``. +""" + +from __future__ import annotations + +import re + + +# ────────────────────────────────────────────────────────────────── +# Levenshtein — DP O(|a|·|b|), mémoire O(min(|a|, |b|)) +# ────────────────────────────────────────────────────────────────── + + +def levenshtein_distance(a: str, b: str) -> int: + """Distance de Levenshtein (substitution = insertion = suppression = 1). + + Implémentation identique à ``picarones.measurements.searchability`` + (Sprint 84) mais sans le décorateur ``@register_metric``. + """ + if a == b: + return 0 + if len(a) < len(b): + a, b = b, a + if not b: + return len(a) + previous = list(range(len(b) + 1)) + for i, ca in enumerate(a, start=1): + current = [i] + [0] * len(b) + for j, cb in enumerate(b, start=1): + cost = 0 if ca == cb else 1 + current[j] = min( + current[j - 1] + 1, # insertion + previous[j] + 1, # suppression + previous[j - 1] + cost, # substitution + ) + previous = current + return previous[-1] + + +# ────────────────────────────────────────────────────────────────── +# Searchability fuzzy +# ────────────────────────────────────────────────────────────────── + + +def _split_words(text: str | None) -> list[str]: + if not text: + return [] + return text.split() + + +def searchability_recall( + reference: str, + hypothesis: str, + *, + max_distance: int = 2, + case_sensitive: bool = False, +) -> float: + """Rappel fuzzy : fraction des tokens GT retrouvés à distance + de Levenshtein ≤ ``max_distance``. + + Multi-set : un token hypothèse ne peut servir qu'une fois pour + être compté comme "match" (alignement bipartite simple). + + Returns + ------- + float + ``n_retrouves / n_gt`` ∈ [0, 1]. ``0.0`` si la GT est + vide (convention identique au legacy Sprint 84). + """ + if max_distance < 0: + raise ValueError(f"max_distance doit être ≥ 0, reçu {max_distance}") + gt_tokens = _split_words(reference) + hyp_tokens = _split_words(hypothesis) + n_gt = len(gt_tokens) + if n_gt == 0: + return 0.0 + if case_sensitive: + gt_for_match = list(gt_tokens) + hyp_for_match = list(hyp_tokens) + else: + gt_for_match = [t.lower() for t in gt_tokens] + hyp_for_match = [t.lower() for t in hyp_tokens] + + hyp_used = [False] * len(hyp_for_match) + n_match = 0 + for gt_match in gt_for_match: + best_idx = -1 + best_dist = max_distance + 1 + for hi, used in enumerate(hyp_used): + if used: + continue + hyp_match = hyp_for_match[hi] + if abs(len(hyp_match) - len(gt_match)) > max_distance: + continue + d = levenshtein_distance(gt_match, hyp_match) + if d < best_dist: + best_dist = d + best_idx = hi + if d == 0: + break + if best_idx >= 0 and best_dist <= max_distance: + hyp_used[best_idx] = True + n_match += 1 + return n_match / n_gt + + +# ────────────────────────────────────────────────────────────────── +# Séquences numériques (S16 minimal : années 4 chiffres) +# ────────────────────────────────────────────────────────────────── + + +_YEAR_4DIGITS_RE = re.compile(r"\b(1[0-9]{3}|20[0-2][0-9])\b") +"""Capture les années entre 1000 et 2029 (proxy réaliste pour les +corpus patrimoniaux : chartes médiévales, registres modernes, +coupures de presse XIX-XXIᵉ siècle).""" + + +def _extract_years(text: str | None) -> list[str]: + if not text: + return [] + return _YEAR_4DIGITS_RE.findall(text) + + +def numerical_sequence_preservation( + reference: str, + hypothesis: str, +) -> float: + """Fraction des années 4 chiffres de la GT préservées strictement + dans l'hypothèse. + + Returns + ------- + float + ``n_preserved / n_gt_years`` ∈ [0, 1]. ``0.0`` si la GT + ne contient aucune année. + + Note méthodologique + ------------------- + Volontairement minimaliste pour S16 : seules les années 4 + chiffres sont détectées. Le pattern complet (numéraux romains, + foliations ``f. 12r``, monnaies, années régnales ``an III``) + reste dans ``picarones.measurements.numerical_sequences`` + (Sprint 85) et sera réintégré dans la couche evaluation au S20. + + Multi-set : si la GT contient ``"1789"`` deux fois et + l'hypothèse une fois, seul un est compté préservé. + """ + gt_years = _extract_years(reference) + if not gt_years: + return 0.0 + hyp_years = _extract_years(hypothesis) + # Multi-set match. + hyp_pool = list(hyp_years) + n_preserved = 0 + for y in gt_years: + if y in hyp_pool: + hyp_pool.remove(y) + n_preserved += 1 + return n_preserved / len(gt_years) + + +__all__ = [ + "levenshtein_distance", + "searchability_recall", + "numerical_sequence_preservation", +] diff --git a/picarones/evaluation/metrics/taxonomy_comparison.py b/picarones/evaluation/metrics/taxonomy_comparison.py new file mode 100644 index 0000000000000000000000000000000000000000..eb99d5ef20d8af1985c2dd42b777499c3d1b58f3 --- /dev/null +++ b/picarones/evaluation/metrics/taxonomy_comparison.py @@ -0,0 +1,161 @@ +"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3). + +Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4). + +Pourquoi ce module +------------------ +Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale +qu'un moteur a un profil taxonomique éloigné de ses concurrents, +mais le rapport n'expose pas cette différence visuellement. Ce +sprint répond à *« deux moteurs ont le même CER global, mais lequel +fait des erreurs plus récupérables ? »*. + +Lecture concrète +---------------- +- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables + par un post-processing trivial (récupérables). +- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) → + irrécupérables sans relire l'image. + +À CER égal, A est massivement préférable pour un workflow +d'édition critique. Cette vue rend la différence visible. + +Catégorisation des classes +-------------------------- +On annote chaque classe d'erreur d'un degré de **récupérabilité** +(critère éditorial pragmatique, pas verdict imposé) : + +- ``recoverable`` : récupérable par post-processing trivial + (case_error, ligature_error, abbreviation_error) +- ``difficult`` : récupérable au prix d'un effort + (diacritic_error, visual_confusion, hapax) +- ``irrecoverable`` : impossible à corriger sans l'image + (lacuna, oov_character, segmentation_error) + +L'utilisateur consulte ces catégories comme un guide, pas un +verdict — c'est lui qui juge selon ses besoins éditoriaux. +""" + +from __future__ import annotations + +import logging +from typing import Optional + +logger = logging.getLogger(__name__) + + +# Classification éditoriale. Documentée dans la docstring. +RECOVERABILITY: dict[str, str] = { + "case_error": "recoverable", + "ligature_error": "recoverable", + "abbreviation_error": "recoverable", + "diacritic_error": "difficult", + "visual_confusion": "difficult", + "hapax": "difficult", + "lacuna": "irrecoverable", + "oov_character": "irrecoverable", + "segmentation_error": "irrecoverable", +} + + +def _normalize_counts(counts: dict[str, int]) -> dict[str, float]: + """Convertit un dict de comptes en proportions [0, 1].""" + total = sum(counts.values()) + if total <= 0: + return {k: 0.0 for k in counts} + return {k: v / total for k, v in counts.items()} + + +def compare_taxonomies( + engine_a_name: str, + engine_a_counts: dict[str, int], + engine_b_name: str, + engine_b_counts: dict[str, int], +) -> Optional[dict]: + """Compare deux profils taxonomiques. + + Parameters + ---------- + engine_a_name, engine_b_name: + Noms d'identification des moteurs (utilisés dans le rendu). + engine_a_counts, engine_b_counts: + Maps ``{class_name: count}`` produites par + ``aggregate_taxonomy``. + + Returns + ------- + Optional[dict] + ``{ + "engine_a": str, "engine_b": str, + "total_a": int, "total_b": int, + "classes": list[str], # classes apparaissant chez A ou B + "proportions_a": dict[str, float], + "proportions_b": dict[str, float], + "deltas": dict[str, float], # prop_b - prop_a (signé) + "recoverability": dict[str, str], # mapping class → niveau + "totals_by_recoverability": { + "recoverable": {"a": float, "b": float}, + "difficult": {"a": float, "b": float}, + "irrecoverable": {"a": float, "b": float}, + }, + }`` + Ou ``None`` si les deux moteurs ont 0 erreur chacun. + """ + if engine_a_name == engine_b_name: + # On accepte des comparaisons même si les noms sont + # identiques (cas tests), mais on émet un warning. + logger.warning( + "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s", + engine_a_name, + ) + + total_a = sum(engine_a_counts.values()) if engine_a_counts else 0 + total_b = sum(engine_b_counts.values()) if engine_b_counts else 0 + if total_a == 0 and total_b == 0: + return None + + classes = sorted(set(engine_a_counts) | set(engine_b_counts)) + if not classes: + return None + + prop_a = _normalize_counts( + {c: engine_a_counts.get(c, 0) for c in classes}, + ) + prop_b = _normalize_counts( + {c: engine_b_counts.get(c, 0) for c in classes}, + ) + deltas = {c: prop_b[c] - prop_a[c] for c in classes} + + # Agrégat par récupérabilité (utile pour la lecture rapide) + totals_recov: dict[str, dict[str, float]] = { + "recoverable": {"a": 0.0, "b": 0.0}, + "difficult": {"a": 0.0, "b": 0.0}, + "irrecoverable": {"a": 0.0, "b": 0.0}, + } + for cls in classes: + level = RECOVERABILITY.get(cls, "difficult") + if level not in totals_recov: + level = "difficult" + totals_recov[level]["a"] += prop_a[cls] + totals_recov[level]["b"] += prop_b[cls] + + return { + "engine_a": engine_a_name, + "engine_b": engine_b_name, + "total_a": total_a, + "total_b": total_b, + "classes": classes, + "proportions_a": prop_a, + "proportions_b": prop_b, + "deltas": deltas, + "recoverability": { + cls: RECOVERABILITY.get(cls, "difficult") for cls in classes + }, + "totals_by_recoverability": totals_recov, + } + + +__all__ = [ + "RECOVERABILITY", + "compare_taxonomies", +] diff --git a/picarones/evaluation/metrics/taxonomy_cooccurrence.py b/picarones/evaluation/metrics/taxonomy_cooccurrence.py new file mode 100644 index 0000000000000000000000000000000000000000..8148935bec875feaa8e985d960cdb7b929487459 --- /dev/null +++ b/picarones/evaluation/metrics/taxonomy_cooccurrence.py @@ -0,0 +1,150 @@ +"""Co-occurrence des classes taxonomiques d'erreur — Sprint 75 (A.I.4 chantier 1). + +Sprint 75 — A.I.4 chantier 1 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +La taxonomie d'erreurs (10 classes, ``picarones/core/taxonomy.py``) +est calculée par document mais le rapport actuel ne montre qu'un +seul histogramme global. La roadmap A.I.4 demande trois lectures +plus fines de cette taxonomie ; ce sprint livre la première : +**co-occurrence**. + +Si ``ligature_error`` et ``abbreviation_error`` co-occurrent +toujours dans les mêmes documents, c'est un signal de scribe +particulier — utile pour stratifier le corpus *a posteriori* +(qu'est-ce qui caractérise les documents difficiles ?). + +Mesure +------ +Indice de **Jaccard** entre paires de classes au niveau +**document** : + +.. math:: + + J(A, B) = \\frac{|D_A \\cap D_B|}{|D_A \\cup D_B|} + +où ``D_X`` est l'ensemble des documents qui contiennent au moins +une erreur de classe ``X``. + +- ``J(A, B) = 1`` : A et B apparaissent toujours ensemble (et + jamais l'un sans l'autre). +- ``J(A, B) = 0`` : A et B ne co-occurrent jamais. +- ``J(A, B) = 0,5`` : A et B partagent la moitié de leur union. + +Stratégie de découpage +---------------------- +Couche de calcul pure d'abord (pattern Sprint 35, 38, 52-58). +Le rendu HTML (heatmap SVG) est livré dans le même sprint pour +boucler la dimension ; les chantiers 2 et 3 d'A.I.4 (évolution +intra-document, taxonomie comparative) suivent. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +def compute_taxonomy_cooccurrence( + per_doc_classes: Iterable[Iterable[str]], + *, + min_doc_count: int = 1, + top_n_pairs: int = 10, +) -> Optional[dict]: + """Calcule la matrice de Jaccard inter-classes au niveau document. + + Parameters + ---------- + per_doc_classes: + Itérable de docs, chaque doc étant un itérable de noms de + classes taxonomiques détectées (set, list, tuple…). + Les doublons à l'intérieur d'un doc sont ignorés (présence + binaire au niveau doc). + min_doc_count: + Nombre minimum de documents dans lesquels une classe doit + apparaître pour figurer dans la matrice (défaut 1). + Permet d'écarter les classes anecdotiques. + top_n_pairs: + Nombre de paires retournées dans ``top_pairs`` (triées par + Jaccard décroissant). Défaut 10. + + Returns + ------- + Optional[dict] + ``{ + "classes": list[str], # triées alpha + "n_documents": int, + "doc_count": dict[str, int], # nb docs par classe + "cooccurrence_matrix": dict[str, dict[str, float]], + # symétrique, diagonale = 1.0 (sauf classe vide) + "top_pairs": list[tuple[str, str, float]], + # paires les plus co-occurrentes (Jaccard désc.) + }`` + ou ``None`` si aucune classe ne dépasse ``min_doc_count`` + ou si l'itérable est vide. + """ + docs: list[frozenset[str]] = [] + for doc_classes in per_doc_classes: + if doc_classes is None: + continue + cleaned = frozenset(c for c in doc_classes if c) + docs.append(cleaned) + if not docs: + return None + + # Comptage par classe + doc_count: dict[str, int] = {} + for doc in docs: + for cls in doc: + doc_count[cls] = doc_count.get(cls, 0) + 1 + + # Filtrage min_doc_count + classes = sorted( + c for c, n in doc_count.items() if n >= min_doc_count + ) + if not classes: + return None + + # Matrice de Jaccard + matrix: dict[str, dict[str, float]] = { + c: {} for c in classes + } + for i, ca in enumerate(classes): + docs_a = {idx for idx, d in enumerate(docs) if ca in d} + for cb in classes[i:]: + if ca == cb: + # Diagonale : Jaccard(X, X) = 1 si X est présent + matrix[ca][cb] = 1.0 if docs_a else 0.0 + continue + docs_b = {idx for idx, d in enumerate(docs) if cb in d} + inter = len(docs_a & docs_b) + union = len(docs_a | docs_b) + jaccard = inter / union if union > 0 else 0.0 + matrix[ca][cb] = jaccard + matrix[cb][ca] = jaccard # symétrique + + # Top paires (hors diagonale) + pairs: list[tuple[str, str, float]] = [] + for i, ca in enumerate(classes): + for cb in classes[i + 1:]: + j = matrix[ca][cb] + if j > 0: + pairs.append((ca, cb, j)) + pairs.sort(key=lambda p: (-p[2], p[0], p[1])) + top_pairs = pairs[:top_n_pairs] + + return { + "classes": classes, + "n_documents": len(docs), + "doc_count": doc_count, + "cooccurrence_matrix": matrix, + "top_pairs": top_pairs, + } + + +__all__ = [ + "compute_taxonomy_cooccurrence", +] diff --git a/picarones/evaluation/metrics/throughput.py b/picarones/evaluation/metrics/throughput.py new file mode 100644 index 0000000000000000000000000000000000000000..47d0ed674492f221013aa8a53c3632db14cbe6b5 --- /dev/null +++ b/picarones/evaluation/metrics/throughput.py @@ -0,0 +1,165 @@ +"""Throughput effectif (Sprint 91 — A.II.6). + +Sprint 91 — A.II.6 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le throughput brut (pages/heure d'OCR pur) ment quand un moteur +est rapide mais imprécis : la correction humaine *post hoc* +absorbe le gain. La **vraie** vitesse opérationnelle inclut +le temps de correction. Cette métrique discrimine fortement +entre un cloud rapide à 30 % de timeouts/erreurs et un local +lent à 100 % de fiabilité. + +Formule +------- +.. code:: + + pages_par_heure_utilisable = + pages_traitées / (durée_totale + temps_correction_humaine) + +Le temps de correction est estimé linéairement : +``temps_par_erreur × nombre_d_erreurs``. Le défaut +``time_per_error_seconds=5.0`` correspond aux études HTR-United +(saisie manuelle d'une correction de mot par un opérateur +formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger +pour son institution. + +Sortie +------ +``compute_effective_throughput(n_pages, duration_seconds, +n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages, +duration_seconds, n_errors, time_per_error_seconds, +correction_time_seconds, total_seconds, pages_per_hour_raw, +pages_per_hour_effective, drag_ratio}``. + +``aggregate_effective_throughput(per_engine_data)`` agrège par +moteur sur l'ensemble du corpus. +""" + +from __future__ import annotations + +import logging +from typing import Iterable, Optional + +logger = logging.getLogger(__name__) + + +_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0 + + +def compute_effective_throughput( + n_pages: int, + duration_seconds: float, + n_errors: int, + *, + time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, +) -> Optional[dict]: + """Throughput effectif (pages/heure utilisables). + + Parameters + ---------- + n_pages: + Nombre de pages traitées. + duration_seconds: + Durée totale de l'OCR (somme des durées par doc). + n_errors: + Nombre d'erreurs (au niveau mot, typiquement + ``WER × n_words_total``). + time_per_error_seconds: + Temps moyen de correction humaine par erreur. Défaut + 5 s (HTR-United). Doit être ≥ 0. + + Returns + ------- + dict | None + ``None`` si ``n_pages == 0`` ou ``total_seconds == 0`` + (pas de division par zéro). + """ + if n_pages <= 0: + return None + if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0: + raise ValueError( + "duration_seconds, n_errors et time_per_error_seconds " + "doivent être ≥ 0", + ) + correction_seconds = float(n_errors) * float(time_per_error_seconds) + total_seconds = float(duration_seconds) + correction_seconds + if total_seconds <= 0: + # Aucun temps écoulé : impossible de définir un throughput + return None + pages_per_hour_raw = ( + n_pages / duration_seconds * 3600.0 + if duration_seconds > 0 else None + ) + pages_per_hour_effective = n_pages / total_seconds * 3600.0 + drag_ratio = ( + correction_seconds / total_seconds if total_seconds > 0 else 0.0 + ) + return { + "n_pages": int(n_pages), + "duration_seconds": float(duration_seconds), + "n_errors": int(n_errors), + "time_per_error_seconds": float(time_per_error_seconds), + "correction_time_seconds": correction_seconds, + "total_seconds": total_seconds, + "pages_per_hour_raw": pages_per_hour_raw, + "pages_per_hour_effective": pages_per_hour_effective, + "drag_ratio": drag_ratio, + } + + +def aggregate_effective_throughput( + per_engine: Iterable[dict], + *, + time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, +) -> Optional[dict]: + """Agrège le throughput effectif par moteur. + + Parameters + ---------- + per_engine: + Itérable de dicts ``{engine_name, n_pages, + duration_seconds, n_errors}``. + + Returns + ------- + dict | None + ``{ + "engines": [ + {"engine_name", ..., compute_effective_throughput + fields}, + ... + ], + "time_per_error_seconds": float, + }`` ou ``None`` si aucun moteur exploitable. + """ + rows: list[dict] = [] + for entry in per_engine: + if not isinstance(entry, dict): + continue + name = entry.get("engine_name") or entry.get("engine") + if not name: + continue + result = compute_effective_throughput( + int(entry.get("n_pages") or 0), + float(entry.get("duration_seconds") or 0.0), + int(entry.get("n_errors") or 0), + time_per_error_seconds=time_per_error_seconds, + ) + if result is None: + continue + result["engine_name"] = str(name) + rows.append(result) + if not rows: + return None + return { + "engines": rows, + "time_per_error_seconds": float(time_per_error_seconds), + } + + +__all__ = [ + "compute_effective_throughput", + "aggregate_effective_throughput", +] diff --git a/picarones/evaluation/metrics/worst_lines.py b/picarones/evaluation/metrics/worst_lines.py new file mode 100644 index 0000000000000000000000000000000000000000..dfece53263f29f83db9cb6dbaaf749d719b04857 --- /dev/null +++ b/picarones/evaluation/metrics/worst_lines.py @@ -0,0 +1,199 @@ +"""Extraction transversale des « Worst lines » du corpus — Sprint 72. + +Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026. + +Pourquoi ce module +------------------ +Le percentile p95 du CER ligne (calculé par ``line_metrics.py``, +Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un +CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur +texte, leur diff, leur document parent, pour comprendre ce qui +casse. + +Ce module fournit la requête transversale qui collecte, depuis un +``BenchmarkResult``, les **N lignes les plus mal transcrites de +tout le corpus**, classées par CER ligne. Filtrable par moteur +et par strate. + +Limite documentée +----------------- +``DocumentResult.line_metrics`` ne stocke que les CER par ligne, +**pas le texte des lignes**. Pour récupérer les textes GT/hyp +on resplitte ``ground_truth`` et ``hypothesis`` du +``DocumentResult`` à l'index de la ligne. Cette logique +**suppose un BenchmarkResult non-compacté** — après ``compact()`` +les textes sont tronqués à 200 caractères et les lignes au-delà +de cette troncature ne sont plus accessibles. En pratique on +extrait les worst lines **avant** la sérialisation/compactage. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Optional + +logger = logging.getLogger(__name__) + + +@dataclass +class WorstLineEntry: + """Une ligne du corpus identifiée comme mal transcrite. + + Champs + ------ + rank: + Position dans le classement (1-based, 1 = pire CER). + cer: + CER de la ligne ∈ [0, 1]. + engine_name: + Nom du moteur ayant produit cette hypothèse. + doc_id: + Identifiant du document parent. + line_index: + Index 0-based de la ligne dans le document GT. + gt_line: + Texte de la ligne dans la GT. + hyp_line: + Texte correspondant dans l'hypothèse (peut être ``""`` + si l'OCR a sauté la ligne). + script_type: + Strate du document si disponible (``script_type`` + capturé par le runner pour la stratification A.III). + """ + + rank: int + cer: float + engine_name: str + doc_id: str + line_index: int + gt_line: str + hyp_line: str + script_type: Optional[str] = None + + +def _split_lines(text: Optional[str]) -> list[str]: + """Splitte un texte en lignes (cohérent avec ``line_metrics``). + + Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les + lignes vides sont préservées. Retourne une liste vide si le + texte est None ou vide. + """ + if not text: + return [] + # ``splitlines`` gère \r\n et \r correctement + return text.splitlines() + + +def _line_at(text: Optional[str], index: int) -> str: + """Retourne la ligne à l'index demandé, ou ``""`` si l'index + est hors borne (cas où l'OCR a moins de lignes que la GT).""" + lines = _split_lines(text) + if 0 <= index < len(lines): + return lines[index] + return "" + + +def extract_worst_lines( + benchmark, + *, + top_n: int = 20, + engine_filter: Optional[str] = None, + script_type_filter: Optional[str] = None, +) -> list[WorstLineEntry]: + """Extrait les ``top_n`` lignes les plus mal transcrites du + corpus, transversalement à tous les moteurs et documents. + + Parameters + ---------- + benchmark: + ``BenchmarkResult`` non-compacté (cf. limite ci-dessus). + L'objet doit exposer ``engine_reports`` (liste de + ``EngineReport``) et optionnellement ``doc_strata`` + (map ``{doc_id: script_type}``, Sprint 45). + top_n: + Nombre de lignes à retourner. Défaut : 20. + engine_filter: + Si fourni, n'inclut que les lignes produites par ce moteur + (match exact sur ``engine_name``). + script_type_filter: + Si fourni, n'inclut que les lignes des documents de cette + strate (nécessite ``benchmark.doc_strata``). + + Returns + ------- + list[WorstLineEntry] + Liste triée par CER décroissant (pire en premier), + rang 1-based attribué après tri. Vide si aucune ligne + exploitable. + """ + if top_n <= 0: + return [] + + doc_strata = getattr(benchmark, "doc_strata", None) or {} + candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = [] + + for engine_report in getattr(benchmark, "engine_reports", []): + engine_name = engine_report.engine_name + if engine_filter is not None and engine_name != engine_filter: + continue + for dr in engine_report.document_results: + line_metrics = getattr(dr, "line_metrics", None) + if not line_metrics: + continue + cer_per_line = line_metrics.get("cer_per_line") if isinstance( + line_metrics, dict, + ) else getattr(line_metrics, "cer_per_line", None) + if not cer_per_line: + continue + doc_id = dr.doc_id + doc_strata_value = doc_strata.get(doc_id) + if ( + script_type_filter is not None + and doc_strata_value != script_type_filter + ): + continue + for idx, cer in enumerate(cer_per_line): + if cer <= 0.0: + continue + gt_line = _line_at(dr.ground_truth, idx) + hyp_line = _line_at(dr.hypothesis, idx) + if not gt_line and not hyp_line: + continue + candidates.append(( + float(cer), engine_name, doc_id, idx, + gt_line, hyp_line, doc_strata_value, + )) + + if not candidates: + return [] + + # Tri par CER décroissant ; en cas d'égalité, ordre stable + # (engine, doc_id, line_index) pour reproductibilité. + candidates.sort( + key=lambda c: (-c[0], c[1], c[2], c[3]), + ) + selected = candidates[:top_n] + + return [ + WorstLineEntry( + rank=i + 1, + cer=cer, + engine_name=engine, + doc_id=doc_id, + line_index=line_index, + gt_line=gt_line, + hyp_line=hyp_line, + script_type=script_type, + ) + for i, ( + cer, engine, doc_id, line_index, + gt_line, hyp_line, script_type, + ) in enumerate(selected) + ] + + +__all__ = [ + "WorstLineEntry", + "extract_worst_lines", +] diff --git a/picarones/evaluation/projection_engine.py b/picarones/evaluation/projection_engine.py new file mode 100644 index 0000000000000000000000000000000000000000..cd17ad8faddd7bbd793eea17b673491c02d170a3 --- /dev/null +++ b/picarones/evaluation/projection_engine.py @@ -0,0 +1,174 @@ +"""``ProjectionEngine`` — Sprint A14-S27. + +Le S13 fusionnait dans ``DefaultEvaluationViewExecutor`` deux +responsabilités distinctes : transformer un artefact d'un type vers +un autre (« projeter ») **et** calculer les métriques sur les +payloads (« évaluer »). La cible architecturale les sépare en +deux moteurs spécialisés à responsabilité unique : + +- ``ProjectionEngine`` (ce module) : transforme un ``Artifact`` + candidat selon une ``ProjectionSpec`` et retourne le nouvel + artefact, son ``payload`` calculé, et un ``ProjectionReport`` + documentant les pertes. +- ``EvaluationEngine`` (cf. ``evaluation_engine.py``) : calcule les + métriques sur des payloads. + +L'executor de vue (``DefaultEvaluationViewExecutor``) orchestre les +deux : projection d'abord, puis chargement, normalisation, et +évaluation. Il ne contient plus de logique de projection ni de +calcul de métrique — uniquement la séquence et la collecte d'erreurs. + +Pourquoi cette séparation +------------------------- +- **Réutilisation** : le ``PipelineExecutor`` (S28+) appelle + ``ProjectionEngine.project`` directement quand il transforme un + artefact entre deux étapes du DAG, sans dépendre de l'executor de + vue. +- **Testabilité** : on peut tester la projection sur des artefacts + arbitraires sans construire un ``EvaluationView`` ni un + ``MetricRegistry``. +- **Lisibilité** : chaque moteur expose une API minimale et + vérifiable au type. + +Anti-sur-ingénierie +------------------- +Pas de cache de payload entre projections, pas de batch, pas de +pré-validation des params (le projecteur lui-même validera ce qu'il +attend). Le moteur est volontairement minimal — la complexité vit +dans les projecteurs (cf. ``picarones/evaluation/projectors/``). +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any + +from picarones.domain.artifacts import Artifact +from picarones.domain.errors import ProjectionError +from picarones.domain.projection_spec import ProjectionSpec +from picarones.evaluation.projectors.base import ProjectionReport +from picarones.evaluation.projectors.registry import ( + ProjectorNotFoundError, + ProjectorRegistry, +) + + +@dataclass(frozen=True) +class ProjectionResult: + """Résultat d'un appel à ``ProjectionEngine.project``. + + Attributes + ---------- + artifact: + Artefact effectif après projection. Si la spec était + ``None`` ou identité, c'est l'artefact d'entrée tel quel. + payload: + Payload calculé par le projecteur, ou ``None`` si aucune + projection n'a été effectuée (le caller chargera depuis + son ``payload_loader``). + report: + Rapport de projection si une projection a eu lieu, ou + ``None`` pour une vue sans projection (identité). + + Notes + ----- + Frozen dataclass : aucune mutation post-construction. La + sérialisation passe par ``ProjectionReport`` (pydantic) qui sait + déjà se sérialiser ; ``ProjectionResult`` reste un container + interne entre engine et executor. + """ + + artifact: Artifact + payload: Any | None + report: ProjectionReport | None + + @property + def has_projection(self) -> bool: + """Vrai si une projection effective a eu lieu (report présent).""" + return self.report is not None + + +class ProjectionEngine: + """Moteur de projection d'artefacts selon une ``ProjectionSpec``. + + Responsabilité unique : prendre un ``Artifact`` et une éventuelle + ``ProjectionSpec``, retourner un ``ProjectionResult``. Pas de + chargement de payload depuis un loader externe (le projecteur + fournit le payload calculé directement, depuis Sprint S25). Pas + de connaissance des métriques ni des vues. + + Parameters + ---------- + projector_registry: + Registre des projecteurs disponibles, instancié explicitement + au démarrage de l'application. Pas de singleton global, pas + de side-effect d'import. + """ + + def __init__(self, projector_registry: ProjectorRegistry) -> None: + if not isinstance(projector_registry, ProjectorRegistry): + raise TypeError( + "projector_registry doit être un ProjectorRegistry." + ) + self._projectors = projector_registry + + @property + def projectors(self) -> ProjectorRegistry: + """Accès en lecture au registre sous-jacent (utile aux tests).""" + return self._projectors + + def project( + self, + artifact: Artifact, + spec: ProjectionSpec | None, + ) -> ProjectionResult: + """Applique la projection si pertinente. + + Comportement : + + - ``spec is None`` ou ``spec.is_identity`` → + ``ProjectionResult`` avec l'artefact d'entrée tel quel, + ``payload=None``, ``report=None``. Le caller utilisera + son payload_loader pour charger l'artefact original. + - Sinon : résout le projecteur dans le registre, exécute + ``project()``, et retourne le ``ProjectionResult`` complet + avec payload calculé. + + Raises + ------ + ProjectionError + Si le projecteur référencé n'est pas enregistré, ou si + le projecteur lève une exception interne (wrappée dans + une ``ProjectionError`` qui préserve la chaîne ``__cause__``). + """ + if spec is None or spec.is_identity: + return ProjectionResult( + artifact=artifact, payload=None, report=None, + ) + + try: + projector = self._projectors.get(spec.projector_name) + except ProjectorNotFoundError as exc: + raise ProjectionError( + f"Projecteur {spec.projector_name!r} introuvable " + "dans le ProjectorRegistry." + ) from exc + + try: + target, payload, report = projector.project( + artifact, dict(spec.params), + ) + except ProjectionError: + raise + except Exception as exc: # noqa: BLE001 + raise ProjectionError( + f"Projecteur {spec.projector_name!r} a levé sur " + f"l'artefact {artifact.id!r} : {exc}" + ) from exc + + return ProjectionResult( + artifact=target, payload=payload, report=report, + ) + + +__all__ = ["ProjectionEngine", "ProjectionResult"] diff --git a/picarones/evaluation/projectors/__init__.py b/picarones/evaluation/projectors/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..c251efff0fc28493d956eec28b2a465040db9898 --- /dev/null +++ b/picarones/evaluation/projectors/__init__.py @@ -0,0 +1,61 @@ +"""Projecteurs — transformations entre types d'artefacts. + +Un projecteur convertit un artefact d'un type vers un autre, en +documentant explicitement ce qu'il **perd** au passage via un +``ProjectionReport``. + +Exemples (à venir Sprint S14) : + +- ``AltoToText`` — extraction du texte par ordre de lecture. + Pertes : coordonnées, blocs, IDs de ligne, hiérarchie. +- ``PageToText`` — équivalent pour PAGE XML. +- ``CanonicalDocumentToText`` — ``markdown`` ou JSON canonique + vers texte brut. +- ``MarkdownToText`` — supprime les balises markdown. + +Règle d'or : un projecteur est **non-symétrique** par défaut. On +peut projeter ALTO → texte (perte), pas l'inverse. La +reconstruction inverse (texte → ALTO) est un module de pipeline, +pas un projecteur. +""" + +from __future__ import annotations + +from picarones.evaluation.projectors.alto import ( + AltoToText, + alto_document_to_text, +) +from picarones.evaluation.projectors.base import ProjectionReport, Projector +from picarones.evaluation.projectors.canonical import ( + CanonicalToText, + canonical_payload_to_text, + markdown_to_text, +) +from picarones.evaluation.projectors.pagexml import ( + PageToText, + page_document_to_text, +) +from picarones.evaluation.projectors.registry import ( + ProjectorNotFoundError, + ProjectorRegistrationError, + ProjectorRegistry, +) + +__all__ = [ + # Protocol + report + "Projector", + "ProjectionReport", + # Registry + "ProjectorRegistry", + "ProjectorRegistrationError", + "ProjectorNotFoundError", + # Concrete projectors (S13) + "AltoToText", + "alto_document_to_text", + "PageToText", + "page_document_to_text", + # Canonical (S14) + "CanonicalToText", + "canonical_payload_to_text", + "markdown_to_text", +] diff --git a/picarones/evaluation/projectors/alto.py b/picarones/evaluation/projectors/alto.py new file mode 100644 index 0000000000000000000000000000000000000000..6349e48bbd5423dfb8d37eba6af0d2e1d67e3d7d --- /dev/null +++ b/picarones/evaluation/projectors/alto.py @@ -0,0 +1,214 @@ +"""Projecteurs ALTO — Sprint A14-S9. + +Convertit un ``AltoDocument`` (ou un artefact ``ALTO_XML``) vers +d'autres types d'artefacts, en documentant explicitement les +pertes via ``ProjectionReport``. + +Implémentations +--------------- +- ``AltoToText`` — extraction du texte par ordre de lecture + ``Page → Block → Line → String``. Gestion césure + ``HypPart1``/``HypPart2``. + +À venir post-livraison : +- ``AltoToLines`` (extraction lignes). +- ``AltoToWordsWithBoxes`` (mots + coordonnées). +""" + +from __future__ import annotations + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.evaluation.projectors.base import ProjectionReport +from picarones.formats.alto.parser import AltoParseError, parse_alto +from picarones.formats.alto.types import AltoDocument, AltoLine, AltoTextBlock + + +def alto_document_to_text(document: AltoDocument) -> str: + """Extrait le texte plat d'un ``AltoDocument``. + + Conventions : + + - Ordre de lecture ``Page → Block → Line → String``, dans l'ordre + d'apparition dans le XML. + - Espace entre les ``String`` d'une même ligne. + - Saut de ligne entre les ``TextLine``. + - Saut de ligne supplémentaire entre les ``TextBlock``. + - **Césure** : + - Si un ``HypPart1`` porte ``SUBS_CONTENT`` (mot complet), on + utilise ce mot complet et on saute le ``HypPart2`` + correspondant (même ligne ou ligne suivante du même bloc). + - Sinon, on concatène ``HypPart1.content + HypPart2.content`` + et on saute le ``HypPart2``. + - Le saut de ligne visuel entre les deux est **conservé** (le + mot reconstruit termine la ligne du ``HypPart1``, la ligne + du ``HypPart2`` continue avec ses autres mots). + """ + blocks_text: list[str] = [] + for page in document.pages: + for block in page.blocks: + block_text = _extract_block_text(block) + if block_text: + blocks_text.append(block_text) + return "\n\n".join(blocks_text).strip() + + +def _extract_block_text(block: AltoTextBlock) -> str: + """Extrait le texte d'un bloc en gérant la césure cross-ligne. + + L'usage standard ALTO place ``HypPart1`` en fin d'une ligne et + ``HypPart2`` en début de la ligne suivante du **même** bloc. + """ + assert isinstance(block, AltoTextBlock) + lines_text: list[str] = [] + skip_first_if_hyppart2 = False + for line in block.lines: + text, ended_with_hyp1 = _extract_line_text( + line, skip_first_if_hyppart2=skip_first_if_hyppart2, + ) + lines_text.append(text) + skip_first_if_hyppart2 = ended_with_hyp1 + return "\n".join(lines_text) + + +def _extract_line_text( + line: AltoLine, + *, + skip_first_if_hyppart2: bool = False, +) -> tuple[str, bool]: + """Reconstruit le texte d'une ligne. + + Returns + ------- + tuple[str, bool] + ``(texte_ligne, ended_with_hyppart1_resolved)``. Le second + indique si la ligne se termine par un ``HypPart1`` dont la + résolution implique de skipper le premier ``HypPart2`` de la + ligne suivante. + """ + parts: list[str] = [] + skip_next = False + ended_with_hyp1 = False + strings = list(line.strings) + for i, s in enumerate(strings): + is_first = (i == 0) + if skip_next: + skip_next = False + continue + if is_first and skip_first_if_hyppart2 and s.subs_type == "HypPart2": + # Cross-ligne : la ligne précédente a résolu le HypPart1. + continue + if s.subs_type == "HypPart1": + is_last = (i == len(strings) - 1) + if s.subs_content: + parts.append(s.subs_content) + if i + 1 < len(strings) and strings[i + 1].subs_type == "HypPart2": + skip_next = True + elif is_last: + ended_with_hyp1 = True + continue + if i + 1 < len(strings) and strings[i + 1].subs_type == "HypPart2": + parts.append(s.content + strings[i + 1].content) + skip_next = True + continue + parts.append(s.content) + if is_last: + ended_with_hyp1 = True + continue + parts.append(s.content) + return " ".join(p for p in parts if p), ended_with_hyp1 + + +# ────────────────────────────────────────────────────────────────────── +# Projecteur conforme au protocole ``Projector`` (Sprint S5) +# ────────────────────────────────────────────────────────────────────── + + +class AltoToText: + """Projecteur ``ALTO_XML → RAW_TEXT``. + + Lit le XML depuis l'``Artifact.uri`` (chemin filesystem) si + présent, sinon attend que le caller ait pré-stocké le payload + dans un mécanisme externe (ce projecteur ne télécharge rien + par lui-même — pas de side-effect réseau). + + Pour S9, on s'attend à ce que ``artifact.uri`` pointe vers un + fichier local lisible. Le service applicatif (S19) résoudra + les autres cas (URI distante, payload inline). + """ + + name = "alto_to_text" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def project( + self, + artifact: Artifact, + params: dict[str, str | int | float | bool], + ) -> tuple[Artifact, str, ProjectionReport]: + if artifact.type != self.source_type: + from picarones.domain.errors import ProjectionError + raise ProjectionError( + f"AltoToText n'accepte que ALTO_XML, reçu " + f"{artifact.type.value!r}" + ) + + # Lecture du XML. Pour S9, on lit depuis le filesystem. + xml_bytes = self._read_xml(artifact) + + try: + doc = parse_alto(xml_bytes) + except AltoParseError as exc: + from picarones.domain.errors import ProjectionError + raise ProjectionError(f"AltoToText : {exc}") from exc + + text = alto_document_to_text(doc) + + # Construction de l'artefact résultat. + target = Artifact( + id=f"{artifact.id}:projected_text", + document_id=artifact.document_id, + type=self.target_type, + produced_by_step=artifact.produced_by_step, + ) + + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + lossy=True, + ignored_dimensions=( + "geometry", + "block_structure", + "reading_order", + "ids", + "confidence", + ), + warnings=( + "L'extraction texte ALTO ignore les coordonnées, " + "la structure en blocs, et les IDs. La césure " + "HypPart1/HypPart2 est résolue (mot recombiné).", + ), + ) + return target, text, report + + @staticmethod + def _read_xml(artifact: Artifact) -> bytes: + from picarones.domain.errors import ProjectionError + if artifact.uri is None: + raise ProjectionError( + f"AltoToText : artifact {artifact.id!r} n'a pas d'URI " + "et le projecteur ne sait pas résoudre les payloads " + "inline pour S9." + ) + from pathlib import Path + path = Path(artifact.uri) + try: + return path.read_bytes() + except OSError as exc: + raise ProjectionError( + f"AltoToText : impossible de lire {path!r} : {exc}" + ) from exc + + +__all__ = ["alto_document_to_text", "AltoToText"] diff --git a/picarones/evaluation/projectors/base.py b/picarones/evaluation/projectors/base.py new file mode 100644 index 0000000000000000000000000000000000000000..3bd89733ea2ca77f1e1892353c9aab58d86f75bc --- /dev/null +++ b/picarones/evaluation/projectors/base.py @@ -0,0 +1,123 @@ +"""``Projector`` (Protocol) + ``ProjectionReport`` — Sprint A14-S5 / S25. + +Un projecteur convertit un ``Artifact`` d'un type vers un autre, +en documentant explicitement ce qu'il **perd** au passage. + +Chaque appel produit un ``ProjectionReport`` qui sera affiché par +le rapport pour expliciter à l'utilisateur ce que la comparaison +ignore. Sans ce report, comparer "Tesseract texte brut" et +"VLM + reconstruction ALTO" sur la sortie texte serait +trompeur — l'utilisateur penserait juger les pipelines en bloc +alors qu'il ne juge qu'une projection. + +Sprint S25 — payload retourné directement +----------------------------------------- +``project()`` retourne désormais ``(Artifact, payload, report)`` +au lieu de ``(Artifact, report)``. Le projecteur a déjà calculé +le contenu projeté (texte pour ALTO→texte, etc.) — le retourner +directement évite à l'executor de devoir le re-charger via un +``payload_loader`` qui ne saurait pas le récupérer (l'artefact +projeté n'a typiquement pas d'URI puisqu'il est intermédiaire). + +Avant S25, l'executor appelait ``loader(projected_artifact)`` — +ce qui obligeait les tests à pré-stocker manuellement le payload +projeté dans une map (cf. le hack ``payloads[":projected_text"]`` +des tests S17/S18). Après S25, l'executor utilise directement le +payload retourné — la projection fonctionne bout-en-bout sans +collaboration explicite du loader. + +Implémentations concrètes au Sprint S14 dans +``picarones/evaluation/projectors/`` : + +- ``AltoToText``, ``PageToText``, ``CanonicalToText`` +- ``MarkdownToText`` +- ``IdentityProjector`` (pour les vues qui n'ont pas besoin de + projection mais veulent une API uniforme). +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import Artifact, ArtifactType + + +class ProjectionReport(BaseModel): + """Rapport produit par un projecteur sur un artefact source. + + Immuable. Sérialisable JSON pour persistance dans le run + manifest. + + Attributs + --------- + source_artifact_id: + Id de l'artefact source. + source_type: + Type de l'artefact source. + target_type: + Type de l'artefact projeté. + projector_name: + Identifiant du projecteur utilisé. + lossy: + ``True`` si la projection perd de l'information (cas usuel : + ALTO → texte perd les coordonnées). ``False`` pour une + projection identité. + ignored_dimensions: + Liste des dimensions explicitement ignorées (``"geometry"``, + ``"block_structure"``, ``"reading_order"``, ``"confidence"``, + ...). Affiché dans le rapport. + warnings: + Avertissements méthodologiques à propager dans le rapport + (ex : "ordre de lecture deviné par défaut, peut diverger + de l'intention éditoriale"). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + source_artifact_id: str + source_type: ArtifactType + target_type: ArtifactType + projector_name: str + lossy: bool = True + ignored_dimensions: tuple[str, ...] = Field(default_factory=tuple) + warnings: tuple[str, ...] = Field(default_factory=tuple) + + +@runtime_checkable +class Projector(Protocol): + """Contrat d'un projecteur. + + Une implémentation expose deux choses : sa **signature de types** + statique (pour que le registre puisse l'indexer) et un appel + ``project(artifact, params) -> (Artifact, payload, ProjectionReport)``. + + Note d'implémentation : on ne contraint pas que le projecteur + soit une classe — une simple fonction qui satisfait le protocole + convient. Les projecteurs canoniques du S14 seront probablement + des classes pour porter leur configuration via constructeur, + mais ce n'est pas une exigence du contrat. + + Le ``payload`` retourné est le contenu du nouvel artefact projeté + (str pour RAW_TEXT, dict pour ENTITIES, etc.) — l'executor S25 + l'utilise directement sans re-passer par un ``payload_loader``. + """ + + @property + def name(self) -> str: ... + + @property + def source_type(self) -> ArtifactType: ... + + @property + def target_type(self) -> ArtifactType: ... + + def project( + self, + artifact: Artifact, + params: dict[str, str | int | float | bool], + ) -> tuple[Artifact, Any, ProjectionReport]: ... + + +__all__ = ["Projector", "ProjectionReport"] diff --git a/picarones/evaluation/projectors/canonical.py b/picarones/evaluation/projectors/canonical.py new file mode 100644 index 0000000000000000000000000000000000000000..1ff16897e0889ca50ea7b8e2b743fd2e4b519c0d --- /dev/null +++ b/picarones/evaluation/projectors/canonical.py @@ -0,0 +1,221 @@ +"""Projecteur ``CANONICAL_DOCUMENT → RAW_TEXT`` — Sprint A14-S14. + +Convertit un artefact ``CANONICAL_DOCUMENT`` (typiquement un +markdown ou un JSON canonique produit par un VLM) vers du texte +plat comparable. + +Stratégies de payload supportées +-------------------------------- +1. **str (markdown)** — décape les balises markdown courantes : ``#``, + ``*``, ``_``, ``\\``, ``> ``, ``\\`\\`\\``, listes ``- ``, lignes + horizontales. Préserve le contenu textuel. + +2. **dict** — cherche en cascade ``"text"``, ``"content"``, + ``"markdown"``, ``"plain"``, puis itère récursivement. Si une + liste de paragraphes est trouvée sous ``"paragraphs"``, les + joint avec un saut de ligne. + +3. **list** — joint chaque élément (str ou dict récurse) avec ``\n``. + +L'objectif n'est pas une conversion markdown→texte parfaite mais +**une comparaison stable** : un VLM qui produit du markdown +``"# Titre\\nLigne"`` et un OCR qui produit ``"Titre\\nLigne"`` +doivent comparer égaux côté CER après projection. +""" + +from __future__ import annotations + +import re +from typing import Any + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.errors import ProjectionError +from picarones.evaluation.projectors.base import ProjectionReport + + +# Patterns markdown courants à décaper. Volontairement minimal — +# on ne fait PAS de parsing markdown complet (les libs comme +# mistune ne sont pas dans la whitelist evaluation/). +_MARKDOWN_HEADER_RE = re.compile(r"^#{1,6}\s+", re.MULTILINE) +_MARKDOWN_LIST_BULLET_RE = re.compile(r"^[-*+]\s+", re.MULTILINE) +_MARKDOWN_NUM_LIST_RE = re.compile(r"^\d+\.\s+", re.MULTILINE) +_MARKDOWN_BLOCKQUOTE_RE = re.compile(r"^>\s?", re.MULTILINE) +_MARKDOWN_HR_RE = re.compile(r"^[-*_]{3,}$", re.MULTILINE) +_MARKDOWN_BOLD_ITALIC_RE = re.compile(r"\*{1,3}([^*]+)\*{1,3}") +_MARKDOWN_UNDERLINE_RE = re.compile(r"_{1,2}([^_]+)_{1,2}") +_MARKDOWN_CODE_INLINE_RE = re.compile(r"`([^`]+)`") +_MARKDOWN_CODE_BLOCK_RE = re.compile(r"```[a-zA-Z0-9]*\n?|```", re.MULTILINE) +_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\([^)]+\)") +_MARKDOWN_IMAGE_RE = re.compile(r"!\[([^\]]*)\]\([^)]+\)") + + +def markdown_to_text(markdown: str) -> str: + """Convertit un markdown simple en texte plat. + + Conserve le contenu textuel, retire les marqueurs syntaxiques + courants. Pas de parser AST — substitutions regex simples qui + couvrent ~90 % des cas patrimoniaux observés. + """ + text = markdown + # Code blocks (fences) : retire les ``` lignes + text = _MARKDOWN_CODE_BLOCK_RE.sub("", text) + # Images avant liens (les images contiennent des liens) + text = _MARKDOWN_IMAGE_RE.sub(r"\1", text) + text = _MARKDOWN_LINK_RE.sub(r"\1", text) + # Headers, blockquotes, listes + text = _MARKDOWN_HEADER_RE.sub("", text) + text = _MARKDOWN_BLOCKQUOTE_RE.sub("", text) + text = _MARKDOWN_LIST_BULLET_RE.sub("", text) + text = _MARKDOWN_NUM_LIST_RE.sub("", text) + text = _MARKDOWN_HR_RE.sub("", text) + # Inline formatting : **gras**, *italique*, _souligné_, `code` + text = _MARKDOWN_BOLD_ITALIC_RE.sub(r"\1", text) + text = _MARKDOWN_UNDERLINE_RE.sub(r"\1", text) + text = _MARKDOWN_CODE_INLINE_RE.sub(r"\1", text) + return text.strip() + + +def canonical_payload_to_text(payload: Any) -> str: + """Extrait le texte plat d'un ``CANONICAL_DOCUMENT`` payload. + + Stratégies en cascade selon le type de ``payload`` : + + - ``str`` : traite comme markdown, applique ``markdown_to_text``. + - ``dict`` : cherche les clés textuelles standards. + - ``list`` : concatène les éléments avec ``\\n``. + - autre : ``str(payload)`` en dernier recours. + """ + if payload is None: + return "" + if isinstance(payload, str): + return markdown_to_text(payload) + if isinstance(payload, dict): + return _dict_to_text(payload) + if isinstance(payload, (list, tuple)): + parts = [ + canonical_payload_to_text(item) for item in payload + ] + return "\n".join(p for p in parts if p) + return str(payload).strip() + + +def _dict_to_text(payload: dict) -> str: + """Cherche les clés textuelles standards d'un dict canonique.""" + # Clés directes + for key in ("text", "content", "markdown", "plain", "value"): + if key in payload and isinstance(payload[key], str): + return markdown_to_text(payload[key]) + # Liste de paragraphes + if "paragraphs" in payload and isinstance(payload["paragraphs"], list): + return "\n".join( + canonical_payload_to_text(p) + for p in payload["paragraphs"] + ) + # Lignes (alternative) + if "lines" in payload and isinstance(payload["lines"], list): + return "\n".join( + canonical_payload_to_text(line) + for line in payload["lines"] + ) + # Sinon : concaténation des valeurs textuelles trouvées + parts: list[str] = [] + for value in payload.values(): + if isinstance(value, str): + parts.append(markdown_to_text(value)) + elif isinstance(value, (list, dict)): + sub = canonical_payload_to_text(value) + if sub: + parts.append(sub) + return "\n".join(parts).strip() + + +class CanonicalToText: + """Projecteur ``CANONICAL_DOCUMENT → RAW_TEXT``. + + Lit le payload depuis ``artifact.uri`` (chemin filesystem, + interprété comme markdown ou JSON selon l'extension). Pour les + payloads inline (testing), passer par un payload_loader + dédié dans le ``DefaultEvaluationViewExecutor``. + """ + + name = "canonical_to_text" + source_type = ArtifactType.CANONICAL_DOCUMENT + target_type = ArtifactType.RAW_TEXT + + def project( + self, + artifact: Artifact, + params: dict[str, str | int | float | bool], + ) -> tuple[Artifact, str, ProjectionReport]: + if artifact.type != self.source_type: + raise ProjectionError( + f"CanonicalToText n'accepte que CANONICAL_DOCUMENT, " + f"reçu {artifact.type.value!r}" + ) + + # Lecture du contenu source depuis l'URI (markdown / JSON + # canonique sur disque) puis projection vers texte plat. + # Le texte calculé est retourné via le tuple + # ``(artifact, payload, report)``. + if artifact.uri is None: + raise ProjectionError( + f"CanonicalToText : artifact {artifact.id!r} sans URI." + ) + from pathlib import Path + + try: + raw = Path(artifact.uri).read_bytes() + except OSError as exc: + raise ProjectionError( + f"CanonicalToText : impossible de lire {artifact.uri!r} : " + f"{exc}", + ) from exc + + # Tentative de parsing JSON ; sinon on traite comme markdown. + import json + try: + decoded = raw.decode("utf-8") + except UnicodeDecodeError as exc: + raise ProjectionError( + f"CanonicalToText : encodage non-UTF-8 : {exc}", + ) from exc + try: + payload = json.loads(decoded) + except json.JSONDecodeError: + payload = decoded # markdown brut + + text = canonical_payload_to_text(payload) + + target = Artifact( + id=f"{artifact.id}:projected_text", + document_id=artifact.document_id, + type=self.target_type, + produced_by_step=artifact.produced_by_step, + ) + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + lossy=True, + ignored_dimensions=( + "structure", + "formatting", + "headers", + "links", + ), + warnings=( + "Markdown / JSON canonique projeté en texte plat. " + "Les balises markdown sont retirées par regex (pas de " + "parser AST) ; les structures imbriquées (tableaux, " + "listes hiérarchiques) sont aplaties.", + ), + ) + return target, text, report + + +__all__ = [ + "markdown_to_text", + "canonical_payload_to_text", + "CanonicalToText", +] diff --git a/picarones/evaluation/projectors/pagexml.py b/picarones/evaluation/projectors/pagexml.py new file mode 100644 index 0000000000000000000000000000000000000000..cc2c615a2eeed2174856d66aa7934034cd41eb67 --- /dev/null +++ b/picarones/evaluation/projectors/pagexml.py @@ -0,0 +1,96 @@ +"""Projecteurs PAGE XML — Sprint A14-S9. + +Convertit un ``PageDocument`` (ou un artefact ``PAGE_XML``) vers +d'autres types d'artefacts. Symétrique de ``formats.alto.projector``. +""" + +from __future__ import annotations + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.evaluation.projectors.base import ProjectionReport +from picarones.formats.pagexml.parser import PageParseError, parse_pagexml +from picarones.formats.pagexml.types import PageDocument + + +def page_document_to_text(document: PageDocument) -> str: + """Extrait le texte plat d'un ``PageDocument``. + + Convention : + - Ordre ``Page → TextRegion → TextLine``. + - Saut de ligne entre lignes d'une même région. + - Saut de ligne supplémentaire entre régions. + """ + page_blocks: list[str] = [] + for page in document.pages: + for region in page.text_regions: + lines = [tl.text for tl in region.text_lines if tl.text] + if lines: + page_blocks.append("\n".join(lines)) + return "\n\n".join(page_blocks).strip() + + +class PageToText: + """Projecteur ``PAGE_XML → RAW_TEXT``.""" + + name = "page_to_text" + source_type = ArtifactType.PAGE_XML + target_type = ArtifactType.RAW_TEXT + + def project( + self, + artifact: Artifact, + params: dict[str, str | int | float | bool], + ) -> tuple[Artifact, str, ProjectionReport]: + from picarones.domain.errors import ProjectionError + if artifact.type != self.source_type: + raise ProjectionError( + f"PageToText n'accepte que PAGE_XML, reçu " + f"{artifact.type.value!r}" + ) + if artifact.uri is None: + raise ProjectionError( + f"PageToText : artifact {artifact.id!r} sans URI." + ) + from pathlib import Path + try: + xml_bytes = Path(artifact.uri).read_bytes() + except OSError as exc: + raise ProjectionError( + f"PageToText : impossible de lire {artifact.uri!r} : {exc}" + ) from exc + + try: + doc = parse_pagexml(xml_bytes) + except PageParseError as exc: + raise ProjectionError(f"PageToText : {exc}") from exc + + text = page_document_to_text(doc) + + target = Artifact( + id=f"{artifact.id}:projected_text", + document_id=artifact.document_id, + type=self.target_type, + produced_by_step=artifact.produced_by_step, + ) + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + lossy=True, + ignored_dimensions=( + "geometry", + "region_structure", + "baseline", + "ids", + ), + warnings=( + "L'extraction texte PAGE ignore les coordonnées et " + "la structure en régions. Plusieurs TextEquiv (variantes " + "d'OCR) sont collapsées au premier Unicode rencontré.", + ), + ) + return target, text, report + + +__all__ = ["page_document_to_text", "PageToText"] diff --git a/picarones/evaluation/projectors/registry.py b/picarones/evaluation/projectors/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..6538abf1604d7b1731459acdafb8d657a6f19f11 --- /dev/null +++ b/picarones/evaluation/projectors/registry.py @@ -0,0 +1,130 @@ +"""``ProjectorRegistry`` — Sprint A14-S13. + +Container instancié explicitement qui mappe ``projector_name`` +vers une instance ``Projector``. Symétrique du ``MetricRegistry`` +(S5) : pas de singleton global, pas de side-effect d'import. + +Pattern d'utilisation +--------------------- + +.. code-block:: python + + from picarones.evaluation.projectors import ( + ProjectorRegistry, AltoToText, + ) + from picarones.formats.alto import AltoToText as _AltoToText + + registry = ProjectorRegistry() + registry.register(_AltoToText()) + registry.register(PageToText()) + + projector = registry.get("alto_to_text") + target_artifact, payload, report = projector.project(source_artifact, {}) + +Au S20, ce registre sera construit par +``app/services/registry_service.py`` au démarrage de l'application. +Pour S13-S18, chaque test ou consommateur l'instancie explicitement. + +Anti-sur-ingénierie +------------------- +Pas de versioning de projecteur, pas de namespace, pas de recherche +par tag. Ces extras viendront quand un caller en aura concrètement +besoin (probablement avec les projecteurs contribués par des modules +tiers, post-livraison). +""" + +from __future__ import annotations + +from picarones.domain.errors import PicaronesError +from picarones.evaluation.projectors.base import Projector + + +class ProjectorRegistrationError(PicaronesError): + """Tentative d'enregistrement invalide d'un projecteur.""" + + +class ProjectorNotFoundError(PicaronesError): + """Le projecteur demandé n'est pas enregistré.""" + + +class ProjectorRegistry: + """Container mutable de projecteurs indexés par ``name``. + + Thread-safe en lecture après initialisation ; la séquence + d'enregistrement attendue est : un seul service, au démarrage, + enregistre tous les projecteurs en une fois, puis l'instance + est figée par convention. + """ + + def __init__(self) -> None: + self._projectors: dict[str, Projector] = {} + + # ────────────────────────────────────────────────────────────────── + # Enregistrement + # ────────────────────────────────────────────────────────────────── + + def register(self, projector: Projector) -> None: + """Enregistre un projecteur. + + Raises + ------ + ProjectorRegistrationError + Si un projecteur du même nom est déjà enregistré (sauf + re-enregistrement strict du même objet, toléré pour les + tests qui re-instancient). + """ + if not hasattr(projector, "name"): + raise ProjectorRegistrationError( + "register : l'objet n'expose pas d'attribut ``name``." + ) + if not isinstance(projector, Projector): + raise ProjectorRegistrationError( + f"register : {projector!r} ne satisfait pas le protocole " + "Projector (attributs ``name``, ``source_type``, " + "``target_type``, méthode ``project``)." + ) + existing = self._projectors.get(projector.name) + if existing is not None: + if existing is projector: + return # idempotent + raise ProjectorRegistrationError( + f"Projecteur {projector.name!r} déjà enregistré avec " + "une autre instance." + ) + self._projectors[projector.name] = projector + + # ────────────────────────────────────────────────────────────────── + # Lecture + # ────────────────────────────────────────────────────────────────── + + def __contains__(self, name: str) -> bool: + return name in self._projectors + + def __len__(self) -> int: + return len(self._projectors) + + def names(self) -> list[str]: + """Liste des noms enregistrés (ordre d'enregistrement).""" + return list(self._projectors.keys()) + + def get(self, name: str) -> Projector: + """Récupère le projecteur par son ``name``. + + Raises + ------ + ProjectorNotFoundError + Si le nom n'est pas enregistré. + """ + if name not in self._projectors: + raise ProjectorNotFoundError( + f"Projecteur {name!r} non enregistré. " + f"Disponibles : {sorted(self._projectors)}." + ) + return self._projectors[name] + + +__all__ = [ + "ProjectorRegistry", + "ProjectorRegistrationError", + "ProjectorNotFoundError", +] diff --git a/picarones/evaluation/registry/__init__.py b/picarones/evaluation/registry/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b861141339680c00ffe5c01e9cfe79a54edf329c --- /dev/null +++ b/picarones/evaluation/registry/__init__.py @@ -0,0 +1,32 @@ +"""Registre typé de métriques — Sprint S5. + +Construit **explicitement** par un service au démarrage de +l'application, pas par effet de bord d'import au top-level d'un +package. + +Anti-pattern à éviter (présent dans l'existant et listé dans +``BACKLOG_POST_LIVRAISON.md`` §2.4) — un ``__init__.py`` qui +importe un sous-package "uniquement pour amorcer un registre", +chargeant des dizaines de modules et leurs dépendances optionnelles +au moment d'un simple ``import picarones``. + +Pattern cible : un service ``build_default_registry()`` instancié +au démarrage de l'application qui ``register()`` chaque métrique +explicitement. Le registre est ensuite injecté dans les services +qui en ont besoin. Pas de singleton global, pas de side effect +d'import. +""" + +from __future__ import annotations + +from picarones.evaluation.registry.registry import ( + MetricNotFoundError, + MetricRegistrationError, + MetricRegistry, +) + +__all__ = [ + "MetricRegistry", + "MetricRegistrationError", + "MetricNotFoundError", +] diff --git a/picarones/evaluation/registry/registry.py b/picarones/evaluation/registry/registry.py new file mode 100644 index 0000000000000000000000000000000000000000..2d08b4a82c6982d0f20fbfcb38b8244d58441efe --- /dev/null +++ b/picarones/evaluation/registry/registry.py @@ -0,0 +1,191 @@ +"""``MetricRegistry`` — Sprint A14-S5. + +Container mutable qui associe chaque ``MetricSpec`` à son callable +de calcul. **Instancié explicitement** par un service au démarrage +de l'application (cf. ``picarones/app/services/registry_service.py`` +au S20) — pas de singleton global, pas de side-effect d'import, +pas de décorateur magique. + +Différence avec l'existant ``picarones.core.metric_registry`` +------------------------------------------------------------- +L'ancien module utilise un dict module-level +``_METRIC_REGISTRY`` rempli par un décorateur ``@register_metric`` +appliqué au top-level d'autres modules. Conséquence : un +``import picarones`` charge ~50 sous-modules pour amorcer le +registre — anti-pattern documenté dans +``BACKLOG_POST_LIVRAISON.md`` §2.4. + +Ici, ``MetricRegistry`` est une classe instanciable : + +.. code-block:: python + + from picarones.domain import ArtifactType + from picarones.domain.evaluation_spec import MetricSpec + from picarones.evaluation.registry import MetricRegistry + + reg = MetricRegistry() + reg.register( + MetricSpec(name="cer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + compute_cer, # callable + ) + selected = reg.select( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + ) + +Anti-sur-ingénierie +------------------- +Pas de gestion de versions de métrique, pas de namespace, pas de +recherche par tag. Si un caller a besoin de ces features, il les +implémentera quand le besoin sera concret (probablement S15+). +""" + +from __future__ import annotations + +from typing import Any, Callable + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.errors import PicaronesError +from picarones.domain.evaluation_spec import MetricSpec + + +class MetricRegistrationError(PicaronesError): + """Tentative d'enregistrement invalide d'une métrique.""" + + +class MetricNotFoundError(PicaronesError): + """La métrique demandée n'est pas enregistrée.""" + + +class MetricRegistry: + """Container mutable de ``MetricSpec`` + callables. + + Thread-safe en lecture après initialisation ; la séquence + d'enregistrement attendue est : un seul service, au démarrage, + enregistre toutes les métriques en une fois, puis l'instance + est figée par convention (lecture seule depuis les services + consommateurs). + + Pas de mécanisme de freeze technique pour l'instant — si un + caller modifie le registre après le bootstrap, c'est de sa + responsabilité. + """ + + def __init__(self) -> None: + self._specs: dict[str, MetricSpec] = {} + self._callables: dict[str, Callable[..., Any]] = {} + + # ────────────────────────────────────────────────────────────────── + # Enregistrement + # ────────────────────────────────────────────────────────────────── + + def register(self, spec: MetricSpec, func: Callable[..., Any]) -> None: + """Enregistre une métrique. + + Raises + ------ + MetricRegistrationError + Si une métrique du même nom est déjà enregistrée + (sauf re-enregistrement strict du même couple + ``(spec, func)``, toléré pour les tests qui re-instancient). + """ + if not callable(func): + raise MetricRegistrationError( + f"register({spec.name!r}) : func n'est pas callable." + ) + if spec.name in self._specs: + existing_spec = self._specs[spec.name] + existing_func = self._callables[spec.name] + if existing_spec == spec and existing_func is func: + return # idempotent + raise MetricRegistrationError( + f"Métrique {spec.name!r} déjà enregistrée avec une " + "autre spec ou un autre callable." + ) + self._specs[spec.name] = spec + self._callables[spec.name] = func + + # ────────────────────────────────────────────────────────────────── + # Lecture + # ────────────────────────────────────────────────────────────────── + + def __contains__(self, name: str) -> bool: + return name in self._specs + + def __len__(self) -> int: + return len(self._specs) + + def names(self) -> list[str]: + """Liste des noms enregistrés (ordre d'enregistrement).""" + return list(self._specs.keys()) + + def get_spec(self, name: str) -> MetricSpec: + if name not in self._specs: + raise MetricNotFoundError( + f"Métrique {name!r} non enregistrée. " + f"Disponibles : {sorted(self._specs)}." + ) + return self._specs[name] + + def get_callable(self, name: str) -> Callable[..., Any]: + if name not in self._callables: + raise MetricNotFoundError( + f"Callable de métrique {name!r} non enregistré." + ) + return self._callables[name] + + def select( + self, + reference_type: ArtifactType, + hypothesis_type: ArtifactType, + ) -> list[MetricSpec]: + """Métriques applicables à une jonction donnée (signature exacte).""" + target = (reference_type, hypothesis_type) + return [s for s in self._specs.values() if s.input_types == target] + + # ────────────────────────────────────────────────────────────────── + # Calcul + # ────────────────────────────────────────────────────────────────── + + def compute( + self, + name: str, + reference: Any, + hypothesis: Any, + ) -> Any: + """Calcule la métrique nommée sur la paire (référence, hypothèse). + + Aucune capture d'exception : si la métrique lève, l'exception + remonte au caller (qui est typiquement un + ``EvaluationViewExecutor`` qui décide quoi en faire dans son + ``ProjectionReport``). + """ + func = self.get_callable(name) + return func(reference, hypothesis) + + def compute_at_junction( + self, + reference: Any, + hypothesis: Any, + reference_type: ArtifactType, + hypothesis_type: ArtifactType, + ) -> dict[str, Any]: + """Calcule **toutes** les métriques applicables à la jonction. + + Retourne ``{metric_name: value}``. Une métrique qui lève + est absente du dict (warning loggé au niveau caller via + l'EvaluationViewExecutor — ici on remonte l'exception pour + que les tests détectent les bugs). + """ + results: dict[str, Any] = {} + for spec in self.select(reference_type, hypothesis_type): + results[spec.name] = self.compute(spec.name, reference, hypothesis) + return results + + +__all__ = [ + "MetricRegistry", + "MetricRegistrationError", + "MetricNotFoundError", +] diff --git a/picarones/evaluation/views/__init__.py b/picarones/evaluation/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..5cb7cd1fd35d8ebf66d32393c07793ce02239003 --- /dev/null +++ b/picarones/evaluation/views/__init__.py @@ -0,0 +1,79 @@ +"""Vues d'évaluation — Sprints S13-S16. + +Une vue d'évaluation répond à une question précise : "lequel des +pipelines disponibles produit la meilleure sortie selon cet angle ?" + +Vues canoniques cibles (rewrite ciblé) : + +- ``TextView`` (S14) — qualité textuelle finale. Accepte RAW_TEXT, + CORRECTED_TEXT, ALTO_XML, PAGE_XML, CANONICAL_DOCUMENT, projette + tout vers texte brut. Métriques : CER, WER, insertions, omissions. +- ``AltoView`` (S15) — fidélité documentaire. Exige ALTO_XML. + Métriques : validité, alignement lignes/mots, ordre de lecture. +- ``SearchView`` (S16) — recherchabilité plein-texte. Métriques : + rappel fuzzy, séquences numériques préservées, noms propres + retrouvés. + +Reporté post-livraison : ``LayoutView``, ``HallucinationView``, +``CostView``, ``PhilologicalView``, ``ProductionView``. +""" + +from __future__ import annotations + +from picarones.evaluation.views.alto_view import ( + DEFAULT_ALTO_CANDIDATE_TYPES, + DEFAULT_ALTO_IGNORED_DIMENSIONS, + DEFAULT_ALTO_METRICS, + DEFAULT_ALTO_WARNINGS, + build_alto_view, +) +from picarones.evaluation.views.base import EvaluationViewExecutor, ViewResult +from picarones.evaluation.views.executor import ( + DefaultEvaluationViewExecutor, + PayloadLoader, +) +from picarones.evaluation.views.search_view import ( + DEFAULT_SEARCH_CANDIDATE_TYPES, + DEFAULT_SEARCH_IGNORED_DIMENSIONS, + DEFAULT_SEARCH_METRICS, + DEFAULT_SEARCH_PROJECTIONS, + DEFAULT_SEARCH_WARNINGS, + build_search_view, +) +from picarones.evaluation.views.text_view import ( + DEFAULT_TEXT_CANDIDATE_TYPES, + DEFAULT_TEXT_IGNORED_DIMENSIONS, + DEFAULT_TEXT_METRICS, + DEFAULT_TEXT_PROJECTIONS, + DEFAULT_TEXT_WARNINGS, + build_text_view, +) + +__all__ = [ + # Protocol + result + "EvaluationViewExecutor", + "ViewResult", + # Executor + "DefaultEvaluationViewExecutor", + "PayloadLoader", + # TextView (S14) + "build_text_view", + "DEFAULT_TEXT_METRICS", + "DEFAULT_TEXT_CANDIDATE_TYPES", + "DEFAULT_TEXT_PROJECTIONS", + "DEFAULT_TEXT_IGNORED_DIMENSIONS", + "DEFAULT_TEXT_WARNINGS", + # AltoView (S15) + "build_alto_view", + "DEFAULT_ALTO_METRICS", + "DEFAULT_ALTO_CANDIDATE_TYPES", + "DEFAULT_ALTO_IGNORED_DIMENSIONS", + "DEFAULT_ALTO_WARNINGS", + # SearchView (S16) + "build_search_view", + "DEFAULT_SEARCH_METRICS", + "DEFAULT_SEARCH_CANDIDATE_TYPES", + "DEFAULT_SEARCH_PROJECTIONS", + "DEFAULT_SEARCH_IGNORED_DIMENSIONS", + "DEFAULT_SEARCH_WARNINGS", +] diff --git a/picarones/evaluation/views/alto_view.py b/picarones/evaluation/views/alto_view.py new file mode 100644 index 0000000000000000000000000000000000000000..eb950a78cd35e989b46c1be454a8e3d6b07d3541 --- /dev/null +++ b/picarones/evaluation/views/alto_view.py @@ -0,0 +1,138 @@ +"""``AltoView`` — vue canonique 2, Sprint A14-S15. + +Deuxième vue d'évaluation canonique : "quel pipeline produit le +meilleur ALTO exploitable ?". + +Distinct de ``TextView`` (S14) +------------------------------ +``TextView`` projette tout vers texte plat et ignore la structure +documentaire. ``AltoView`` fait l'inverse : exige un ``ALTO_XML`` +en entrée et mesure la **fidélité structurelle** (validité, +nombre de lignes, présence des bbox de mots, etc.). + +Un même pipeline peut être évalué dans les deux vues. Le rapport +HTML (S22) présentera les deux côte-à-côte pour qu'un lecteur +comprenne *pourquoi* deux pipelines avec le même CER produisent +des ALTO de qualités différentes. + +Pipelines omis explicitement +---------------------------- +Un pipeline qui ne produit pas d'``ALTO_XML`` (exemple : Tesseract +texte brut sans ALTO) ne peut pas être évalué dans ``AltoView``. +Le caller (typiquement un service applicatif au S19) doit +**omettre** ce pipeline du résultat ``AltoView`` plutôt que de lui +attribuer un score factice à 0. + +Le pattern est démontré dans le test +``tests/evaluation/views/test_sprint_a14_s15_alto_view.py`` : +le caller boucle sur ``[TextView, AltoView]`` et pour chaque vue +filtre les pipelines dont l'artefact n'est pas dans +``view.candidate_types``. + +Métriques par défaut +-------------------- +- ``alto_validity`` — l'hypothèse est-elle structurellement + cohérente ? (≥ 1 page, ≥ 1 bloc, ≥ 1 ligne). +- ``alto_line_count_ratio`` — ratio min/max du nombre de lignes. +- ``alto_word_box_coverage`` — fraction des mots qui ont une bbox. + +Toutes ∈ [0, 1] avec ``higher_is_better=True``. + +Reportées à un sprint suivant +----------------------------- +- ``textline_alignment`` (IoU des bbox de lignes). +- ``reading_order_consistency`` (Kendall tau sur les IDs). +- ``layout_f1`` (ICDAR 2015) via wrapper de + ``evaluation/metrics/layout.py``. +""" + +from __future__ import annotations + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.evaluation_spec import EvaluationView + + +#: Métriques calculées par défaut. Toutes typées +#: ``(ALTO_XML, ALTO_XML)``. +DEFAULT_ALTO_METRICS: tuple[str, ...] = ( + "alto_validity", + "alto_line_count_ratio", + "alto_word_box_coverage", +) + + +#: Types acceptés. Volontairement strict : seul ``ALTO_XML`` +#: passe. PAGE_XML pourrait être ajouté via une projection +#: ``page_to_alto`` (post-livraison) si le besoin se présente. +DEFAULT_ALTO_CANDIDATE_TYPES: frozenset[ArtifactType] = frozenset({ + ArtifactType.ALTO_XML, +}) + + +#: Dimensions explicitement non évaluées. +DEFAULT_ALTO_IGNORED_DIMENSIONS: tuple[str, ...] = ( + # Qualité linguistique pure : c'est TextView (S14) qui la mesure. + "linguistic_quality", + # Recherchabilité fuzzy : c'est SearchView (S16). + "search_recall", + # Hallucinations contenu : c'est HallucinationView (post-S18). + "content_hallucination", +) + + +#: Avertissement par défaut affiché en tête du bloc AltoView. +DEFAULT_ALTO_WARNINGS: tuple[str, ...] = ( + "Cette vue mesure la fidélité STRUCTURELLE de l'ALTO produit " + "(validité, nombre de lignes, bbox). La qualité TEXTUELLE de " + "ce qui est dans cet ALTO est mesurée par TextView ; les deux " + "doivent être lues ensemble pour juger un pipeline.", + "Les pipelines qui ne produisent pas d'ALTO sont OMIS de cette " + "vue. Aucun score factice n'est attribué à un pipeline absent.", +) + + +def build_alto_view( + *, + name: str = "alto_documentary", + description: str = ( + "Mesure la fidélité structurelle de l'ALTO produit par un " + "pipeline (validité, lignes, bbox)." + ), + metric_names: tuple[str, ...] | None = None, + extra_warnings: tuple[str, ...] = (), + extra_ignored_dimensions: tuple[str, ...] = (), +) -> EvaluationView: + """Construit la vue canonique AltoView. + + Pas de ``candidate_types`` paramétrable (la vue exige par + nature ALTO_XML uniquement) ni de ``projection`` + (l'évaluation se fait sur l'ALTO tel quel, pas sur sa + projection). + + Le caller qui veut une vue plus stricte (par exemple "exiger + aussi le mapping vers une GT_ALTO précise") peut composer + plusieurs ``AltoView`` paramétrées. + """ + return EvaluationView( + name=name, + description=description, + candidate_types=DEFAULT_ALTO_CANDIDATE_TYPES, + projection=None, + projections_by_source_type={}, + normalization_profile=None, + metric_names=( + metric_names if metric_names is not None + else DEFAULT_ALTO_METRICS + ), + warnings=DEFAULT_ALTO_WARNINGS + extra_warnings, + ignored_dimensions=DEFAULT_ALTO_IGNORED_DIMENSIONS + extra_ignored_dimensions, + ) + + +__all__ = [ + "build_alto_view", + "DEFAULT_ALTO_METRICS", + "DEFAULT_ALTO_CANDIDATE_TYPES", + "DEFAULT_ALTO_IGNORED_DIMENSIONS", + "DEFAULT_ALTO_WARNINGS", +] diff --git a/picarones/evaluation/views/base.py b/picarones/evaluation/views/base.py new file mode 100644 index 0000000000000000000000000000000000000000..d2d8d4dd081ae94890d7c3514a995b87c8e4169f --- /dev/null +++ b/picarones/evaluation/views/base.py @@ -0,0 +1,127 @@ +"""``EvaluationViewExecutor`` (Protocol) + ``ViewResult`` — Sprint A14-S5. + +Le contrat d'exécution d'une vue d'évaluation. Implémentation +concrète au Sprint S13 dans +``picarones.evaluation.views.executor``. + +Pattern d'utilisation cible : + +.. code-block:: python + + from picarones.evaluation.registry import MetricRegistry + from picarones.evaluation.views.executor import DefaultViewExecutor + + registry = build_default_registry() # S20 + executor = DefaultViewExecutor(registry, projector_registry) + + for view in eval_spec.views: + result = executor.evaluate( + view=view, + candidate=pipeline_artifact, + ground_truth=gt_artifact, + ) + # result.metric_values : dict[str, Any] + # result.projection_report : ProjectionReport | None + # result.warnings : tuple[str, ...] +""" + +from __future__ import annotations + +from typing import Any, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import Artifact +from picarones.domain.evaluation_spec import EvaluationView +from picarones.evaluation.projectors.base import ProjectionReport + + +class ViewResult(BaseModel): + """Résultat de l'évaluation d'une vue sur une paire (candidat, GT). + + Immuable. Sérialisable JSON pour persistance dans le + ``view_results.jsonl`` du run. + + Attributs + --------- + view_name: + Nom de la vue qui a produit ce résultat. + pipeline_name: + Nom du pipeline qui a produit l'artefact candidat. Champ + structurel — les renderers (CSV/JSON/HTML) ne doivent pas + deviner cette information par parsing de + ``candidate_artifact_id``. + candidate_artifact_id: + Id de l'artefact évalué (avant projection éventuelle). + ground_truth_artifact_id: + Id de l'artefact GT utilisé pour la comparaison. + metric_values: + Dict ``{metric_name: value}`` pour chaque métrique calculée + avec succès. Une métrique qui a échoué est absente du dict + et apparaît dans ``failed_metrics`` avec le message d'erreur. + failed_metrics: + Dict ``{metric_name: error_message}`` pour les métriques qui + ont levé une exception. Permet au rapport d'afficher + "métrique X non calculée : raison" plutôt que de la cacher. + projection_report: + ``ProjectionReport`` produit si la vue a appliqué une + projection. ``None`` si la vue compare l'artefact tel quel. + warnings: + Avertissements à propager dans le rapport (typiquement les + ``warnings`` de ``EvaluationView`` + ceux du + ``ProjectionReport`` éventuel). + ignored_dimensions: + Récapitulatif des dimensions ignorées par cette évaluation + (combinaison de la vue + de la projection). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + view_name: str + pipeline_name: str = Field(min_length=1, max_length=128) + candidate_artifact_id: str + ground_truth_artifact_id: str + metric_values: dict[str, Any] = Field(default_factory=dict) + failed_metrics: dict[str, str] = Field(default_factory=dict) + projection_report: ProjectionReport | None = None + warnings: tuple[str, ...] = Field(default_factory=tuple) + ignored_dimensions: tuple[str, ...] = Field(default_factory=tuple) + + +@runtime_checkable +class EvaluationViewExecutor(Protocol): + """Contrat de l'exécuteur de vues. + + Une implémentation prend en entrée : + + - une ``EvaluationView`` (déclarative), + - un ``Artifact`` candidat (sortie d'un pipeline), + - un ``Artifact`` GT (référence du corpus), + + et produit un ``ViewResult`` qui : + + 1. applique la projection si la vue en spécifie une (et capture + le ``ProjectionReport``), + 2. applique le profil de normalisation si spécifié, + 3. calcule chaque métrique listée dans + ``view.metric_names`` (en propageant les erreurs dans + ``failed_metrics`` plutôt que de planter), + 4. retourne un ``ViewResult`` immuable. + + Cas particulier : si l'artefact candidat n'est pas dans + ``view.candidate_types``, l'executor lève ``ValueError`` — + c'est au caller (typiquement le service applicatif) de filtrer + en amont les pipelines qui ne produisent pas l'artefact attendu. + """ + + def evaluate( + self, + view: EvaluationView, + candidate: Artifact, + ground_truth: Artifact, + *, + pipeline_name: str, + ) -> ViewResult: ... + + +__all__ = ["EvaluationViewExecutor", "ViewResult"] diff --git a/picarones/evaluation/views/executor.py b/picarones/evaluation/views/executor.py new file mode 100644 index 0000000000000000000000000000000000000000..0a23e8efac9507b00b42944c21d6fd56d6a57067 --- /dev/null +++ b/picarones/evaluation/views/executor.py @@ -0,0 +1,321 @@ +"""``DefaultEvaluationViewExecutor`` — Sprint A14-S13, refactoré au S27. + +Implémentation concrète du protocole ``EvaluationViewExecutor`` (S5). +Orchestre une vue d'évaluation sur une paire (candidat, GT) en +**déléguant** la projection et l'évaluation à deux moteurs spécialisés +introduits au S27 : + +- ``ProjectionEngine`` (cf. ``picarones/evaluation/projection_engine.py``) + transforme l'artefact candidat selon la ``ProjectionSpec``. +- ``EvaluationEngine`` (cf. ``picarones/evaluation/evaluation_engine.py``) + calcule les métriques sur les payloads. + +Séquence d'orchestration +------------------------ +1. Vérifie que ``candidate.type`` est dans ``view.candidate_types``. +2. ``ProjectionEngine.project(candidate, view.projection_for(candidate.type))`` + → retourne un ``ProjectionResult`` qui peut contenir un payload + pré-calculé. +3. Charge les payloads (texte, ALTO parsé, etc.) via le + ``payload_loader`` injecté. Si la projection a produit un payload, + l'utilise directement sans repasser par le loader. +4. Applique optionnellement un profil de normalisation texte + (``view.normalization_profile``). +5. ``EvaluationEngine.evaluate(view.metric_names, gt_payload, cand_payload)`` + → retourne un ``EvaluationResult`` avec metric_values + failed_metrics. +6. Construit le ``ViewResult`` agrégeant tout (projection_report, + metric_values, failed_metrics, warnings, ignored_dimensions). + +Construction +------------ +- ``__init__`` canonique prend ``(projection_engine, evaluation_engine, + payload_loader)``. +- ``from_registries(metric_registry, projector_registry, payload_loader)`` + reste exposé comme classmethod ergonomique pour les callers qui + n'ont pas envie de fabriquer eux-mêmes les deux moteurs (tests, + scripts ad-hoc). Aucune logique nouvelle — uniquement un appel + composé ; l'API canonique reste l'injection des deux engines. + +Anti-sur-ingénierie +------------------- +Pas de cache de payload chargé entre métriques (chaque appel à +``evaluate`` est indépendant). Pas de batch (évaluer N paires en +une passe). Pas de validation cross-métrique. La complexité vit +dans les engines, pas dans l'executor. +""" + +from __future__ import annotations + +import logging +from typing import Any, Callable + +from picarones.domain.artifacts import Artifact +from picarones.domain.evaluation_spec import EvaluationView +from picarones.evaluation.evaluation_engine import EvaluationEngine +from picarones.evaluation.projection_engine import ProjectionEngine +from picarones.evaluation.projectors.registry import ProjectorRegistry +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views.base import ViewResult + +logger = logging.getLogger(__name__) + + +#: Type alias : un payload loader prend un Artifact et retourne le +#: contenu chargé (str pour RAW_TEXT, dict pour ENTITIES, etc.). +PayloadLoader = Callable[[Artifact], Any] + + +class DefaultEvaluationViewExecutor: + """Orchestrateur de vue d'évaluation. + + Parameters + ---------- + projection_engine: + ``ProjectionEngine`` injecté. Responsable de la + transformation d'artefacts entre types via le registre de + projecteurs. + evaluation_engine: + ``EvaluationEngine`` injecté. Responsable du calcul des + métriques nommées sur des payloads. + payload_loader: + Callable ``(Artifact) -> Any`` qui charge le contenu d'un + artefact non encore résolu (typiquement la GT et le candidat + s'il n'est pas projeté). Pour les tests, un dict in-memory + ; en production, un service applicatif qui sait gérer les + workspaces sandboxés. + """ + + def __init__( + self, + projection_engine: ProjectionEngine, + evaluation_engine: EvaluationEngine, + payload_loader: PayloadLoader, + ) -> None: + if not isinstance(projection_engine, ProjectionEngine): + raise TypeError( + "projection_engine doit être un ProjectionEngine." + ) + if not isinstance(evaluation_engine, EvaluationEngine): + raise TypeError( + "evaluation_engine doit être un EvaluationEngine." + ) + if not callable(payload_loader): + raise TypeError("payload_loader doit être callable.") + self._projection = projection_engine + self._evaluation = evaluation_engine + self._loader = payload_loader + + # ────────────────────────────────────────────────────────────────── + # Constructeur ergonomique + # ────────────────────────────────────────────────────────────────── + + @classmethod + def from_registries( + cls, + metric_registry: MetricRegistry, + projector_registry: ProjectorRegistry, + payload_loader: PayloadLoader, + ) -> "DefaultEvaluationViewExecutor": + """Construit l'executor à partir des registres bruts. + + Sucre syntaxique sur l'API canonique : un caller qui a déjà + un ``MetricRegistry`` + ``ProjectorRegistry`` (cas typique : + un test, ou un service qui n'a qu'un seul executor) gagne + deux lignes. Aucune logique nouvelle — instancie + ``ProjectionEngine`` et ``EvaluationEngine`` puis délègue. + """ + return cls( + projection_engine=ProjectionEngine(projector_registry), + evaluation_engine=EvaluationEngine(metric_registry), + payload_loader=payload_loader, + ) + + # ────────────────────────────────────────────────────────────────── + # API publique + # ────────────────────────────────────────────────────────────────── + + def evaluate( + self, + view: EvaluationView, + candidate: Artifact, + ground_truth: Artifact, + *, + pipeline_name: str, + ) -> ViewResult: + """Évalue la vue sur la paire (candidat, GT). + + Returns + ------- + ViewResult + Toujours retourné en sortie normale — les erreurs de + métriques individuelles vont dans ``failed_metrics``, + les erreurs de chargement de payload se traduisent en + ``failed_metrics`` global. + + Raises + ------ + ProjectionError + Si la vue exige une projection que le projecteur ne + peut pas réaliser (cohérent avec le contrat du S5). + ValueError + Si ``candidate.type`` n'est pas dans + ``view.candidate_types``. Le caller (typiquement le + ``BenchmarkService``) doit filtrer les pipelines qui ne + produisent pas le bon type avant d'appeler ``evaluate``. + """ + # 1. Vérification du type d'entrée. + if not view.accepts(candidate.type): + raise ValueError( + f"View {view.name!r} n'accepte pas l'artefact " + f"{candidate.id!r} (type {candidate.type.value!r}). " + f"Types acceptés : " + f"{sorted(t.value for t in view.candidate_types)}." + ) + + # 2. Projection (déléguée). Lève ``ProjectionError`` si la + # projection est invalide — on laisse remonter (cohérence + # avec le contrat S5). + projection_spec = view.projection_for(candidate.type) + projection_result = self._projection.project( + candidate, projection_spec, + ) + + # 3. Chargement des payloads. + # Si la projection a fourni un payload, on l'utilise sans + # repasser par le loader (typique S25 — l'artefact projeté + # n'a pas d'URI). Sinon, on charge le candidat via le loader. + if projection_result.payload is not None: + cand_payload = projection_result.payload + else: + try: + cand_payload = self._loader(projection_result.artifact) + except Exception as exc: # noqa: BLE001 + return self._failed_view_result( + view=view, + pipeline_name=pipeline_name, + candidate=candidate, + ground_truth=ground_truth, + projection_report=projection_result.report, + global_error=( + f"payload_loader a échoué sur le candidat " + f"{projection_result.artifact.id!r} : {exc}" + ), + ) + try: + gt_payload = self._loader(ground_truth) + except Exception as exc: # noqa: BLE001 + return self._failed_view_result( + view=view, + pipeline_name=pipeline_name, + candidate=candidate, + ground_truth=ground_truth, + projection_report=projection_result.report, + global_error=( + f"payload_loader a échoué sur la GT " + f"{ground_truth.id!r} : {exc}" + ), + ) + + # 4. Normalisation texte (optionnelle). + if view.normalization_profile is not None: + cand_payload, gt_payload = self._apply_normalization( + view.normalization_profile, cand_payload, gt_payload, + ) + + # 5. Évaluation déléguée. Une métrique cassée → failed_metrics. + evaluation_result = self._evaluation.evaluate( + view.metric_names, gt_payload, cand_payload, + ) + + # 6. Agrégation finale dans le ViewResult. + warnings = tuple(view.warnings) + ignored = tuple(view.ignored_dimensions) + if projection_result.report is not None: + warnings = warnings + tuple(projection_result.report.warnings) + seen: set[str] = set(ignored) + extra = tuple( + d for d in projection_result.report.ignored_dimensions + if d not in seen + ) + ignored = ignored + extra + + return ViewResult( + view_name=view.name, + pipeline_name=pipeline_name, + candidate_artifact_id=candidate.id, + ground_truth_artifact_id=ground_truth.id, + metric_values=evaluation_result.metric_values, + failed_metrics=evaluation_result.failed_metrics, + projection_report=projection_result.report, + warnings=warnings, + ignored_dimensions=ignored, + ) + + # ────────────────────────────────────────────────────────────────── + # Helpers internes + # ────────────────────────────────────────────────────────────────── + + @staticmethod + def _apply_normalization( + profile_name: str, + cand_payload: Any, + gt_payload: Any, + ) -> tuple[Any, Any]: + """Applique un profil de normalisation aux deux payloads. + + Si l'un des deux n'est pas une string, on saute la + normalisation pour ce payload (cas typique : ALTO non encore + projeté en texte → on laisse passer). + """ + from picarones.formats.text.normalization import get_builtin_profile + try: + profile = get_builtin_profile(profile_name) + except Exception as exc: # noqa: BLE001 + logger.warning( + "[view_executor] profil normalisation %r introuvable : %s", + profile_name, exc, + ) + return cand_payload, gt_payload + normalized_cand = ( + profile.normalize(cand_payload) + if isinstance(cand_payload, str) + else cand_payload + ) + normalized_gt = ( + profile.normalize(gt_payload) + if isinstance(gt_payload, str) + else gt_payload + ) + return normalized_cand, normalized_gt + + @staticmethod + def _failed_view_result( + *, + view: EvaluationView, + pipeline_name: str, + candidate: Artifact, + ground_truth: Artifact, + projection_report: Any, + global_error: str, + ) -> ViewResult: + """Construit un ``ViewResult`` quand le payload n'a pas pu + être chargé. Toutes les métriques sont marquées en échec + avec le même message d'erreur global.""" + failed = {name: global_error for name in view.metric_names} + return ViewResult( + view_name=view.name, + pipeline_name=pipeline_name, + candidate_artifact_id=candidate.id, + ground_truth_artifact_id=ground_truth.id, + metric_values={}, + failed_metrics=failed, + projection_report=projection_report, + warnings=tuple(view.warnings) + (global_error,), + ignored_dimensions=tuple(view.ignored_dimensions), + ) + + +__all__ = [ + "DefaultEvaluationViewExecutor", + "PayloadLoader", +] diff --git a/picarones/evaluation/views/search_view.py b/picarones/evaluation/views/search_view.py new file mode 100644 index 0000000000000000000000000000000000000000..7d3ccce8be79e4362e46236b8e76702238489194 --- /dev/null +++ b/picarones/evaluation/views/search_view.py @@ -0,0 +1,161 @@ +"""``SearchView`` — vue canonique 3, Sprint A14-S16. + +Troisième vue d'évaluation canonique : "quel pipeline maximise la +**recherchabilité plein-texte** ?". + +Distinct de TextView et AltoView +-------------------------------- +| Vue | Question | Métriques | +|---|---|---| +| TextView (S14) | meilleur texte final ? | CER, WER, MER, WIL | +| AltoView (S15) | meilleur ALTO exploitable ? | validity, line_count, word_box | +| SearchView (S16) | meilleur pour la recherche plein-texte ? | searchability_recall, numerical_seq | + +Un même pipeline peut avoir un excellent CER (TextView) tout en +étant mauvais pour la recherche fuzzy (SearchView), si ses erreurs +se concentrent sur des noms propres ou des dates. Et inversement, +un pipeline avec un CER médiocre peut donner une excellente +recherchabilité si les erreurs sont sur des caractères non-significatifs. + +Cette divergence est précisément ce que le rapport BnF doit rendre +visible — c'est l'objet du document +``docs/views/comparing-views.md``. + +Types acceptés +-------------- +Comme TextView : RAW_TEXT, CORRECTED_TEXT, ALTO_XML, PAGE_XML, +CANONICAL_DOCUMENT. La projection vers RAW_TEXT est appliquée +automatiquement par ``projections_by_source_type``. + +Métriques par défaut +-------------------- +- ``searchability_recall`` — fraction des tokens GT retrouvés à + distance de Levenshtein ≤ 2 (proxy Elastic). +- ``numerical_sequence_preservation`` — fraction des années 4 + chiffres de la GT préservées strictement. + +Toutes ∈ [0, 1] avec ``higher_is_better=True``. + +higher_is_better +---------------- +**Critique** : les métriques de cette vue sont des recall +(``higher_is_better=True``), à l'inverse de TextView dont les +métriques sont des erreurs (``higher_is_better=False``). Le +rapport doit colorier les chiffres de SearchView dans le sens +opposé de ceux de TextView. +""" + +from __future__ import annotations + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.projection_spec import ProjectionSpec + + +#: Métriques calculées par défaut. +DEFAULT_SEARCH_METRICS: tuple[str, ...] = ( + "searchability_recall", + "numerical_sequence_preservation", +) + + +#: Types acceptés. Identique à TextView : tout ce qui peut être +#: projeté vers RAW_TEXT est éligible. +DEFAULT_SEARCH_CANDIDATE_TYPES: frozenset[ArtifactType] = frozenset({ + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + ArtifactType.PAGE_XML, + ArtifactType.CANONICAL_DOCUMENT, +}) + + +#: Mapping ``source_type → ProjectionSpec`` (identique à TextView). +DEFAULT_SEARCH_PROJECTIONS: dict[ArtifactType, ProjectionSpec] = { + ArtifactType.ALTO_XML: ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ), + ArtifactType.PAGE_XML: ProjectionSpec( + source_type=ArtifactType.PAGE_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="page_to_text", + ), + ArtifactType.CANONICAL_DOCUMENT: ProjectionSpec( + source_type=ArtifactType.CANONICAL_DOCUMENT, + target_type=ArtifactType.RAW_TEXT, + projector_name="canonical_to_text", + ), +} + + +#: Dimensions explicitement non évaluées. +DEFAULT_SEARCH_IGNORED_DIMENSIONS: tuple[str, ...] = ( + # Qualité caractère par caractère : c'est TextView (S14). + "char_level_accuracy", + # Structure documentaire : c'est AltoView (S15). + "geometry", + "block_structure", + "reading_order", + # Sémantique (synonymes, paraphrases) : non évaluée par cette + # vue, qui reste lexicale. + "semantic_equivalence", +) + + +#: Avertissement par défaut. +DEFAULT_SEARCH_WARNINGS: tuple[str, ...] = ( + "Cette vue mesure la recherchabilité PLEIN-TEXTE (rappel " + "fuzzy à distance de Levenshtein ≤ 2, années préservées). " + "Un pipeline avec un excellent CER peut être moyen ici si " + "ses erreurs se concentrent sur les noms propres ou les " + "dates. Et inversement. Lire ensemble TextView et SearchView " + "pour juger un pipeline.", + "Métriques higher_is_better=True (rappel) — le sens de " + "coloration est OPPOSÉ à celui de TextView (qui mesure des " + "erreurs, lower_is_better).", +) + + +def build_search_view( + *, + name: str = "searchability", + description: str = ( + "Mesure la recherchabilité plein-texte d'un pipeline " + "(rappel fuzzy + années préservées)." + ), + candidate_types: frozenset[ArtifactType] | None = None, + metric_names: tuple[str, ...] | None = None, + normalization_profile: str | None = None, + extra_warnings: tuple[str, ...] = (), + extra_ignored_dimensions: tuple[str, ...] = (), +) -> EvaluationView: + """Construit la vue canonique SearchView.""" + return EvaluationView( + name=name, + description=description, + candidate_types=( + candidate_types if candidate_types is not None + else DEFAULT_SEARCH_CANDIDATE_TYPES + ), + projection=None, + projections_by_source_type=DEFAULT_SEARCH_PROJECTIONS, + normalization_profile=normalization_profile, + metric_names=( + metric_names if metric_names is not None + else DEFAULT_SEARCH_METRICS + ), + warnings=DEFAULT_SEARCH_WARNINGS + extra_warnings, + ignored_dimensions=DEFAULT_SEARCH_IGNORED_DIMENSIONS + extra_ignored_dimensions, + ) + + +__all__ = [ + "build_search_view", + "DEFAULT_SEARCH_METRICS", + "DEFAULT_SEARCH_CANDIDATE_TYPES", + "DEFAULT_SEARCH_PROJECTIONS", + "DEFAULT_SEARCH_IGNORED_DIMENSIONS", + "DEFAULT_SEARCH_WARNINGS", +] diff --git a/picarones/evaluation/views/text_view.py b/picarones/evaluation/views/text_view.py new file mode 100644 index 0000000000000000000000000000000000000000..9431083e8ec179fd60d6bccba0cacdd69c508010 --- /dev/null +++ b/picarones/evaluation/views/text_view.py @@ -0,0 +1,179 @@ +"""``TextView`` — vue canonique 1, Sprint A14-S14. + +Première vue d'évaluation cible BnF : "quel pipeline produit le +meilleur texte final ?" + +Cette vue répond à un cas d'usage central : comparer librement +plusieurs pipelines hétérogènes (Tesseract texte brut, OCR+LLM +texte corrigé, OCR+LLM+ALTO remappé, VLM avec reconstruction +ALTO, etc.) en projetant **toutes** leurs sorties vers du texte +plat avant de calculer CER/WER. + +Garde-fou méthodologique +------------------------ +Comparer un texte brut OCR et un ALTO reconstruit serait +trompeur si on regardait juste les chiffres : l'ALTO porte une +structure que le texte plat n'a pas. ``TextView`` documente +explicitement cette projection dans le ``ProjectionReport`` du +``ViewResult`` : pour chaque artefact non-RAW_TEXT, le rapport +listera les ``ignored_dimensions`` (``geometry``, ``blocks``, +``reading_order``, ``ids``...) et les ``warnings`` du projecteur +correspondant. + +Types acceptés +-------------- +- ``RAW_TEXT`` : pas de projection (identité). +- ``CORRECTED_TEXT`` : pas de projection (identité). +- ``ALTO_XML`` : projeté via ``AltoToText``. +- ``PAGE_XML`` : projeté via ``PageToText``. +- ``CANONICAL_DOCUMENT`` : projeté via ``CanonicalToText``. + +Métriques par défaut +-------------------- +``cer``, ``wer``, ``mer``, ``wil``. Le caller peut surcharger +via le paramètre ``metric_names`` du builder. + +Limites assumées +---------------- +- Pas de comparaison fuzzy / search recall — c'est ``SearchView`` + (S16). +- Pas d'évaluation structurelle ALTO — c'est ``AltoView`` (S15). +- ``CANONICAL_DOCUMENT`` peut perdre beaucoup de structure ; le + warning du ``ProjectionReport`` le signale. +""" + +from __future__ import annotations + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.projection_spec import ProjectionSpec + + +#: Métriques calculées par défaut quand on construit une ``TextView`` +#: sans surcharge. Toutes typées ``(RAW_TEXT, RAW_TEXT)`` (la +#: comparaison se fait toujours après projection vers texte). +DEFAULT_TEXT_METRICS: tuple[str, ...] = ("cer", "wer", "mer", "wil") + + +#: Types acceptés par défaut. Le caller peut restreindre +#: (par exemple en construisant une ``TextView`` "OCR seul" qui +#: n'accepte que ``RAW_TEXT``). +DEFAULT_TEXT_CANDIDATE_TYPES: frozenset[ArtifactType] = frozenset({ + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + ArtifactType.PAGE_XML, + ArtifactType.CANONICAL_DOCUMENT, +}) + + +#: Mapping ``source_type → ProjectionSpec`` pour la projection +#: automatique vers RAW_TEXT. Aucune projection pour RAW_TEXT et +#: CORRECTED_TEXT (déjà du texte). +DEFAULT_TEXT_PROJECTIONS: dict[ArtifactType, ProjectionSpec] = { + ArtifactType.ALTO_XML: ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ), + ArtifactType.PAGE_XML: ProjectionSpec( + source_type=ArtifactType.PAGE_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="page_to_text", + ), + ArtifactType.CANONICAL_DOCUMENT: ProjectionSpec( + source_type=ArtifactType.CANONICAL_DOCUMENT, + target_type=ArtifactType.RAW_TEXT, + projector_name="canonical_to_text", + ), +} + + +#: ``ignored_dimensions`` par défaut. Listées explicitement dans +#: le rapport pour qu'un lecteur sache **ce que la vue ne dit +#: PAS** sur les pipelines comparés. +DEFAULT_TEXT_IGNORED_DIMENSIONS: tuple[str, ...] = ( + "geometry", + "block_structure", + "reading_order", + "ids", + "confidence", + "formatting", +) + + +#: ``warnings`` par défaut. Affichés en tête du bloc TextView +#: dans le rapport pour signaler la portée de la comparaison. +DEFAULT_TEXT_WARNINGS: tuple[str, ...] = ( + "Cette vue compare les sorties textuelles finales après " + "projection éventuelle. Les pipelines qui produisent ALTO/PAGE/" + "markdown sont projetés vers du texte plat — leurs structures " + "spatiale et documentaire ne sont PAS évaluées ici. Pour " + "évaluer la qualité ALTO, voir AltoView (S15).", +) + + +def build_text_view( + *, + name: str = "text_final", + description: str = ( + "Compare les sorties textuelles finales après projection " + "éventuelle (ALTO/PAGE/markdown → texte plat)." + ), + candidate_types: frozenset[ArtifactType] | None = None, + metric_names: tuple[str, ...] | None = None, + normalization_profile: str | None = None, + extra_warnings: tuple[str, ...] = (), + extra_ignored_dimensions: tuple[str, ...] = (), +) -> EvaluationView: + """Construit la vue canonique TextView. + + Parameters + ---------- + name: + Identifiant lisible de la vue (``"text_final"`` par défaut). + description: + Phrase courte affichée dans le rapport. + candidate_types: + Set des types acceptés. Défaut : tous les 5 types texte + ou projetables vers texte. + metric_names: + Métriques calculées. Défaut : ``("cer", "wer", "mer", "wil")``. + normalization_profile: + Profil de normalisation texte appliqué après projection + (cf. ``picarones.formats.text.normalization``). ``None`` + par défaut (NFC implicite). Exemples utiles : + ``"medieval_french"``, ``"caseless"``, ``"sans_apostrophes"``. + extra_warnings: + Avertissements additionnels à propager dans le rapport en + plus des warnings par défaut. + extra_ignored_dimensions: + Dimensions additionnelles à signaler comme ignorées. + """ + return EvaluationView( + name=name, + description=description, + candidate_types=( + candidate_types if candidate_types is not None + else DEFAULT_TEXT_CANDIDATE_TYPES + ), + projection=None, + projections_by_source_type=DEFAULT_TEXT_PROJECTIONS, + normalization_profile=normalization_profile, + metric_names=( + metric_names if metric_names is not None + else DEFAULT_TEXT_METRICS + ), + warnings=DEFAULT_TEXT_WARNINGS + extra_warnings, + ignored_dimensions=DEFAULT_TEXT_IGNORED_DIMENSIONS + extra_ignored_dimensions, + ) + + +__all__ = [ + "build_text_view", + "DEFAULT_TEXT_METRICS", + "DEFAULT_TEXT_CANDIDATE_TYPES", + "DEFAULT_TEXT_PROJECTIONS", + "DEFAULT_TEXT_IGNORED_DIMENSIONS", + "DEFAULT_TEXT_WARNINGS", +] diff --git a/picarones/extras/importers/_fallback_log.py b/picarones/extras/importers/_fallback_log.py index ac7df34a631eb97f739ee0265446684ce5ecbb5d..9369275d4a08e459b8054a0d99dad303ff88d631 100644 --- a/picarones/extras/importers/_fallback_log.py +++ b/picarones/extras/importers/_fallback_log.py @@ -1,98 +1,7 @@ -"""Journal en mémoire des fallbacks d'importer (Sprint A3, item B-3). - -Quand un importer (HuggingFace, HTR-United, Gallica, eScriptorium…) -bascule en mode dégradé (timeout réseau, JSON mal formé, ZIP corrompu, -catalogue distant indisponible…), il enregistre un incident ici via -:func:`record_fallback`. Le moteur narratif consomme ces incidents via -:func:`consume_fallback_log`, qui **vide** la liste pour qu'un benchmark -suivant ne remonte pas les incidents du précédent. - -Conception volontairement minimale : - -- Pas de persistance disque (les incidents sont contextuels à un run). -- Pas de structure complexe (juste un ``list[dict]`` thread-safe). -- Le runner / le rapport peuvent ignorer la liste sans casser. - -Le détecteur de Fact correspondant (``FactType.IMPORTER_FALLBACK_TRIGGERED``) -est implémenté dans -:mod:`picarones.measurements.narrative.detectors.history`. +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.corpus._fallback_log``. """ from __future__ import annotations -import logging -import threading -from typing import Any - -logger = logging.getLogger(__name__) - -_lock = threading.Lock() -_fallbacks: list[dict[str, Any]] = [] - - -def record_fallback( - importer: str, - operation: str, - error: BaseException | None = None, - *, - extra: dict[str, Any] | None = None, -) -> None: - """Enregistre un incident de mode dégradé. - - Logge également via ``logger.warning`` pour qu'un opérateur voit - l'incident en temps réel sans dépendre du rapport. - - Parameters - ---------- - importer: - Nom court de l'importer (ex : ``"huggingface"``, ``"htr_united"``). - operation: - Description courte de l'opération (ex : ``"yaml_catalogue_parse"``, - ``"image_save"``, ``"hub_search"``). - error: - Exception originelle (utilisée pour le message log et stockée dans - le payload sous forme de chaîne — pas l'objet, pour éviter les - références persistantes). - extra: - Champs additionnels (URL distante, identifiant dataset…) qui peuvent - être utiles à un détecteur de Fact ultérieur. - """ - error_repr = repr(error) if error is not None else None - logger.warning( - "[importers/%s] %s a échoué (mode dégradé) : %s", - importer, - operation, - error_repr, - ) - entry: dict[str, Any] = { - "importer": importer, - "operation": operation, - "error": error_repr, - } - if extra: - entry["extra"] = dict(extra) - with _lock: - _fallbacks.append(entry) - - -def consume_fallback_log() -> list[dict[str, Any]]: - """Retourne ET vide la liste des incidents accumulés. - - Le moteur narratif appelle cette fonction au moment de construire - la synthèse pour transformer chaque incident en ``Fact``.""" - with _lock: - out = list(_fallbacks) - _fallbacks.clear() - return out - - -def peek_fallback_log() -> list[dict[str, Any]]: - """Retourne une copie sans vider — utile pour les tests.""" - with _lock: - return list(_fallbacks) - - -def reset_fallback_log() -> None: - """Vide la liste sans rien retourner — utile pour les fixtures pytest.""" - with _lock: - _fallbacks.clear() +from picarones.adapters.corpus._fallback_log import * # noqa: F401,F403 diff --git a/picarones/extras/importers/htr_united.py b/picarones/extras/importers/htr_united.py index 70821dc9f126487cc7c4ab555c22a117c11f3bfc..39d22e70bbaf87e03244ae9915f1074e59e14531 100644 --- a/picarones/extras/importers/htr_united.py +++ b/picarones/extras/importers/htr_united.py @@ -1,473 +1,7 @@ -"""Import depuis le catalogue HTR-United. - -HTR-United est un catalogue communautaire de vérités terrain HTR/OCR publiées -sur GitHub sous licence ouverte. Les métadonnées sont stockées dans un fichier -YAML (catalogue.yml) sur https://github.com/HTR-United/htr-united. - -Ce module fournit : -- :class:`HTRUnitedCatalogue` — chargement et recherche dans le catalogue -- :func:`fetch_catalogue` — téléchargement du catalogue depuis GitHub -- :func:`import_htr_united_corpus` — téléchargement et import d'un corpus - -Exemple -------- - catalogue = HTRUnitedCatalogue.from_remote() - results = catalogue.search("français médiéval") - corpus = import_htr_united_corpus(results[0], output_dir="./corpus/") +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.corpus.htr_united``. """ from __future__ import annotations -import json -import logging -import re -import urllib.error -import urllib.request -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - -# --------------------------------------------------------------------------- -# Catalogue remote URL -# --------------------------------------------------------------------------- - -_CATALOGUE_URL = ( - "https://raw.githubusercontent.com/HTR-United/htr-united/master/htr-united.yml" -) -_CATALOGUE_API_URL = ( - "https://api.github.com/repos/HTR-United/htr-united/contents/htr-united.yml" -) - -# Catalogue de démonstration / fallback (hors-ligne) -_DEMO_CATALOGUE: list[dict] = [ - { - "id": "lectaurep-repertoires", - "title": "Lectaurep — Répertoires de notaires parisiens", - "url": "https://github.com/HTR-United/lectaurep-repertoires", - "language": ["French"], - "script": ["Cursiva"], - "century": [17, 18], - "institution": "Archives nationales (France)", - "description": "Transcriptions de répertoires de notaires, XVIIe-XVIIIe siècles.", - "license": "CC-BY 4.0", - "lines": 12400, - "format": "ALTO", - "tags": ["notaires", "Paris", "cursive", "imprimé"], - }, - { - "id": "bvmm-manuscripts", - "title": "BVMM — Manuscrits enluminés", - "url": "https://github.com/HTR-United/bvmm-manuscripts", - "language": ["Latin", "French"], - "script": ["Gothic"], - "century": [13, 14, 15], - "institution": "IRHT", - "description": "Manuscrits médiévaux latins et français, XIIIe-XVe siècles.", - "license": "CC-BY 4.0", - "lines": 8700, - "format": "ALTO", - "tags": ["manuscrits", "latin", "médiéval", "enluminure"], - }, - { - "id": "cremma-medieval", - "title": "CREMMA Médiéval", - "url": "https://github.com/HTR-United/cremma-medieval", - "language": ["French", "Latin"], - "script": ["Gothic", "Humanistica"], - "century": [12, 13, 14, 15], - "institution": "École des chartes / Inria", - "description": "Corpus CREMMA de manuscrits médiévaux français et latins.", - "license": "CC-BY 4.0", - "lines": 6200, - "format": "ALTO", - "tags": ["médiéval", "chartes", "manuscrits"], - }, - { - "id": "simssa-ocr-printed", - "title": "SIMSSA — Imprimés anciens (XVe-XVIIe)", - "url": "https://github.com/HTR-United/simssa-printed", - "language": ["French", "Latin"], - "script": ["Rotunda", "Roman"], - "century": [15, 16, 17], - "institution": "McGill University", - "description": "Corpus d'imprimés anciens romains et gothiques.", - "license": "CC-BY 4.0", - "lines": 4500, - "format": "PAGE", - "tags": ["imprimés", "incunables", "roman", "gothique"], - }, - { - "id": "fonds-gallica-presse", - "title": "Presse ancienne — Gallica (XIXe)", - "url": "https://github.com/HTR-United/gallica-presse-xix", - "language": ["French"], - "script": ["Roman"], - "century": [19], - "institution": "Gallica", - "description": "Numérisations de journaux du XIXe siècle (Gallica).", - "license": "etalab-2.0", - "lines": 31000, - "format": "ALTO", - "tags": ["presse", "XIXe", "Gallica", "journaux"], - }, - { - "id": "archives-departem-correspondances", - "title": "Correspondances administratives (XVIIIe-XIXe)", - "url": "https://github.com/HTR-United/correspondances-admin", - "language": ["French"], - "script": ["Cursiva"], - "century": [18, 19], - "institution": "Archives départementales", - "description": "Lettres et correspondances administratives manuscrites.", - "license": "CC-BY 4.0", - "lines": 9800, - "format": "ALTO", - "tags": ["correspondances", "administratif", "cursive"], - }, - { - "id": "e-codices-latin", - "title": "e-codices — Manuscrits latins (Suisse)", - "url": "https://github.com/HTR-United/e-codices-latin", - "language": ["Latin"], - "script": ["Caroline", "Gothic"], - "century": [9, 10, 11, 12], - "institution": "Bibliothèque cantonale universitaire de Lausanne", - "description": "Manuscrits carolingiens et gothiques des bibliothèques suisses.", - "license": "CC-BY 4.0", - "lines": 3100, - "format": "ALTO", - "tags": ["caroline", "latin", "médiéval", "Suisse"], - }, - { - "id": "registres-paroissiaux-17", - "title": "Registres paroissiaux — Bretagne (XVIIe)", - "url": "https://github.com/HTR-United/registres-paroissiaux-bretagne", - "language": ["French", "Latin"], - "script": ["Cursiva"], - "century": [17], - "institution": "Archives départementales du Finistère", - "description": "Registres paroissiaux bretons du XVIIe siècle.", - "license": "CC-BY 4.0", - "lines": 15600, - "format": "ALTO", - "tags": ["registres", "Bretagne", "paroissial", "cursive"], - }, -] - - -# --------------------------------------------------------------------------- -# Dataclass entrée catalogue -# --------------------------------------------------------------------------- - -@dataclass -class HTRUnitedEntry: - """Une entrée dans le catalogue HTR-United.""" - - id: str - title: str - url: str - language: list[str] = field(default_factory=list) - script: list[str] = field(default_factory=list) - century: list[int] = field(default_factory=list) - institution: str = "" - description: str = "" - license: str = "" - lines: int = 0 - format: str = "ALTO" - tags: list[str] = field(default_factory=list) - - def as_dict(self) -> dict: - return { - "id": self.id, - "title": self.title, - "url": self.url, - "language": self.language, - "script": self.script, - "century": self.century, - "institution": self.institution, - "description": self.description, - "license": self.license, - "lines": self.lines, - "format": self.format, - "tags": self.tags, - } - - @classmethod - def from_dict(cls, d: dict) -> "HTRUnitedEntry": - return cls( - id=d.get("id", ""), - title=d.get("title", ""), - url=d.get("url", ""), - language=d.get("language", []), - script=d.get("script", []), - century=d.get("century", []), - institution=d.get("institution", ""), - description=d.get("description", ""), - license=d.get("license", ""), - lines=d.get("lines", 0), - format=d.get("format", "ALTO"), - tags=d.get("tags", []), - ) - - @property - def century_str(self) -> str: - """Siècles formatés en chiffres romains.""" - roman = { - 1: "Ier", 2: "IIe", 3: "IIIe", 4: "IVe", 5: "Ve", - 6: "VIe", 7: "VIIe", 8: "VIIIe", 9: "IXe", 10: "Xe", - 11: "XIe", 12: "XIIe", 13: "XIIIe", 14: "XIVe", 15: "XVe", - 16: "XVIe", 17: "XVIIe", 18: "XVIIIe", 19: "XIXe", 20: "XXe", - } - return ", ".join(roman.get(c, f"{c}e") for c in self.century) - - -# --------------------------------------------------------------------------- -# Catalogue -# --------------------------------------------------------------------------- - -class HTRUnitedCatalogue: - """Catalogue HTR-United avec recherche et filtrage.""" - - def __init__(self, entries: list[HTRUnitedEntry], source: str = "demo") -> None: - self.entries = entries - self.source = source # "remote" | "demo" | "cache" - - def __len__(self) -> int: - return len(self.entries) - - @classmethod - def from_demo(cls) -> "HTRUnitedCatalogue": - """Charge le catalogue de démonstration intégré.""" - entries = [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE] - return cls(entries, source="demo") - - @classmethod - def from_remote(cls, timeout: int = 10) -> "HTRUnitedCatalogue": - """Télécharge le catalogue depuis GitHub. - - En cas d'erreur réseau, retourne le catalogue de démonstration. - """ - try: - req = urllib.request.Request( - _CATALOGUE_URL, - headers={"User-Agent": "picarones-htr-united-importer/1.0"}, - ) - with urllib.request.urlopen(req, timeout=timeout) as resp: - raw = resp.read().decode("utf-8") - entries = _parse_yml_catalogue(raw) - return cls(entries, source="remote") - except (urllib.error.URLError, Exception) as exc: - # Fallback démo avec avertissement - logger.warning( - "[HTR-United] impossible de charger le catalogue distant (%s) : %s. " - "Utilisation des données de démonstration.", - _CATALOGUE_URL, exc, - ) - return cls.from_demo() - - def search( - self, - query: str = "", - language: Optional[str] = None, - script: Optional[str] = None, - century_min: Optional[int] = None, - century_max: Optional[int] = None, - ) -> list[HTRUnitedEntry]: - """Recherche dans le catalogue avec filtres optionnels.""" - results = self.entries - - if query: - q = query.lower() - results = [ - e for e in results - if (q in e.title.lower() - or q in e.description.lower() - or q in e.institution.lower() - or any(q in t.lower() for t in e.tags) - or any(q in lang.lower() for lang in e.language)) - ] - - if language: - lang_lower = language.lower() - results = [ - e for e in results - if any(lang_lower in lg.lower() for lg in e.language) - ] - - if script: - sc_lower = script.lower() - results = [ - e for e in results - if any(sc_lower in s.lower() for s in e.script) - ] - - if century_min is not None: - results = [ - e for e in results - if any(c >= century_min for c in e.century) - ] - - if century_max is not None: - results = [ - e for e in results - if any(c <= century_max for c in e.century) - ] - - return results - - def get_by_id(self, entry_id: str) -> Optional[HTRUnitedEntry]: - """Retourne une entrée par son identifiant.""" - for e in self.entries: - if e.id == entry_id: - return e - return None - - def available_languages(self) -> list[str]: - seen: set[str] = set() - result: list[str] = [] - for e in self.entries: - for lang in e.language: - if lang not in seen: - seen.add(lang) - result.append(lang) - return sorted(result) - - def available_scripts(self) -> list[str]: - seen: set[str] = set() - result: list[str] = [] - for e in self.entries: - for sc in e.script: - if sc not in seen: - seen.add(sc) - result.append(sc) - return sorted(result) - - -# --------------------------------------------------------------------------- -# Import de corpus -# --------------------------------------------------------------------------- - -def import_htr_united_corpus( - entry: HTRUnitedEntry, - output_dir: str | Path, - max_samples: int = 100, - show_progress: bool = True, -) -> dict: - """Importe un corpus HTR-United dans un dossier local. - - Retourne un dict avec les métadonnées de l'import. - Note : en l'absence d'accès réseau au dépôt GitHub, génère des fichiers - placeholder (pour tests et démo). - """ - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - # Sauvegarder les métadonnées - meta = { - "source": "htr-united", - "entry_id": entry.id, - "title": entry.title, - "url": entry.url, - "language": entry.language, - "script": entry.script, - "century": entry.century, - "institution": entry.institution, - "license": entry.license, - "format": entry.format, - "imported_at": _iso_now(), - } - (output_path / "htr_united_meta.json").write_text( - json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8" - ) - - # Essai de téléchargement réel depuis GitHub (archive releases) - downloaded = _try_download_corpus(entry, output_path, max_samples, show_progress) - - return { - "entry_id": entry.id, - "title": entry.title, - "output_dir": str(output_path), - "files_imported": downloaded, - "metadata_file": str(output_path / "htr_united_meta.json"), - } - - -def _try_download_corpus( - entry: HTRUnitedEntry, - output_path: Path, - max_samples: int, - show_progress: bool, -) -> int: - """Tente de télécharger le corpus depuis GitHub. Retourne le nombre de fichiers importés.""" - # Construit l'URL de l'archive ZIP du dépôt GitHub - repo_path = _extract_github_repo(entry.url) - if not repo_path: - return 0 - - zip_url = f"https://github.com/{repo_path}/archive/refs/heads/main.zip" - try: - req = urllib.request.Request( - zip_url, - headers={"User-Agent": "picarones-htr-united-importer/1.0"}, - ) - with urllib.request.urlopen(req, timeout=30) as resp: - import io - import zipfile - - data = resp.read() - with zipfile.ZipFile(io.BytesIO(data)) as zf: - # Extraire les fichiers ALTO/PAGE/GT - gt_files = [ - n for n in zf.namelist() - if n.endswith((".alto.xml", ".page.xml", ".gt.txt", ".xml")) - and not n.endswith("/") - ][:max_samples] - for i, fname in enumerate(gt_files): - dest = output_path / Path(fname).name - dest.write_bytes(zf.read(fname)) - return len(gt_files) - except Exception as exc: # noqa: BLE001 — large surface (réseau, ZIP, FS) - # Sprint A3 (B-3) : on documente l'incident plutôt que de le - # masquer ; le caller reçoit toujours 0 pour préserver le - # contrat numérique de retour. - from picarones.extras.importers._fallback_log import record_fallback - record_fallback( - importer="htr_united", - operation="download_zip_samples", - error=exc, - extra={"output_path": str(output_path)}, - ) - return 0 - - -def _extract_github_repo(url: str) -> Optional[str]: - """Extrait 'owner/repo' depuis une URL GitHub.""" - m = re.match(r"https?://github\.com/([^/]+/[^/]+?)(?:\.git)?/?$", url) - return m.group(1) if m else None - - -def _parse_yml_catalogue(raw: str) -> list[HTRUnitedEntry]: - """Parse rudimentaire du YAML catalogue HTR-United.""" - try: - import yaml - data = yaml.safe_load(raw) - if isinstance(data, list): - return [HTRUnitedEntry.from_dict(d) for d in data if isinstance(d, dict)] - except Exception as exc: # noqa: BLE001 — yaml + parsing user-supplied - # Sprint A3 (B-3) : un YAML mal formé bascule en mode démo - # sans que l'utilisateur en soit averti — on logge et on émet - # un Fact pour que la synthèse du rapport mentionne l'incident. - from picarones.extras.importers._fallback_log import record_fallback - record_fallback( - importer="htr_united", - operation="yaml_catalogue_parse", - error=exc, - ) - return [HTRUnitedEntry.from_dict(d) for d in _DEMO_CATALOGUE] - - -def _iso_now() -> str: - from datetime import datetime, timezone - return datetime.now(timezone.utc).isoformat(timespec="seconds") +from picarones.adapters.corpus.htr_united import * # noqa: F401,F403 diff --git a/picarones/extras/importers/huggingface.py b/picarones/extras/importers/huggingface.py index 28b4fe4e178ba465f99a34c3f0945aca4b19aa7e..2631c4ab143050e79c33f86264c0d934eea03ca4 100644 --- a/picarones/extras/importers/huggingface.py +++ b/picarones/extras/importers/huggingface.py @@ -1,464 +1,11 @@ -"""Import de datasets OCR/HTR depuis HuggingFace Hub. +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.corpus.huggingface``. -⚠ **Statut : expérimental** (phase C du chantier de refonte en 3 cercles). -L'API ``datasets`` HuggingFace évolue fréquemment et ce module n'a pas -de tests d'intégration. À utiliser à vos risques jusqu'à ce qu'un cas -d'usage institutionnel valide son comportement. Un ``UserWarning`` est -émis à l'import pour le rappeler. - -Ce module fournit : -- :class:`HuggingFaceDataset` — métadonnées d'un dataset HuggingFace -- :class:`HuggingFaceImporter` — recherche et import de datasets -- :func:`search_hf_datasets` — recherche par tags dans l'API HuggingFace -- :func:`import_hf_dataset` — téléchargement d'un dataset vers un dossier local - -Les datasets patrimoniaux de référence sont pré-référencés pour une découverte -rapide sans requête réseau. - -Exemple -------- - importer = HuggingFaceImporter() - results = importer.search("medieval OCR", tags=["ocr"]) - corpus = importer.import_dataset(results[0].dataset_id, output_dir="./corpus/") +Ré-expose explicitement ``_REFERENCE_DATASETS`` (importé par les +tests web). """ from __future__ import annotations -import json -import os -import urllib.error -import urllib.parse -import urllib.request -import warnings -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - - -# Émission du warning ``experimental`` à l'import. Phase C du chantier -# de refonte — voir docstring du module ci-dessus. -warnings.warn( - "picarones.extras.importers.huggingface is experimental and may " - "change or be removed without notice. Use at your own risk until " - "an institutional use case validates the API.", - category=UserWarning, - stacklevel=2, -) - -# --------------------------------------------------------------------------- -# Datasets de référence pré-référencés -# --------------------------------------------------------------------------- - -_REFERENCE_DATASETS: list[dict] = [ - { - "dataset_id": "Teklia/RIMES", - "title": "RIMES — Reconnaissance et Indexation de données Manuscrites et de fac-similEs", - "description": "Corpus de courriers manuscrits français modernes. Standard de référence pour la reconnaissance d'écriture manuscrite.", - "language": ["French"], - "tags": ["htr", "ocr", "handwritten", "french", "modern"], - "license": "cc-by-4.0", - "size_category": "1K dict: - return { - "dataset_id": self.dataset_id, - "title": self.title, - "description": self.description, - "language": self.language, - "tags": self.tags, - "license": self.license, - "size_category": self.size_category, - "task": self.task, - "institution": self.institution, - "downloads": self.downloads, - "source": self.source, - } - - @classmethod - def from_dict(cls, d: dict) -> "HuggingFaceDataset": - return cls( - dataset_id=d.get("dataset_id", d.get("id", "")), - title=d.get("title", d.get("dataset_id", "")), - description=d.get("description", ""), - language=d.get("language", []), - tags=d.get("tags", []), - license=d.get("license", ""), - size_category=d.get("size_category", d.get("cardData", {}).get("size_categories", [""])[0] if isinstance(d.get("cardData"), dict) else ""), - task=d.get("task", "image-to-text"), - institution=d.get("institution", ""), - downloads=d.get("downloads", d.get("downloadsAllTime", 0)), - source=d.get("source", "api"), - ) - - @property - def hf_url(self) -> str: - return f"https://huggingface.co/datasets/{self.dataset_id}" - - -# --------------------------------------------------------------------------- -# Importer principal -# --------------------------------------------------------------------------- - -class HuggingFaceImporter: - """Recherche et importe des datasets depuis HuggingFace Hub.""" - - _API_BASE = "https://huggingface.co/api" - - def __init__(self, token: Optional[str] = None) -> None: - self._token = token or os.environ.get("HF_TOKEN") or os.environ.get("HUGGINGFACE_TOKEN") - - def _headers(self) -> dict: - h = {"User-Agent": "picarones-hf-importer/1.0"} - if self._token: - h["Authorization"] = f"Bearer {self._token}" - return h - - def search( - self, - query: str = "", - tags: Optional[list[str]] = None, - language: Optional[str] = None, - limit: int = 20, - use_reference: bool = True, - ) -> list[HuggingFaceDataset]: - """Recherche des datasets avec filtres. - - Interroge d'abord les datasets de référence pré-intégrés, puis - l'API HuggingFace si disponible. - """ - results: list[HuggingFaceDataset] = [] - - # Datasets de référence - if use_reference: - ref_results = self._search_reference(query, tags, language) - results.extend(ref_results) - - # API HuggingFace (optionnel, peut échouer silencieusement) - try: - api_results = self._search_api(query, tags, language, limit) - # Déduplique (priorité aux références) - existing_ids = {r.dataset_id for r in results} - for ds in api_results: - if ds.dataset_id not in existing_ids: - results.append(ds) - existing_ids.add(ds.dataset_id) - except Exception as exc: # noqa: BLE001 — réseau/API tierce - # Sprint A3 (B-3) : la recherche API échoue silencieusement → - # l'utilisateur ne voit que les datasets de référence et croit - # que l'API est vide. On documente l'incident. - from picarones.extras.importers._fallback_log import record_fallback - record_fallback( - importer="huggingface", - operation="hub_search_api", - error=exc, - extra={"query": query, "language": language, "limit": limit}, - ) - - return results[:limit] - - def _search_reference( - self, - query: str, - tags: Optional[list[str]], - language: Optional[str], - ) -> list[HuggingFaceDataset]: - datasets = [HuggingFaceDataset.from_dict(d) for d in _REFERENCE_DATASETS] - datasets = [ds._replace_source("reference") for ds in datasets] - - if query: - q = query.lower() - datasets = [ - ds for ds in datasets - if (q in ds.title.lower() - or q in ds.description.lower() - or q in ds.dataset_id.lower() - or any(q in t.lower() for t in ds.tags) - or any(q in lg.lower() for lg in ds.language)) - ] - - if tags: - for tag in tags: - t_lower = tag.lower() - datasets = [ - ds for ds in datasets - if any(t_lower in dt.lower() for dt in ds.tags) - ] - - if language: - lang_lower = language.lower() - datasets = [ - ds for ds in datasets - if any(lang_lower in lg.lower() for lg in ds.language) - ] - - return datasets - - def _search_api( - self, - query: str, - tags: Optional[list[str]], - language: Optional[str], - limit: int, - ) -> list[HuggingFaceDataset]: - params: dict[str, str] = { - "task_categories": "image-to-text", - "limit": str(min(limit, 50)), - "full": "False", - } - if query: - params["search"] = query - if language: - params["language"] = language - if tags: - params["tags"] = ",".join(tags) - - url = f"{self._API_BASE}/datasets?" + urllib.parse.urlencode(params) - req = urllib.request.Request(url, headers=self._headers()) - with urllib.request.urlopen(req, timeout=10) as resp: - data = json.loads(resp.read().decode("utf-8")) - - results = [] - for item in data if isinstance(data, list) else []: - ds = HuggingFaceDataset( - dataset_id=item.get("id", ""), - title=item.get("id", ""), - description=item.get("description", ""), - language=item.get("language", []), - tags=item.get("tags", []), - license=item.get("license", ""), - size_category=( - item.get("cardData", {}).get("size_categories", [""])[0] - if isinstance(item.get("cardData"), dict) - else "" - ), - task="image-to-text", - downloads=item.get("downloadsAllTime", 0), - source="api", - ) - if ds.dataset_id: - results.append(ds) - return results - - def import_dataset( - self, - dataset_id: str, - output_dir: str | Path, - split: str = "train", - max_samples: int = 100, - show_progress: bool = True, - ) -> dict: - """Importe un dataset depuis HuggingFace vers un dossier local. - - Retourne les métadonnées de l'import. - """ - output_path = Path(output_dir) - output_path.mkdir(parents=True, exist_ok=True) - - meta = { - "source": "huggingface", - "dataset_id": dataset_id, - "split": split, - "max_samples": max_samples, - "imported_at": _iso_now(), - } - meta_file = output_path / "huggingface_meta.json" - meta_file.write_text(json.dumps(meta, ensure_ascii=False, indent=2), encoding="utf-8") - - # Tentative d'import via datasets library si disponible - files_imported = _try_import_with_datasets_lib( - dataset_id, output_path, split, max_samples, show_progress - ) - - return { - "dataset_id": dataset_id, - "output_dir": str(output_path), - "files_imported": files_imported, - "metadata_file": str(meta_file), - } - - -def _try_import_with_datasets_lib( - dataset_id: str, - output_path: Path, - split: str, - max_samples: int, - show_progress: bool, -) -> int: - """Essaie d'importer avec la librairie `datasets` de HuggingFace.""" - try: - from datasets import load_dataset # type: ignore - - ds = load_dataset(dataset_id, split=split, streaming=True) - count = 0 - for i, item in enumerate(ds): - if i >= max_samples: - break - # Cherche champ image et texte - image = item.get("image") or item.get("img") - text = item.get("text") or item.get("transcription") or item.get("ground_truth", "") - - if image is not None: - img_file = output_path / f"doc_{i:04d}.jpg" - try: - image.save(str(img_file)) - except Exception as exc: # noqa: BLE001 — PIL/PIL-IO - # Sprint A3 (B-3) : un échec de sauvegarde d'image - # produirait un GT orphelin (texte sans image). On - # documente et on continue — le GT est tout de même - # écrit pour préserver la cohérence numérique du compteur. - from picarones.extras.importers._fallback_log import record_fallback - record_fallback( - importer="huggingface", - operation="image_save", - error=exc, - extra={"img_file": str(img_file), "doc_index": i}, - ) - - gt_file = output_path / f"doc_{i:04d}.gt.txt" - gt_file.write_text(str(text), encoding="utf-8") - count += 1 - - return count - except (ImportError, Exception): - return 0 - - -def _iso_now() -> str: - from datetime import datetime, timezone - return datetime.now(timezone.utc).isoformat(timespec="seconds") - - -# --------------------------------------------------------------------------- -# Extension de HuggingFaceDataset (helper privé) -# --------------------------------------------------------------------------- - -def _patch_dataset_replace_source() -> None: - """Ajoute un helper _replace_source à HuggingFaceDataset.""" - def _replace_source(self, source: str) -> "HuggingFaceDataset": - from dataclasses import replace - return replace(self, source=source) - HuggingFaceDataset._replace_source = _replace_source - - -_patch_dataset_replace_source() +from picarones.adapters.corpus.huggingface import * # noqa: F401,F403 +from picarones.adapters.corpus.huggingface import _REFERENCE_DATASETS # noqa: F401 diff --git a/picarones/formats/__init__.py b/picarones/formats/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..fc6ec40981c2e561618c77250ad6c4577bf233d5 --- /dev/null +++ b/picarones/formats/__init__.py @@ -0,0 +1,26 @@ +"""Cercle 2 — Formats documentaires. + +Parsers, writers et validateurs pour les formats d'entrée/sortie +patrimoniaux. Tout le code XML / namespaces / parsing vit ici, à +l'écart du domain (qui ne connaît que des ``ArtifactType``) et de +``evaluation/`` (qui consomme des structures de données déjà +parsées). + +Sous-packages : + +- ``alto/`` — ALTO XML 4.x (Sprint S9). Parser tolérant aux 3 + versions de namespace, writer déterministe, validator schéma. +- ``pagexml/`` — PAGE XML (PRIMA, transkribus). +- ``text/`` — normalisation texte (NFC, casefold, profils + diplomatiques, exclusion de caractères). Cible du déplacement + de ``picarones.measurements.normalization`` au Sprint S9. + +Règle d'import : ces modules peuvent importer ``lxml`` et +``defusedxml``. Ils ne doivent **jamais** importer un moteur OCR +ou un calcul de métrique — ils opèrent sur des bytes / des chaînes, +pas sur des résultats d'OCR. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/formats/alto/__init__.py b/picarones/formats/alto/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1aec2b2948cd701d4241990ec0a92c8916389f56 --- /dev/null +++ b/picarones/formats/alto/__init__.py @@ -0,0 +1,59 @@ +"""Format ALTO XML 4.x (et v2/v3 tolérés). + +Sprint A14-S9 livre : + +- ``types.py`` — ``AltoDocument``, ``AltoPage``, ``AltoTextBlock``, + ``AltoLine``, ``AltoString``, ``AltoBBox``. Frozen pydantic. +- ``parser.py`` — ``parse_alto(xml_bytes)`` détection auto v2/v3/v4 + via le namespace du root. Sécurité ``defusedxml``. +- ``writer.py`` — ``write_alto(doc, version="v4", pretty=False)`` + sortie déterministe (round-trip byte-stable avec ``parser``). +- ``projector.py`` — ``alto_document_to_text(doc)`` (helper) + + ``AltoToText`` (projecteur conforme au protocole S5). Gestion + césure ``HypPart1``/``HypPart2``. + +Anti-sur-ingénierie +------------------- +- Validator XSD reporté quand un caller en aura concrètement besoin + (la plupart des outils consommateurs acceptent un ALTO bien formé + sans validation stricte). +- ``Illustration``, ``ComposedBlock``, ``GraphicalElement``, + ``StyleRefs``, ``ProcessingStep`` : non préservés au round-trip + pour S9. +""" + +from __future__ import annotations + +from picarones.formats.alto.parser import AltoParseError, parse_alto +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) +from picarones.formats.alto.writer import write_alto + +# S13 — les projecteurs ``alto_document_to_text`` et ``AltoToText`` +# vivent désormais dans ``picarones.evaluation.projectors.alto`` +# (la projection est conceptuellement un composant d'évaluation, +# pas un format). Importer depuis le nouveau chemin : +# +# from picarones.evaluation.projectors import ( +# AltoToText, alto_document_to_text, +# ) + +__all__ = [ + # Types + "AltoBBox", + "AltoString", + "AltoLine", + "AltoTextBlock", + "AltoPage", + "AltoDocument", + # Parser / Writer + "parse_alto", + "AltoParseError", + "write_alto", +] diff --git a/picarones/formats/alto/parser.py b/picarones/formats/alto/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..123a0703239c97e3d44c82c18ef9280c1222c9a1 --- /dev/null +++ b/picarones/formats/alto/parser.py @@ -0,0 +1,227 @@ +"""Parser ALTO XML tolérant aux namespaces — Sprint A14-S9. + +Détection auto de la version ALTO (v2/v3/v4) via le namespace du +root element. Tolérant aux variantes : un ALTO sans namespace est +accepté ; un ALTO avec déclaration partielle (```` sans xmlns) +aussi. + +Sécurité +-------- +Utilise ``defusedxml.ElementTree`` pour bloquer XXE, Billion Laughs, +DTD retrieval — un ALTO peut venir d'un module tiers ou d'un +utilisateur web non authentifié. + +Anti-sur-ingénierie +------------------- +- Pas de validation de schéma XSD pour S9 (le ``validator.py`` du + plan est reporté quand un caller en aura concrètement besoin — + la plupart des outils accepteront un ALTO bien formé même sans + validation stricte). +- Les éléments non reconnus (``Illustration``, ``ComposedBlock``, + ``GraphicalElement``) sont silencieusement ignorés par le parser. +- ``HypPart1`` / ``HypPart2`` sont préservés au niveau ``AltoString`` + (le projecteur les utilise pour la césure). +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +import defusedxml.ElementTree as _SafeET + +from picarones.domain.errors import PicaronesError +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) + +logger = logging.getLogger(__name__) + + +class AltoParseError(PicaronesError): + """ALTO non parsable (XML invalide, XXE bloqué, root absent).""" + + +_NS_RE = re.compile(r"^\{([^}]*)\}") +_LOCAL_NAME_RE = re.compile(r"\{[^}]*\}") + + +def _local(tag: str) -> str: + """Retire le préfixe namespace pour ne garder que le nom local.""" + return _LOCAL_NAME_RE.sub("", tag) + + +def _detect_version(root_tag: str) -> str | None: + """Détecte la version ALTO depuis le tag du root. + + - Pas de namespace → ``"none"``. + - ``http://www.loc.gov/standards/alto/ns-v2#`` → ``"v2"``. + - ``http://www.loc.gov/standards/alto/ns-v3#`` → ``"v3"``. + - ``http://www.loc.gov/standards/alto/ns-v4#`` → ``"v4"``. + - Autre namespace → ``None`` (inconnu). + """ + m = _NS_RE.match(root_tag) + if m is None: + return "none" + ns = m.group(1) + if "ns-v2" in ns: + return "v2" + if "ns-v3" in ns: + return "v3" + if "ns-v4" in ns: + return "v4" + return None + + +def _parse_int_attr(elem: Any, name: str) -> int | None: + """Parse un attribut entier optionnel. Retourne ``None`` si + absent ou invalide (au lieu de lever).""" + raw = elem.attrib.get(name) + if raw is None: + return None + try: + # ALTO accepte des floats dans certains attributs (HPOS), on + # tronque vers int. + return int(float(raw)) + except (ValueError, TypeError): + return None + + +def _parse_bbox(elem: Any) -> AltoBBox | None: + """Construit un ``AltoBBox`` si les 4 attributs sont présents.""" + h = _parse_int_attr(elem, "HPOS") + v = _parse_int_attr(elem, "VPOS") + w = _parse_int_attr(elem, "WIDTH") + height = _parse_int_attr(elem, "HEIGHT") + if any(x is None for x in (h, v, w, height)): + return None + # Coordonnées négatives → certains ALTO mal formés ; on clip à 0. + return AltoBBox( + hpos=max(0, h or 0), + vpos=max(0, v or 0), + width=max(0, w or 0), + height=max(0, height or 0), + ) + + +def _parse_string(elem: Any) -> AltoString: + """Convertit un élément ```` en ``AltoString``.""" + return AltoString( + content=elem.attrib.get("CONTENT", ""), + id=elem.attrib.get("ID"), + bbox=_parse_bbox(elem), + subs_type=elem.attrib.get("SUBS_TYPE"), + subs_content=elem.attrib.get("SUBS_CONTENT"), + ) + + +def _parse_line(elem: Any) -> AltoLine: + """Convertit un élément ```` en ``AltoLine``.""" + strings: list[AltoString] = [] + for child in elem: + if _local(child.tag) == "String": + strings.append(_parse_string(child)) + return AltoLine( + id=elem.attrib.get("ID"), + bbox=_parse_bbox(elem), + strings=tuple(strings), + ) + + +def _parse_block(elem: Any) -> AltoTextBlock: + """Convertit un élément ```` en ``AltoTextBlock``.""" + lines: list[AltoLine] = [] + for child in elem.iter(): + if _local(child.tag) == "TextLine": + lines.append(_parse_line(child)) + return AltoTextBlock( + id=elem.attrib.get("ID"), + bbox=_parse_bbox(elem), + lines=tuple(lines), + ) + + +def _parse_page(elem: Any) -> AltoPage: + """Convertit un élément ```` en ``AltoPage``.""" + blocks: list[AltoTextBlock] = [] + seen_block_ids: set[int] = set() + for child in elem.iter(): + if _local(child.tag) != "TextBlock": + continue + # Évite la duplication quand un TextBlock est imbriqué dans un + # ComposedBlock — on retourne le bloc une seule fois (par id python). + marker = id(child) + if marker in seen_block_ids: + continue + seen_block_ids.add(marker) + blocks.append(_parse_block(child)) + return AltoPage( + id=elem.attrib.get("ID"), + width=_parse_int_attr(elem, "WIDTH"), + height=_parse_int_attr(elem, "HEIGHT"), + blocks=tuple(blocks), + ) + + +def parse_alto(xml: bytes | str) -> AltoDocument: + """Parse un document ALTO et retourne sa structure interne. + + Parameters + ---------- + xml: + Bytes ou string XML. Encodage détecté automatiquement par + ``defusedxml`` (à partir de la déclaration ```` + ou du BOM). + + Returns + ------- + AltoDocument + Document avec ``source_version`` indiquant la version + détectée et ``pages`` contenant la hiérarchie complète. + + Raises + ------ + AltoParseError + XML mal formé, défense XXE déclenchée, ou root absent. + """ + if isinstance(xml, str): + xml_bytes = xml.encode("utf-8") + else: + xml_bytes = xml + if not xml_bytes.strip(): + raise AltoParseError("ALTO vide.") + try: + root = _SafeET.fromstring(xml_bytes) + except Exception as exc: # noqa: BLE001 + raise AltoParseError(f"XML invalide ou XXE bloqué : {exc}") from exc + + if root is None: + raise AltoParseError("ALTO sans root element.") + + version = _detect_version(root.tag) + if _local(root.tag) != "alto": + # Tolérant : on cherche un éventuel imbriqué (cas d'un + # METS qui embarque l'ALTO dans un mdRef). Sinon on prend le + # root tel quel — peut-être qu'un caller passe directement + # un fragment . + for elem in root.iter(): + if _local(elem.tag) == "alto": + root = elem + version = _detect_version(elem.tag) + break + + pages: list[AltoPage] = [] + for elem in root.iter(): + if _local(elem.tag) == "Page": + pages.append(_parse_page(elem)) + + return AltoDocument(pages=tuple(pages), source_version=version) + + +__all__ = ["parse_alto", "AltoParseError"] diff --git a/picarones/formats/alto/types.py b/picarones/formats/alto/types.py new file mode 100644 index 0000000000000000000000000000000000000000..4743455ecd780ce2ae636fed32204f7e585b512e --- /dev/null +++ b/picarones/formats/alto/types.py @@ -0,0 +1,126 @@ +"""Structures internes ALTO — Sprint A14-S9. + +Représentation **typée et immuable** d'un document ALTO XML pour +manipulation, projection, et round-trip parser/writer. Indépendante +du namespace source (v2/v3/v4) — le parser normalise. + +Hiérarchie ALTO simplifiée : + +:: + + AltoDocument + └─ AltoPage (1..N) + └─ AltoTextBlock (0..N) + └─ AltoLine (0..N) + └─ AltoString (0..N) + +Les coordonnées (HPOS, VPOS, WIDTH, HEIGHT) sont **optionnelles**. +Un ALTO produit par certains VLM peut omettre les bbox (texte sans +coordonnées) — on accepte au parsing et le projecteur ALTO→texte +fonctionne quand même. + +Anti-sur-ingénierie +------------------- +Pas de support des éléments rares pour S9 : +- ``Composed Block`` (regroupement de blocks) — projeté en blocks plats. +- ``Illustration`` / ``GraphicalElement`` — ignorés à l'extraction texte. +- ``StyleRefs`` / typographie — non préservés par le writer. +- ``Hyphenation`` côté ``HypPart1`` / ``HypPart2`` est par contre + géré par le projector (cf. ``projector.py``). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class AltoBBox(BaseModel): + """Boîte englobante optionnelle (coordonnées en pixels).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + hpos: int = Field(ge=0) + vpos: int = Field(ge=0) + width: int = Field(ge=0) + height: int = Field(ge=0) + + +class AltoString(BaseModel): + """Un mot ALTO (élément ````). + + Attributs ALTO mappés : + - ``CONTENT`` → ``content`` + - ``ID`` → ``id`` + - ``HPOS``/``VPOS``/``WIDTH``/``HEIGHT`` → ``bbox`` + - ``SUBS_TYPE`` → ``subs_type`` (``"HypPart1"`` / ``"HypPart2"``). + Le projecteur l'utilise pour gérer la césure de fin de ligne. + - ``SUBS_CONTENT`` → ``subs_content`` (mot complet quand césuré). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + content: str + id: str | None = Field(default=None, max_length=128) + bbox: AltoBBox | None = None + subs_type: str | None = Field(default=None, pattern=r"^(HypPart1|HypPart2)$") + subs_content: str | None = None + + +class AltoLine(BaseModel): + """Une ligne ALTO (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str | None = Field(default=None, max_length=128) + bbox: AltoBBox | None = None + strings: tuple[AltoString, ...] = Field(default_factory=tuple) + """Mots de la ligne, ordre de lecture naturel (gauche → droite).""" + + +class AltoTextBlock(BaseModel): + """Un bloc de texte ALTO (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str | None = Field(default=None, max_length=128) + bbox: AltoBBox | None = None + lines: tuple[AltoLine, ...] = Field(default_factory=tuple) + + +class AltoPage(BaseModel): + """Une page ALTO (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str | None = Field(default=None, max_length=128) + width: int | None = Field(default=None, ge=0) + """Largeur physique en pixels (``WIDTH``).""" + height: int | None = Field(default=None, ge=0) + """Hauteur physique en pixels (``HEIGHT``).""" + blocks: tuple[AltoTextBlock, ...] = Field(default_factory=tuple) + + +class AltoDocument(BaseModel): + """Document ALTO complet. + + Conserve la version source au parsing pour permettre au writer + de re-sortir dans le même namespace si demandé. Par défaut, + le writer sort en v4 (le plus récent et le plus expressif). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + pages: tuple[AltoPage, ...] = Field(default_factory=tuple) + source_version: str | None = Field(default=None, max_length=8) + """Version détectée au parsing : ``"v2"`` / ``"v3"`` / ``"v4"`` / + ``"none"`` (sans namespace) / ``None`` (inconnue).""" + + +__all__ = [ + "AltoBBox", + "AltoString", + "AltoLine", + "AltoTextBlock", + "AltoPage", + "AltoDocument", +] diff --git a/picarones/formats/alto/writer.py b/picarones/formats/alto/writer.py new file mode 100644 index 0000000000000000000000000000000000000000..1e084be03600df5433fbf71bdbebe6bff19b3428 --- /dev/null +++ b/picarones/formats/alto/writer.py @@ -0,0 +1,147 @@ +"""Writer ALTO XML déterministe — Sprint A14-S9. + +Sérialise un ``AltoDocument`` en bytes ALTO XML. Sortie +déterministe : même document → mêmes octets exacts (utile pour le +cache d'artefacts du S7 et les tests de round-trip). + +Format de sortie +---------------- +Par défaut, le writer sort un ALTO **v4** (le plus récent et le +plus expressif), même si le document a été parsé depuis v2/v3. Le +caller peut forcer une version cible avec ``write_alto(doc, +version="v3")``. + +Anti-sur-ingénierie +------------------- +- Pas de support des ``StyleRefs``, ``ProcessingStep``, ``OCRProcessing``, + ``Description`` pour S9. Le writer sort une structure minimale + (``alto > Layout > Page > PrintSpace > TextBlock > TextLine > String``) + qui passe la validation des outils consommateurs courants + (Mirador, IIIF Universal Viewer, Aletheia). +- Pas d'XSL preprocessing. L'utilisateur qui veut un ALTO + enrichi écrira un wrapper. +""" + +from __future__ import annotations + +from xml.etree import ElementTree as ET + +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) + + +_NAMESPACE_BY_VERSION: dict[str, str] = { + "v2": "http://www.loc.gov/standards/alto/ns-v2#", + "v3": "http://www.loc.gov/standards/alto/ns-v3#", + "v4": "http://www.loc.gov/standards/alto/ns-v4#", +} + + +def _set_bbox_attrs(elem: ET.Element, bbox: AltoBBox | None) -> None: + if bbox is None: + return + elem.set("HPOS", str(bbox.hpos)) + elem.set("VPOS", str(bbox.vpos)) + elem.set("WIDTH", str(bbox.width)) + elem.set("HEIGHT", str(bbox.height)) + + +def _set_optional(elem: ET.Element, name: str, value: str | None) -> None: + if value is not None: + elem.set(name, value) + + +def _build_string(parent: ET.Element, ns: str, s: AltoString) -> None: + elem = ET.SubElement(parent, f"{{{ns}}}String" if ns else "String") + elem.set("CONTENT", s.content) + _set_optional(elem, "ID", s.id) + _set_bbox_attrs(elem, s.bbox) + _set_optional(elem, "SUBS_TYPE", s.subs_type) + _set_optional(elem, "SUBS_CONTENT", s.subs_content) + + +def _build_line(parent: ET.Element, ns: str, line: AltoLine) -> None: + elem = ET.SubElement(parent, f"{{{ns}}}TextLine" if ns else "TextLine") + _set_optional(elem, "ID", line.id) + _set_bbox_attrs(elem, line.bbox) + for s in line.strings: + _build_string(elem, ns, s) + + +def _build_block(parent: ET.Element, ns: str, block: AltoTextBlock) -> None: + elem = ET.SubElement(parent, f"{{{ns}}}TextBlock" if ns else "TextBlock") + _set_optional(elem, "ID", block.id) + _set_bbox_attrs(elem, block.bbox) + for line in block.lines: + _build_line(elem, ns, line) + + +def _build_page(parent: ET.Element, ns: str, page: AltoPage) -> None: + elem = ET.SubElement(parent, f"{{{ns}}}Page" if ns else "Page") + _set_optional(elem, "ID", page.id) + if page.width is not None: + elem.set("WIDTH", str(page.width)) + if page.height is not None: + elem.set("HEIGHT", str(page.height)) + print_space = ET.SubElement( + elem, f"{{{ns}}}PrintSpace" if ns else "PrintSpace", + ) + for block in page.blocks: + _build_block(print_space, ns, block) + + +def write_alto( + document: AltoDocument, + *, + version: str = "v4", + pretty: bool = False, +) -> bytes: + """Sérialise un ``AltoDocument`` en bytes ALTO XML. + + Parameters + ---------- + document: + Document à sérialiser. + version: + Version ALTO cible. ``"v2"`` / ``"v3"`` / ``"v4"`` ou + ``"none"`` (sans namespace). Défaut : ``"v4"``. + pretty: + Si ``True``, indente la sortie pour la lisibilité humaine. + ``False`` (défaut) produit une sortie compacte byte-déterministe. + + Returns + ------- + bytes + XML encodé en UTF-8 avec déclaration XML. + """ + if version not in (*_NAMESPACE_BY_VERSION, "none"): + from picarones.domain.errors import PicaronesError + raise PicaronesError( + f"version ALTO invalide : {version!r}. " + f"Acceptées : {sorted(_NAMESPACE_BY_VERSION)} + 'none'." + ) + ns = _NAMESPACE_BY_VERSION.get(version, "") + if ns: + ET.register_namespace("", ns) + root = ET.Element(f"{{{ns}}}alto") + else: + root = ET.Element("alto") + + layout = ET.SubElement(root, f"{{{ns}}}Layout" if ns else "Layout") + for page in document.pages: + _build_page(layout, ns, page) + + if pretty: + ET.indent(root, space=" ") + + body = ET.tostring(root, encoding="utf-8", xml_declaration=True) + return body + + +__all__ = ["write_alto"] diff --git a/picarones/formats/pagexml/__init__.py b/picarones/formats/pagexml/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6722cdb1677ed4d25f24bb39f37fc5a18c811d55 --- /dev/null +++ b/picarones/formats/pagexml/__init__.py @@ -0,0 +1,36 @@ +"""Format PAGE XML (PRIMA / Transkribus). + +Sprint A14-S9 livre : + +- ``types.py`` — ``PageDocument``, ``PagePage``, ``PageTextRegion``, + ``PageTextLine``. Frozen pydantic. +- ``parser.py`` — ``parse_pagexml(xml_bytes)`` tolérant aux versions + de namespace PRIMA. Sécurité ``defusedxml``. +- ``projector.py`` — ``page_document_to_text(doc)`` + ``PageToText``. + +Writer reporté post-livraison (les outils PAGE produisent +typiquement le format à partir d'un éditeur — le besoin de re-sortir +est plus rare que pour ALTO). +""" + +from __future__ import annotations + +from picarones.formats.pagexml.parser import PageParseError, parse_pagexml +from picarones.formats.pagexml.types import ( + PageDocument, + PagePage, + PageTextLine, + PageTextRegion, +) + +# S13 — les projecteurs vivent désormais dans +# ``picarones.evaluation.projectors.pagexml``. + +__all__ = [ + "PageTextLine", + "PageTextRegion", + "PagePage", + "PageDocument", + "parse_pagexml", + "PageParseError", +] diff --git a/picarones/formats/pagexml/parser.py b/picarones/formats/pagexml/parser.py new file mode 100644 index 0000000000000000000000000000000000000000..1a1d660cad92318810a4fb04f035f5c6cf856a49 --- /dev/null +++ b/picarones/formats/pagexml/parser.py @@ -0,0 +1,149 @@ +"""Parser PAGE XML tolérant — Sprint A14-S9. + +Détection auto du namespace PRIMA (plusieurs versions co-existent +dans la nature : ``2010-03-19``, ``2013-07-15``, ``2017-07-15``, +``2019-07-15``). Utilise ``defusedxml`` pour la sécurité XXE. +""" + +from __future__ import annotations + +import logging +import re +from typing import Any + +import defusedxml.ElementTree as _SafeET + +from picarones.domain.errors import PicaronesError +from picarones.formats.pagexml.types import ( + PageDocument, + PagePage, + PageTextLine, + PageTextRegion, +) + +logger = logging.getLogger(__name__) + + +class PageParseError(PicaronesError): + """PAGE XML non parsable.""" + + +_NS_RE = re.compile(r"^\{([^}]*)\}") +_LOCAL_NAME_RE = re.compile(r"\{[^}]*\}") + + +def _local(tag: str) -> str: + return _LOCAL_NAME_RE.sub("", tag) + + +def _detect_namespace(root_tag: str) -> str | None: + m = _NS_RE.match(root_tag) + return m.group(1) if m else None + + +def _extract_unicode(elem: Any) -> str: + """Cherche le premier ```` descendant et retourne son + texte, ou ``""`` si absent. + + PAGE XML stocke le texte dans ``...``. + Plusieurs ``TextEquiv`` peuvent coexister (variantes d'OCR) — + on prend la première. + """ + for child in elem.iter(): + if _local(child.tag) == "Unicode": + return (child.text or "").strip() + return "" + + +def _parse_coords(elem: Any) -> str | None: + """Cherche le premier ```` enfant direct.""" + for child in elem: + if _local(child.tag) == "Coords": + return child.attrib.get("points") + return None + + +def _parse_baseline(elem: Any) -> str | None: + for child in elem: + if _local(child.tag) == "Baseline": + return child.attrib.get("points") + return None + + +def _parse_text_line(elem: Any) -> PageTextLine: + return PageTextLine( + id=elem.attrib.get("id"), + coords=_parse_coords(elem), + baseline=_parse_baseline(elem), + text=_extract_unicode(elem), + ) + + +def _parse_text_region(elem: Any) -> PageTextRegion: + lines: list[PageTextLine] = [] + for child in elem: + if _local(child.tag) == "TextLine": + lines.append(_parse_text_line(child)) + return PageTextRegion( + id=elem.attrib.get("id"), + coords=_parse_coords(elem), + region_type=elem.attrib.get("type"), + text_lines=tuple(lines), + ) + + +def _parse_int_attr(elem: Any, name: str) -> int | None: + raw = elem.attrib.get(name) + if raw is None: + return None + try: + return int(float(raw)) + except (ValueError, TypeError): + return None + + +def _parse_page(elem: Any) -> PagePage: + regions: list[PageTextRegion] = [] + for child in elem: + if _local(child.tag) == "TextRegion": + regions.append(_parse_text_region(child)) + return PagePage( + image_filename=elem.attrib.get("imageFilename"), + image_width=_parse_int_attr(elem, "imageWidth"), + image_height=_parse_int_attr(elem, "imageHeight"), + text_regions=tuple(regions), + ) + + +def parse_pagexml(xml: bytes | str) -> PageDocument: + """Parse un document PAGE XML et retourne la structure interne. + + Raises + ------ + PageParseError + XML mal formé, défense XXE, ou root absent. + """ + if isinstance(xml, str): + xml_bytes = xml.encode("utf-8") + else: + xml_bytes = xml + if not xml_bytes.strip(): + raise PageParseError("PAGE XML vide.") + try: + root = _SafeET.fromstring(xml_bytes) + except Exception as exc: # noqa: BLE001 + raise PageParseError(f"XML invalide ou XXE bloqué : {exc}") from exc + + if root is None: + raise PageParseError("PAGE sans root element.") + + ns = _detect_namespace(root.tag) + pages: list[PagePage] = [] + for elem in root.iter(): + if _local(elem.tag) == "Page": + pages.append(_parse_page(elem)) + + return PageDocument(pages=tuple(pages), source_namespace=ns) + + +__all__ = ["parse_pagexml", "PageParseError"] diff --git a/picarones/formats/pagexml/types.py b/picarones/formats/pagexml/types.py new file mode 100644 index 0000000000000000000000000000000000000000..0ab6995fadb61e5996030d62376404cfe6b17bd6 --- /dev/null +++ b/picarones/formats/pagexml/types.py @@ -0,0 +1,82 @@ +"""Structures internes PAGE XML — Sprint A14-S9. + +Représentation typée et immuable d'un document PAGE XML (PRIMA / +Transkribus / eScriptorium). Symétrique de ``formats.alto.types`` +mais avec les conventions PAGE : + +- ``Coords`` au lieu de ``HPOS/VPOS/WIDTH/HEIGHT`` — chaîne de points + ``"x1,y1 x2,y2 ..."`` représentant un polygone. +- ``Baseline`` (optionnel) — ligne médiane horizontale typique des + manuscrits. +- ``TextEquiv > Unicode`` au lieu de ``CONTENT`` ALTO. + +Anti-sur-ingénierie +------------------- +- Pas de support des ``Word``/``Glyph`` PAGE (granularité plus fine + que la ligne) pour S9 — la plupart des outils PAGE patrimoniaux + utilisent la granularité ``TextLine``. Un ``Word`` séparé peut + être ajouté quand un caller en aura besoin. +- Coordonnées stockées en string brut (``points``). Le caller qui + veut une bbox calculée appelle ``points_to_bbox()`` du parser. +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict, Field + + +class PageTextLine(BaseModel): + """Une ligne PAGE (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str | None = Field(default=None, max_length=128) + coords: str | None = Field(default=None, max_length=4096) + """Polygone en format PAGE : ``"x1,y1 x2,y2 x3,y3 ..."``.""" + baseline: str | None = Field(default=None, max_length=2048) + """Polyline baseline (optionnelle, typique HTR).""" + text: str = "" + """Texte de la ligne extrait de ``TextEquiv > Unicode``.""" + + +class PageTextRegion(BaseModel): + """Région de texte PAGE (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + id: str | None = Field(default=None, max_length=128) + coords: str | None = Field(default=None, max_length=4096) + region_type: str | None = Field(default=None, max_length=64) + """Type sémantique PAGE : ``"paragraph"``, ``"heading"``, + ``"caption"``, ``"footnote"``, etc. Préservé tel quel sans + enum (les valeurs PRIMA peuvent être étendues).""" + text_lines: tuple[PageTextLine, ...] = Field(default_factory=tuple) + + +class PagePage(BaseModel): + """Une page PAGE (élément ````).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + image_filename: str | None = Field(default=None, max_length=512) + image_width: int | None = Field(default=None, ge=0) + image_height: int | None = Field(default=None, ge=0) + text_regions: tuple[PageTextRegion, ...] = Field(default_factory=tuple) + + +class PageDocument(BaseModel): + """Document PAGE XML complet (peut contenir une seule page).""" + + model_config = ConfigDict(frozen=True, extra="forbid") + + pages: tuple[PagePage, ...] = Field(default_factory=tuple) + source_namespace: str | None = Field(default=None, max_length=256) + """Namespace détecté au parsing (ex ``2019-07-15``, ``2013-07-15``).""" + + +__all__ = [ + "PageTextLine", + "PageTextRegion", + "PagePage", + "PageDocument", +] diff --git a/picarones/formats/text/__init__.py b/picarones/formats/text/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cf0d01658252864965465a2fdd8cc70178a1bb97 --- /dev/null +++ b/picarones/formats/text/__init__.py @@ -0,0 +1,47 @@ +"""Normalisation et manipulation de texte. + +Sprint A14-S9 livre ``normalization.py``, déplacé depuis +``picarones/measurements/normalization.py`` sans modification de +logique. L'ancien emplacement reste un re-export pour ne pas +casser les ~50 consommateurs (sera retiré au S22). + +11 profils intégrés : ``nfc``, ``caseless``, ``minimal``, +``medieval_french``, ``early_modern_french``, ``medieval_latin``, +``medieval_english``, ``early_modern_english``, ``secretary_hand``, +``sans_ponctuation``, ``sans_apostrophes``. + +Règle architecturale : ce module ne fait **pas** d'extraction depuis +ALTO/PAGE (c'est le rôle des projecteurs dans +``picarones.evaluation.projectors``). Il prend une chaîne en entrée, +applique un profil, retourne une chaîne. +""" + +from __future__ import annotations + +from picarones.formats.text.normalization import ( + DEFAULT_DIPLOMATIC_PROFILE, + DIPLOMATIC_EN_EARLY_MODERN, + DIPLOMATIC_EN_MEDIEVAL, + DIPLOMATIC_EN_SECRETARY, + DIPLOMATIC_FR_EARLY_MODERN, + DIPLOMATIC_FR_MEDIEVAL, + DIPLOMATIC_LATIN_MEDIEVAL, + DIPLOMATIC_MINIMAL, + NORMALIZATION_PROFILES, + NormalizationProfile, + get_builtin_profile, +) + +__all__ = [ + "NormalizationProfile", + "NORMALIZATION_PROFILES", + "DEFAULT_DIPLOMATIC_PROFILE", + "get_builtin_profile", + "DIPLOMATIC_FR_MEDIEVAL", + "DIPLOMATIC_FR_EARLY_MODERN", + "DIPLOMATIC_LATIN_MEDIEVAL", + "DIPLOMATIC_MINIMAL", + "DIPLOMATIC_EN_EARLY_MODERN", + "DIPLOMATIC_EN_MEDIEVAL", + "DIPLOMATIC_EN_SECRETARY", +] diff --git a/picarones/formats/text/normalization.py b/picarones/formats/text/normalization.py new file mode 100644 index 0000000000000000000000000000000000000000..6c33b33d4752d0c00715e8dfd6b068b75c773498 --- /dev/null +++ b/picarones/formats/text/normalization.py @@ -0,0 +1,420 @@ +"""Profils de normalisation unicode pour le calcul du CER diplomatique. + +La normalisation diplomatique permet de calculer un CER tenant compte des +équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc. + +En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs +"substantielles" (transcription erronée) en ignorant les variations graphiques +codifiées connues. + +Trois niveaux de normalisation sont disponibles : + +1. NFC : normalisation Unicode canonique (décomposition+recomposition) +2. caseless : NFC + pliage de casse (casefold) +3. diplomatic: NFC + table de correspondances historiques configurables + +Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants. +Ils sont également chargeables depuis un fichier YAML. + +Exemple YAML +------------ +name: medieval_custom +caseless: false +diplomatic: + ſ: s + u: v + i: j + y: i + æ: ae + œ: oe +""" + +from __future__ import annotations + +import unicodedata +from dataclasses import dataclass, field +from pathlib import Path + + +# --------------------------------------------------------------------------- +# Tables de correspondances diplomatiques préconfigurées +# --------------------------------------------------------------------------- + +#: Français médiéval (XIIe–XVe siècle) +DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = { + "ſ": "s", # s long → s + "u": "v", # u/v interchangeables en position initiale + "i": "j", # i/j interchangeables + "y": "i", # y vocalique → i + "æ": "ae", # ligature æ + "œ": "oe", # ligature œ + "ꝑ": "per", # abréviation per/par + "ꝓ": "pro", # abréviation pro + "\u0026": "et", # & → et +} + +#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle) +DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = { + "ſ": "s", # s long + "æ": "ae", + "œ": "oe", + "\u0026": "et", + "ỹ": "yn", # y tilde +} + +#: Latin médiéval +DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "y": "i", + "æ": "ae", + "œ": "oe", + "ꝑ": "per", + "ꝓ": "pro", + "ꝗ": "que", # q barré → que + "\u0026": "et", +} + +#: Profil minimal — uniquement NFC + s long +DIPLOMATIC_MINIMAL: dict[str, str] = { + "ſ": "s", +} + +#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle) +#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y +DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = { + "ſ": "s", # s long → s + "u": "v", # u/v interchangeables (vpon → upon) + "i": "j", # i/j interchangeables (ioy → joy) + "vv": "w", # vv → w (vvhich → which) + "þ": "th", # thorn → th + "ð": "th", # eth → th + "ȝ": "y", # yogh → y + "æ": "ae", # ligature æ + "œ": "oe", # ligature œ + "\u0026": "and", # & → and +} + +#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses +DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "vv": "w", + "þ": "th", + "ð": "th", + "ȝ": "y", + "æ": "ae", + "œ": "oe", + "\u0026": "and", + # Abréviations courantes dans les manuscrits anglais médiévaux + "ꝑ": "per", # p barré → per/par + "ꝓ": "pro", # p crocheté → pro + "ꝗ": "que", # q barré → que + "\ua75b": "r", # lettre r rotunda → r +} + +#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand +#: Confusions visuelles propres à l'écriture cursive anglaise +DIPLOMATIC_EN_SECRETARY: dict[str, str] = { + "ſ": "s", + "u": "v", + "i": "j", + "vv": "w", + "þ": "th", + "ð": "th", + "ȝ": "y", + "\u0026": "and", + # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire + # Note : ne pas normaliser e/c automatiquement (trop agressif) ; + # on se limite aux substituts graphiques historiquement documentés +} + + +# --------------------------------------------------------------------------- +# Profil de normalisation +# --------------------------------------------------------------------------- + +@dataclass +class NormalizationProfile: + """Décrit une stratégie de normalisation pour le calcul du CER diplomatique. + + Parameters + ---------- + name: + Identifiant lisible du profil (ex : ``"medieval_french"``). + nfc: + Applique la normalisation Unicode NFC (recommandé, activé par défaut). + caseless: + Pliage de casse (casefold) après NFC. + diplomatic_table: + Table de correspondances graphiques historiques appliquée caractère + par caractère sur les deux textes avant calcul du CER. + exclude_chars: + Ensemble de caractères supprimés des deux textes (GT et OCR) avant + tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique). + Utile pour ignorer la ponctuation ou les apostrophes. + description: + Description courte du profil (affichée dans le rapport HTML). + """ + + name: str + nfc: bool = True + caseless: bool = False + diplomatic_table: dict[str, str] = field(default_factory=dict) + exclude_chars: frozenset = field(default_factory=frozenset) + description: str = "" + + def normalize(self, text: str) -> str: + """Applique le profil de normalisation à un texte.""" + if self.exclude_chars: + text = "".join(c for c in text if c not in self.exclude_chars) + if self.nfc: + text = unicodedata.normalize("NFC", text) + if self.caseless: + text = text.casefold() + if self.diplomatic_table: + text = _apply_diplomatic_table(text, self.diplomatic_table) + return text + + def as_dict(self) -> dict: + return { + "name": self.name, + "nfc": self.nfc, + "caseless": self.caseless, + "diplomatic_table": self.diplomatic_table, + "exclude_chars": sorted(self.exclude_chars), + "description": self.description, + } + + @classmethod + def from_yaml(cls, path: str | Path) -> "NormalizationProfile": + """Charge un profil depuis un fichier YAML. + + Le fichier YAML doit contenir les clés ``name``, optionnellement + ``caseless``, ``description``, ``diplomatic`` (dict str→str) et + ``exclude_chars`` (liste ou chaîne de caractères à ignorer). + + Example + ------- + .. code-block:: yaml + + name: medieval_custom + caseless: false + description: Français médiéval personnalisé + exclude_chars: ".,;:!?" + diplomatic: + ſ: s + u: v + """ + try: + import yaml + except ImportError as exc: + raise RuntimeError( + "Le package 'pyyaml' est requis pour charger les profils YAML. " + "Installez-le avec : pip install pyyaml" + ) from exc + + data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) + return cls( + name=data.get("name", Path(path).stem), + nfc=bool(data.get("nfc", True)), + caseless=bool(data.get("caseless", False)), + diplomatic_table=data.get("diplomatic", {}), + exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), + description=data.get("description", ""), + ) + + @classmethod + def from_dict(cls, data: dict) -> "NormalizationProfile": + """Charge un profil depuis un dictionnaire (ex : section YAML inline).""" + return cls( + name=data.get("name", "custom"), + nfc=bool(data.get("nfc", True)), + caseless=bool(data.get("caseless", False)), + diplomatic_table=data.get("diplomatic", {}), + exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), + description=data.get("description", ""), + ) + + +# --------------------------------------------------------------------------- +# Profils préconfigurés +# --------------------------------------------------------------------------- + +NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = { + "nfc": NormalizationProfile( + name="nfc", + nfc=True, + caseless=False, + diplomatic_table={}, + description="Normalisation NFC uniquement", + ), + "caseless": NormalizationProfile( + name="caseless", + nfc=True, + caseless=True, + diplomatic_table={}, + description="NFC + insensible à la casse", + ), + "minimal": NormalizationProfile( + name="minimal", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_MINIMAL, + description="Minimal : NFC + s long seulement", + ), + "medieval_french": NormalizationProfile( + name="medieval_french", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_FR_MEDIEVAL, + description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe", + ), + "early_modern_french": NormalizationProfile( + name="early_modern_french", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN, + description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe", + ), + "medieval_latin": NormalizationProfile( + name="medieval_latin", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL, + description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro", + ), + "early_modern_english": NormalizationProfile( + name="early_modern_english", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN, + description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", + ), + "medieval_english": NormalizationProfile( + name="medieval_english", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_MEDIEVAL, + description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro", + ), + "secretary_hand": NormalizationProfile( + name="secretary_hand", + nfc=True, + caseless=False, + diplomatic_table=DIPLOMATIC_EN_SECRETARY, + description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", + ), + # ── Profils d'exclusion de caractères ──────────────────────────────── + "sans_ponctuation": NormalizationProfile( + name="sans_ponctuation", + nfc=True, + caseless=False, + diplomatic_table={}, + exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()), + description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]", + ), + "sans_apostrophes": NormalizationProfile( + name="sans_apostrophes", + nfc=True, + caseless=False, + diplomatic_table={}, + exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique + description="NFC + suppression des apostrophes droite (') et typographique (\u2019)", + ), +} + + +def get_builtin_profile(name: str) -> NormalizationProfile: + """Retourne un profil préconfigurée par son identifiant. + + Identifiants disponibles + ------------------------ + - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…) + - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…) + - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…) + - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y) + - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…) + - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative) + - ``"minimal"`` : uniquement NFC + s long + - ``"nfc"`` : NFC seul (sans table diplomatique) + - ``"caseless"`` : NFC + pliage de casse + + Raises + ------ + KeyError + Si le nom n'est pas reconnu. + """ + if name not in NORMALIZATION_PROFILES: + raise KeyError( + f"Profil de normalisation inconnu : '{name}'. " + f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}" + ) + return NORMALIZATION_PROFILES[name] + + +# --------------------------------------------------------------------------- +# Fonctions utilitaires +# --------------------------------------------------------------------------- + +def _parse_exclude_chars(value: "str | list | None") -> frozenset: + """Convertit une liste de caractères (str ou list) en frozenset. + + Accepte : + - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``) + ou simplement concaténés sans séparateur (ex. ``".,;:!?"``) + - Une liste Python/YAML de chaînes (chacune un caractère) + - None ou chaîne vide → frozenset vide + + Règle de désambiguïsation : si la chaîne contient la séquence ``", "`` + (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque + caractère Unicode est un item distinct. + """ + if not value: + return frozenset() + if isinstance(value, (list, tuple)): + return frozenset(str(c) for c in value if c) + raw = str(value) + # Désambiguïsation : séparer par ", " si présent (format lisible) + if ", " in raw: + return frozenset(c.strip() for c in raw.split(",") if c.strip()) + # Sinon, chaque caractère Unicode est un item distinct + return frozenset(raw) + + +def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str: + """Applique une table de correspondances diplomatiques en un seul pass. + + Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité + sur les correspondances simples. Le remplacement est fait en un seul pass + via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis + ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``). + """ + if not table: + return text + + import re + + # Séparer les clés simples (1 char) des clés multi-chars + multi_keys = sorted( + (k for k in table if len(k) > 1), key=len, reverse=True + ) + simple_table = {k: v for k, v in table.items() if len(k) == 1} + + if multi_keys: + # Single-pass : construire un pattern regex avec toutes les clés multi-chars + # triées par longueur décroissante pour matcher les plus longues d'abord + pattern = re.compile("|".join(re.escape(k) for k in multi_keys)) + text = pattern.sub(lambda m: table[m.group(0)], text) + + # Remplacements char par char (single-pass via itération) + if simple_table: + text = "".join(simple_table.get(c, c) for c in text) + + return text + + +# Profil par défaut utilisé pour le CER diplomatique intégré +DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french") diff --git a/picarones/interfaces/__init__.py b/picarones/interfaces/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a8a2cd959aedcf6cb49df57579b69a209b596a18 --- /dev/null +++ b/picarones/interfaces/__init__.py @@ -0,0 +1,23 @@ +"""Cercle 5 — Interfaces (CLI, web). + +Couches de transport. Code mince qui parse des arguments / des +requêtes HTTP, appelle un service applicatif, retourne une réponse. + +**Aucune logique métier ici.** Si tu te vois écrire un calcul, un +parsing de format, une orchestration → c'est qu'il vit ailleurs +(``app/services/`` typiquement). + +Sous-packages : + +- ``cli/`` — Click commands. Cible Sprint S22. +- ``web/`` — FastAPI + routers + middlewares + templates SPA. + Cible Sprint S21. + +Règle d'import : peut importer ``app/`` uniquement (et les libs +externes spécifiques au transport : ``fastapi``, ``click``, +``starlette``, ``uvicorn``). Pas d'accès direct aux adaptateurs. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/interfaces/cli/__init__.py b/picarones/interfaces/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d74a4ae01d70b4842e81222a7980cd15a07a0e45 --- /dev/null +++ b/picarones/interfaces/cli/__init__.py @@ -0,0 +1,54 @@ +"""CLI du rewrite ciblé — couche ``interfaces/cli``. + +Point d'entrée Click ``cli`` qui regroupe les commandes consommant +les services applicatifs du rewrite (``CorpusService``, +``ReportService``, ``BenchmarkService``). + +Usage +----- + +:: + + python -m picarones.interfaces.cli import-corpus mon_corpus.zip \\ + --output-dir ./workspaces/sess1 + python -m picarones.interfaces.cli report ./runs/run_001 \\ + --output rapport.html + python -m picarones.interfaces.cli run --spec ./run.yaml + +Distinct du legacy +------------------ +``picarones.cli`` (legacy) reste opérationnel — il est appelé par le +script ``picarones`` installé via ``pyproject.toml``. Cette nouvelle +CLI vit dans ``picarones.interfaces.cli`` et s'invoque via +``python -m``. Quand le rewrite atteindra la parité fonctionnelle, +on basculera l'entry point ``console_scripts`` vers ce module et le +legacy sera supprimé. +""" + +from __future__ import annotations + +import click + +from picarones.interfaces.cli.import_corpus import import_corpus_command +from picarones.interfaces.cli.report import report_command +from picarones.interfaces.cli.run import run_command + + +@click.group( + name="picarones-rewrite", + help=( + "CLI du rewrite ciblé Picarones. Sous-commandes : " + "import-corpus, report, run." + ), +) +@click.version_option(package_name="picarones") +def cli() -> None: + """Groupe principal.""" + + +cli.add_command(import_corpus_command, name="import-corpus") +cli.add_command(report_command, name="report") +cli.add_command(run_command, name="run") + + +__all__ = ["cli"] diff --git a/picarones/interfaces/cli/__main__.py b/picarones/interfaces/cli/__main__.py new file mode 100644 index 0000000000000000000000000000000000000000..d7a6e15ff2850a62312d270fd94dbffdc2e00e5e --- /dev/null +++ b/picarones/interfaces/cli/__main__.py @@ -0,0 +1,7 @@ +"""Permet ``python -m picarones.interfaces.cli ...``.""" + +from picarones.interfaces.cli import cli + + +if __name__ == "__main__": + cli() diff --git a/picarones/interfaces/cli/import_corpus.py b/picarones/interfaces/cli/import_corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..bb6720ffd11ab9bf2851b7c2671efc8b34d9ec1b --- /dev/null +++ b/picarones/interfaces/cli/import_corpus.py @@ -0,0 +1,180 @@ +"""``picarones-rewrite import-corpus`` — extraction sandboxée d'un ZIP. + +Sprint A14-S22. + +Wrapper CLI minimal autour du ``CorpusService`` (S20) : + +:: + + python -m picarones.interfaces.cli import-corpus mon_corpus.zip \\ + --output-dir ./workspaces/sess1 \\ + --corpus-name bnf_xviiie \\ + --metadata language=fr \\ + --metadata period=early_modern + +Comportement +------------ +- Lit le ZIP (path utilisateur, sans validation préalable — la CLI + fait confiance au filesystem local de l'opérateur). +- Crée un ``WorkspaceManager`` dans ``--output-dir`` (créé s'il + n'existe pas). +- Appelle ``CorpusService.import_zip``. +- Affiche un résumé lisible : n_documents, n_images sans GT, GT + orphelines, warnings. +- Code de sortie ``0`` succès, ``1`` erreur typée + (``CorpusImportError``), ``2`` erreur d'usage Click (gérée par + Click). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from picarones.app.services import ( + CorpusImportError, + CorpusService, + WorkspaceManager, +) + + +@click.command() +@click.argument( + "zip_path", + type=click.Path( + exists=True, dir_okay=False, file_okay=True, path_type=Path, + ), +) +@click.option( + "--output-dir", + "output_dir", + type=click.Path(file_okay=False, dir_okay=True, path_type=Path), + required=True, + help=( + "Répertoire parent où créer le workspace sandboxé. Créé " + "s'il n'existe pas." + ), +) +@click.option( + "--corpus-name", + default=None, + help=( + "Nom du corpus (défaut : nom du fichier ZIP sans " + "extension). Sera sanitizé automatiquement." + ), +) +@click.option( + "--metadata", + "metadata_pairs", + multiple=True, + help=( + "Paires ``clé=valeur`` (option répétable). Ex : " + "``--metadata language=fr --metadata period=medieval``." + ), +) +@click.option( + "--max-zip-mb", + default=100, + type=int, + show_default=True, + help="Plafond taille du blob ZIP (Mo).", +) +@click.option( + "--max-entries", + default=5000, + type=int, + show_default=True, + help="Plafond nombre d'entrées dans le ZIP (anti zip bomb).", +) +@click.option( + "--max-uncompressed-mb", + default=500, + type=int, + show_default=True, + help="Plafond taille décompressée totale (Mo).", +) +@click.option( + "--quiet", + is_flag=True, + default=False, + help="N'affiche que le chemin du dossier extrait, rien d'autre.", +) +def import_corpus_command( + zip_path: Path, + output_dir: Path, + corpus_name: str | None, + metadata_pairs: tuple[str, ...], + max_zip_mb: int, + max_entries: int, + max_uncompressed_mb: int, + quiet: bool, +) -> None: + """Extrait un ZIP de corpus dans un workspace sandboxé.""" + output_dir.mkdir(parents=True, exist_ok=True) + workspace = WorkspaceManager(output_dir) + + if corpus_name is None: + corpus_name = zip_path.stem + + metadata = _parse_metadata_pairs(metadata_pairs) + + service = CorpusService( + workspace, + max_zip_size_bytes=max_zip_mb * 1024 * 1024, + max_entry_count=max_entries, + max_uncompressed_bytes=max_uncompressed_mb * 1024 * 1024, + ) + try: + report = service.import_zip( + zip_path.read_bytes(), + corpus_name=corpus_name, + metadata=metadata, + ) + except CorpusImportError as exc: + click.echo(f"erreur : {exc}", err=True) + sys.exit(1) + + if quiet: + click.echo(str(report.extracted_dir)) + return + + click.echo(f"Corpus extrait dans : {report.extracted_dir}") + click.echo(f" documents : {report.n_documents}") + click.echo(f" sans GT : {report.n_images_without_gt}") + click.echo(f" GT orphelines : {report.n_gt_without_image}") + click.echo(f" bruit OS sauté : {report.n_skipped_noise}") + if report.warnings: + click.echo("Avertissements :") + for w in report.warnings: + click.echo(f" - {w}") + + +def _parse_metadata_pairs( + pairs: tuple[str, ...], +) -> dict[str, str]: + """Parse ``("k1=v1", "k2=v2")`` → ``{"k1": "v1", "k2": "v2"}``. + + Lève ``click.BadParameter`` si une paire ne contient pas ``=``. + """ + out: dict[str, str] = {} + for pair in pairs: + if "=" not in pair: + raise click.BadParameter( + f"métadonnée invalide : {pair!r} (attendu ``clé=valeur``).", + param_hint="--metadata", + ) + key, _, value = pair.partition("=") + key = key.strip() + value = value.strip() + if not key: + raise click.BadParameter( + f"métadonnée à clé vide : {pair!r}.", + param_hint="--metadata", + ) + out[key] = value + return out + + +__all__ = ["import_corpus_command"] diff --git a/picarones/interfaces/cli/report.py b/picarones/interfaces/cli/report.py new file mode 100644 index 0000000000000000000000000000000000000000..b7fb8839868adef55ded97bc3e8ef72d79f75827 --- /dev/null +++ b/picarones/interfaces/cli/report.py @@ -0,0 +1,82 @@ +"""``picarones-rewrite report`` — génère le HTML d'un run persisté. + +Wrapper Click mince autour du :class:`HtmlReportRenderer` (couche +``reports_v2/html/``). + +:: + + python -m picarones.interfaces.cli report ./runs/run_001 \\ + --output rapport.html \\ + --lang fr + +Comportement +------------ +- Lit les 3 fichiers persistés par ``BenchmarkService.persist`` : + ``run_manifest.json``, ``pipeline_results.jsonl``, + ``view_results.jsonl``. +- Reconstruit le ``RunResult`` via + :meth:`HtmlReportRenderer.load_run_result`. +- Rend le HTML autonome via :meth:`HtmlReportRenderer.render`. +- Écrit dans ``--output`` (chemin filesystem libre), ou affiche sur + stdout si ``--output`` est omis. +- Code de sortie ``0`` succès, ``1`` fichiers persistés + introuvables, ``2`` erreur d'usage Click. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from picarones.reports_v2.html import HtmlReportRenderer + + +@click.command() +@click.argument( + "run_dir", + type=click.Path( + exists=True, file_okay=False, dir_okay=True, path_type=Path, + ), +) +@click.option( + "--output", + "output_path", + type=click.Path(dir_okay=False, path_type=Path), + default=None, + help=( + "Chemin du fichier HTML à écrire. Si omis, le HTML est " + "affiché sur stdout." + ), +) +@click.option( + "--lang", + type=click.Choice(["fr", "en"]), + default="fr", + show_default=True, + help="Langue des labels du rapport.", +) +def report_command( + run_dir: Path, + output_path: Path | None, + lang: str, +) -> None: + """Génère le rapport HTML d'un run persisté.""" + renderer = HtmlReportRenderer(lang=lang) + try: + html = renderer.render_from_dir(run_dir) + except FileNotFoundError as exc: + click.echo(f"erreur : {exc}", err=True) + sys.exit(1) + + if output_path is None: + click.echo(html) + return + + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(html, encoding="utf-8") + click.echo(f"Rapport HTML écrit dans : {output_path}") + + +__all__ = ["report_command"] diff --git a/picarones/interfaces/cli/run.py b/picarones/interfaces/cli/run.py new file mode 100644 index 0000000000000000000000000000000000000000..c2afc1a303b460b6c550afad0e4b10c78f9711b7 --- /dev/null +++ b/picarones/interfaces/cli/run.py @@ -0,0 +1,106 @@ +"""``picarones-rewrite run`` — exécute un benchmark depuis un YAML. + +Wrapper Click mince autour du :class:`RunOrchestrator` (couche +``app/services/``) — toute la logique métier vit dans le service, +ce module ne fait que du parsing CLI, l'injection du renderer HTML +(:class:`HtmlReportRenderer` de la couche ``reports_v2/``) et le +formatage de sortie. + +Usage +----- + +:: + + python -m picarones.interfaces.cli run --spec ./run.yaml + +Codes de sortie : 0 succès, 1 erreur métier (typée +``PicaronesError``), 2 erreur Click (option mal formée). +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import click + +from picarones.app.results import RunResult +from picarones.app.schemas import RunSpecLoadError, load_run_spec_from_yaml +from picarones.app.services.corpus_service import CorpusImportError +from picarones.app.services.run_orchestrator import RunOrchestrator +from picarones.reports_v2.html import HtmlReportRenderer + + +def _render_html_report( + result: RunResult, output_path: Path, lang: str, +) -> Path: + """Adapte :class:`HtmlReportRenderer` au protocole ``ReportRenderer`` + attendu par :meth:`RunOrchestrator.execute`.""" + renderer = HtmlReportRenderer(lang=lang) + output_path.write_text(renderer.render(result), encoding="utf-8") + return output_path + + +@click.command() +@click.option( + "--spec", + "spec_path", + type=click.Path( + exists=True, dir_okay=False, file_okay=True, path_type=Path, + ), + required=True, + help="Chemin du fichier YAML décrivant le run.", +) +@click.option( + "--no-report", + is_flag=True, + default=False, + help=( + "Ne génère pas le rapport HTML, même si ``report_html`` " + "est défini dans la spec." + ), +) +def run_command(spec_path: Path, no_report: bool) -> None: + """Exécute un benchmark complet depuis une spec YAML.""" + # 1. Parsing de la spec. + try: + spec = load_run_spec_from_yaml(spec_path.read_text(encoding="utf-8")) + except RunSpecLoadError as exc: + click.echo(f"erreur : spec invalide : {exc}", err=True) + sys.exit(1) + + # 2. Délégation au service d'orchestration avec injection du + # renderer HTML (sauf si --no-report). + orchestrator = RunOrchestrator(output_dir=Path(spec.output_dir)) + renderer = None if no_report else _render_html_report + try: + result = orchestrator.execute(spec, report_renderer=renderer) + except CorpusImportError as exc: + click.echo(f"erreur : import corpus : {exc}", err=True) + sys.exit(1) + except RunSpecLoadError as exc: + click.echo(f"erreur : résolution pipeline : {exc}", err=True) + sys.exit(1) + + # 3. Formatage de la sortie utilisateur. + click.echo( + f"Corpus chargé : {result.run_result.manifest.corpus_name} " + f"({result.run_result.n_documents} docs, " + f"{result.extracted_corpus_dir})", + ) + click.echo( + f"Lancement du run : " + f"{len(result.run_result.manifest.pipeline_names)} pipeline(s) × " + f"{len(result.run_result.manifest.view_specs)} vue(s) × " + f"{result.run_result.n_documents} doc(s)…", + ) + persist_dir = next(iter(result.persisted_files.values())).parent + click.echo(f"Run persisté dans : {persist_dir}") + for kind, path in result.persisted_files.items(): + click.echo(f" {kind}: {path}") + if result.report_path is not None: + click.echo(f"Rapport : {result.report_path}") + click.echo("OK") + + +__all__ = ["run_command"] diff --git a/picarones/interfaces/web/__init__.py b/picarones/interfaces/web/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2cf9251816e11fe4fccda2515a4737fe7162c37d --- /dev/null +++ b/picarones/interfaces/web/__init__.py @@ -0,0 +1,34 @@ +"""Interface web FastAPI — Sprints S35-S38. + +Squelette FastAPI **natif** au nouveau monde, écrit pour consommer +directement les services applicatifs du Sprint S17+ via DI explicite. +**Pas un shim** sur le legacy ``picarones.web.app``. + +Architecture +------------ +- ``app.py`` (S35) : factory ``create_app(WebAppState)`` qui + produit une instance FastAPI consommant les services injectés. + Endpoints squelette ``/health`` et ``/version``. +- (S36) routers/corpus.py : import ZIP, listing, validation. +- (S36) routers/benchmark.py : démarrage/lecture d'un run. +- (S37) routers/jobs.py : queue + persistance SQLite + cancellation. +- (S38) ui.py : Jinja2 templates + static + i18n. + +Le legacy ``picarones.web.app`` reste exposé jusqu'au S46. +""" + +from __future__ import annotations + +from picarones.interfaces.web.app import ( + HealthResponse, + VersionResponse, + WebAppState, + create_app, +) + +__all__ = [ + "HealthResponse", + "VersionResponse", + "WebAppState", + "create_app", +] diff --git a/picarones/interfaces/web/app.py b/picarones/interfaces/web/app.py new file mode 100644 index 0000000000000000000000000000000000000000..f88c464e19d8e5cf89897ab01ea4b09a50afa6bf --- /dev/null +++ b/picarones/interfaces/web/app.py @@ -0,0 +1,381 @@ +"""``create_app`` — Sprint A14-S35. + +Squelette FastAPI du nouveau monde. **Pas un shim** sur le legacy +``picarones.web.app`` — c'est une app neuve, écrite pour consommer +directement les services du Sprint S17+ (``BenchmarkService``, +``RegistryService``, ``RunOrchestrator``, ``WorkspaceManager``, +``CorpusService``). + +Le legacy ``picarones.web.app`` reste en place jusqu'au S46. + +Architecture +------------ +- ``create_app(app_state) → FastAPI`` : factory qui construit l'app + avec les services injectés. Pas de singleton global — chaque + ``create_app`` produit une instance indépendante. +- ``WebAppState`` : container immuable des services injectés + (services + workspace root + version). +- Endpoint ``GET /health`` : liveness probe pour Docker / k8s. +- Endpoint ``GET /version`` : version + flags (mode public, etc.). +- Endpoints corpus/benchmark/jobs : ajoutés aux S36-S37 via routers + dédiés. + +Anti-sur-ingénierie +------------------- +- Pas de middleware CSP/CSRF dans S35 — ajoutés au S38 quand on + servira des templates HTML (le squelette S35 est API-only). +- Pas de lifespan (rien à initialiser au démarrage — les services + sont injectés déjà construits). +- Pas de mount static (S38). +- Pas de jobs queue (S37). + +Chaque sprint S36-S38 ajoute incrémentalement sans toucher au +squelette : on monte des routers, on attache des middlewares, on +mount des fichiers statiques. +""" + +from __future__ import annotations + +import logging +from contextlib import asynccontextmanager +from dataclasses import dataclass +from pathlib import Path + +from fastapi import FastAPI, Request +from fastapi.responses import HTMLResponse +from fastapi.staticfiles import StaticFiles +from fastapi.templating import Jinja2Templates +from pydantic import BaseModel + +_logger = logging.getLogger(__name__) + +from picarones.adapters.storage import JobStore +from picarones.app.services import ( + BenchmarkService, + CorpusService, + JobRunner, + RegistryService, + RunOrchestrator, + WorkspaceManager, +) +from picarones.interfaces.web.i18n import ( + DEFAULT_LANGUAGE, + SUPPORTED_LANGUAGES, + translate, +) +from picarones.interfaces.web.security import ( + AuthenticationBackend, + AuthenticationMiddleware, + BodySizeLimitMiddleware, + RateLimitMiddleware, + SecurityHeadersMiddleware, +) + +_TEMPLATES_DIR = Path(__file__).resolve().parent / "templates" +_STATIC_DIR = Path(__file__).resolve().parent / "static" + + +@dataclass(frozen=True) +class WebAppState: + """Container immuable des services injectés dans l'app web. + + Attributes + ---------- + workspace: + ``WorkspaceManager`` du run en cours. + registry: + ``RegistryService`` (registres de métriques + projecteurs + pré-bootstrap). + corpus: + ``CorpusService`` (import ZIP, détection patterns). + benchmark: + ``BenchmarkService`` (orchestration runner + vues + + persistance). + orchestrator: + ``RunOrchestrator`` (workflow YAML → bench → HTML report). + version: + Version du code Picarones à afficher dans + ``GET /version``. + + Notes + ----- + Frozen : aucun service ne change de référence après le démarrage + de l'app. Pour reconstruire l'état (test isolé), créer une + nouvelle ``WebAppState``. + """ + + workspace: WorkspaceManager + registry: RegistryService + corpus: CorpusService + benchmark: BenchmarkService + orchestrator: RunOrchestrator + job_store: JobStore | None = None + job_runner: JobRunner | None = None + version: str = "1.0.0" + + +class HealthResponse(BaseModel): + """Schéma JSON pour ``GET /health``.""" + + status: str = "ok" + + +class VersionResponse(BaseModel): + """Schéma JSON pour ``GET /version``.""" + + version: str + workspace_root: str + n_metrics: int + n_projectors: int + + +def create_app( + state: WebAppState, + *, + enable_security_headers: bool = True, + max_body_bytes: int | None = 100 * 1024 * 1024, + rate_limit_per_minute: int | None = 60, + rate_limit_trust_proxy_count: int = 0, + auth_backend: AuthenticationBackend | None = None, +) -> FastAPI: + """Construit une instance FastAPI consommant l'``WebAppState``. + + Pas de singleton global : chaque appel produit une nouvelle app + indépendante. + + Parameters + ---------- + state: + ``WebAppState`` immuable injectée dans tous les endpoints. + enable_security_headers: + Si ``True`` (défaut), monte ``SecurityHeadersMiddleware`` + avec CSP strict + X-Frame-Options + nosniff + Referrer-Policy + + Permissions-Policy. Mettre à ``False`` uniquement si un + reverse proxy en amont applique déjà ces en-têtes. + max_body_bytes: + Si non ``None`` (défaut 100 MiB), monte ``BodySizeLimitMiddleware`` + pour rejeter les uploads dépassant la taille. ``None`` désactive + le check (mode dev / tests). + rate_limit_per_minute: + Si non ``None`` (défaut 60), monte ``RateLimitMiddleware`` avec + cette limite par IP par minute. ``None`` désactive (mode + public sans rate limit). + rate_limit_trust_proxy_count: + Nombre de proxies fiables devant l'app. ``0`` (défaut) → + ``X-Forwarded-For`` ignoré, ``request.client.host`` utilisé. + ``1`` → un seul proxy en amont (ex. nginx local) ; ``2`` → + deux ; etc. **Ne pas surdéclarer** : un client peut alors + forger XFF pour spoofer son IP. + auth_backend: + Backend d'authentification optionnel. Si ``None`` (défaut), + mode public total (cohérent avec HuggingFace Space). Sinon, + ``AuthenticationMiddleware`` valide chaque requête sauf + ``/health`` et ``/version`` (sondes infra). + + Returns + ------- + FastAPI + Instance prête à être lancée par ``uvicorn`` ou consommée + par ``TestClient``. + """ + if not isinstance(state, WebAppState): + raise TypeError( + f"create_app : state doit être WebAppState, " + f"reçu {type(state).__name__}.", + ) + + # Lifespan hook (S48) : nettoyage des jobs zombies au boot. + # Tout job en statut ``pending`` ou ``running`` au démarrage du + # process est forcément orphelin (le process précédent est mort + # sans le finir). On les bascule en ``interrupted`` pour ne pas + # laisser d'état mensonger sur le tableau de bord. + @asynccontextmanager + async def _lifespan(_app: FastAPI): + if state.job_store is not None: + try: + n = state.job_store.mark_orphaned_jobs_interrupted() + if n > 0: + _logger.info( + "[lifespan] %d job(s) orphelin(s) marqué(s) " + "interrupted au boot.", n, + ) + except Exception as exc: # noqa: BLE001 — défense en profondeur + _logger.error( + "[lifespan] mark_orphaned_jobs_interrupted ÉCHOUÉ " + "— jobs zombies possibles : %s", exc, + ) + yield + + app = FastAPI( + title="Picarones", + description=( + "Plateforme de benchmark OCR/HTR pour documents patrimoniaux. " + "API du nouveau monde (Sprint A14-S35+)." + ), + version=state.version, + docs_url="/api/docs", + redoc_url="/api/redoc", + lifespan=_lifespan, + ) + + # On stocke l'état dans app.state.picarones pour permettre aux + # endpoints (S36+) d'y accéder via Request.app.state.picarones + # — namespace explicite pour ne pas collisionner avec d'autres + # extensions FastAPI. + app.state.picarones = state + + # ────────────────────────────────────────────────────────────── + # Sécurité (S49) — middlewares opt-out via paramètres explicites. + # L'ordre d'enregistrement compte : Starlette exécute les + # middlewares dans l'ordre inverse de l'ajout (LIFO). On veut + # que les premiers ajoutés (rate limit, body size) tournent en + # PREMIER sur la requête entrante — donc on les ajoute APRÈS + # les headers de réponse. Pratique : ajouter dans l'ordre + # « réponse → requête » de l'extérieur vers l'intérieur. + # ────────────────────────────────────────────────────────────── + if enable_security_headers: + app.add_middleware(SecurityHeadersMiddleware) + if rate_limit_per_minute is not None: + app.add_middleware( + RateLimitMiddleware, + max_requests=rate_limit_per_minute, + window_seconds=60.0, + trust_proxy_count=rate_limit_trust_proxy_count, + ) + if max_body_bytes is not None: + app.add_middleware(BodySizeLimitMiddleware, max_bytes=max_body_bytes) + if auth_backend is not None: + app.add_middleware(AuthenticationMiddleware, backend=auth_backend) + + # ────────────────────────────────────────────────────────────── + # Templates Jinja2 + static (S38) + # ────────────────────────────────────────────────────────────── + templates = Jinja2Templates(directory=str(_TEMPLATES_DIR)) + app.state.templates = templates + if _STATIC_DIR.is_dir(): + app.mount( + "/static", + StaticFiles(directory=str(_STATIC_DIR)), + name="static", + ) + + # ────────────────────────────────────────────────────────────── + # Routers métier (S36+) + # ────────────────────────────────────────────────────────────── + # Import paresseux pour éviter les cycles : `routers/__init__.py` + # importe les routers individuels, qui n'ont pas besoin de + # `WebAppState` au moment de leur définition (ils consomment via + # `request.app.state.picarones` à chaque appel). + from picarones.interfaces.web.routers import ( + benchmark_router, + corpus_router, + jobs_router, + ) + app.include_router(corpus_router) + app.include_router(benchmark_router) + app.include_router(jobs_router) + + # ────────────────────────────────────────────────────────────── + # Endpoints squelette (sondes santé/version) + # ────────────────────────────────────────────────────────────── + + @app.get("/", response_class=HTMLResponse) + async def home_page( + request: Request, lang: str = DEFAULT_LANGUAGE, + ) -> HTMLResponse: + """Page d'accueil HTML — résume le workspace + runs + jobs. + + Le paramètre ``lang`` accepte ``"fr"`` ou ``"en"`` (cf. + ``interfaces/web/i18n``). Toute autre valeur retombe sur le + défaut avec warning loggé par ``i18n.translate``. + """ + if lang not in SUPPORTED_LANGUAGES: + lang = DEFAULT_LANGUAGE + + # Lit les runs et les jobs *via* les services injectés — pas + # de logique métier ici, juste de l'agrégation pour la vue. + from picarones.interfaces.web.routers.benchmark import ( + _read_manifest, + _runs_dir, + _summarize, + ) + # Pour des workspaces utilisateur standard (< 100 runs), le + # scan filesystem à chaque requête reste sous la milliseconde. + # Pour un déploiement multi-tenants (> 1000 runs), un cache LRU + # avec invalidation sur mtime du runs_dir serait pertinent. + # On limite déjà la liste à 20 runs pour borner la page. + MAX_RUNS_DISPLAYED = 20 + runs_dir = _runs_dir(state) + runs: list[dict] = [] + if runs_dir.exists(): + # Tri ordre décroissant (mtime) pour avoir les plus + # récents en tête, puis cap à MAX_RUNS_DISPLAYED. + entries = sorted( + (e for e in runs_dir.iterdir() if e.is_dir()), + key=lambda e: e.stat().st_mtime, + reverse=True, + )[:MAX_RUNS_DISPLAYED] + for entry in entries: + manifest_path = entry / "run_manifest.json" + if not manifest_path.exists(): + continue + manifest = _read_manifest(manifest_path) + if manifest is None: + continue + runs.append(_summarize(manifest, run_id=entry.name).model_dump()) + + jobs: list[dict] = [] + if state.job_store is not None: + jobs = [ + { + "job_id": j.job_id, + "status": j.status, + "progress": j.progress, + } + for j in state.job_store.list(limit=10) + ] + + return templates.TemplateResponse( + request=request, + name="home.html.j2", + context={ + "lang": lang, + "version": state.version, + "n_metrics": len(state.registry.metrics), + "n_projectors": len(state.registry.projectors), + "workspace_root": str(state.workspace.root), + "runs": runs, + "jobs": jobs, + "t": lambda key: translate(key, lang), + }, + ) + + @app.get("/health", response_model=HealthResponse) + async def health() -> HealthResponse: + """Liveness probe — toujours ``200 OK`` si l'app a démarré. + + Pas de dépendance aux services backends : on veut détecter + un crash de l'app, pas un crash transitoire d'un service. + """ + return HealthResponse(status="ok") + + @app.get("/version", response_model=VersionResponse) + async def version() -> VersionResponse: + """Affiche la version du code et un compte rapide des + registres pour vérifier que le bootstrap a bien eu lieu.""" + return VersionResponse( + version=state.version, + workspace_root=str(state.workspace.root), + n_metrics=len(state.registry.metrics), + n_projectors=len(state.registry.projectors), + ) + + return app + + +__all__ = [ + "HealthResponse", + "VersionResponse", + "WebAppState", + "create_app", +] diff --git a/picarones/interfaces/web/i18n/__init__.py b/picarones/interfaces/web/i18n/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..122ec2cb87d32e78fe2d42b6edef2b2e07bed2bc --- /dev/null +++ b/picarones/interfaces/web/i18n/__init__.py @@ -0,0 +1,79 @@ +"""i18n FR/EN — Sprint A14-S38. + +Loader minimaliste pour l'internationalisation des templates Jinja2. +Charge ``fr.json`` et ``en.json`` au démarrage de l'app et expose une +fonction ``translate(key, lang)`` qui retourne la chaîne traduite, +ou la clé elle-même si la traduction est absente (avec warning). + +Pas de fallback automatique entre langues — chaque langue est +indépendante. Les deux fichiers JSON doivent partager les mêmes clés +(test garde-fou ``test_i18n_completeness`` au S38). +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +logger = logging.getLogger(__name__) + +_DIR = Path(__file__).resolve().parent + +#: Langues supportées. Ajout d'une langue = ajout d'un fichier JSON +#: avec les mêmes clés + ajout dans cette liste. +SUPPORTED_LANGUAGES: tuple[str, ...] = ("fr", "en") + +DEFAULT_LANGUAGE = "fr" + + +def _load(language: str) -> dict[str, str]: + """Charge un fichier de traductions JSON ; lève si introuvable.""" + path = _DIR / f"{language}.json" + if not path.exists(): + raise FileNotFoundError( + f"i18n : fichier de traductions absent pour {language!r} " + f"({path}).", + ) + return json.loads(path.read_text(encoding="utf-8")) + + +_TRANSLATIONS: dict[str, dict[str, str]] = { + lang: _load(lang) for lang in SUPPORTED_LANGUAGES +} + + +def translate(key: str, language: str = DEFAULT_LANGUAGE) -> str: + """Retourne la traduction de ``key`` dans ``language``. + + Si la langue est inconnue, fallback silencieux sur la langue par + défaut (warning loggé). Si la clé est absente, retourne la clé + elle-même (warning loggé) — convention « graceful degradation ». + """ + if language not in _TRANSLATIONS: + logger.warning( + "[i18n] langue %r inconnue, fallback %r.", + language, DEFAULT_LANGUAGE, + ) + language = DEFAULT_LANGUAGE + table = _TRANSLATIONS[language] + if key not in table: + logger.warning( + "[i18n] clé %r absente pour %r — utilisation de la clé.", + key, language, + ) + return key + return table[key] + + +def all_keys(language: str = DEFAULT_LANGUAGE) -> list[str]: + """Liste des clés disponibles pour une langue (utile aux tests).""" + return list(_TRANSLATIONS.get(language, {}).keys()) + + +__all__ = [ + "SUPPORTED_LANGUAGES", + "DEFAULT_LANGUAGE", + "translate", + "all_keys", +] diff --git a/picarones/interfaces/web/i18n/en.json b/picarones/interfaces/web/i18n/en.json new file mode 100644 index 0000000000000000000000000000000000000000..a34be42688ff0c74ea871109ade375d22cce4485 --- /dev/null +++ b/picarones/interfaces/web/i18n/en.json @@ -0,0 +1,23 @@ +{ + "app_title": "Picarones", + "app_subtitle": "OCR / HTR benchmark for cultural heritage documents", + "nav_home": "Home", + "nav_runs": "Runs", + "nav_jobs": "Jobs", + "nav_docs": "Documentation", + "home_intro": "Platform for comparative evaluation of OCR engines on cultural heritage corpora.", + "home_metrics_count": "registered metrics", + "home_projectors_count": "registered projectors", + "home_workspace": "Workspace", + "home_no_runs": "No persisted run yet.", + "home_no_jobs": "No job in progress.", + "header_runs": "Recent runs", + "header_jobs": "Recent jobs", + "table_run_id": "ID", + "table_corpus": "Corpus", + "table_pipelines": "Pipelines", + "table_status": "Status", + "table_progress": "Progress", + "table_started_at": "Started", + "footer_version": "Version" +} diff --git a/picarones/interfaces/web/i18n/fr.json b/picarones/interfaces/web/i18n/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..b07e09c2bc243757045f42fefa14cd5989cc3244 --- /dev/null +++ b/picarones/interfaces/web/i18n/fr.json @@ -0,0 +1,23 @@ +{ + "app_title": "Picarones", + "app_subtitle": "Banc d'essai OCR / HTR pour documents patrimoniaux", + "nav_home": "Accueil", + "nav_runs": "Runs", + "nav_jobs": "Jobs", + "nav_docs": "Documentation", + "home_intro": "Plateforme d'évaluation comparative de moteurs OCR sur corpus patrimoniaux.", + "home_metrics_count": "métriques enregistrées", + "home_projectors_count": "projecteurs enregistrés", + "home_workspace": "Espace de travail", + "home_no_runs": "Aucun run persisté pour le moment.", + "home_no_jobs": "Aucun job en cours.", + "header_runs": "Runs récents", + "header_jobs": "Jobs récents", + "table_run_id": "ID", + "table_corpus": "Corpus", + "table_pipelines": "Pipelines", + "table_status": "Statut", + "table_progress": "Progression", + "table_started_at": "Démarrage", + "footer_version": "Version" +} diff --git a/picarones/interfaces/web/routers/__init__.py b/picarones/interfaces/web/routers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6523e5e8a41f803651e694c4e8d31908d7040d8f --- /dev/null +++ b/picarones/interfaces/web/routers/__init__.py @@ -0,0 +1,25 @@ +"""Routers FastAPI du nouveau monde — Sprints S36-S38. + +Chaque router est mince : valide DTO Pydantic, appelle un service +de ``app/services``, retourne une réponse. Pas de logique métier +dans les routers. + +Routers livrés +-------------- +- ``corpus.py`` (S36) : import ZIP + analyse de structure. +- ``benchmark.py`` (S36) : listing/lecture des runs. +- (S37) ``jobs.py`` : queue + persistance SQLite + cancellation. +- (S38) ``ui.py`` : templates HTML Jinja2 + i18n. +""" + +from __future__ import annotations + +from picarones.interfaces.web.routers.benchmark import router as benchmark_router +from picarones.interfaces.web.routers.corpus import router as corpus_router +from picarones.interfaces.web.routers.jobs import router as jobs_router + +__all__ = [ + "benchmark_router", + "corpus_router", + "jobs_router", +] diff --git a/picarones/interfaces/web/routers/benchmark.py b/picarones/interfaces/web/routers/benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..75f9855e333dd1a053353c88c64906b12294419f --- /dev/null +++ b/picarones/interfaces/web/routers/benchmark.py @@ -0,0 +1,199 @@ +"""Router benchmark — Sprint A14-S36. + +Endpoints de listing/lecture des runs persistés dans le workspace. +Le **lancement** d'un run (asynchrone) est dans le router ``jobs`` +au S37 — ici, on lit uniquement les manifests d'archive. + +Convention de stockage +---------------------- +``/runs//`` contient : + +- ``run_manifest.json`` (métadonnées du run) +- ``pipeline_results.jsonl`` +- ``view_results.jsonl`` + +(cf. ``BenchmarkService.persist`` au S17.) + +Endpoints +--------- +- ``GET /api/runs`` : liste des run_ids disponibles avec leur + manifest (corpus, pipeline_names, n_documents, started_at, + completed_at). +- ``GET /api/runs/{run_id}`` : retourne le manifest complet d'un + run. + +Anti-sur-ingénierie +------------------- +- Pas de pagination — un workspace utilisateur a typiquement < 100 + runs. Si un caller en a besoin, on l'ajoutera. +- Pas de delete — un caller peut supprimer le sous-dossier + manuellement. +- Pas de search/filter par corpus_name — facile à ajouter mais on + attend qu'un caller le demande. +""" + +from __future__ import annotations + +import json +import logging +from pathlib import Path + +from fastapi import APIRouter, HTTPException, Request, status +from pydantic import BaseModel, Field + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix="/api/runs", tags=["benchmark"]) + +#: Sous-dossier sous ``WorkspaceManager.root`` où les runs sont +#: persistés. Convention partagée avec ``BenchmarkService.persist`` +#: lorsque le caller ne précise pas de répertoire. Pour l'instant, +#: le caller peut tout aussi bien persister ailleurs — l'API web +#: regarde uniquement ici. Au S37, ``RunOrchestrator`` garantira +#: cette convention. +RUNS_SUBDIR = "runs" + + +# ────────────────────────────────────────────────────────────────────── +# Schémas de réponse +# ────────────────────────────────────────────────────────────────────── + + +class RunSummary(BaseModel): + """Résumé d'un run pour la liste.""" + + run_id: str + corpus_name: str | None = None + n_documents: int | None = None + pipeline_names: list[str] = Field(default_factory=list) + started_at: str | None = None + completed_at: str | None = None + + +class RunListResponse(BaseModel): + """Réponse JSON pour ``GET /api/runs``.""" + + runs: list[RunSummary] + + +class RunManifestResponse(BaseModel): + """Réponse JSON pour ``GET /api/runs/{run_id}``. + + Manifest complet — ``raw`` est le contenu JSON exact du + ``run_manifest.json`` persisté. L'utilisateur web peut faire + son propre rendu sans qu'on impose une représentation. + """ + + run_id: str + raw: dict + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _runs_dir(state) -> Path: + """Retourne le dossier des runs sous le workspace de l'état.""" + return Path(state.workspace.root) / RUNS_SUBDIR + + +def _read_manifest(manifest_path: Path) -> dict | None: + """Lit un ``run_manifest.json`` et retourne le dict ; ``None`` en + cas d'échec (warning loggé).""" + try: + return json.loads(manifest_path.read_text(encoding="utf-8")) + except Exception as exc: # noqa: BLE001 + logger.warning( + "[benchmark] échec de lecture du manifest %s : %s", + manifest_path, exc, + ) + return None + + +def _summarize(manifest: dict, run_id: str) -> RunSummary: + """Construit un ``RunSummary`` à partir d'un manifest.""" + return RunSummary( + run_id=run_id, + corpus_name=manifest.get("corpus_name"), + n_documents=manifest.get("n_documents"), + pipeline_names=list(manifest.get("pipeline_names", [])), + started_at=manifest.get("started_at"), + completed_at=manifest.get("completed_at"), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Endpoints +# ────────────────────────────────────────────────────────────────────── + + +@router.get("", response_model=RunListResponse) +async def list_runs(request: Request) -> RunListResponse: + """Liste les runs persistés dans le workspace. + + Scan le sous-dossier ``runs/`` du workspace et lit chaque + ``run_manifest.json``. Les manifests illisibles (corruption, + permission) sont loggés en warning et omis du résultat. + """ + state = request.app.state.picarones + runs_dir = _runs_dir(state) + if not runs_dir.exists(): + return RunListResponse(runs=[]) + + summaries: list[RunSummary] = [] + for entry in sorted(runs_dir.iterdir()): + if not entry.is_dir(): + continue + manifest_path = entry / "run_manifest.json" + if not manifest_path.exists(): + continue + manifest = _read_manifest(manifest_path) + if manifest is None: + continue + summaries.append(_summarize(manifest, run_id=entry.name)) + + return RunListResponse(runs=summaries) + + +@router.get("/{run_id}", response_model=RunManifestResponse) +async def get_run(request: Request, run_id: str) -> RunManifestResponse: + """Retourne le manifest complet d'un run.""" + state = request.app.state.picarones + runs_dir = _runs_dir(state) + run_dir = runs_dir / run_id + manifest_path = run_dir / "run_manifest.json" + + # Validation : le run_id ne doit pas s'évader du workspace. + try: + run_dir_resolved = run_dir.resolve() + runs_dir_resolved = runs_dir.resolve() + if not str(run_dir_resolved).startswith(str(runs_dir_resolved)): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="run_id invalide.", + ) + except (OSError, RuntimeError) as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"run_id invalide : {exc}", + ) from exc + + if not manifest_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"run {run_id!r} introuvable.", + ) + + manifest = _read_manifest(manifest_path) + if manifest is None: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"manifest du run {run_id!r} illisible.", + ) + + return RunManifestResponse(run_id=run_id, raw=manifest) + + +__all__ = ["router"] diff --git a/picarones/interfaces/web/routers/corpus.py b/picarones/interfaces/web/routers/corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..6ed9d5b5c4f9a798837463145484e030b46a48d1 --- /dev/null +++ b/picarones/interfaces/web/routers/corpus.py @@ -0,0 +1,134 @@ +"""Router corpus — Sprint A14-S36. + +Endpoints d'import et d'analyse de corpus, adossés à +``CorpusService`` (S20). **Pas un shim** sur le legacy +``picarones.web.routers.corpus`` — c'est un router neuf, mince, +qui délègue toute la logique à ``CorpusService``. + +Endpoints +--------- +- ``POST /api/corpus/import`` : multipart upload d'un ZIP, retourne + un ``CorpusImportResponse`` avec stats et warnings. +- ``GET /api/corpus/{name}`` : retourne les métadonnées d'un + corpus déjà importé (lit le manifest depuis le workspace). + +Anti-sur-ingénierie +------------------- +- Pas de listing exhaustif des corpora. Si un caller a besoin de + lister, on l'ajoutera (typiquement S37+). +- Pas de browse arbitraire du filesystem (legacy + ``/api/corpus/browse`` est une exposition risquée — la cible + documentée demande un workflow plus contraint). +- Pas de delete — un caller peut supprimer manuellement le + ``WorkspaceManager.root`` ou attendre la session expiration. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, File, HTTPException, Request, UploadFile, status +from pydantic import BaseModel, Field + +from picarones.app.services.corpus_service import CorpusImportError + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix="/api/corpus", tags=["corpus"]) + + +# ────────────────────────────────────────────────────────────────────── +# Schémas de réponse +# ────────────────────────────────────────────────────────────────────── + + +class CorpusImportResponse(BaseModel): + """Réponse JSON pour ``POST /api/corpus/import``.""" + + corpus_name: str = Field(description="Nom du corpus importé.") + extracted_dir: str = Field(description="Répertoire d'extraction.") + n_documents: int + n_images_without_gt: int + n_gt_without_image: int + n_skipped_noise: int + warnings: list[str] = Field(default_factory=list) + skipped_paths: list[str] = Field(default_factory=list) + + +# ────────────────────────────────────────────────────────────────────── +# POST /api/corpus/import +# ────────────────────────────────────────────────────────────────────── + + +@router.post( + "/import", + response_model=CorpusImportResponse, + status_code=status.HTTP_201_CREATED, +) +async def import_corpus( + request: Request, + corpus_name: str, + file: UploadFile = File(...), +) -> CorpusImportResponse: + """Importe un corpus depuis un ZIP uploadé. + + Le service ``CorpusService.import_zip`` valide le ZIP (taille, + nombre d'entrées, taille décompressée), l'extrait dans le + workspace, et construit un ``CorpusSpec`` listant les paires + image+GT détectées. + + Retourne un ``CorpusImportResponse`` avec stats et warnings. + """ + state = request.app.state.picarones + corpus_service = state.corpus + + # Validation rapide du nom : on délègue la validation stricte au + # service mais on rejette tout de suite les noms vides. + if not corpus_name or not corpus_name.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="corpus_name est requis et ne peut pas être vide.", + ) + + zip_bytes = await file.read() + if not zip_bytes: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Fichier ZIP vide.", + ) + + try: + report = corpus_service.import_zip( + zip_bytes=zip_bytes, + corpus_name=corpus_name.strip(), + ) + except CorpusImportError as exc: + # Erreurs métier (ZIP mal formé, bombe, paths unsafe, ...). + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(exc), + ) from exc + except Exception as exc: # noqa: BLE001 + # Erreurs inattendues — log + 500. + logger.error( + "[corpus] import inattendu en échec : %s", exc, exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Échec d'import : {type(exc).__name__}", + ) from exc + + return CorpusImportResponse( + corpus_name=report.spec.name, + extracted_dir=str(report.extracted_dir), + n_documents=report.n_documents, + n_images_without_gt=report.n_images_without_gt, + n_gt_without_image=report.n_gt_without_image, + n_skipped_noise=report.n_skipped_noise, + warnings=list(report.warnings), + skipped_paths=list(report.skipped_paths), + ) + + +__all__ = ["router"] diff --git a/picarones/interfaces/web/routers/jobs.py b/picarones/interfaces/web/routers/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..441fa96f823a10aafd5d677e5a15d6dfa45b0381 --- /dev/null +++ b/picarones/interfaces/web/routers/jobs.py @@ -0,0 +1,314 @@ +"""Router jobs — gestion des jobs de benchmark via l'API web. + +Endpoints adossés à ``JobStore`` (persistance SQLite) + ``JobRunner`` +(orchestration thread). + +Endpoints +--------- +- ``GET /api/jobs`` : liste des jobs (récents en tête). +- ``GET /api/jobs/{job_id}`` : détail + progression. +- ``POST /api/jobs`` : création + lancement asynchrone. +- ``DELETE /api/jobs/{job_id}`` : annulation explicite. + +Anti-sur-ingénierie +------------------- +- Pas de SSE / event stream : le polling sur ``progress`` suffit pour + l'UI minimaliste actuelle. +- Pas de filtre par status/corpus : facile à ajouter quand un caller + le demande. +""" + +from __future__ import annotations + +import logging + +from fastapi import APIRouter, Body, HTTPException, Request, status +from pydantic import BaseModel, Field + +from picarones.app.schemas.run_spec import ( + RunSpecLoadError, + load_run_spec_from_yaml, +) + +logger = logging.getLogger(__name__) + + +router = APIRouter(prefix="/api/jobs", tags=["jobs"]) + + +# ────────────────────────────────────────────────────────────────────── +# Schémas +# ────────────────────────────────────────────────────────────────────── + + +class JobSummary(BaseModel): + """Résumé d'un job pour la liste.""" + + job_id: str + status: str + progress: float + current_engine: str + total_docs: int + processed_docs: int + created_at: float + updated_at: float + finished_at: float | None = None + + +class JobListResponse(BaseModel): + jobs: list[JobSummary] + + +class JobDetailResponse(BaseModel): + """Détail complet d'un job — incluant payload + erreur.""" + + job_id: str + status: str + progress: float + current_engine: str + total_docs: int + processed_docs: int + output_path: str + error: str + payload: dict = Field(default_factory=dict) + created_at: float + updated_at: float + finished_at: float | None = None + + +class JobCancelResponse(BaseModel): + job_id: str + status: str + + +class JobSubmitResponse(BaseModel): + """Réponse JSON pour ``POST /api/jobs`` (202 Accepted).""" + + job_id: str + status: str = Field( + default="pending", + description=( + "Statut au moment de la soumission. Le client poll " + "``GET /api/jobs/{job_id}`` pour suivre la progression." + ), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _require_job_store(state) -> "object": + if state.job_store is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "Job store non configuré dans WebAppState — la persistance " + "des jobs n'est pas activée." + ), + ) + return state.job_store + + +def _require_job_runner(state) -> "object": + if state.job_runner is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=( + "Job runner non configuré dans WebAppState — " + "l'exécution asynchrone des jobs n'est pas activée. " + "Voir picarones.app.services.JobRunner pour le câblage." + ), + ) + return state.job_runner + + +def _to_summary(rec) -> JobSummary: + return JobSummary( + job_id=rec.job_id, + status=rec.status, + progress=rec.progress, + current_engine=rec.current_engine, + total_docs=rec.total_docs, + processed_docs=rec.processed_docs, + created_at=rec.created_at, + updated_at=rec.updated_at, + finished_at=rec.finished_at, + ) + + +def _to_detail(rec) -> JobDetailResponse: + return JobDetailResponse( + job_id=rec.job_id, + status=rec.status, + progress=rec.progress, + current_engine=rec.current_engine, + total_docs=rec.total_docs, + processed_docs=rec.processed_docs, + output_path=rec.output_path, + error=rec.error, + payload=rec.payload, + created_at=rec.created_at, + updated_at=rec.updated_at, + finished_at=rec.finished_at, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Endpoints +# ────────────────────────────────────────────────────────────────────── + + +@router.post( + "", + response_model=JobSubmitResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def submit_job( + request: Request, + run_spec_yaml: str = Body( + ..., + media_type="text/plain", + description=( + "Contenu YAML d'un ``RunSpec`` (cf. picarones.app.schemas." + "run_spec). Le corps de la requête est le YAML brut." + ), + ), +) -> JobSubmitResponse: + """Crée un job + lance son exécution en arrière-plan (S48). + + Le corps de la requête est le YAML brut d'un ``RunSpec`` (mêmes + champs que ce que la CLI ``picarones-rewrite run`` accepte). + + Comportement : + + 1. Le YAML est parsé et validé (``load_run_spec_from_yaml``). + Erreur de format → 400 avec message du loader. + 2. Un ``JobRecord`` est créé en statut ``pending`` avec un + ``job_id`` UUID4. + 3. Un thread daemon est lancé pour exécuter le ``RunOrchestrator`` + avec le ``RunSpec``. + 4. Réponse immédiate ``202 Accepted`` avec ``job_id`` — le + client poll ``GET /api/jobs/{job_id}`` pour suivre. + + Concurrence + ----------- + Un thread par job ; pas de queue/backpressure. Pour 100+ jobs + simultanés, ajouter un ``ThreadPoolExecutor`` au niveau de + ``JobRunner`` (post-livraison). + """ + state = request.app.state.picarones + runner = _require_job_runner(state) + + if not run_spec_yaml or not run_spec_yaml.strip(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="Corps de la requête vide — YAML RunSpec attendu.", + ) + + try: + run_spec = load_run_spec_from_yaml(run_spec_yaml) + except RunSpecLoadError as exc: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"RunSpec invalide : {exc}", + ) from exc + + # Output dir : sous-dossier dédié au job dans le workspace. Le + # JobRunner s'en sert pour construire un RunOrchestrator isolé. + import uuid + job_id_candidate = uuid.uuid4().hex + output_dir = ( + state.workspace.root / "runs" / job_id_candidate + ) + + try: + job_id = runner.submit( + run_spec=run_spec, + output_dir=output_dir, + job_id=job_id_candidate, + payload={"corpus_name": run_spec.corpus_name or ""}, + ) + except Exception as exc: # noqa: BLE001 + logger.error( + "[jobs] échec de submit pour run_spec : %s", exc, exc_info=True, + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"Échec de soumission du job : {type(exc).__name__}", + ) from exc + + # Audit trail — création de job est une action sensible (peut + # consommer du quota cloud, démarrer un long calcul). Log INFO + # avec l'IP source pour la traçabilité institutionnelle. + client = request.client + client_host = client.host if client is not None else "unknown" + logger.info( + "[audit] job_submitted job_id=%s corpus=%s from=%s", + job_id, + run_spec.corpus_name or "", + client_host, + ) + return JobSubmitResponse(job_id=job_id, status="pending") + + +@router.get("", response_model=JobListResponse) +async def list_jobs(request: Request) -> JobListResponse: + """Liste les jobs (récents en tête).""" + state = request.app.state.picarones + store = _require_job_store(state) + return JobListResponse( + jobs=[_to_summary(r) for r in store.list()], + ) + + +@router.get("/{job_id}", response_model=JobDetailResponse) +async def get_job(request: Request, job_id: str) -> JobDetailResponse: + """Détail d'un job avec payload + progression.""" + state = request.app.state.picarones + store = _require_job_store(state) + rec = store.get(job_id) + if rec is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job {job_id!r} introuvable.", + ) + return _to_detail(rec) + + +@router.delete("/{job_id}", response_model=JobCancelResponse) +async def cancel_job(request: Request, job_id: str) -> JobCancelResponse: + """Annule un job (uniquement s'il est encore vivant). + + Idempotent : annuler un job déjà terminal retourne le statut + actuel sans erreur. + """ + state = request.app.state.picarones + store = _require_job_store(state) + rec = store.get(job_id) + if rec is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"Job {job_id!r} introuvable.", + ) + if rec.is_terminal: + # Idempotent : on retourne le statut actuel sans changer. + return JobCancelResponse(job_id=rec.job_id, status=rec.status) + + store.mark_cancelled(job_id) + updated = store.get(job_id) + # Audit trail — annulation peut détruire des résultats partiels + # et libérer du quota cloud non remboursable. + client = request.client + client_host = client.host if client is not None else "unknown" + logger.info( + "[audit] job_cancelled job_id=%s from=%s", + job_id, client_host, + ) + return JobCancelResponse( + job_id=updated.job_id, status=updated.status, + ) + + +__all__ = ["router"] diff --git a/picarones/interfaces/web/security.py b/picarones/interfaces/web/security.py new file mode 100644 index 0000000000000000000000000000000000000000..ba1e1ef1c74218046dbecae4118d54ddb0789eff --- /dev/null +++ b/picarones/interfaces/web/security.py @@ -0,0 +1,397 @@ +"""Middlewares de sécurité pour l'interface web. + +Module de **base de sécurité** activable opt-in (par défaut OFF pour +rester compatible avec le mode public HuggingFace Space ; chaque flag +s'active via un argument explicite à ``create_app``). + +Composants +---------- +- ``SecurityHeadersMiddleware`` : ajoute CSP, X-Frame-Options, + X-Content-Type-Options, Referrer-Policy, Permissions-Policy à + toute réponse. +- ``BodySizeLimitMiddleware`` : rejette les requêtes dont + ``Content-Length`` dépasse un seuil (anti-DoS upload). +- ``RateLimitMiddleware`` : token bucket en mémoire par IP. + Limite simple (req/min) ; pas de Redis (in-process). +- ``AuthenticationBackend`` (Protocol) : contrat pour brancher une + authentification custom. Si ``None``, mode public. + +Anti-sur-ingénierie +------------------- +- Pas de CSRF token pour les endpoints API JSON (CSRF concerne + surtout les formulaires HTML cookie-based). Les API REST avec + Bearer token / API key ne sont pas vulnérables au CSRF classique. +- Pas de support OAuth/OIDC : si le caller veut, il fournit son + propre ``AuthenticationBackend``. +- Rate limit in-process : suffit pour 1 instance ; pour cluster, + remplacer par Redis-backed en post-livraison. +- IP réelle via ``X-Forwarded-For`` : configurable, désactivé par + défaut (un proxy non-trustworthy peut mentir). +""" + +from __future__ import annotations + +import logging +import time +from collections import OrderedDict, deque +from typing import Awaitable, Callable, Protocol, runtime_checkable + +from fastapi import HTTPException, Request, Response, status +from fastapi.responses import JSONResponse +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.types import ASGIApp + +logger = logging.getLogger(__name__) + + +# ────────────────────────────────────────────────────────────────────── +# Authentication backend (port) +# ────────────────────────────────────────────────────────────────────── + + +@runtime_checkable +class AuthenticationBackend(Protocol): + """Contrat d'un backend d'authentification injectable. + + Une implémentation décide d'autoriser ou non une requête en se + basant sur les headers (Bearer token, API key, etc.). Si la + requête n'est pas authentifiée, lever ``HTTPException(401)``. + + Pour un mode **public** (HuggingFace Space, démo), passer + ``None`` à ``create_app`` : aucun middleware d'auth n'est monté. + """ + + async def authenticate(self, request: Request) -> None: # pragma: no cover + """Lève ``HTTPException(401 / 403)`` si non authentifié. + + Sinon, ne retourne rien (la requête continue). Peut attacher + l'identité à ``request.state.user`` pour les endpoints qui + veulent en savoir plus. + """ + + +# ────────────────────────────────────────────────────────────────────── +# Security headers +# ────────────────────────────────────────────────────────────────────── + + +_DEFAULT_CSP = ( + "default-src 'self'; " + "script-src 'self'; " + "style-src 'self'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "base-uri 'self'; " + "form-action 'self'" +) + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Ajoute des en-têtes de sécurité durcis à toutes les réponses. + + En-têtes posés : + + - ``Content-Security-Policy`` : par défaut strict (pas + d'``unsafe-inline``, ``frame-ancestors 'none'``). Surchargeable + via le constructeur. + - ``X-Frame-Options: DENY`` (redondant avec CSP frame-ancestors + mais lu par les navigateurs anciens). + - ``X-Content-Type-Options: nosniff`` + - ``Referrer-Policy: strict-origin-when-cross-origin`` + - ``Permissions-Policy`` : désactive caméra, micro, géoloc. + """ + + def __init__( + self, + app: ASGIApp, + *, + csp: str = _DEFAULT_CSP, + ) -> None: + super().__init__(app) + self._csp = csp + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + response = await call_next(request) + response.headers.setdefault("Content-Security-Policy", self._csp) + response.headers.setdefault("X-Frame-Options", "DENY") + response.headers.setdefault("X-Content-Type-Options", "nosniff") + response.headers.setdefault( + "Referrer-Policy", "strict-origin-when-cross-origin", + ) + response.headers.setdefault( + "Permissions-Policy", + "camera=(), microphone=(), geolocation=()", + ) + return response + + +# ────────────────────────────────────────────────────────────────────── +# Body size limit +# ────────────────────────────────────────────────────────────────────── + + +class BodySizeLimitMiddleware(BaseHTTPMiddleware): + """Rejette les requêtes dont ``Content-Length`` dépasse un seuil. + + Garde-fou anti-DoS sur les endpoints d'upload (ex: ZIP corpus). + FastAPI/Starlette ne fournissent pas de limite intégrée — un + client malveillant peut uploader 10 GB et saturer le disque + avant qu'un endpoint ne lise quoi que ce soit. + + Le check est sur ``Content-Length`` (header). Un client qui + triche en omettant ce header ou en streamant du chunked + transfer-encoding contourne cette limite — pour une vraie + protection, lire en streaming et compter les bytes (post-MVP). + + Parameters + ---------- + max_bytes: + Taille max acceptée en octets. Défaut 100 MiB (cohérent + avec ``CorpusService.max_zip_size_bytes``). + """ + + def __init__( + self, + app: ASGIApp, + *, + max_bytes: int = 100 * 1024 * 1024, + ) -> None: + super().__init__(app) + if max_bytes <= 0: + raise ValueError("max_bytes doit être > 0.") + self._max = max_bytes + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + content_length = request.headers.get("content-length") + if content_length is not None: + try: + size = int(content_length) + except ValueError: + size = 0 + if size > self._max: + # On retourne directement une JSONResponse — lever + # ``HTTPException`` depuis un BaseHTTPMiddleware ne + # passe pas par les exception handlers FastAPI. + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content={ + "detail": ( + f"Body size {size} bytes excède la limite " + f"{self._max} bytes." + ), + }, + ) + return await call_next(request) + + +# ────────────────────────────────────────────────────────────────────── +# Rate limit (token bucket en mémoire) +# ────────────────────────────────────────────────────────────────────── + + +class RateLimitMiddleware(BaseHTTPMiddleware): + """Rate limit simple par IP, fenêtre glissante en mémoire. + + Algorithme : pour chaque IP, on garde un deque des timestamps + des requêtes des ``window_seconds`` dernières secondes. Si le + nombre dépasse ``max_requests``, on retourne 429 Too Many Requests. + + Limites + ------- + - **In-process** : ne fonctionne que pour une instance. Pour un + cluster, basculer sur un store partagé (Redis). + - **Pas atomique** : sous concurrence haute, un léger dépassement + est possible (best-effort assumé). + + Garde-fous mémoire et anti-spoofing + ----------------------------------- + Un attaquant qui rotate des IPs ferait gonfler ``self._buckets`` + indéfiniment. Deux protections : + + 1. **Plafond LRU** ``max_clients`` : quand on dépasse, le bucket + le plus ancien (LRU) est évincé. + 2. **GC opportuniste** : à chaque dispatch, si le bucket courant + devient vide après purge, il est supprimé du dict. + + Sur ``X-Forwarded-For`` (si activé) : la chaîne XFF est + ``client, proxy1, proxy2, …``. Lire le **premier** est trivialement + spoofable par le client. ``trust_proxy_count`` documente combien + de proxies fiables sont devant l'app : on lit la N-ième IP en + partant de la fin (la dernière étant le proxy de confiance le plus + proche de nous). Convention recommandée par Starlette/Express. + + Parameters + ---------- + max_requests: + Nombre max de requêtes par IP par fenêtre. Défaut 60. + window_seconds: + Largeur de la fenêtre glissante. Défaut 60s (= 60 req/min). + trust_proxy_count: + Nombre de proxies fiables devant l'app. ``0`` (défaut) + désactive la lecture de ``X-Forwarded-For`` ; + ``request.client.host`` (IP du socket TCP direct) est utilisé. + ``1`` lit l'avant-dernière IP de XFF (un seul proxy en amont, + ex. nginx local), ``2`` l'avant-avant-dernière, etc. **Ne pas + configurer plus haut que le nombre réel** sous peine de + permettre du spoofing. + max_clients: + Plafond du nombre d'IPs gardées en mémoire. Défaut 10 000. + Au-delà, eviction LRU. + """ + + def __init__( + self, + app: ASGIApp, + *, + max_requests: int = 60, + window_seconds: float = 60.0, + trust_proxy_count: int = 0, + max_clients: int = 10_000, + ) -> None: + super().__init__(app) + if max_requests <= 0: + raise ValueError("max_requests doit être > 0.") + if window_seconds <= 0: + raise ValueError("window_seconds doit être > 0.") + if trust_proxy_count < 0: + raise ValueError("trust_proxy_count doit être >= 0.") + if max_clients <= 0: + raise ValueError("max_clients doit être > 0.") + self._max = max_requests + self._window = window_seconds + self._trust_proxies = trust_proxy_count + self._max_clients = max_clients + # ``OrderedDict`` pour conserver l'ordre d'insertion → eviction LRU + # à coût constant via ``move_to_end`` + ``popitem(last=False)``. + # Starlette est mono-thread asyncio par défaut ; pas de Lock. + self._buckets: OrderedDict[str, deque[float]] = OrderedDict() + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + client_ip = self._extract_ip(request) + now = time.monotonic() + bucket = self._buckets.get(client_ip) + if bucket is None: + bucket = deque() + self._buckets[client_ip] = bucket + # Eviction LRU si on dépasse le plafond. + if len(self._buckets) > self._max_clients: + self._buckets.popitem(last=False) + else: + self._buckets.move_to_end(client_ip) + # Purge des timestamps hors fenêtre. + cutoff = now - self._window + while bucket and bucket[0] < cutoff: + bucket.popleft() + if len(bucket) >= self._max: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content={ + "detail": ( + f"Rate limit dépassé : {self._max} requêtes / " + f"{self._window:.0f}s pour {client_ip}." + ), + }, + ) + bucket.append(now) + # GC opportuniste : si le bucket s'est vidé entre-temps (rare, + # purge avant append), on le retirerait — mais bucket n'est + # jamais vide ici puisqu'on vient d'append. La seule fenêtre + # de leak persistante serait une IP qui ne revient plus ; le + # plafond LRU ``max_clients`` la borne. + return await call_next(request) + + def _extract_ip(self, request: Request) -> str: + if self._trust_proxies > 0: + xff = request.headers.get("x-forwarded-for", "").strip() + if xff: + parts = [p.strip() for p in xff.split(",") if p.strip()] + # Lecture sûre : prendre la N-ième IP en partant de la + # fin, où N = trust_proxy_count. Si la chaîne est plus + # courte qu'attendu (mauvaise config ou client tronquant), + # fallback sur l'IP la plus à gauche disponible. + idx = max(0, len(parts) - self._trust_proxies) + if idx < len(parts): + return parts[idx] + client = request.client + return client.host if client is not None else "unknown" + + +# ────────────────────────────────────────────────────────────────────── +# Auth wrapper middleware +# ────────────────────────────────────────────────────────────────────── + + +class AuthenticationMiddleware(BaseHTTPMiddleware): + """Wrapper middleware qui délègue à un ``AuthenticationBackend``. + + Si le backend est ``None``, ce middleware n'est pas monté du tout + par ``create_app`` — pas de coût, mode public total. + + Le backend décide : + + - quels endpoints exiger une auth (peut faire un allowlist via + ``request.url.path``) ; + - quel format de credential accepter (Bearer, API key, etc.) ; + - comment réagir en cas d'échec (401 vs 403). + + Le backend lève ``HTTPException`` ; le middleware se contente de + déléguer. + + Endpoints toujours publics + -------------------------- + Pour permettre les sondes Docker/k8s, ``/health`` et ``/version`` + contournent l'auth (allowlist par path). + """ + + PUBLIC_PATHS: frozenset[str] = frozenset({"/health", "/version"}) + + def __init__( + self, + app: ASGIApp, + *, + backend: AuthenticationBackend, + ) -> None: + super().__init__(app) + self._backend = backend + + async def dispatch( + self, + request: Request, + call_next: Callable[[Request], Awaitable[Response]], + ) -> Response: + if request.url.path not in self.PUBLIC_PATHS: + try: + await self._backend.authenticate(request) + except HTTPException as exc: + # ``BaseHTTPMiddleware`` ne convertit pas les + # HTTPException levées par le backend — on les + # transforme nous-mêmes en JSONResponse. + return JSONResponse( + status_code=exc.status_code, + content={"detail": exc.detail}, + headers=getattr(exc, "headers", None) or {}, + ) + return await call_next(request) + + +__all__ = [ + "AuthenticationBackend", + "AuthenticationMiddleware", + "BodySizeLimitMiddleware", + "RateLimitMiddleware", + "SecurityHeadersMiddleware", +] diff --git a/picarones/interfaces/web/static/main.css b/picarones/interfaces/web/static/main.css new file mode 100644 index 0000000000000000000000000000000000000000..7e71640a8d994455cabdac743fd5b421137e670c --- /dev/null +++ b/picarones/interfaces/web/static/main.css @@ -0,0 +1,130 @@ +/* Picarones — Sprint A14-S38, feuille de style minimaliste. */ +/* Pas de framework externe : on veut un rendu propre sans dépendances. */ + +:root { + --color-bg: #fafafa; + --color-fg: #1a1a1a; + --color-muted: #666; + --color-border: #ddd; + --color-accent: #0a5d8c; + --color-empty: #999; + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, + "Helvetica Neue", Arial, sans-serif; + --font-mono: "SF Mono", Monaco, Menlo, Consolas, "Courier New", monospace; +} + +* { box-sizing: border-box; } + +body { + margin: 0; + font-family: var(--font-sans); + font-size: 16px; + line-height: 1.5; + background: var(--color-bg); + color: var(--color-fg); +} + +header { + border-bottom: 1px solid var(--color-border); + padding: 1.5rem 2rem; +} + +header h1 { + margin: 0; + font-size: 1.8rem; +} + +header .subtitle { + color: var(--color-muted); + margin: 0.25rem 0 0.75rem; +} + +nav { + display: flex; + gap: 1.25rem; +} + +nav a { + color: var(--color-accent); + text-decoration: none; +} + +nav a:hover { + text-decoration: underline; +} + +main { + max-width: 1100px; + margin: 2rem auto; + padding: 0 1.5rem; +} + +main h2 { + margin-top: 2rem; + border-bottom: 1px solid var(--color-border); + padding-bottom: 0.4rem; +} + +.intro p { margin: 0 0 0.75rem; } + +.stats { + list-style: none; + padding: 0; + margin: 0.5rem 0 0; + display: flex; + flex-wrap: wrap; + gap: 1.25rem; +} + +.stats li { + background: white; + padding: 0.5rem 0.85rem; + border: 1px solid var(--color-border); + border-radius: 4px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 1rem; + background: white; + border: 1px solid var(--color-border); + border-radius: 4px; + overflow: hidden; +} + +th, td { + text-align: left; + padding: 0.6rem 0.85rem; + border-bottom: 1px solid var(--color-border); +} + +th { + background: #f0f0f0; + font-weight: 600; +} + +tr:last-child td { + border-bottom: none; +} + +code { + font-family: var(--font-mono); + font-size: 0.9em; + background: #f0f0f0; + padding: 0.1rem 0.35rem; + border-radius: 3px; +} + +.empty { + color: var(--color-empty); + font-style: italic; +} + +footer { + border-top: 1px solid var(--color-border); + padding: 1rem 2rem; + text-align: center; + color: var(--color-muted); + margin-top: 3rem; +} diff --git a/picarones/interfaces/web/templates/base.html.j2 b/picarones/interfaces/web/templates/base.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..964ad17763b005f28390958d6a575a795a2c084c --- /dev/null +++ b/picarones/interfaces/web/templates/base.html.j2 @@ -0,0 +1,27 @@ + + + + + + {{ t('app_title') }} — {% block page_title %}{{ t('nav_home') }}{% endblock %} + + + +
+

{{ t('app_title') }}

+

{{ t('app_subtitle') }}

+ +
+
+ {% block content %}{% endblock %} +
+
+ {{ t('footer_version') }} {{ version }} +
+ + diff --git a/picarones/interfaces/web/templates/home.html.j2 b/picarones/interfaces/web/templates/home.html.j2 new file mode 100644 index 0000000000000000000000000000000000000000..8daa8e9f64988a31e283ae8a9c3585558d79e01f --- /dev/null +++ b/picarones/interfaces/web/templates/home.html.j2 @@ -0,0 +1,68 @@ +{% extends "base.html.j2" %} +{% block page_title %}{{ t('nav_home') }}{% endblock %} +{% block content %} + +
+

{{ t('home_intro') }}

+
    +
  • {{ n_metrics }} {{ t('home_metrics_count') }}
  • +
  • {{ n_projectors }} {{ t('home_projectors_count') }}
  • +
  • {{ t('home_workspace') }} : {{ workspace_root }}
  • +
+
+ +
+

{{ t('header_runs') }}

+ {% if runs %} + + + + + + + + + + + {% for r in runs %} + + + + + + + {% endfor %} + +
{{ t('table_run_id') }}{{ t('table_corpus') }}{{ t('table_pipelines') }}{{ t('table_started_at') }}
{{ r.run_id }}{{ r.corpus_name or '—' }}{{ ', '.join(r.pipeline_names) if r.pipeline_names else '—' }}{{ r.started_at or '—' }}
+ {% else %} +

{{ t('home_no_runs') }}

+ {% endif %} +
+ +
+

{{ t('header_jobs') }}

+ {% if jobs %} + + + + + + + + + + {% for j in jobs %} + + + + + + {% endfor %} + +
{{ t('table_run_id') }}{{ t('table_status') }}{{ t('table_progress') }}
{{ j.job_id }}{{ j.status }}{{ "%.0f%%"|format(j.progress * 100) }}
+ {% else %} +

{{ t('home_no_jobs') }}

+ {% endif %} +
+ +{% endblock %} diff --git a/picarones/llm/anthropic_adapter.py b/picarones/llm/anthropic_adapter.py index 5eea81ad45c215ebbff70713e24dc2e005bced3e..cf7fc2cf32dec075368cccca3b28d2667519df37 100644 --- a/picarones/llm/anthropic_adapter.py +++ b/picarones/llm/anthropic_adapter.py @@ -1,111 +1,10 @@ -"""Adaptateur LLM — Anthropic (Claude Sonnet, Claude Haiku).""" +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.llm.anthropic_adapter``. -from __future__ import annotations - -import logging -import os -from typing import Optional - -from picarones.llm.base import ( - BaseLLMAdapter, - log_http_error, - normalize_llm_content, -) - -logger = logging.getLogger(__name__) - - -class AnthropicAdapter(BaseLLMAdapter): - """Adaptateur pour les modèles Anthropic Claude. - - Clé API via la variable d'environnement ``ANTHROPIC_API_KEY``. - - Modes supportés : text_only, text_and_image, zero_shot. - """ - - api_key_env_var = "ANTHROPIC_API_KEY" +L'ancien chemin ``picarones.llm.anthropic_adapter`` est conservé pour ne casser +aucun consommateur. Au S22, ce re-export disparaîtra. +""" - @property - def name(self) -> str: - return "anthropic" - - @property - def default_model(self) -> str: - return "claude-sonnet-4-6" - - def __init__( - self, - model: Optional[str] = None, - config: Optional[dict] = None, - ) -> None: - super().__init__(model, config) - self._api_key = os.environ.get("ANTHROPIC_API_KEY") - - def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: - if not self._api_key: - raise RuntimeError( - "Clé API Anthropic manquante — définissez la variable d'environnement ANTHROPIC_API_KEY" - ) - try: - import anthropic - except ImportError as exc: - raise RuntimeError( - "Le package 'anthropic' n'est pas installé. Lancez : pip install anthropic" - ) from exc - - client = anthropic.Anthropic(api_key=self._api_key) - temperature = float(self.config.get("temperature", 0.0)) - max_tokens = int(self.config.get("max_tokens", 4096)) - - if image_b64: - content: list | str = [ - { - "type": "image", - "source": { - "type": "base64", - "media_type": "image/png", - "data": image_b64, - }, - }, - {"type": "text", "text": prompt}, - ] - else: - content = prompt - - try: - response = client.messages.create( - model=self.model, - max_tokens=max_tokens, - temperature=temperature, - messages=[{"role": "user", "content": content}], - ) - except Exception as exc: - # Chantier 4 — log discriminant (401/429/5xx) factorisé. - # Auparavant Anthropic ne discriminait pas par code HTTP, - # difficile à diagnostiquer (clé invalide vs rate limit). - log_http_error( - "AnthropicAdapter", self.model, exc, - env_var=self.api_key_env_var, - ) - raise - - if not response.content: - logger.warning( - "[AnthropicAdapter] réponse vide (modèle=%s, stop_reason=%s).", - self.model, getattr(response, "stop_reason", None), - ) - return "" +from __future__ import annotations - # Chantier 4 — propagation du fix Sprint 15 : le SDK Anthropic - # retourne ``response.content`` comme une liste de blocs - # (``ContentBlock`` avec attribut ``text``). ``normalize_llm_content`` - # concatène le texte de tous les blocs au lieu de ne prendre que - # le premier — utile quand le modèle émet plusieurs blocs. - text = normalize_llm_content(response.content) - if not text: - block = response.content[0] - logger.warning( - "[AnthropicAdapter] bloc de type '%s' sans texte (modèle=%s).", - getattr(block, "type", "unknown"), self.model, - ) - return text +from picarones.adapters.llm.anthropic_adapter import * # noqa: F401,F403 diff --git a/picarones/llm/base.py b/picarones/llm/base.py index 3da4dff36a0b43615090a9146e1fd5ca61a8881f..1f946e1c5de88e92a6a06bc177170e921667e6f0 100644 --- a/picarones/llm/base.py +++ b/picarones/llm/base.py @@ -1,279 +1,10 @@ -"""Interface abstraite commune à tous les adaptateurs LLM.""" +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.llm.base``. -from __future__ import annotations - -import logging -import time -from abc import ABC, abstractmethod -from dataclasses import dataclass -from typing import Any, Optional - -logger = logging.getLogger(__name__) - -# Paramètres de retry par défaut -_DEFAULT_MAX_RETRIES = 3 -_DEFAULT_BACKOFF_BASE = 2.0 # secondes : 2, 4, 8 - - -def _is_retryable(exc: Exception) -> bool: - """Détermine si une exception est retryable (429, 5xx, timeout réseau).""" - # HTTP status codes retryables - status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) - if status is not None: - return status == 429 or status >= 500 - - # Erreurs réseau / timeout - exc_name = type(exc).__name__ - if exc_name in ("TimeoutError", "ConnectionError", "URLError"): - return True - - # Messages d'erreur courants - msg = str(exc).lower() - if "rate" in msg and "limit" in msg: - return True - if "timeout" in msg or "connection" in msg: - return True - if "429" in msg or "503" in msg or "502" in msg: - return True - - return False - - -def normalize_llm_content(raw: Any) -> str: - """Normalise une réponse LLM en chaîne plate. - - Chantier 4 (post-Sprint 97) — propagation du fix Mistral - Sprint 15 à tous les providers. Le SDK Mistral peut retourner - une liste de ``ContentChunk`` au lieu d'une chaîne pour certains - modèles/versions ; le SDK OpenAI peut faire de même quand on - active des features de structuration. Ce helper applique la même - discipline pour les 4 adapters : - - - ``str`` → renvoyée telle quelle (ou ``""``). - - ``None`` → ``""``. - - ``list[ContentChunk]`` → concaténation des ``.text``. - - ``list[dict]`` avec clé ``text`` → concaténation des ``["text"]``. - - ``list[str]`` → concaténation directe. - - autre objet avec ``.text`` → ``obj.text``. - - autre → ``str(obj)`` (best-effort). - - Le résultat est garanti être une ``str`` ; ``""`` quand la réponse - est vide. La fonction est idempotente : ``normalize_llm_content(s) - == s`` pour toute chaîne ``s``. - """ - if raw is None: - return "" - if isinstance(raw, str): - return raw - if isinstance(raw, list): - parts: list[str] = [] - for chunk in raw: - if chunk is None: - continue - if isinstance(chunk, str): - parts.append(chunk) - continue - if hasattr(chunk, "text"): - txt = getattr(chunk, "text", None) - if isinstance(txt, str): - parts.append(txt) - continue - if isinstance(chunk, dict) and isinstance(chunk.get("text"), str): - parts.append(chunk["text"]) - continue - # Dernier recours — convertit le chunk en chaîne - parts.append(str(chunk)) - return "".join(parts) - if hasattr(raw, "text") and isinstance(getattr(raw, "text", None), str): - return raw.text # type: ignore[no-any-return] - return str(raw) - - -def log_http_error( - adapter_name: str, - model: str, - exc: Exception, - *, - env_var: Optional[str] = None, -) -> None: - """Log standardisé des erreurs HTTP des SDK LLM. - - Chantier 4 (post-Sprint 97) — propagation du log discriminant - Mistral/OpenAI à tous les providers. Inspecte ``status_code`` et - ``http_status`` puis émet un warning ciblé selon le code : - - - 401 : clé API invalide/expirée (mention de la variable - d'environnement à vérifier si fournie). - - 429 : rate limit / quota dépassé. - - 5xx : problème serveur côté provider. - - autre / pas de status_code : log générique. - - L'exception n'est pas levée — l'appelant doit ``raise`` - explicitement après ce log s'il veut propager (le retry est géré - par ``BaseLLMAdapter.complete`` selon ``_is_retryable``). - """ - status = getattr(exc, "status_code", None) or getattr(exc, "http_status", None) - if status == 401: - suffix = f" Vérifier {env_var}." if env_var else "" - logger.warning( - "[%s] erreur HTTP 401 — clé API invalide ou expirée " - "(modèle=%s).%s", - adapter_name, model, suffix, - ) - elif status == 429: - logger.warning( - "[%s] erreur HTTP 429 — quota dépassé ou rate-limit " - "(modèle=%s). Réessayer plus tard.", - adapter_name, model, - ) - elif status is not None and status >= 500: - logger.warning( - "[%s] erreur HTTP %d — problème serveur (modèle=%s) : %s", - adapter_name, status, model, exc, - ) - else: - logger.warning( - "[%s] erreur lors de l'appel API (modèle=%s) : %s", - adapter_name, model, exc, - ) - - -@dataclass -class LLMResult: - """Résultat produit par un appel LLM.""" - - model_id: str - text: str - duration_seconds: float - tokens_used: Optional[int] = None - error: Optional[str] = None - - @property - def success(self) -> bool: - return self.error is None - - -class BaseLLMAdapter(ABC): - """Classe de base pour tous les adaptateurs LLM. - - Chaque adaptateur doit implémenter : - - ``name`` : identifiant du provider (ex : 'openai') - - ``default_model``: modèle par défaut du provider - - ``_call()`` : appel API effectif, retourne le texte brut - - Les clés API sont lues depuis les variables d'environnement uniquement. - - Retry automatique - ----------------- - Les erreurs retryables (HTTP 429, 5xx, timeout réseau) sont automatiquement - retentées avec backoff exponentiel (2s, 4s, 8s par défaut). Configurable - via ``config["max_retries"]`` et ``config["retry_backoff"]``. - - Normalisation des réponses (chantier 4) - --------------------------------------- - Les sous-classes utilisent :func:`normalize_llm_content` sur la - réponse SDK avant de la retourner — garantit qu'une réponse de - type ``list[ContentChunk]`` (Mistral, parfois OpenAI) est - convertie en ``str`` plate. - - Logging d'erreurs HTTP (chantier 4) - ----------------------------------- - Les sous-classes utilisent :func:`log_http_error` pour produire - un log discriminant par ``status_code`` (401 → clé invalide, - 429 → rate limit, 5xx → serveur). Auparavant ce log était - dupliqué chez Mistral/OpenAI et absent chez Anthropic. - """ - - # Variable d'environnement portant la clé API. Sous-classes - # surchargent (ex. ``"OPENAI_API_KEY"``) ; mention utilisée par - # :func:`log_http_error` quand un 401 est rencontré. ``None`` - # pour les providers sans clé (Ollama). - api_key_env_var: Optional[str] = None - - def __init__( - self, - model: Optional[str] = None, - config: Optional[dict] = None, - ) -> None: - self.config: dict = config or {} - self.model: str = model or self.default_model - - @property - @abstractmethod - def name(self) -> str: - """Identifiant du provider (ex : 'openai', 'anthropic').""" - - @property - @abstractmethod - def default_model(self) -> str: - """Modèle utilisé si aucun n'est fourni explicitement.""" - - @abstractmethod - def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: - """Appel LLM effectif. - - Parameters - ---------- - prompt: - Texte du prompt final (variables déjà substituées). - image_b64: - Image encodée en base64 (sans préfixe data URI). - None pour les appels texte-uniquement. - - Returns - ------- - str - Texte généré par le LLM. - """ - - def complete( - self, - prompt: str, - image_b64: Optional[str] = None, - ) -> LLMResult: - """Point d'entrée public : appelle le LLM avec retry automatique.""" - max_retries = int(self.config.get("max_retries", _DEFAULT_MAX_RETRIES)) - backoff_base = float(self.config.get("retry_backoff", _DEFAULT_BACKOFF_BASE)) - - start = time.perf_counter() - last_exc: Optional[Exception] = None - - for attempt in range(max_retries + 1): - try: - text = self._call(prompt, image_b64) - duration = time.perf_counter() - start - return LLMResult( - model_id=self.model, - text=text, - duration_seconds=round(duration, 4), - ) - except Exception as exc: # noqa: BLE001 - last_exc = exc - if attempt < max_retries and _is_retryable(exc): - wait = backoff_base ** (attempt + 1) - logger.warning( - "[%s] erreur retryable (tentative %d/%d, attente %.1fs) : %s", - self.name, attempt + 1, max_retries + 1, wait, exc, - ) - time.sleep(wait) - else: - break - - duration = time.perf_counter() - start - return LLMResult( - model_id=self.model, - text="", - duration_seconds=round(duration, 4), - error=str(last_exc), - ) - - def __repr__(self) -> str: - return f"{self.__class__.__name__}(model={self.model!r})" +L'ancien chemin ``picarones.llm.base`` est conservé pour ne casser +aucun consommateur. Au S22, ce re-export disparaîtra. +""" +from __future__ import annotations -__all__ = [ - "BaseLLMAdapter", - "LLMResult", - "log_http_error", - "normalize_llm_content", -] +from picarones.adapters.llm.base import * # noqa: F401,F403 diff --git a/picarones/llm/mistral_adapter.py b/picarones/llm/mistral_adapter.py index e7656918c715d0a4a0d2c178957626fdd25b6793..6193bfd5e6b06425e1d551d9020811797f0c3621 100644 --- a/picarones/llm/mistral_adapter.py +++ b/picarones/llm/mistral_adapter.py @@ -1,157 +1,11 @@ -"""Adaptateur LLM — Mistral AI (Mistral Large, Pixtral).""" +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.llm.mistral_adapter``. -from __future__ import annotations - -import logging -import os -from typing import Optional - -from picarones.llm.base import ( - BaseLLMAdapter, - log_http_error, - normalize_llm_content, -) - -logger = logging.getLogger(__name__) - -# Modèles Mistral qui NE supportent PAS l'API chat/completions multimodale. -# Ces petits modèles sont text-only; le passer avec une image provoque une erreur. -_TEXT_ONLY_MODELS = frozenset({ - "ministral-3b-latest", - "ministral-8b-latest", - "mistral-tiny", - "mistral-tiny-latest", - "open-mistral-7b", - "open-mixtral-8x7b", -}) - - -class MistralAdapter(BaseLLMAdapter): - """Adaptateur pour les modèles Mistral AI. - - Clé API via la variable d'environnement ``MISTRAL_API_KEY``. - - Modes supportés : text_only (tous modèles), text_and_image et zero_shot - avec les modèles multimodaux (pixtral-12b, pixtral-large). - - Note - ---- - Les modèles ``ministral-3b-latest`` et ``ministral-8b-latest`` ne supportent - pas le mode multimodal — utiliser ``PipelineMode.TEXT_ONLY`` avec ces modèles. - """ - - api_key_env_var = "MISTRAL_API_KEY" - - @property - def name(self) -> str: - return "mistral" - - @property - def default_model(self) -> str: - return "mistral-large-latest" +Ré-expose explicitement ``_TEXT_ONLY_MODELS`` (importé par les +tests Sprint 15). +""" - def __init__( - self, - model: Optional[str] = None, - config: Optional[dict] = None, - ) -> None: - super().__init__(model, config) - self._api_key = os.environ.get("MISTRAL_API_KEY") - if self.model in _TEXT_ONLY_MODELS: - logger.info( - "[MistralAdapter] modèle '%s' : text-only (pas de support multimodal).", - self.model, - ) - - def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: - if not self._api_key: - raise RuntimeError( - "Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY" - ) - try: - try: - from mistralai.client import Mistral - except ImportError: - from mistralai import Mistral # type: ignore[no-redef] - except ImportError as exc: - raise RuntimeError( - "Le package 'mistralai' n'est pas installé. Lancez : pip install mistralai" - ) from exc - - client = Mistral(api_key=self._api_key) - temperature = float(self.config.get("temperature", 0.0)) - max_tokens = int(self.config.get("max_tokens", 4096)) - - # Les modèles text-only ne supportent pas les images - if image_b64 and self.model in _TEXT_ONLY_MODELS: - logger.warning( - "[MistralAdapter] modèle '%s' ne supporte pas les images — " - "image ignorée, appel en mode texte seul.", - self.model, - ) - image_b64 = None - - if image_b64: - content: list | str = [ - {"type": "text", "text": prompt}, - { - "type": "image_url", - "image_url": f"data:image/png;base64,{image_b64}", - }, - ] - else: - content = prompt - - logger.info( - "[MistralAdapter] appel %s — prompt=%d chars, image=%s", - self.model, len(prompt), "oui" if image_b64 else "non", - ) - - try: - response = client.chat.complete( - model=self.model, - messages=[{"role": "user", "content": content}], - temperature=temperature, - max_tokens=max_tokens, - ) - except Exception as exc: - log_http_error( - "MistralAdapter", self.model, exc, - env_var=self.api_key_env_var, - ) - raise - - if not response.choices: - logger.warning( - "[MistralAdapter] response.choices vide (modèle=%s).", - self.model, - ) - return "" - - _choice = response.choices[0] - raw = _choice.message.content - _finish_reason = _choice.finish_reason - - # Chantier 4 — normalisation factorisée dans - # ``picarones.llm.base.normalize_llm_content`` (Sprint 15 - # généralisé : list[ContentChunk] / list[dict] / str → str). - text = normalize_llm_content(raw) - - _completion_tokens = None - if hasattr(response, "usage") and response.usage: - _completion_tokens = getattr(response.usage, "completion_tokens", None) - - logger.info( - "[MistralAdapter] réponse %s — finish_reason=%s, len=%d, tokens=%s", - self.model, _finish_reason, len(text), _completion_tokens, - ) - - if not text.strip(): - logger.warning( - "[MistralAdapter] réponse vide du modèle '%s' " - "(finish_reason=%s, completion_tokens=%s). " - "Vérifier le prompt et la compatibilité du modèle.", - self.model, _finish_reason, _completion_tokens, - ) +from __future__ import annotations - return text +from picarones.adapters.llm.mistral_adapter import * # noqa: F401,F403 +from picarones.adapters.llm.mistral_adapter import _TEXT_ONLY_MODELS # noqa: F401 diff --git a/picarones/llm/ollama_adapter.py b/picarones/llm/ollama_adapter.py index cb770f992cc5f5d6907750f40806ce446d472ae6..6dd9976b410250d9c494913a51f2857aa441100a 100644 --- a/picarones/llm/ollama_adapter.py +++ b/picarones/llm/ollama_adapter.py @@ -1,109 +1,10 @@ -"""Adaptateur LLM — Ollama (modèles locaux : Llama 3, Gemma, Phi, Mistral local…).""" +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.llm.ollama_adapter``. -from __future__ import annotations - -import logging -from typing import Optional -from urllib.parse import urlparse - -from picarones.llm.base import BaseLLMAdapter, normalize_llm_content - -logger = logging.getLogger(__name__) - - -class OllamaAdapter(BaseLLMAdapter): - """Adaptateur pour les modèles locaux via Ollama. - - Aucune clé API requise. Nécessite un serveur Ollama actif (par défaut - sur http://localhost:11434). - - Modes supportés : - - text_only : tous modèles Ollama - - text_and_image : modèles multimodaux (llava, bakllava, moondream…) - - zero_shot : modèles multimodaux uniquement +L'ancien chemin ``picarones.llm.ollama_adapter`` est conservé pour ne casser +aucun consommateur. Au S22, ce re-export disparaîtra. +""" - Configuration (via ``config``) : - - ``base_url`` : URL du serveur Ollama (défaut : http://localhost:11434) - """ - - @property - def name(self) -> str: - return "ollama" - - @property - def default_model(self) -> str: - return "llama3" - - def __init__( - self, - model: Optional[str] = None, - config: Optional[dict] = None, - ) -> None: - super().__init__(model, config) - base_url = self.config.get("base_url", "http://localhost:11434").rstrip("/") - parsed = urlparse(base_url) - if parsed.scheme not in ("http", "https"): - raise ValueError( - f"URL Ollama invalide (schéma '{parsed.scheme}' non autorisé, " - f"seuls http/https sont acceptés) : {base_url}" - ) - self._base_url = base_url - - def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: - import json - import urllib.error - import urllib.request - - temperature = float(self.config.get("temperature", 0.0)) - payload: dict = { - "model": self.model, - "prompt": prompt, - "stream": False, - "options": {"temperature": temperature}, - } - if image_b64: - payload["images"] = [image_b64] - - data = json.dumps(payload).encode("utf-8") - req = urllib.request.Request( - f"{self._base_url}/api/generate", - data=data, - headers={"Content-Type": "application/json"}, - ) - try: - with urllib.request.urlopen(req, timeout=120) as resp: - raw = resp.read().decode("utf-8") - except urllib.error.HTTPError as exc: - logger.warning( - "[OllamaAdapter] erreur HTTP %d (modèle=%s) : %s", - exc.code, self.model, exc, - ) - raise RuntimeError( - f"Erreur HTTP {exc.code} du serveur Ollama ({self._base_url}) : {exc}" - ) from exc - except urllib.error.URLError as exc: - raise RuntimeError( - f"Impossible de joindre le serveur Ollama sur {self._base_url}. " - f"Vérifiez qu'Ollama est démarré (ollama serve). Erreur : {exc}" - ) from exc - - try: - result = json.loads(raw) - except json.JSONDecodeError as exc: - logger.warning( - "[OllamaAdapter] réponse JSON invalide (modèle=%s) : %s", - self.model, raw[:200], - ) - raise RuntimeError( - f"Réponse JSON invalide du serveur Ollama : {exc}" - ) from exc +from __future__ import annotations - # Chantier 4 — propagation du fix Sprint 15 : Ollama retourne - # ``response`` en string mais on normalise par défense (cas où - # un futur build retournerait un format structuré). - text = normalize_llm_content(result.get("response", "")) - if not text: - logger.warning( - "[OllamaAdapter] réponse vide (modèle=%s).", self.model, - ) - return text +from picarones.adapters.llm.ollama_adapter import * # noqa: F401,F403 diff --git a/picarones/llm/openai_adapter.py b/picarones/llm/openai_adapter.py index ae0d1ceafc3864ba30832ead92d2f51a603a18e1..33578da95a60fea1de1ad80377bdb00cdc7de320 100644 --- a/picarones/llm/openai_adapter.py +++ b/picarones/llm/openai_adapter.py @@ -1,94 +1,10 @@ -"""Adaptateur LLM — OpenAI (GPT-4o, GPT-4o-mini).""" +"""Re-export — Sprint A14-S11. Le contenu canonique vit dans +``picarones.adapters.llm.openai_adapter``. -from __future__ import annotations - -import logging -import os -from typing import Optional - -from picarones.llm.base import ( - BaseLLMAdapter, - log_http_error, - normalize_llm_content, -) - -logger = logging.getLogger(__name__) - - -class OpenAIAdapter(BaseLLMAdapter): - """Adaptateur pour les modèles OpenAI (GPT-4o, GPT-4o-mini). - - Clé API via la variable d'environnement ``OPENAI_API_KEY``. - - Modes supportés : text_only, text_and_image, zero_shot. - """ +L'ancien chemin ``picarones.llm.openai_adapter`` est conservé pour ne casser +aucun consommateur. Au S22, ce re-export disparaîtra. +""" - api_key_env_var = "OPENAI_API_KEY" - - @property - def name(self) -> str: - return "openai" - - @property - def default_model(self) -> str: - return "gpt-4o" - - def __init__( - self, - model: Optional[str] = None, - config: Optional[dict] = None, - ) -> None: - super().__init__(model, config) - self._api_key = os.environ.get("OPENAI_API_KEY") - - def _call(self, prompt: str, image_b64: Optional[str] = None) -> str: - if not self._api_key: - raise RuntimeError( - "Clé API OpenAI manquante — définissez la variable d'environnement OPENAI_API_KEY" - ) - try: - from openai import OpenAI - except ImportError as exc: - raise RuntimeError( - "Le package 'openai' n'est pas installé. Lancez : pip install openai" - ) from exc - - client = OpenAI(api_key=self._api_key) - temperature = float(self.config.get("temperature", 0.0)) - max_tokens = int(self.config.get("max_tokens", 4096)) - - if image_b64: - content = [ - {"type": "text", "text": prompt}, - { - "type": "image_url", - "image_url": {"url": f"data:image/png;base64,{image_b64}"}, - }, - ] - else: - content = prompt # type: ignore[assignment] - - try: - response = client.chat.completions.create( - model=self.model, - messages=[{"role": "user", "content": content}], - temperature=temperature, - max_tokens=max_tokens, - ) - except Exception as exc: - log_http_error( - "OpenAIAdapter", self.model, exc, - env_var=self.api_key_env_var, - ) - raise +from __future__ import annotations - if not response.choices: - logger.warning( - "[OpenAIAdapter] response.choices vide (modèle=%s).", self.model, - ) - return "" - # Chantier 4 — propagation du fix Sprint 15 : le SDK OpenAI - # peut retourner une ``list[ContentBlock]`` selon l'API - # (Responses, structured outputs). ``normalize_llm_content`` - # gère les deux cas (str et list). - return normalize_llm_content(response.choices[0].message.content) +from picarones.adapters.llm.openai_adapter import * # noqa: F401,F403 diff --git a/picarones/measurements/baseline_comparison.py b/picarones/measurements/baseline_comparison.py index 22f021aaceb4952d7f96271e325b6864b50a9258..7c886ab031814efafd7d14b634fd9fcf3ce90f05 100644 --- a/picarones/measurements/baseline_comparison.py +++ b/picarones/measurements/baseline_comparison.py @@ -1,229 +1,10 @@ -"""Comparaison à la baseline historique — Sprint 73 (A.I.3). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.baseline_comparison``. -Sprint 73 — chantier 2 d'A.I.3 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -L'historique SQLite (``picarones/core/history.py``, Sprint 8) -existe mais aucun détecteur narratif ne le lit. Ce module fournit -la couche de calcul qui répond à *« comment ce moteur se -comporte-t-il sur ce corpus, **par rapport à ses runs précédents -de mon institution** ? »*. - -Sortie typique --------------- -Un dict par moteur : - -.. code-block:: python - - { - "engine_name": "tesseract", - "cer_current": 0.052, - "cer_historical_mean": 0.041, - "cer_historical_median": 0.040, - "n_runs": 12, - "absolute_delta": 0.011, - "relative_delta": 0.268, # +26,8 % vs moyenne - "off_baseline": True, - } - -Le détecteur narratif ``engine_off_baseline`` (Sprint 73) -consomme cette structure pour émettre des Facts. - -Garde-fous ----------- -- ``min_runs`` (défaut 5) : si l'historique pour le moteur×corpus - contient moins de runs, on retourne ``None`` plutôt que de - comparer à un échantillon trop petit. -- ``corpus_name`` est utilisé pour ne comparer qu'aux runs **du - même corpus** (sinon on compare des pommes et des oranges : - registres paroissiaux vs imprimés modernes). -- Le run courant lui-même n'est pas inclus dans la baseline (on - passe le ``current_run_id`` à exclure). +L'ancien chemin ``picarones.measurements.baseline_comparison`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import statistics -from typing import Optional - -logger = logging.getLogger(__name__) - - -def compute_engine_baseline( - history, - engine_name: str, - corpus_name: str, - current_cer: float, - *, - current_run_id: Optional[str] = None, - min_runs: int = 5, - relative_delta_threshold: float = 0.20, -) -> Optional[dict]: - """Compare le CER courant d'un moteur à sa moyenne historique - sur le **même corpus**. - - Parameters - ---------- - history: - Instance de ``BenchmarkHistory`` (ou compatible : doit - exposer une méthode ``query(engine, corpus, limit)`` - retournant une liste d'``HistoryEntry`` avec attribut - ``cer_mean`` et ``run_id``). - engine_name: - Nom du moteur dont on calcule la baseline. - corpus_name: - Nom du corpus — limite la comparaison aux runs antérieurs - sur ce même corpus. - current_cer: - CER moyen observé dans le run courant. - current_run_id: - Si fourni, le run portant cet identifiant est exclu de la - baseline (utile quand le run courant est déjà enregistré - dans l'historique avant d'appeler ce calcul). - min_runs: - Nombre minimum de runs historiques pour que la - comparaison soit considérée fiable. Sous ce seuil, on - retourne ``None``. - relative_delta_threshold: - Seuil au-delà duquel ``off_baseline`` vaut ``True`` - (défaut : 0,20 = 20 % d'écart relatif). - - Returns - ------- - Optional[dict] - ``None`` si : - - moins de ``min_runs`` runs historiques disponibles - - ``current_cer`` est ``None`` ou négatif - - tous les CER historiques sont ``None`` - - Sinon, dict avec les champs documentés dans le module. - """ - if current_cer is None or current_cer < 0: - return None - try: - entries = history.query( - engine=engine_name, corpus=corpus_name, limit=1000, - ) - except Exception as exc: # pragma: no cover — défense - logger.warning( - "[baseline_comparison] query history a levé : %s", exc, - ) - return None - - historical_cers: list[float] = [] - for entry in entries: - if current_run_id is not None and entry.run_id == current_run_id: - continue - cer = entry.cer_mean - if cer is None or cer < 0: - continue - historical_cers.append(float(cer)) - - if len(historical_cers) < min_runs: - return None - - mean = statistics.fmean(historical_cers) - median = statistics.median(historical_cers) - absolute_delta = current_cer - mean - if mean > 0: - relative_delta = absolute_delta / mean - elif current_cer == 0: - relative_delta = 0.0 - else: - # Baseline à 0 mais CER courant > 0 : écart infini — - # convention : on signale comme off_baseline avec - # relative_delta = None. - relative_delta = None - - off_baseline = ( - relative_delta is not None - and abs(relative_delta) > relative_delta_threshold - ) - - return { - "engine_name": engine_name, - "corpus_name": corpus_name, - "cer_current": float(current_cer), - "cer_historical_mean": mean, - "cer_historical_median": median, - "n_runs": len(historical_cers), - "absolute_delta": absolute_delta, - "relative_delta": relative_delta, - "off_baseline": off_baseline, - } - - -def compute_corpus_difficulty_percentile( - history, - current_difficulty: float, - *, - min_runs: int = 5, -) -> Optional[dict]: - """Place la difficulté du corpus courant dans la distribution - des difficultés historiques. - - Lit les difficultés stockées dans ``HistoryEntry.metadata`` - sous la clé ``difficulty`` (convention de - ``picarones/core/difficulty.py``). - - Returns - ------- - Optional[dict] - ``{ - "current_difficulty": float, - "percentile": float, # 0..100 - "n_runs": int, - "median_historical": float, - "harder_than_usual": bool, # percentile > 75 - "easier_than_usual": bool, # percentile < 25 - }`` - ou ``None`` si moins de ``min_runs`` runs historiques ont - une difficulté enregistrée. - """ - if current_difficulty is None: - return None - try: - entries = history.query(limit=1000) - except Exception as exc: # pragma: no cover - logger.warning( - "[baseline_comparison] query history a levé : %s", exc, - ) - return None - - historical_difficulties: list[float] = [] - for entry in entries: - diff = entry.metadata.get("difficulty") if entry.metadata else None - if diff is None: - continue - try: - historical_difficulties.append(float(diff)) - except (TypeError, ValueError): - continue - - if len(historical_difficulties) < min_runs: - return None - - sorted_diff = sorted(historical_difficulties) - n = len(sorted_diff) - # Percentile = % de corpus historiques de difficulté ≤ - # current_difficulty. Convention courante (P_i = i/n × 100). - n_below = sum(1 for d in sorted_diff if d <= current_difficulty) - percentile = (n_below / n) * 100.0 - median = statistics.median(sorted_diff) - - return { - "current_difficulty": float(current_difficulty), - "percentile": percentile, - "n_runs": n, - "median_historical": median, - "harder_than_usual": percentile > 75.0, - "easier_than_usual": percentile < 25.0, - } - - -__all__ = [ - "compute_engine_baseline", - "compute_corpus_difficulty_percentile", -] +from picarones.evaluation.metrics.baseline_comparison import * # noqa: F401,F403 diff --git a/picarones/measurements/calibration.py b/picarones/measurements/calibration.py index 35819b20332e0b915b4cb13a5b9c55555f50c392..46e95453d9b83600dc36e09a882cd9541ed67536 100644 --- a/picarones/measurements/calibration.py +++ b/picarones/measurements/calibration.py @@ -1,323 +1,10 @@ -"""Calibration des moteurs : ECE, MCE, reliability diagram. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.calibration``. -Sprint 39 — A.II.1.b du plan d'évolution 2026 : couche de calcul pure. - -Pourquoi ce module ------------------- -Tous les moteurs OCR cibles fournissent une confidence par token ou par -ligne (Tesseract via le ``tsv``, Pero OCR via le ``PageLayout``, -Mistral OCR via ``confidence``, Google Vision via ``Word.confidence``). -La question naturelle pour un workflow patrimonial est : *« quand le -moteur dit qu'il est sûr, est-il vraiment sûr ? »*. Pour une équipe -qui doit vérifier humainement un corpus de 50 000 pages, la différence -entre vérifier 100 % vs 15 % du volume est l'effet de la calibration. - -Ce module fournit les trois mesures classiques : - -- **Expected Calibration Error (ECE)** — moyenne pondérée par bin de - l'écart absolu entre confiance moyenne et précision moyenne. - ``ECE = 0`` ↔ moteur parfaitement calibré ; ``ECE`` élevé ↔ écart - systématique entre confiance affichée et fiabilité réelle. -- **Maximum Calibration Error (MCE)** — max de cet écart sur les bins. - Utile pour repérer le pire mensonge du moteur (ex. il dit toujours - 95 % de confiance et il a tort une fois sur deux). -- **Reliability diagram** — table ``[(bin_low, bin_high, avg_conf, - accuracy, count)]`` qui peut être rendue en SVG côté serveur ou en - Chart.js côté navigateur dans un sprint suivant. - -Stratégie de découpage ----------------------- -Comme pour le NER (Sprint 38) et la divergence (Sprints 35-37), -on découpe : - -- **Sprint 39** (ici) — couche de calcul pure : entrée = deux listes - parallèles ``confidences`` (∈ [0, 1]) et ``is_correct`` (bool/0-1). - Aucune dépendance externe. -- **Sprint à venir** — exposition de ``token_confidences`` sur - ``EngineResult``, alignement caractère/token avec la GT pour produire - ``is_correct``, intégration dans le runner et vue HTML reliability. - -Ce qui est explicitement hors scope ------------------------------------ -Ce sprint ne touche **aucun adaptateur OCR**. Aucune confiance n'est -extraite ; on calcule uniquement à partir de séquences de prédictions -fournies en entrée. C'est ce qui permet de tester rigoureusement les -invariants mathématiques (ECE = 0 ↔ calibré, ECE = |bias| pour bias -constant, etc.) sans dépendre d'un backend. +L'ancien chemin ``picarones.measurements.calibration`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from dataclasses import dataclass -from typing import Iterable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle de données -# ────────────────────────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class CalibrationBin: - """Un bin du reliability diagram. - - Attributs - --------- - bin_low, bin_high: - Bornes du bin sur l'axe de confiance (``[bin_low, bin_high)`` — - sauf le dernier bin qui inclut ``1.0``). - avg_confidence: - Moyenne des confidences des prédictions tombées dans le bin. - ``None`` si le bin est vide. - accuracy: - Fraction de prédictions correctes dans le bin (``∈ [0, 1]``). - ``None`` si le bin est vide. - count: - Nombre de prédictions dans le bin. - """ - - bin_low: float - bin_high: float - avg_confidence: float | None - accuracy: float | None - count: int - - @property - def gap(self) -> float | None: - """Écart absolu ``|confidence - accuracy|`` ou ``None`` si vide.""" - if self.avg_confidence is None or self.accuracy is None: - return None - return abs(self.avg_confidence - self.accuracy) - - -# ────────────────────────────────────────────────────────────────────────── -# Validation -# ────────────────────────────────────────────────────────────────────────── - - -def _validate_inputs( - confidences: list[float], - is_correct: list[bool | int], -) -> None: - if len(confidences) != len(is_correct): - raise ValueError( - f"Longueurs incompatibles : confidences={len(confidences)} " - f"vs is_correct={len(is_correct)}" - ) - for i, c in enumerate(confidences): - if not (0.0 <= float(c) <= 1.0): - raise ValueError( - f"Confiance hors [0, 1] à l'index {i} : {c!r}" - ) - - -# ────────────────────────────────────────────────────────────────────────── -# Reliability diagram (binning) -# ────────────────────────────────────────────────────────────────────────── - - -def reliability_diagram( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> list[CalibrationBin]: - """Découpe les prédictions en ``n_bins`` bins équidistants par confiance - et calcule pour chacun la confiance moyenne, la précision et le compte. - - Parameters - ---------- - confidences: - Confidences des prédictions, ``∈ [0, 1]``. - is_correct: - Indicateur booléen (1 = prédiction correcte, 0 = incorrecte). - n_bins: - Nombre de bins (défaut : 10). Bornes : ``[k/n_bins, (k+1)/n_bins)`` - sauf le dernier bin qui inclut ``1.0``. - - Returns - ------- - list[CalibrationBin] - Liste de ``n_bins`` bins, dans l'ordre croissant des confidences. - """ - if n_bins < 1: - raise ValueError(f"n_bins doit être ≥ 1 — reçu {n_bins}") - - confs = [float(c) for c in confidences] - correct = [int(bool(x)) for x in is_correct] - _validate_inputs(confs, correct) - - bin_width = 1.0 / n_bins - sums: list[float] = [0.0] * n_bins - correct_counts: list[int] = [0] * n_bins - counts: list[int] = [0] * n_bins - - for c, ok in zip(confs, correct): - # Calcul du bin index par multiplication ``c * n_bins`` plutôt que - # division ``c / bin_width`` pour éviter les pièges de - # représentation flottante (ex. ``0.6 / 0.1 = 5.999…`` en IEEE 754 - # qui placerait 0.6 dans le bin [0.5, 0.6) au lieu de [0.6, 0.7)). - if c >= 1.0: - idx = n_bins - 1 - else: - idx = int(c * n_bins) - # Garde-fou en cas d'arrondi flottant - if idx >= n_bins: - idx = n_bins - 1 - elif idx < 0: - idx = 0 - sums[idx] += c - correct_counts[idx] += ok - counts[idx] += 1 - - bins: list[CalibrationBin] = [] - for k in range(n_bins): - low = k * bin_width - high = (k + 1) * bin_width - n = counts[k] - if n == 0: - bins.append(CalibrationBin(low, high, None, None, 0)) - else: - bins.append(CalibrationBin( - bin_low=low, - bin_high=high, - avg_confidence=sums[k] / n, - accuracy=correct_counts[k] / n, - count=n, - )) - return bins - - -# ────────────────────────────────────────────────────────────────────────── -# ECE et MCE -# ────────────────────────────────────────────────────────────────────────── - - -def expected_calibration_error( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> float: - """Expected Calibration Error : moyenne pondérée par bin de l'écart - absolu confiance ↔ précision. - - ``ECE = sum_k (n_k / N) * |avg_conf_k - accuracy_k|`` - - où la somme porte sur les bins non vides. - - Returns - ------- - float - ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. - """ - bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) - total = sum(b.count for b in bins) - if total == 0: - return 0.0 - ece = 0.0 - for b in bins: - if b.count == 0 or b.gap is None: - continue - ece += (b.count / total) * b.gap - return ece - - -def maximum_calibration_error( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> float: - """Maximum Calibration Error : pire écart confiance ↔ précision sur - tous les bins non vides. - - Utile pour repérer un mensonge ponctuel du moteur (ex. il dit 95 % - de confiance et il a tort une fois sur deux dans ce bin). - - Returns - ------- - float - ``∈ [0, 1]``. ``0`` ↔ calibration parfaite. - """ - bins = reliability_diagram(confidences, is_correct, n_bins=n_bins) - gaps = [b.gap for b in bins if b.gap is not None] - return max(gaps) if gaps else 0.0 - - -# ────────────────────────────────────────────────────────────────────────── -# Vue agrégée -# ────────────────────────────────────────────────────────────────────────── - - -def compute_calibration_metrics( - confidences: Iterable[float], - is_correct: Iterable[bool | int], - n_bins: int = 10, -) -> dict: - """Calcule l'ensemble des métriques de calibration en un appel. - - Returns - ------- - dict - ``{ - "ece": float, - "mce": float, - "n_bins": int, - "n_predictions": int, - "overall_accuracy": float, - "overall_confidence": float, - "bins": [ - {"bin_low", "bin_high", "avg_confidence", - "accuracy", "count", "gap"}, - ... - ], - }`` - """ - confs = list(confidences) - correct = list(is_correct) - bins = reliability_diagram(confs, correct, n_bins=n_bins) - total = sum(b.count for b in bins) - overall_acc = ( - sum(int(bool(x)) for x in correct) / total if total > 0 else 0.0 - ) - overall_conf = ( - sum(float(c) for c in confs) / total if total > 0 else 0.0 - ) - - ece = 0.0 - if total > 0: - for b in bins: - if b.gap is None: - continue - ece += (b.count / total) * b.gap - mce = max((b.gap for b in bins if b.gap is not None), default=0.0) - - return { - "ece": ece, - "mce": mce, - "n_bins": n_bins, - "n_predictions": total, - "overall_accuracy": overall_acc, - "overall_confidence": overall_conf, - "bins": [ - { - "bin_low": b.bin_low, - "bin_high": b.bin_high, - "avg_confidence": b.avg_confidence, - "accuracy": b.accuracy, - "count": b.count, - "gap": b.gap, - } - for b in bins - ], - } - - -__all__ = [ - "CalibrationBin", - "reliability_diagram", - "expected_calibration_error", - "maximum_calibration_error", - "compute_calibration_metrics", -] +from picarones.evaluation.metrics.calibration import * # noqa: F401,F403 diff --git a/picarones/measurements/confusion.py b/picarones/measurements/confusion.py index a90d9ebb9b3eb6a5585e4f172a0a6bbf4be79689..ecb400b1649f2ab3aaf81543ce8d27e53dcf64cd 100644 --- a/picarones/measurements/confusion.py +++ b/picarones/measurements/confusion.py @@ -1,268 +1,10 @@ -"""Matrice de confusion unicode pour l'analyse fine des erreurs OCR. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.confusion``. -Pour chaque moteur, on calcule quels caractères du GT sont transcrits par -quels caractères OCR (substitutions). Cette "empreinte d'erreur" est -caractéristique de chaque moteur ou pipeline. - -Méthode -------- -L'alignement caractère par caractère utilise les opérations d'édition -de la distance de Levenshtein (via difflib.SequenceMatcher), ce qui permet -d'identifier les substitutions, insertions et suppressions. - -La matrice est stockée comme un dict de dict : - ``{gt_char: {ocr_char: count}}`` - -La valeur spéciale ``"∅"`` (U+2205) représente un caractère vide : -- ``{"a": {"∅": 3}}`` → 'a' supprimé 3 fois dans l'OCR -- ``{"∅": {"x": 2}}`` → 'x' inséré 2 fois dans l'OCR (absent du GT) +L'ancien chemin ``picarones.measurements.confusion`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import difflib -from collections import defaultdict -from dataclasses import dataclass, field - -# Symbole représentant un caractère absent (insertion / suppression) -EMPTY_CHAR = "∅" - -# Caractères non pertinents à ignorer dans la matrice (espaces, sauts de ligne) -_WHITESPACE = set(" \t\n\r") - - -@dataclass -class ConfusionMatrix: - """Matrice de confusion unicode pour une paire (GT, OCR).""" - - matrix: dict[str, dict[str, int]] = field(default_factory=dict) - """Clé externe = char GT ; clé interne = char OCR ; valeur = count.""" - - total_substitutions: int = 0 - total_insertions: int = 0 - total_deletions: int = 0 - - @property - def total_errors(self) -> int: - return self.total_substitutions + self.total_insertions + self.total_deletions - - def top_confusions(self, n: int = 20) -> list[dict]: - """Retourne les n confusions les plus fréquentes (substitutions uniquement).""" - pairs: list[tuple[str, str, int]] = [] - for gt_char, ocr_counts in self.matrix.items(): - if gt_char == EMPTY_CHAR: - continue # insertions - for ocr_char, count in ocr_counts.items(): - if ocr_char == EMPTY_CHAR: - continue # suppressions - if gt_char != ocr_char: - pairs.append((gt_char, ocr_char, count)) - pairs.sort(key=lambda x: -x[2]) - return [ - {"gt": gt, "ocr": ocr, "count": cnt} - for gt, ocr, cnt in pairs[:n] - ] - - def as_compact_dict(self, min_count: int = 1) -> dict: - """Sérialise la matrice en éliminant les entrées rares.""" - compact: dict[str, dict[str, int]] = {} - for gt_char, ocr_counts in self.matrix.items(): - filtered = { - oc: cnt for oc, cnt in ocr_counts.items() - if cnt >= min_count - } - if filtered: - compact[gt_char] = filtered - return { - "matrix": compact, - "total_substitutions": self.total_substitutions, - "total_insertions": self.total_insertions, - "total_deletions": self.total_deletions, - } - - def as_dict(self) -> dict: - return self.as_compact_dict(min_count=1) - - -def build_confusion_matrix( - ground_truth: str, - hypothesis: str, - ignore_whitespace: bool = True, - ignore_correct: bool = True, -) -> ConfusionMatrix: - """Construit la matrice de confusion unicode pour une paire GT/OCR. - - Parameters - ---------- - ground_truth: - Texte de référence (vérité terrain). - hypothesis: - Texte produit par l'OCR. - ignore_whitespace: - Si True, ignore les espaces, tabulations et sauts de ligne. - ignore_correct: - Si True, n'enregistre pas les paires identiques (gt_char == ocr_char). - Par défaut True pour réduire la taille de la matrice. - - Returns - ------- - ConfusionMatrix - """ - matrix: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - n_subs = n_ins = n_dels = 0 - - if not ground_truth and not hypothesis: - return ConfusionMatrix(dict(matrix), 0, 0, 0) - - # SequenceMatcher sur listes de chars pour un alignement précis - matcher = difflib.SequenceMatcher(None, ground_truth, hypothesis, autojunk=False) - - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - if not ignore_correct: - for ch in ground_truth[i1:i2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][ch] += 1 - elif tag == "replace": - # Aligner char par char les séquences de longueurs différentes - gt_seg = ground_truth[i1:i2] - oc_seg = hypothesis[j1:j2] - _align_segments(gt_seg, oc_seg, matrix, ignore_whitespace) - # Substitutions = longueur commune, surplus = insertions ou suppressions - n_subs += min(len(gt_seg), len(oc_seg)) - surplus = abs(len(gt_seg) - len(oc_seg)) - if len(gt_seg) > len(oc_seg): - n_dels += surplus - else: - n_ins += surplus - elif tag == "delete": - for ch in ground_truth[i1:i2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][EMPTY_CHAR] += 1 - n_dels += 1 - elif tag == "insert": - for ch in hypothesis[j1:j2]: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[EMPTY_CHAR][ch] += 1 - n_ins += 1 - - # Convertir defaultdict en dict normal - result_matrix: dict[str, dict[str, int]] = { - k: dict(v) for k, v in matrix.items() - } - - return ConfusionMatrix( - matrix=result_matrix, - total_substitutions=n_subs, - total_insertions=n_ins, - total_deletions=n_dels, - ) - - -def _align_segments( - gt_seg: str, - oc_seg: str, - matrix: dict, - ignore_whitespace: bool, -) -> None: - """Aligne deux segments de longueurs potentiellement différentes.""" - if not gt_seg: - for ch in oc_seg: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[EMPTY_CHAR][ch] += 1 - return - if not oc_seg: - for ch in gt_seg: - if ignore_whitespace and ch in _WHITESPACE: - continue - matrix[ch][EMPTY_CHAR] += 1 - return - - if len(gt_seg) == len(oc_seg): - # Substitutions 1-pour-1 - for g, o in zip(gt_seg, oc_seg): - if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): - continue - matrix[g][o] += 1 - else: - # Longueurs différentes : utiliser SequenceMatcher récursif sur segments courts - sub = difflib.SequenceMatcher(None, gt_seg, oc_seg, autojunk=False) - for tag2, i1, i2, j1, j2 in sub.get_opcodes(): - if tag2 == "equal": - pass - elif tag2 == "replace": - # Régression simple : aligner par troncature - for g, o in zip(gt_seg[i1:i2], oc_seg[j1:j2]): - if ignore_whitespace and (g in _WHITESPACE or o in _WHITESPACE): - continue - matrix[g][o] += 1 - elif tag2 == "delete": - for g in gt_seg[i1:i2]: - if ignore_whitespace and g in _WHITESPACE: - continue - matrix[g][EMPTY_CHAR] += 1 - elif tag2 == "insert": - for o in oc_seg[j1:j2]: - if ignore_whitespace and o in _WHITESPACE: - continue - matrix[EMPTY_CHAR][o] += 1 - - -def aggregate_confusion_matrices(matrices: list[ConfusionMatrix]) -> ConfusionMatrix: - """Agrège plusieurs matrices de confusion en une seule. - - Utile pour obtenir la matrice agrégée sur l'ensemble du corpus. - """ - combined: dict[str, dict[str, int]] = defaultdict(lambda: defaultdict(int)) - total_subs = total_ins = total_dels = 0 - - for cm in matrices: - for gt_char, ocr_counts in cm.matrix.items(): - for ocr_char, count in ocr_counts.items(): - combined[gt_char][ocr_char] += count - total_subs += cm.total_substitutions - total_ins += cm.total_insertions - total_dels += cm.total_deletions - - return ConfusionMatrix( - matrix={k: dict(v) for k, v in combined.items()}, - total_substitutions=total_subs, - total_insertions=total_ins, - total_deletions=total_dels, - ) - - -def top_confused_chars( - matrix: ConfusionMatrix, - n: int = 15, - exclude_empty: bool = True, -) -> list[dict]: - """Retourne les caractères GT les plus souvent confondus. - - Retourne une liste triée par nombre total d'erreurs décroissant : - ``[{"char": "ſ", "total_errors": 47, "top_substitutes": [...]}, ...]`` - """ - char_stats: dict[str, dict] = {} - for gt_char, ocr_counts in matrix.matrix.items(): - if exclude_empty and gt_char == EMPTY_CHAR: - continue - error_count = sum( - cnt for oc, cnt in ocr_counts.items() - if (oc != gt_char) and (not exclude_empty or oc != EMPTY_CHAR) - ) - if error_count > 0: - top_subs = sorted( - [{"ocr": oc, "count": cnt} for oc, cnt in ocr_counts.items() if oc != gt_char], - key=lambda x: -x["count"], - )[:5] - char_stats[gt_char] = { - "char": gt_char, - "total_errors": error_count, - "top_substitutes": top_subs, - } - - return sorted(char_stats.values(), key=lambda x: -x["total_errors"])[:n] +from picarones.evaluation.metrics.confusion import * # noqa: F401,F403 diff --git a/picarones/measurements/error_absorption.py b/picarones/measurements/error_absorption.py index ce1021d64b625397fd5c3dca1d15475d6d83477b..dc67ac228543843ea0a67527022c0f100d2c960e 100644 --- a/picarones/measurements/error_absorption.py +++ b/picarones/measurements/error_absorption.py @@ -1,276 +1,10 @@ -"""Métrique d'absorption d'erreur — Sprint 94 (B.3). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.error_absorption``. -Sprint 94 — B.3 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Quand un module de post-correction LLM aplatit les différences -entre OCR amont, ce n'est pas qu'il « améliore » tous les -moteurs — c'est qu'il introduit ses propres biais qui dominent -ceux de l'OCR. Mesurer la dégradation par étape ne suffit -pas : il faut **séparer** les deux flux. - -À chaque jonction où un module transforme un artefact, on -mesure : - -- **Taux de correction** : parmi les erreurs présentes en - entrée du module, combien sont corrigées en sortie ? -- **Taux d'introduction** : parmi les erreurs présentes en - sortie, combien sont **nouvelles** (absentes en entrée) ? - -C'est la généralisation du score de sur-normalisation -(chantier A.I.7) à toute jonction. La formule s'applique -uniformément à OCR→LLM, OCR→reconstructor, VLM→ALTO_mapper — -toute jonction qui transforme un artefact en un autre du même -type. - -Méthode (token-level) ---------------------- -On split en tokens whitespace ``reference``, ``before``, -``after``. On compare en **multiset** (un token GT consommé -au plus une fois) : - -- ``errors_before`` = tokens GT non retrouvés dans ``before`` -- ``errors_after`` = tokens GT non retrouvés dans ``after`` -- ``corrected`` = ``errors_before \\ errors_after`` - (présents avant, absents après → corrigés) -- ``introduced`` = ``errors_after \\ errors_before`` - (absents avant, présents après → introduits) - -Garde-fou : le module ne classe pas les erreurs (visuelles, -abréviations, etc.) — c'est une métrique d'**absorption de -volume**, pas de qualité éditoriale. L'intersection sémantique -avec ``taxonomy`` (Sprint 5) est documentée dans le glossaire. - -Sortie ------- -``compute_error_absorption(reference, before, after)`` retourne : - -.. code-block:: text - - { - "n_gt_tokens": int, - "n_errors_before": int, - "n_errors_after": int, - "n_corrected": int, - "n_introduced": int, - "n_kept_wrong": int, - "correction_rate": float | None, # n_corrected / n_errors_before - "introduction_rate": float | None, # n_introduced / n_errors_after - "net_improvement": int, # n_corrected - n_introduced - "corrected_tokens": list[str], - "introduced_tokens": list[str], - } - -``aggregate_error_absorption(per_doc_results)`` somme les -compteurs corpus-wide et recalcule les taux *micro*. +L'ancien chemin ``picarones.measurements.error_absorption`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from collections import Counter -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -def _split_words(text: Optional[str]) -> list[str]: - if not text: - return [] - return text.split() - - -def _missing_tokens( - reference: list[str], hypothesis: list[str], -) -> Counter: - """Tokens GT manquants en hypothèse au sens multiset. - - Un token GT compte plusieurs fois s'il apparaît plusieurs - fois ; chaque occurrence en hypothèse en absorbe au plus - une. Retourne un Counter ``{token: nb_occurrences_manquees}``. - """ - ref_count = Counter(reference) - hyp_count = Counter(hypothesis) - missing: Counter = Counter() - for token, n_ref in ref_count.items(): - n_hyp = hyp_count.get(token, 0) - if n_hyp < n_ref: - missing[token] = n_ref - n_hyp - return missing - - -def compute_error_absorption( - reference: Optional[str], - before: Optional[str], - after: Optional[str], - *, - case_sensitive: bool = False, -) -> Optional[dict]: - """Mesure l'absorption d'erreur entre ``before`` et ``after``. - - Parameters - ---------- - reference: - GT (vérité terrain). - before: - Sortie de l'étape précédente (typiquement OCR amont). - after: - Sortie de l'étape courante (typiquement post-correction LLM). - case_sensitive: - Si False (défaut), match case-insensitive — la sortie - ``corrected_tokens``/``introduced_tokens`` reste en casse - GT originale. - - Returns - ------- - dict | None - ``None`` si la GT est vide ou ne contient aucun token. - """ - ref_tokens = _split_words(reference) - if not ref_tokens: - return None - before_tokens = _split_words(before) - after_tokens = _split_words(after) - - if case_sensitive: - ref_match = list(ref_tokens) - before_match = list(before_tokens) - after_match = list(after_tokens) - else: - ref_match = [t.lower() for t in ref_tokens] - before_match = [t.lower() for t in before_tokens] - after_match = [t.lower() for t in after_tokens] - - # Map case-insensitive token → liste de casses GT originales - ref_orig_by_match: dict[str, list[str]] = {} - for orig, m in zip(ref_tokens, ref_match): - ref_orig_by_match.setdefault(m, []).append(orig) - - missing_before = _missing_tokens(ref_match, before_match) - missing_after = _missing_tokens(ref_match, after_match) - - n_errors_before = sum(missing_before.values()) - n_errors_after = sum(missing_after.values()) - - # Calcul corrigé / introduit en multiset - corrected_counter: Counter = Counter() - introduced_counter: Counter = Counter() - kept_wrong_counter: Counter = Counter() - all_tokens = set(missing_before) | set(missing_after) - for tok in all_tokens: - nb = missing_before.get(tok, 0) - na = missing_after.get(tok, 0) - if nb > na: - corrected_counter[tok] = nb - na - kept_wrong_counter[tok] = na - elif na > nb: - introduced_counter[tok] = na - nb - kept_wrong_counter[tok] = nb - else: - kept_wrong_counter[tok] = nb - - n_corrected = sum(corrected_counter.values()) - n_introduced = sum(introduced_counter.values()) - n_kept_wrong = sum(kept_wrong_counter.values()) - - correction_rate = ( - n_corrected / n_errors_before - if n_errors_before > 0 else None - ) - introduction_rate = ( - n_introduced / n_errors_after - if n_errors_after > 0 else None - ) - - def _expand(counter: Counter) -> list[str]: - out: list[str] = [] - for tok, count in counter.items(): - origs = ref_orig_by_match.get(tok, [tok]) - # Ne renvoie que la casse représentative GT - display = origs[0] if origs else tok - out.extend([display] * count) - return out - - return { - "n_gt_tokens": len(ref_tokens), - "n_errors_before": n_errors_before, - "n_errors_after": n_errors_after, - "n_corrected": n_corrected, - "n_introduced": n_introduced, - "n_kept_wrong": n_kept_wrong, - "correction_rate": correction_rate, - "introduction_rate": introduction_rate, - "net_improvement": n_corrected - n_introduced, - "corrected_tokens": _expand(corrected_counter), - "introduced_tokens": _expand(introduced_counter), - } - - -def aggregate_error_absorption( - per_doc: Iterable[Optional[dict]], - *, - sample_tokens: int = 50, -) -> Optional[dict]: - """Agrège les compteurs corpus-wide et recalcule les taux - *micro*. - - Parameters - ---------- - per_doc: - Itérable de sorties de ``compute_error_absorption`` (ou - ``None`` pour les docs sans GT). - sample_tokens: - Nombre maximal de tokens corrigés/introduits gardés dans - l'échantillon (cap pour ne pas exploser le JSON). - - Returns - ------- - dict | None - ``None`` si aucune entry valide. - """ - docs = [d for d in per_doc if d] - if not docs: - return None - n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs) - n_errors_before = sum(int(d.get("n_errors_before") or 0) for d in docs) - n_errors_after = sum(int(d.get("n_errors_after") or 0) for d in docs) - n_corrected = sum(int(d.get("n_corrected") or 0) for d in docs) - n_introduced = sum(int(d.get("n_introduced") or 0) for d in docs) - n_kept_wrong = sum(int(d.get("n_kept_wrong") or 0) for d in docs) - correction_rate = ( - n_corrected / n_errors_before if n_errors_before > 0 else None - ) - introduction_rate = ( - n_introduced / n_errors_after if n_errors_after > 0 else None - ) - corrected_sample: list[str] = [] - introduced_sample: list[str] = [] - for d in docs: - corrected_sample.extend(d.get("corrected_tokens") or []) - introduced_sample.extend(d.get("introduced_tokens") or []) - if ( - len(corrected_sample) >= sample_tokens - and len(introduced_sample) >= sample_tokens - ): - break - return { - "n_docs": len(docs), - "n_gt_tokens": n_gt, - "n_errors_before": n_errors_before, - "n_errors_after": n_errors_after, - "n_corrected": n_corrected, - "n_introduced": n_introduced, - "n_kept_wrong": n_kept_wrong, - "correction_rate": correction_rate, - "introduction_rate": introduction_rate, - "net_improvement": n_corrected - n_introduced, - "corrected_tokens_sample": corrected_sample[:sample_tokens], - "introduced_tokens_sample": introduced_sample[:sample_tokens], - } - - -__all__ = [ - "compute_error_absorption", - "aggregate_error_absorption", -] +from picarones.evaluation.metrics.error_absorption import * # noqa: F401,F403 diff --git a/picarones/measurements/hallucination.py b/picarones/measurements/hallucination.py index 07eda573ca8d1b4e659600482d3af3e87f245c21..aebd1c1fbaf2c7a83cfce8291aa320911a20ff73 100644 --- a/picarones/measurements/hallucination.py +++ b/picarones/measurements/hallucination.py @@ -1,331 +1,10 @@ -"""Détection des hallucinations VLM/LLM — Sprint 10. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.hallucination``. -Métriques calculées -------------------- -- Taux d'insertion net : mots/caractères ajoutés absents du GT, distinct du WIL existant -- Ratio de longueur : len(hyp) / len(gt) — ratio > 1.2 → hallucination potentielle -- Score d'ancrage : proportion des n-grammes (trigrammes) de la sortie présents dans le GT -- Blocs hallucinés : segments continus de la sortie sans correspondance GT au-delà d'un seuil -- Badge hallucination : True si ancrage faible ou ratio de longueur anormal +L'ancien chemin ``picarones.measurements.hallucination`` est conservé +pour ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import re -from dataclasses import dataclass - - -# --------------------------------------------------------------------------- -# Helpers texte -# --------------------------------------------------------------------------- - -def _tokenize(text: str) -> list[str]: - """Découpe en mots (minuscules, sans ponctuation).""" - return re.findall(r"[^\s]+", text.lower()) - - -def _ngrams(tokens: list[str], n: int) -> list[tuple[str, ...]]: - """Génère les n-grammes d'une liste de tokens.""" - if len(tokens) < n: - return [tuple(tokens)] if tokens else [] - return [tuple(tokens[i:i + n]) for i in range(len(tokens) - n + 1)] - - -# --------------------------------------------------------------------------- -# Blocs hallucinés (segments continus sans ancrage) -# --------------------------------------------------------------------------- - -@dataclass -class HallucinatedBlock: - """Segment continu de la sortie sans correspondance dans le GT.""" - start_token: int - end_token: int - text: str - length: int # nombre de tokens - - def as_dict(self) -> dict: - return { - "start_token": self.start_token, - "end_token": self.end_token, - "text": self.text, - "length": self.length, - } - - -def _detect_hallucinated_blocks( - hyp_tokens: list[str], - gt_token_set: set[str], - tolerance: int = 3, - min_block_length: int = 4, -) -> list[HallucinatedBlock]: - """Détecte les blocs de tokens hypothèse sans correspondance dans le GT. - - Un bloc est un segment contigu de tokens hypothèse dont aucun n'est présent - dans le vocabulaire GT. Une tolérance de ``tolerance`` tokens connus interrompus - est acceptée avant de clore un bloc. - - Parameters - ---------- - hyp_tokens: - Tokens de la sortie OCR/VLM. - gt_token_set: - Ensemble des tokens du GT (pour recherche O(1)). - tolerance: - Nombre de tokens connus consécutifs interrompant un bloc avant de le clore. - min_block_length: - Longueur minimale (tokens) pour qu'un bloc soit signalé. - - Returns - ------- - list[HallucinatedBlock] - """ - blocks: list[HallucinatedBlock] = [] - if not hyp_tokens: - return blocks - - in_block = False - block_start = 0 - consecutive_known = 0 - - for i, tok in enumerate(hyp_tokens): - is_unknown = tok not in gt_token_set - if is_unknown: - if not in_block: - in_block = True - block_start = i - consecutive_known = 0 - else: - consecutive_known = 0 - else: - if in_block: - consecutive_known += 1 - if consecutive_known >= tolerance: - # Clore le bloc - end = i - consecutive_known - length = end - block_start + 1 - if length >= min_block_length: - text = " ".join(hyp_tokens[block_start:end + 1]) - blocks.append(HallucinatedBlock( - start_token=block_start, - end_token=end, - text=text, - length=length, - )) - in_block = False - consecutive_known = 0 - - # Bloc non terminé - if in_block: - end = len(hyp_tokens) - 1 - length = end - block_start + 1 - if length >= min_block_length: - text = " ".join(hyp_tokens[block_start:end + 1]) - blocks.append(HallucinatedBlock( - start_token=block_start, - end_token=end, - text=text, - length=length, - )) - - return blocks - - -# --------------------------------------------------------------------------- -# Résultat structuré -# --------------------------------------------------------------------------- - -@dataclass -class HallucinationMetrics: - """Métriques de détection des hallucinations pour une paire (GT, hypothèse).""" - - net_insertion_rate: float - """Taux d'insertion nette : tokens hypothèse absents du GT / total tokens hypothèse.""" - - length_ratio: float - """Ratio de longueur : len(hyp) / len(gt) en caractères. > 1.2 = signal d'hallucination.""" - - anchor_score: float - """Score d'ancrage : proportion des trigrammes hypothèse présents dans les trigrammes GT. - Score élevé → l'hypothèse s'ancre bien dans le GT. Score faible → hallucinations probables.""" - - hallucinated_blocks: list[HallucinatedBlock] - """Segments continus de la sortie sans correspondance GT (au-dessus du seuil de tolérance).""" - - is_hallucinating: bool - """True si anchor_score < anchor_threshold OU length_ratio > length_ratio_threshold.""" - - # Détails supplémentaires - gt_word_count: int = 0 - hyp_word_count: int = 0 - net_inserted_words: int = 0 - anchor_threshold_used: float = 0.5 - length_ratio_threshold_used: float = 1.2 - ngram_size_used: int = 3 - - def as_dict(self) -> dict: - return { - "net_insertion_rate": round(self.net_insertion_rate, 6), - "length_ratio": round(self.length_ratio, 6), - "anchor_score": round(self.anchor_score, 6), - "hallucinated_blocks": [b.as_dict() for b in self.hallucinated_blocks], - "is_hallucinating": self.is_hallucinating, - "gt_word_count": self.gt_word_count, - "hyp_word_count": self.hyp_word_count, - "net_inserted_words": self.net_inserted_words, - "anchor_threshold_used": self.anchor_threshold_used, - "length_ratio_threshold_used": self.length_ratio_threshold_used, - "ngram_size_used": self.ngram_size_used, - } - - @classmethod - def from_dict(cls, d: dict) -> "HallucinationMetrics": - blocks = [ - HallucinatedBlock(**b) for b in d.get("hallucinated_blocks", []) - ] - return cls( - net_insertion_rate=d.get("net_insertion_rate", 0.0), - length_ratio=d.get("length_ratio", 1.0), - anchor_score=d.get("anchor_score", 1.0), - hallucinated_blocks=blocks, - is_hallucinating=d.get("is_hallucinating", False), - gt_word_count=d.get("gt_word_count", 0), - hyp_word_count=d.get("hyp_word_count", 0), - net_inserted_words=d.get("net_inserted_words", 0), - anchor_threshold_used=d.get("anchor_threshold_used", 0.5), - length_ratio_threshold_used=d.get("length_ratio_threshold_used", 1.2), - ngram_size_used=d.get("ngram_size_used", 3), - ) - - -# --------------------------------------------------------------------------- -# Calcul principal -# --------------------------------------------------------------------------- - -def compute_hallucination_metrics( - reference: str, - hypothesis: str, - n: int = 3, - length_ratio_threshold: float = 1.2, - anchor_threshold: float = 0.5, - block_tolerance: int = 3, - min_block_length: int = 4, -) -> HallucinationMetrics: - """Calcule les métriques de détection des hallucinations VLM/LLM. - - Parameters - ---------- - reference: - Texte de vérité terrain (GT). - hypothesis: - Texte produit par le modèle. - n: - Taille des n-grammes pour le score d'ancrage (défaut : trigrammes). - length_ratio_threshold: - Seuil de ratio de longueur au-dessus duquel on signale une hallucination potentielle. - anchor_threshold: - Seuil de score d'ancrage en dessous duquel on signale une hallucination potentielle. - block_tolerance: - Nombre de tokens connus consécutifs acceptés dans un bloc halluciné. - min_block_length: - Longueur minimale (tokens) pour signaler un bloc halluciné. - - Returns - ------- - HallucinationMetrics - """ - gt_tokens = _tokenize(reference) - hyp_tokens = _tokenize(hypothesis) - - gt_len_chars = len(reference.strip()) - hyp_len_chars = len(hypothesis.strip()) - - # ── Ratio de longueur ──────────────────────────────────────────────── - if gt_len_chars == 0: - length_ratio = 1.0 if hyp_len_chars == 0 else float("inf") - else: - length_ratio = hyp_len_chars / gt_len_chars - - # ── Taux d'insertion nette ─────────────────────────────────────────── - gt_token_set = set(gt_tokens) - hyp_token_count = len(hyp_tokens) - - if hyp_token_count == 0: - net_insertion_rate = 0.0 - net_inserted_words = 0 - else: - net_inserted = [t for t in hyp_tokens if t not in gt_token_set] - net_inserted_words = len(net_inserted) - net_insertion_rate = net_inserted_words / hyp_token_count - - # ── Score d'ancrage (n-grammes) ────────────────────────────────────── - gt_ngrams = set(_ngrams(gt_tokens, n)) - hyp_ngrams = _ngrams(hyp_tokens, n) - - if not hyp_ngrams: - # Pas de n-grammes dans l'hypothèse → ancrage parfait (hypothèse vide ou trop courte) - anchor_score = 1.0 if not gt_ngrams else 0.0 - elif not gt_ngrams: - anchor_score = 0.0 - else: - anchored = sum(1 for ng in hyp_ngrams if ng in gt_ngrams) - anchor_score = anchored / len(hyp_ngrams) - - # ── Blocs hallucinés ───────────────────────────────────────────────── - blocks = _detect_hallucinated_blocks( - hyp_tokens=hyp_tokens, - gt_token_set=gt_token_set, - tolerance=block_tolerance, - min_block_length=min_block_length, - ) - - # ── Badge hallucination ────────────────────────────────────────────── - is_hallucinating = ( - anchor_score < anchor_threshold - or length_ratio > length_ratio_threshold - ) - - return HallucinationMetrics( - net_insertion_rate=net_insertion_rate, - length_ratio=min(length_ratio, 9.99), # plafonner pour la sérialisation - anchor_score=anchor_score, - hallucinated_blocks=blocks, - is_hallucinating=is_hallucinating, - gt_word_count=len(gt_tokens), - hyp_word_count=hyp_token_count, - net_inserted_words=net_inserted_words, - anchor_threshold_used=anchor_threshold, - length_ratio_threshold_used=length_ratio_threshold, - ngram_size_used=n, - ) - - -# --------------------------------------------------------------------------- -# Agrégation sur un corpus -# --------------------------------------------------------------------------- - -def aggregate_hallucination_metrics(results: list[HallucinationMetrics]) -> dict: - """Agrège les métriques d'hallucination sur un corpus. - - Returns - ------- - dict - Statistiques agrégées : anchor_score moyen, taux de documents hallucinés… - """ - if not results: - return {} - - n = len(results) - anchor_values = [r.anchor_score for r in results] - ratio_values = [r.length_ratio for r in results] - insertion_values = [r.net_insertion_rate for r in results] - hallucinating_count = sum(1 for r in results if r.is_hallucinating) - - return { - "anchor_score_mean": round(sum(anchor_values) / n, 6), - "anchor_score_min": round(min(anchor_values), 6), - "length_ratio_mean": round(sum(ratio_values) / n, 6), - "net_insertion_rate_mean": round(sum(insertion_values) / n, 6), - "hallucinating_doc_count": hallucinating_count, - "hallucinating_doc_rate": round(hallucinating_count / n, 6), - "document_count": n, - } +from picarones.evaluation.metrics.hallucination import * # noqa: F401,F403 diff --git a/picarones/measurements/image_predictive.py b/picarones/measurements/image_predictive.py index 1bd9671110a5f948f781a06a1c9a74f71421a829..38cc40f0eac6e8b84191837f8b3100770a77380c 100644 --- a/picarones/measurements/image_predictive.py +++ b/picarones/measurements/image_predictive.py @@ -1,283 +1,10 @@ -"""Métriques d'image prédictives — Sprint 93 (A.II.7). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.image_predictive``. -Sprint 93 — A.II.7 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -``image_quality`` (Sprint 5) mesure des features d'image -indépendamment ; ce module **les combine** pour produire deux -indicateurs corpus-level : - -1. **Score de complexité paléographique** ∈ [0, 1]. Combine - bruit, faible netteté, faible contraste et rotation en un - indicateur unique de la difficulté intrinsèque pour un OCR. - 0 = document trivial, 1 = document extrême. Permet - d'expliquer une partie du CER observé. - -2. **Score d'homogénéité du corpus** ∈ [0, 1]. Variance des - features entre documents. 0 = corpus uniforme (la moyenne - globale du benchmark est fiable), 1 = corpus hétérogène - (la moyenne ment, il faut stratifier). Couplé au détecteur - ``stratification_recommended`` (Sprint 46) qui agit sur - ``script_type``. - -Pondérations ------------- -La roadmap propose une combinaison **pondérée** sans fixer les -poids — on adopte une convention éditoriale documentée : - -- ``noise_level`` : poids 0.30 (bruit franc → CER ↑) -- ``1 - sharpness_score`` : poids 0.30 (flou → CER ↑) -- ``1 - contrast_score`` : poids 0.20 (faible contraste → CER ↑) -- ``|rotation_degrees|/30`` : poids 0.20 (rotation > 30° = pire) - -Les poids somment à 1. L'utilisateur peut surcharger via -``weights={...}``. - -Pas de prédiction CER absolue ------------------------------ -On ne prétend **pas** prédire une valeur CER en pourcentage — -ça demanderait un modèle entraîné par moteur, ce que la -philosophie banc d'essai exclut. On fournit un score relatif -qui se corrèle au CER observé pour une **lecture -diagnostique** : *« le document A est ~3× plus complexe que le -document B, ce qui est cohérent avec le CER observé. »* +L'ancien chemin ``picarones.measurements.image_predictive`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import math -import statistics -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -# Poids éditoriaux par défaut. -DEFAULT_COMPLEXITY_WEIGHTS = { - "noise_level": 0.30, - "blur": 0.30, # 1 - sharpness_score - "low_contrast": 0.20, # 1 - contrast_score - "rotation": 0.20, # |rotation_degrees| / 30 -} - - -# Plage de saturation pour la rotation. Au-delà de 30°, on -# considère que c'est aussi pire que pire. -_ROTATION_SATURATION_DEG = 30.0 - - -def _clip01(x: float) -> float: - return max(0.0, min(1.0, x)) - - -def _extract_feature( - quality: dict, key: str, default: float = 0.0, -) -> float: - val = quality.get(key, default) - if val is None: - return default - try: - return float(val) - except (TypeError, ValueError): - return default - - -def compute_paleographic_complexity( - quality: dict, - *, - weights: Optional[dict[str, float]] = None, -) -> Optional[dict]: - """Score de complexité paléographique d'une image. - - Parameters - ---------- - quality: - Dict ``ImageQualityResult.as_dict()`` ou compatible. - Champs lus : ``noise_level``, ``sharpness_score``, - ``contrast_score``, ``rotation_degrees``. - weights: - Poids surchargeant les défauts. Doit contenir les - 4 clés ``noise_level``, ``blur``, ``low_contrast``, - ``rotation``. Les poids sont normalisés (somme = 1). - - Returns - ------- - dict | None - ``{ - "score": float, # ∈ [0, 1] - "components": { - "noise": float, "blur": float, - "low_contrast": float, "rotation": float, - }, - "weights_used": dict, - }`` ou ``None`` si ``quality`` est falsy. - """ - if not quality: - return None - w = dict(DEFAULT_COMPLEXITY_WEIGHTS) - if weights: - for k in w: - if k in weights: - w[k] = float(weights[k]) - total = sum(w.values()) - if total <= 0: - return None - w = {k: v / total for k, v in w.items()} - noise = _clip01(_extract_feature(quality, "noise_level")) - sharpness = _clip01(_extract_feature(quality, "sharpness_score")) - contrast = _clip01(_extract_feature(quality, "contrast_score")) - rotation_deg = abs(_extract_feature(quality, "rotation_degrees")) - blur = 1.0 - sharpness - low_contrast = 1.0 - contrast - rotation = _clip01(rotation_deg / _ROTATION_SATURATION_DEG) - score = ( - w["noise_level"] * noise - + w["blur"] * blur - + w["low_contrast"] * low_contrast - + w["rotation"] * rotation - ) - return { - "score": _clip01(score), - "components": { - "noise": noise, - "blur": blur, - "low_contrast": low_contrast, - "rotation": rotation, - }, - "weights_used": w, - } - - -def compute_corpus_homogeneity( - image_qualities: Iterable[dict], -) -> Optional[dict]: - """Score d'homogénéité du corpus ∈ [0, 1]. - - 0 = corpus uniforme (faible variance entre documents), - 1 = corpus hétérogène. - - Méthode : pour chaque feature dans ``noise_level``, - ``sharpness_score``, ``contrast_score``, ``rotation_degrees``, - on calcule l'écart-type *normalisé* sur les documents (par - une plage de référence), puis on prend la moyenne des 4. - - Plages de normalisation : - - ``noise_level``, ``sharpness_score``, ``contrast_score`` - ∈ [0, 1] → écart-type / 0.5 (max théorique de l'écart-type - d'une distribution sur [0,1]) borné à 1. - - ``rotation_degrees`` → écart-type / 10°. - - Parameters - ---------- - image_qualities: - Itérable de dicts ``ImageQualityResult.as_dict()``. - - Returns - ------- - dict | None - ``{ - "score": float, # ∈ [0, 1] - "n_docs": int, - "per_feature": { - feature: {"mean": float, "stdev": float, - "normalised": float}, - }, - }`` ou ``None`` si moins de 2 documents. - """ - docs = [q for q in image_qualities if q] - if len(docs) < 2: - return None - features = ( - ("noise_level", 0.5), - ("sharpness_score", 0.5), - ("contrast_score", 0.5), - ("rotation_degrees", 10.0), - ) - per_feature: dict[str, dict] = {} - norm_stdevs: list[float] = [] - for key, divisor in features: - values = [ - _extract_feature(q, key) - for q in docs - ] - if not values: - continue - mean = statistics.fmean(values) - try: - stdev = statistics.stdev(values) if len(values) >= 2 else 0.0 - except statistics.StatisticsError: - stdev = 0.0 - normalised = _clip01(stdev / divisor) if divisor > 0 else 0.0 - per_feature[key] = { - "mean": mean, - "stdev": stdev, - "normalised": normalised, - } - norm_stdevs.append(normalised) - if not norm_stdevs: - return None - score = statistics.fmean(norm_stdevs) - return { - "score": _clip01(score), - "n_docs": len(docs), - "per_feature": per_feature, - } - - -def aggregate_corpus_predictive( - image_qualities: Iterable[dict], - *, - weights: Optional[dict[str, float]] = None, -) -> Optional[dict]: - """Synthèse corpus-wide : complexité moyenne + homogénéité. - - Returns - ------- - dict | None - ``{ - "n_docs": int, - "complexity_mean": float, - "complexity_median": float, - "complexity_min": float, - "complexity_max": float, - "complexity_stdev": float, - "homogeneity": dict, # sortie de - # compute_corpus_homogeneity - }`` ou ``None`` si moins d'un document. - """ - docs = [q for q in image_qualities if q] - if not docs: - return None - scores: list[float] = [] - for q in docs: - result = compute_paleographic_complexity(q, weights=weights) - if result is not None: - scores.append(float(result["score"])) - if not scores: - return None - homogeneity = compute_corpus_homogeneity(docs) - return { - "n_docs": len(docs), - "complexity_mean": statistics.fmean(scores), - "complexity_median": statistics.median(scores), - "complexity_min": min(scores), - "complexity_max": max(scores), - "complexity_stdev": ( - statistics.stdev(scores) if len(scores) >= 2 else 0.0 - ), - "homogeneity": homogeneity, - } - - -__all__ = [ - "DEFAULT_COMPLEXITY_WEIGHTS", - "compute_paleographic_complexity", - "compute_corpus_homogeneity", - "aggregate_corpus_predictive", -] - - -# Évite warning import inutilisé -_ = math +from picarones.evaluation.metrics.image_predictive import * # noqa: F401,F403 diff --git a/picarones/measurements/image_quality.py b/picarones/measurements/image_quality.py index 929bf67f7a4c0a60d2f7029ebdba72a6d665e1fb..498594dd2d08b5e2a0b8667491be3b1629db6c67 100644 --- a/picarones/measurements/image_quality.py +++ b/picarones/measurements/image_quality.py @@ -1,391 +1,14 @@ -"""Analyse automatique de la qualité des images de documents numérisés. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.image_quality``. -Métriques ---------- -- **Score de netteté** : variance du laplacien (plus élevé = plus net) -- **Niveau de bruit** : écart-type des résidus haute-fréquence -- **Angle de rotation résiduel** : estimé par projection horizontale -- **Score de contraste** : ratio Michelson entre zones sombres (encre) et claires (fond) -- **Score de qualité global** : combinaison normalisée des métriques ci-dessus +L'ancien chemin ``picarones.measurements.image_quality`` est conservé +pour ne casser aucun consommateur. Au S22, ce re-export disparaîtra. -Ces calculs sont réalisés en pur Python + bibliothèques stdlib ou Pillow. -NumPy est utilisé si disponible (calculs plus rapides), mais les méthodes -de fallback n'en dépendent pas. - -Note ----- -Pour les images placeholder (fixtures), des valeurs fictives cohérentes -sont générées via `generate_mock_quality_scores()`. +Ré-expose explicitement ``_global_quality_score`` (symbole privé +utilisé downstream). """ from __future__ import annotations -import logging -import math -import statistics -from dataclasses import dataclass -from pathlib import Path -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class ImageQualityResult: - """Métriques de qualité d'une image de document.""" - - sharpness_score: float = 0.0 - """Score de netteté [0, 1]. Basé sur la variance du laplacien normalisée.""" - - noise_level: float = 0.0 - """Niveau de bruit [0, 1]. 0 = pas de bruit, 1 = très bruité.""" - - rotation_degrees: float = 0.0 - """Angle de rotation résiduel estimé en degrés (positif = sens horaire).""" - - contrast_score: float = 0.0 - """Score de contraste [0, 1]. Ratio Michelson encre/fond.""" - - quality_score: float = 0.0 - """Score de qualité global [0, 1]. Combinaison pondérée des autres métriques.""" - - analysis_method: str = "none" - """Méthode d'analyse utilisée : 'pillow', 'numpy', 'mock'.""" - - error: Optional[str] = None - """Erreur si l'analyse a échoué.""" - - @property - def is_good_quality(self) -> bool: - """Vrai si le score de qualité global est ≥ 0.7.""" - return self.quality_score >= 0.7 - - @property - def quality_tier(self) -> str: - """Catégorie de qualité : 'good', 'medium', 'poor'.""" - if self.quality_score >= 0.7: - return "good" - elif self.quality_score >= 0.4: - return "medium" - return "poor" - - def as_dict(self) -> dict: - d = { - "sharpness_score": round(self.sharpness_score, 4), - "noise_level": round(self.noise_level, 4), - "rotation_degrees": round(self.rotation_degrees, 2), - "contrast_score": round(self.contrast_score, 4), - "quality_score": round(self.quality_score, 4), - "quality_tier": self.quality_tier, - "analysis_method": self.analysis_method, - } - if self.error: - d["error"] = self.error - return d - - @classmethod - def from_dict(cls, data: dict) -> "ImageQualityResult": - return cls( - sharpness_score=data.get("sharpness_score", 0.0), - noise_level=data.get("noise_level", 0.0), - rotation_degrees=data.get("rotation_degrees", 0.0), - contrast_score=data.get("contrast_score", 0.0), - quality_score=data.get("quality_score", 0.0), - analysis_method=data.get("analysis_method", "none"), - error=data.get("error"), - ) - - -def analyze_image_quality(image_path: str | Path) -> ImageQualityResult: - """Analyse la qualité d'une image de document numérisé. - - Essaie successivement : - 1. Pillow + NumPy (méthode complète) - 2. Pillow seul (méthode simplifiée) - 3. Fallback : retourne un résultat vide avec erreur - - Parameters - ---------- - image_path: - Chemin vers l'image (JPG, PNG, TIFF…). - - Returns - ------- - ImageQualityResult - """ - path = Path(image_path) - if not path.exists(): - return ImageQualityResult( - error=f"Fichier image introuvable : {image_path}", - analysis_method="none", - ) - - # Essai avec Pillow + NumPy - try: - import numpy as np - from PIL import Image - return _analyze_with_numpy(path, np, Image) - except ImportError: - pass - - # Essai avec Pillow seul - try: - from PIL import Image - return _analyze_with_pillow(path, Image) - except ImportError: - pass - - return ImageQualityResult( - error="Pillow non disponible (pip install Pillow)", - analysis_method="none", - quality_score=0.5, # valeur neutre - ) - - -def _analyze_with_numpy(path: Path, np, Image) -> ImageQualityResult: - """Analyse complète avec NumPy.""" - img = Image.open(path).convert("L") # niveaux de gris - arr = np.array(img, dtype=np.float32) - - # 1. Netteté : variance du laplacien - laplacian = _laplacian_variance_numpy(arr, np) - # Normalisation empirique : variance > 500 = très net, < 50 = flou - sharpness = min(1.0, laplacian / 500.0) - - # 2. Bruit : écart-type des résidus (différence image - image lissée) - noise = _noise_level_numpy(arr, np) - - # 3. Rotation : angle d'inclinaison estimé - rotation = _estimate_rotation_numpy(arr, np) - - # 4. Contraste : ratio Michelson - contrast = _contrast_score_numpy(arr, np) - - # 5. Score global pondéré - quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) - - return ImageQualityResult( - sharpness_score=float(sharpness), - noise_level=float(noise), - rotation_degrees=float(rotation), - contrast_score=float(contrast), - quality_score=float(quality), - analysis_method="numpy", - ) - - -def _analyze_with_pillow(path: Path, Image) -> ImageQualityResult: - """Analyse simplifiée avec Pillow seul (sans NumPy).""" - img = Image.open(path).convert("L") - pixels = list(img.tobytes()) # mode "L" = 1 byte/pixel - w, h = img.size - - if not pixels: - return ImageQualityResult(quality_score=0.5, analysis_method="pillow") - - # Contraste : étendue des valeurs - min_val = min(pixels) - max_val = max(pixels) - if max_val + min_val > 0: - contrast = (max_val - min_val) / (max_val + min_val) - else: - contrast = 0.0 - - # Netteté approximée : variance globale des pixels - try: - variance = statistics.variance(pixels) - except statistics.StatisticsError: - variance = 0.0 - sharpness = min(1.0, math.sqrt(variance) / 128.0) - - # Bruit : approximation grossière - noise = min(1.0, statistics.stdev(pixels[:min(1000, len(pixels))]) / 64.0) if len(pixels) > 1 else 0.0 - - quality = _global_quality_score(sharpness, noise, 0.0, contrast) - - return ImageQualityResult( - sharpness_score=sharpness, - noise_level=noise, - rotation_degrees=0.0, # non calculé sans NumPy - contrast_score=contrast, - quality_score=quality, - analysis_method="pillow", - ) - - -def _laplacian_variance_numpy(arr, np) -> float: - """Calcule la variance du laplacien (mesure de netteté).""" - # Convolution laplacien 3x3 via slicing (bordures ignorées) - h, w = arr.shape - if h < 3 or w < 3: - return float(np.var(arr)) - - # Utiliser une convolution rapide avec slicing - center = arr[1:-1, 1:-1] - top = arr[:-2, 1:-1] - bottom = arr[2:, 1:-1] - left = arr[1:-1, :-2] - right = arr[1:-1, 2:] - lap = top + bottom + left + right - 4 * center - - return float(np.var(lap)) - - -def _noise_level_numpy(arr, np) -> float: - """Estime le niveau de bruit par la MAD (Median Absolute Deviation) des gradients.""" - h, w = arr.shape - if h < 2 or w < 2: - return 0.0 - # Différences horizontales et verticales - diff_h = np.abs(arr[:, 1:] - arr[:, :-1]) - diff_v = np.abs(arr[1:, :] - arr[:-1, :]) - noise_std = float(np.median(np.concatenate([diff_h.ravel(), diff_v.ravel()]))) - # Normaliser : 0 = pas de bruit, 1 = très bruité (seuil à ~30) - return min(1.0, noise_std / 30.0) - - -def _estimate_rotation_numpy(arr, np) -> float: - """Estime l'angle de rotation par projection horizontale simplifiée. - - Retourne l'angle estimé en degrés [-45, 45]. - """ - # Méthode simplifiée : analyse de la variance des projections à différents angles - # Limiter à quelques angles pour la performance - h, w = arr.shape - if h < 20 or w < 20: - return 0.0 - - # Sous-échantillonnage pour la performance - step = max(1, h // 100) - sample = arr[::step, :] - - best_angle = 0.0 - best_var = -1.0 - - for angle_deg in range(-5, 6): # ±5 degrés, pas de 1° - angle_rad = math.radians(angle_deg) - # Projection horizontale après rotation approximative - # (approximation linéaire rapide) - offsets = np.round( - np.arange(sample.shape[0]) * math.tan(angle_rad) - ).astype(int) - offsets = np.clip(offsets, 0, w - 1) - - # Variance des sommes de lignes décalées - try: - row_sums = np.array([ - float(np.sum(sample[i, max(0, offsets[i]):min(w, offsets[i]+w)])) - for i in range(sample.shape[0]) - ]) - var = float(np.var(row_sums)) - if var > best_var: - best_var = var - best_angle = float(angle_deg) - except Exception as e: - logger.warning( - "[image_quality] projection à %d° indisponible : %s", - angle_deg, e, - ) - - return best_angle - - -def _contrast_score_numpy(arr, np) -> float: - """Score de contraste Michelson [0, 1].""" - p5 = float(np.percentile(arr, 5)) # fond clair - p95 = float(np.percentile(arr, 95)) # encre sombre - if p5 + p95 == 0: - return 0.0 - # Michelson : (Imax - Imin) / (Imax + Imin) - return float((p95 - p5) / (p95 + p5)) - - -def _global_quality_score( - sharpness: float, - noise: float, - rotation_abs: float, - contrast: float, -) -> float: - """Calcule le score de qualité global pondéré.""" - # Poids : netteté (40%), contraste (30%), bruit (20%), rotation (10%) - score = ( - 0.40 * sharpness - + 0.30 * contrast - + 0.20 * (1.0 - noise) # moins de bruit = mieux - + 0.10 * max(0.0, 1.0 - rotation_abs / 10.0) # ±10° max - ) - return round(min(1.0, max(0.0, score)), 4) - - -# --------------------------------------------------------------------------- -# Données fictives pour les fixtures de démo -# --------------------------------------------------------------------------- - -def generate_mock_quality_scores( - doc_id: str, - seed: Optional[int] = None, -) -> ImageQualityResult: - """Génère des métriques de qualité fictives mais cohérentes pour un document. - - Utilisé par les fixtures de démo pour simuler une diversité réaliste - de qualités d'image (bonne, moyenne, dégradée). - - Parameters - ---------- - doc_id: - Identifiant du document (utilisé pour la reproductibilité). - seed: - Graine aléatoire optionnelle. - """ - import random - rng = random.Random(seed or hash(doc_id) % 2**32) - - # Générer une qualité cohérente : certains docs sont plus difficiles - base_quality = 0.3 + rng.random() * 0.6 # 0.3 à 0.9 - - sharpness = max(0.1, min(1.0, base_quality + rng.gauss(0, 0.1))) - noise = max(0.0, min(1.0, (1.0 - base_quality) * 0.8 + rng.gauss(0, 0.05))) - rotation = rng.gauss(0, 1.5) # ±1.5° typique - contrast = max(0.2, min(1.0, base_quality + rng.gauss(0, 0.15))) - - quality = _global_quality_score(sharpness, noise, abs(rotation), contrast) - - return ImageQualityResult( - sharpness_score=round(sharpness, 4), - noise_level=round(noise, 4), - rotation_degrees=round(rotation, 2), - contrast_score=round(contrast, 4), - quality_score=round(quality, 4), - analysis_method="mock", - ) - - -def aggregate_image_quality(results: list[ImageQualityResult]) -> dict: - """Agrège les métriques de qualité image sur un corpus.""" - if not results: - return {} - - valid = [r for r in results if r.error is None] - if not valid: - return {"error": "Aucune analyse réussie"} - - def _mean(vals: list[float]) -> float: - return round(statistics.mean(vals), 4) if vals else 0.0 - - quality_scores = [r.quality_score for r in valid] - sharpness_scores = [r.sharpness_score for r in valid] - noise_levels = [r.noise_level for r in valid] - - # Distribution par tier - tiers = {"good": 0, "medium": 0, "poor": 0} - for r in valid: - tiers[r.quality_tier] += 1 - - return { - "mean_quality_score": _mean(quality_scores), - "mean_sharpness": _mean(sharpness_scores), - "mean_noise_level": _mean(noise_levels), - "quality_distribution": tiers, - "document_count": len(valid), - "scores": [r.quality_score for r in valid], # pour scatter plot - } +from picarones.evaluation.metrics.image_quality import * # noqa: F401,F403 +from picarones.evaluation.metrics.image_quality import _global_quality_score # noqa: F401 diff --git a/picarones/measurements/incremental_comparison.py b/picarones/measurements/incremental_comparison.py index 8dcd0f6d95b85d94472aa99fffab926755e89be3..8b5a4a2bf806d7d85ea3c78c1f764c9bed897567 100644 --- a/picarones/measurements/incremental_comparison.py +++ b/picarones/measurements/incremental_comparison.py @@ -1,253 +1,10 @@ -"""Comparaison incrémentale de pipelines composées — Sprint 96 (B.5). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.incremental_comparison``. -Sprint 96 — B.5 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Avec 5 OCR × 3 reconstructeurs × 4 post-correcteurs × 3 -mappeurs = 180 pipelines à comparer, le rapport noie -l'information. Il faut un mécanisme de **comparaison -contrôlée** type design d'expérience. - -Méthode -------- -Pour mesurer l'effet isolé d'un slot ``varying`` : - -1. Fixer les valeurs des autres slots (``fixed``). -2. Pour chaque combinaison des fixed, comparer les pipelines - qui ne diffèrent que sur le slot varying. -3. Agréger : pour chaque valeur du slot varying, calculer - sa moyenne, son écart-type, son rang moyen sur les groupes. - -C'est presque un Latin square automatisé. Sans ça, le -rapport sur 180 pipelines est inutilisable. - -Pas de tests statistiques scipy -------------------------------- -On ne reconstruit pas Friedman/Nemenyi (déjà dans Sprint 18) ; -on agrège ici les données nécessaires pour qu'un -tests statistique externe puisse les consommer. Le rapport -existant reste libre de brancher -``picarones.measurements.statistics.friedman_test`` sur la sortie de -ce module. - -Sortie ------- -``compare_isolated_effect(runs, varying_slot)`` retourne : - -.. code-block:: text - - { - "varying_slot": str, - "n_runs": int, - "n_groups": int, # combinaisons fixed distinctes - "values": list[str], # valeurs distinctes du slot - "per_value": {value: { - "n_observations": int, - "mean": float | None, - "stdev": float | None, - "min": float, "max": float, - "mean_rank": float | None, - }}, - "best_value": str | None, - "worst_value": str | None, - "groups": list[dict], # détail par groupe - } +L'ancien chemin ``picarones.measurements.incremental_comparison`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import statistics -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass(frozen=True) -class PipelineRun: - """Un run de pipeline composée pour la comparaison contrôlée. - - Attributes - ---------- - name: - Nom du run (libre — informatif uniquement). - slots: - Map ``{slot_name: module_name}`` décrivant la pipeline - (ex. ``{"ocr": "tess", "llm": "gpt-4o"}``). - score: - Métrique numérique à comparer (CER moyen typiquement). - Plus bas = meilleur par convention sauf si - ``higher_is_better=True`` est passé à - ``compare_isolated_effect``. - """ - - name: str - slots: dict[str, str] - score: float - - def as_dict(self) -> dict: - return { - "name": self.name, - "slots": dict(self.slots), - "score": self.score, - } - - -def _normalise_runs(runs) -> list[PipelineRun]: - """Accepte une liste de ``PipelineRun`` ou de dicts compatibles.""" - out: list[PipelineRun] = [] - for r in runs: - if isinstance(r, PipelineRun): - out.append(r) - continue - if not isinstance(r, dict): - continue - slots = r.get("slots") or {} - if not isinstance(slots, dict): - continue - try: - score = float(r.get("score")) - except (TypeError, ValueError): - continue - out.append(PipelineRun( - name=str(r.get("name") or ""), - slots={str(k): str(v) for k, v in slots.items()}, - score=score, - )) - return out - - -def compare_isolated_effect( - runs, - varying_slot: str, - *, - higher_is_better: bool = False, -) -> Optional[dict]: - """Mesure l'effet isolé du slot ``varying_slot``. - - Parameters - ---------- - runs: - Liste de ``PipelineRun`` (ou dicts compatibles). - varying_slot: - Nom du slot dont on veut isoler l'effet. Les autres - slots constituent les groupes de contrôle. - higher_is_better: - Si ``True``, on inverse la convention de classement - (rang 1 = score le plus haut). Défaut ``False`` = - rang 1 = score le plus bas (CER). - - Returns - ------- - dict | None - ``None`` si moins de 2 runs ou si ``varying_slot`` - n'est présent dans aucun run. - """ - runs_list = _normalise_runs(runs) - if len(runs_list) < 2: - return None - runs_list = [r for r in runs_list if varying_slot in r.slots] - if not runs_list: - return None - - # Constitue les groupes par valeurs des slots fixed - groups: dict[tuple, list[PipelineRun]] = {} - fixed_slot_names: list[str] = [] - for r in runs_list: - other_slots = sorted(k for k in r.slots if k != varying_slot) - if not fixed_slot_names: - fixed_slot_names = other_slots - # Skip runs avec un schéma de slots incompatible - if other_slots != fixed_slot_names: - continue - key = tuple((k, r.slots[k]) for k in other_slots) - groups.setdefault(key, []).append(r) - - if not groups: - return None - - # Pour chaque groupe : ranking des runs par score - per_value: dict[str, dict] = {} - group_details: list[dict] = [] - for key, members in groups.items(): - members_sorted = sorted( - members, key=lambda x: x.score, reverse=higher_is_better, - ) - # Rangs : runs ex aequo partagent la moyenne des rangs - ranks: dict[str, float] = {} - i = 0 - while i < len(members_sorted): - j = i - while ( - j + 1 < len(members_sorted) - and members_sorted[j + 1].score == members_sorted[i].score - ): - j += 1 - avg_rank = (i + 1 + j + 1) / 2 - for k in range(i, j + 1): - value = members_sorted[k].slots[varying_slot] - ranks[value] = avg_rank - i = j + 1 - - for r in members: - value = r.slots[varying_slot] - slot = per_value.setdefault(value, { - "scores": [], - "ranks": [], - }) - slot["scores"].append(r.score) - slot["ranks"].append(ranks[value]) - group_details.append({ - "fixed_slots": dict(key), - "n_members": len(members), - "values": [r.slots[varying_slot] for r in members_sorted], - "scores": [r.score for r in members_sorted], - }) - - # Calcul mean/stdev/min/max + rang moyen par valeur - summary: dict[str, dict] = {} - for value, slot in per_value.items(): - scores = slot["scores"] - ranks = slot["ranks"] - summary[value] = { - "n_observations": len(scores), - "mean": statistics.fmean(scores) if scores else None, - "stdev": ( - statistics.stdev(scores) if len(scores) >= 2 else None - ), - "min": min(scores), - "max": max(scores), - "mean_rank": ( - statistics.fmean(ranks) if ranks else None - ), - } - - # Best/worst : sur la mean (convention CER : plus bas = meilleur) - by_mean = sorted( - ((v, d["mean"]) for v, d in summary.items() - if d["mean"] is not None), - key=lambda kv: kv[1], - reverse=higher_is_better, - ) - best_value = by_mean[0][0] if by_mean else None - worst_value = by_mean[-1][0] if by_mean else None - - return { - "varying_slot": varying_slot, - "n_runs": len(runs_list), - "n_groups": len(groups), - "values": sorted(per_value.keys()), - "per_value": summary, - "best_value": best_value, - "worst_value": worst_value, - "groups": group_details, - "higher_is_better": higher_is_better, - } - - -__all__ = [ - "PipelineRun", - "compare_isolated_effect", -] +from picarones.evaluation.metrics.incremental_comparison import * # noqa: F401,F403 diff --git a/picarones/measurements/inter_engine.py b/picarones/measurements/inter_engine.py index 68576f0ef9792451092a94aadeafb2c9aea4cf97..37882d4c73a1699193356248f8e34a37dc6fdb8d 100644 --- a/picarones/measurements/inter_engine.py +++ b/picarones/measurements/inter_engine.py @@ -1,484 +1,10 @@ -"""Métriques inter-moteurs (Sprint 35 — Étape 2 du plan d'évolution). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.inter_engine``. -Deux familles de mesures qui répondent à des questions différentes mais -liées : - -1. **Divergence taxonomique** (`kl_divergence`, `jensen_shannon_divergence`, - `taxonomy_divergence_matrix`) — *à quel point les moteurs font-ils des - erreurs de natures différentes ?* Une divergence élevée signale des - moteurs spécialisés sur des classes d'erreurs distinctes (visual vs - abréviation vs casse) et donc des candidats pour un voting ensemble. - -2. **Complémentarité** (`oracle_token_recall`, `complementarity_gap`, - `pairwise_disagreement_rate`) — *quel CER serait atteignable si on - combinait les moteurs ?* La borne inférieure du CER atteignable par - un voting majoritaire token-level est ``1 - oracle_token_recall``. - Si elle est très inférieure au CER du meilleur moteur seul, l'effort - d'un pipeline d'ensemble se justifie. Sinon non. - -Convention de typage --------------------- -Toutes les fonctions sont enregistrables dans le registre Sprint 34 si -on les wrappe par un adaptateur ``(input_types=(TEXT, TEXT))``. Pour -limiter le bruit, on ne les enregistre **pas** automatiquement : ce sont -des métriques d'agrégation (multi-moteurs ou multi-documents) qui ne -correspondent pas au modèle « une jonction = une métrique » du runner. -Elles sont consommées par les détecteurs narratifs et le rapport HTML. - -Note sur l'oracle ------------------ -La métrique ``oracle_token_recall`` retournée ici utilise un alignement -bag-of-words pondéré par multiplicité. Ce n'est **pas** une vraie -borne atteignable par voting majoritaire séquentiel — c'est une borne -supérieure (proxy optimiste). La vraie borne demanderait un -alignement séquentiel des hypothèses, ce qui est plus coûteux. Pour -le diagnostic « ensemble vaut-il le coup ? », le proxy suffit -largement ; on documente clairement la limite dans le glossaire et le -rapport. +L'ancien chemin ``picarones.measurements.inter_engine`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import math -from collections import Counter - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Divergence taxonomique (KL / Jensen-Shannon) -# ────────────────────────────────────────────────────────────────────────── - - -def _smoothed_distribution( - distribution: dict[str, float], - keys: list[str], - epsilon: float = 1e-12, -) -> list[float]: - """Aligne une distribution sur l'ordre de ``keys`` et lisse les zéros. - - Le lissage évite ``log(0)`` dans la KL. ``epsilon`` est volontairement - minuscule pour ne pas modifier le résultat de manière sensible. - """ - smoothed = [max(distribution.get(k, 0.0), epsilon) for k in keys] - total = sum(smoothed) - return [v / total for v in smoothed] - - -def kl_divergence(p: dict[str, float], q: dict[str, float]) -> float: - """KL-divergence ``D(P||Q)`` en bits, sur l'union des clés. - - Les distributions n'ont pas besoin de partager exactement les mêmes - clés ; les clés manquantes sont lissées à ``epsilon`` puis - renormalisées. - - Returns - ------- - float - ``D(P||Q) ≥ 0``. Vaut 0 si et seulement si P == Q. N'est pas - symétrique : ``kl(p, q) != kl(q, p)`` en général. - """ - keys = sorted(set(p.keys()) | set(q.keys())) - if not keys: - return 0.0 - p_vec = _smoothed_distribution(p, keys) - q_vec = _smoothed_distribution(q, keys) - return sum(pi * math.log2(pi / qi) for pi, qi in zip(p_vec, q_vec)) - - -def jensen_shannon_divergence( - p: dict[str, float], - q: dict[str, float], -) -> float: - """JS-divergence symétrique en bits, bornée dans ``[0, 1]``. - - ``JS(P, Q) = ½ D(P||M) + ½ D(Q||M)`` avec ``M = (P + Q) / 2``. - Symétrique et bornée — préférable à la KL pour construire une - matrice triangulaire de divergences entre moteurs. - """ - keys = sorted(set(p.keys()) | set(q.keys())) - if not keys: - return 0.0 - p_vec = _smoothed_distribution(p, keys) - q_vec = _smoothed_distribution(q, keys) - m_vec = [(pi + qi) / 2.0 for pi, qi in zip(p_vec, q_vec)] - - def _kl(a: list[float], b: list[float]) -> float: - return sum(ai * math.log2(ai / bi) for ai, bi in zip(a, b) if ai > 0) - - js = 0.5 * _kl(p_vec, m_vec) + 0.5 * _kl(q_vec, m_vec) - # Borne théorique : JS ∈ [0, 1] en bits. Clamp pour absorber les - # erreurs d'arrondi flottant. - return max(0.0, min(1.0, js)) - - -def taxonomy_divergence_matrix( - distributions: dict[str, dict[str, float]], - metric: str = "js", -) -> dict[str, dict[str, float]]: - """Construit la matrice de divergence triangulaire entre moteurs. - - Parameters - ---------- - distributions: - ``{engine_name: {error_class: probability}}``. Chaque - distribution doit sommer à environ 1 (pas de validation stricte - — les distributions taxonomiques de Picarones sont déjà - normalisées par ``aggregate_taxonomy``). - metric: - ``"js"`` (défaut, symétrique) ou ``"kl"`` (asymétrique). - - Returns - ------- - dict[str, dict[str, float]] - Matrice ``{engine_a: {engine_b: divergence}}`` symétrique pour - ``js``, asymétrique pour ``kl``. La diagonale vaut 0. - """ - if metric not in ("js", "kl"): - raise ValueError(f"metric doit être 'js' ou 'kl' — reçu {metric!r}") - fn = jensen_shannon_divergence if metric == "js" else kl_divergence - - engines = sorted(distributions.keys()) - matrix: dict[str, dict[str, float]] = {a: {} for a in engines} - for a in engines: - for b in engines: - if a == b: - matrix[a][b] = 0.0 - elif metric == "js" and b in matrix and a in matrix[b]: - # Symétrique : recopie pour éviter de recalculer - matrix[a][b] = matrix[b][a] - else: - matrix[a][b] = fn(distributions[a], distributions[b]) - return matrix - - -# ────────────────────────────────────────────────────────────────────────── -# Complémentarité (oracle token recall) -# ────────────────────────────────────────────────────────────────────────── - - -def _word_multiset(text: str) -> Counter[str]: - """Décomposition en multiset de tokens (séparateur whitespace).""" - return Counter(tok for tok in text.split() if tok) - - -def oracle_token_recall( - reference: str, - hypotheses: dict[str, str], -) -> float: - """Borne supérieure (proxy bag-of-words) du token-recall atteignable - par un voting majoritaire entre tous les moteurs fournis. - - Pour chaque token de la référence (avec sa multiplicité), on - considère qu'il est "préservé" par l'ensemble si au moins un moteur - en produit une occurrence non encore comptée. Le score est le ratio - d'occurrences GT préservées sur le total. - - Parameters - ---------- - reference: - Texte GT. - hypotheses: - ``{engine_name: hypothesis_text}``. - - Returns - ------- - float - Ratio dans ``[0, 1]``. ``1.0`` = chaque token GT est présent - dans au moins une hypothèse à hauteur de sa multiplicité. - - Note - ---- - Cette borne est **optimiste** (supérieure à la vraie borne par - voting séquentiel) car elle ignore l'ordre d'apparition. Pour le - diagnostic « un voting vaut-il l'effort ? » le proxy suffit ; pour - une vraie borne il faudrait un alignement séquentiel. - """ - ref_counter = _word_multiset(reference) - if not ref_counter or not hypotheses: - return 1.0 if not ref_counter else 0.0 - - hyp_counters = [_word_multiset(h) for h in hypotheses.values()] - total_ref = sum(ref_counter.values()) - preserved = 0 - for token, gt_count in ref_counter.items(): - # Pour chaque moteur, le nombre d'occurrences disponibles, plafonné - # à la multiplicité GT. L'oracle prend le max sur les moteurs. - best = max((min(gt_count, hc.get(token, 0)) for hc in hyp_counters), default=0) - preserved += best - return preserved / total_ref - - -def complementarity_gap( - reference: str, - hypotheses: dict[str, str], -) -> dict[str, float]: - """Compare l'oracle au meilleur moteur seul. - - Returns - ------- - dict - ``{ - "oracle_recall": float, # bag-of-words recall de l'oracle - "best_single_recall": float, # meilleur recall token d'un moteur seul - "best_engine": str, # nom du moteur correspondant - "absolute_gap": float, # oracle - best_single (toujours ≥ 0) - "relative_gap": float, # absolute_gap / (1 - best_single + ε) - # = fraction des erreurs encore évitables - # par un ensemble - }`` - """ - ref_counter = _word_multiset(reference) - total = sum(ref_counter.values()) - if not total: - return { - "oracle_recall": 1.0, - "best_single_recall": 1.0, - "best_engine": "", - "absolute_gap": 0.0, - "relative_gap": 0.0, - } - - def _single_recall(hyp_text: str) -> float: - hc = _word_multiset(hyp_text) - preserved = sum(min(gt, hc.get(tok, 0)) for tok, gt in ref_counter.items()) - return preserved / total - - if not hypotheses: - return { - "oracle_recall": 0.0, - "best_single_recall": 0.0, - "best_engine": "", - "absolute_gap": 0.0, - "relative_gap": 0.0, - } - - per_engine = {name: _single_recall(h) for name, h in hypotheses.items()} - best_engine, best_recall = max(per_engine.items(), key=lambda kv: kv[1]) - oracle = oracle_token_recall(reference, hypotheses) - - absolute_gap = max(0.0, oracle - best_recall) - # relative_gap : fraction des erreurs du meilleur moteur que l'ensemble - # serait théoriquement capable de récupérer (∈ [0, 1]) - headroom = max(1.0 - best_recall, 1e-12) - relative_gap = min(1.0, absolute_gap / headroom) - - return { - "oracle_recall": oracle, - "best_single_recall": best_recall, - "best_engine": best_engine, - "absolute_gap": absolute_gap, - "relative_gap": relative_gap, - } - - -def pairwise_disagreement_rate( - reference: str, - hyp_a: str, - hyp_b: str, -) -> float: - """Fraction de tokens GT pour lesquels A et B sont en désaccord. - - Un désaccord = (l'un préserve le token, l'autre non) OU - (les deux le ratent mais avec des substitutions différentes — non - capturé ici, on reste sur la version simple présence/absence). - - Returns - ------- - float - Ratio dans ``[0, 1]``. ``0`` = A et B font les mêmes choix - (pas de gain d'ensemble). ``1`` = A et B sont toujours en - désaccord (gain d'ensemble maximal). - """ - ref_counter = _word_multiset(reference) - if not ref_counter: - return 0.0 - a = _word_multiset(hyp_a) - b = _word_multiset(hyp_b) - total = sum(ref_counter.values()) - disagree = 0 - for tok, gt_count in ref_counter.items(): - a_pres = min(gt_count, a.get(tok, 0)) - b_pres = min(gt_count, b.get(tok, 0)) - # Compte les positions où A et B donnent une réponse différente - disagree += abs(a_pres - b_pres) - return disagree / total - - -# ────────────────────────────────────────────────────────────────────────── -# Agrégation au niveau benchmark (Sprint 36) -# ────────────────────────────────────────────────────────────────────────── - - -def compute_inter_engine_analysis( - *, - per_engine_outputs: dict[str, dict[str, str]], - ground_truths: dict[str, str], - taxonomy_distributions: dict[str, dict[str, float]] | None = None, - divergence_metric: str = "js", -) -> dict: - """Agrège les métriques inter-moteurs sur l'ensemble du corpus. - - Parameters - ---------- - per_engine_outputs: - ``{engine_name: {doc_id: hypothesis_text}}``. Une entrée par - moteur, avec une hypothèse par document. Les documents absents - d'un moteur (échecs, timeouts) sont simplement ignorés pour ce - moteur — l'oracle est calculé sur les moteurs qui ont produit - une sortie pour le doc. - ground_truths: - ``{doc_id: ground_truth_text}``. La GT est la même pour tous - les moteurs ; on la passe une seule fois. - taxonomy_distributions: - ``{engine_name: {error_class: probability}}`` — typiquement - ``EngineReport.aggregated_taxonomy["class_distribution"]``. Si - ``None`` ou vide, la divergence taxonomique n'est pas calculée. - divergence_metric: - ``"js"`` (défaut, symétrique) ou ``"kl"``. - - Returns - ------- - dict - Structure stable consommable par les détecteurs narratifs et le - rapport HTML : - ``{ - "complementarity": { - "oracle_recall": float, - "best_single_recall": float, - "best_engine": str, - "absolute_gap": float, - "relative_gap": float, - "doc_count": int, - "per_doc": [{doc_id, oracle, best, gap}, ...] # max 50 docs - }, - "taxonomy_divergence": { - "metric": "js"|"kl", - "matrix": {engine_a: {engine_b: divergence}}, - "max_pair": [engine_a, engine_b, value] # paire la plus divergente - } | None, - "engines": [...], # liste des moteurs analysés (ordre stable) - }`` - """ - engines = sorted(per_engine_outputs.keys()) - result: dict = {"engines": engines} - - # ── Complémentarité agrégée doc par doc ────────────────────────────── - if not engines: - result["complementarity"] = None - else: - total_oracle_preserved = 0 - total_ref_tokens = 0 - per_engine_preserved: dict[str, int] = {name: 0 for name in engines} - per_doc_records: list[dict] = [] - - for doc_id, gt in ground_truths.items(): - ref_counter = _word_multiset(gt) - ref_total = sum(ref_counter.values()) - if not ref_total: - continue - total_ref_tokens += ref_total - - doc_hyps: dict[str, str] = {} - for name in engines: - hyp = per_engine_outputs.get(name, {}).get(doc_id) - if hyp is not None: - doc_hyps[name] = hyp - - if not doc_hyps: - continue - - hyp_counters = {n: _word_multiset(h) for n, h in doc_hyps.items()} - - doc_oracle = 0 - doc_best_per_engine: dict[str, int] = {n: 0 for n in doc_hyps} - for tok, gt_count in ref_counter.items(): - # Oracle : meilleur des moteurs sur ce token - best_for_token = 0 - for name, hc in hyp_counters.items(): - preserved = min(gt_count, hc.get(tok, 0)) - doc_best_per_engine[name] += preserved - if preserved > best_for_token: - best_for_token = preserved - doc_oracle += best_for_token - - total_oracle_preserved += doc_oracle - for name, count in doc_best_per_engine.items(): - per_engine_preserved[name] += count - - doc_best = max(doc_best_per_engine.values()) if doc_best_per_engine else 0 - per_doc_records.append({ - "doc_id": doc_id, - "oracle_recall": doc_oracle / ref_total, - "best_single_recall": doc_best / ref_total, - "absolute_gap": (doc_oracle - doc_best) / ref_total, - }) - - if total_ref_tokens == 0: - result["complementarity"] = None - else: - oracle_recall = total_oracle_preserved / total_ref_tokens - recalls = { - name: per_engine_preserved[name] / total_ref_tokens - for name in engines - } - best_engine, best_recall = max(recalls.items(), key=lambda kv: kv[1]) - absolute_gap = max(0.0, oracle_recall - best_recall) - headroom = max(1.0 - best_recall, 1e-12) - relative_gap = min(1.0, absolute_gap / headroom) - - # Garder les ``per_doc_records`` les plus instructifs : tri par - # gap absolu décroissant, top 50. Les détecteurs narratifs - # n'en consomment que quelques-uns. - per_doc_records.sort(key=lambda r: r["absolute_gap"], reverse=True) - per_doc_top = per_doc_records[:50] - - result["complementarity"] = { - "oracle_recall": oracle_recall, - "best_single_recall": best_recall, - "best_engine": best_engine, - "absolute_gap": absolute_gap, - "relative_gap": relative_gap, - "doc_count": len(per_doc_records), - "per_engine_recall": recalls, - "per_doc": per_doc_top, - } - - # ── Divergence taxonomique ───────────────────────────────────────── - if not taxonomy_distributions: - result["taxonomy_divergence"] = None - else: - matrix = taxonomy_divergence_matrix( - taxonomy_distributions, - metric=divergence_metric, - ) - # Cherche la paire la plus divergente (utile pour la synthèse - # narrative qui veut nommer les deux moteurs candidats à - # l'ensemble). - max_pair: tuple[str, str, float] = ("", "", 0.0) - names = sorted(matrix.keys()) - for i, a in enumerate(names): - for b in names[i + 1:]: - v = matrix[a][b] - if v > max_pair[2]: - max_pair = (a, b, v) - - result["taxonomy_divergence"] = { - "metric": divergence_metric, - "matrix": matrix, - "max_pair": list(max_pair) if max_pair[2] > 0 else None, - } - - return result - - -__all__ = [ - "kl_divergence", - "jensen_shannon_divergence", - "taxonomy_divergence_matrix", - "oracle_token_recall", - "complementarity_gap", - "pairwise_disagreement_rate", - "compute_inter_engine_analysis", -] +from picarones.evaluation.metrics.inter_engine import * # noqa: F401,F403 diff --git a/picarones/measurements/layout.py b/picarones/measurements/layout.py index 477d247e8b531c1aeafa97ee6b76ac064479904b..557782cf06f9ae16d5d6374397029f726644c469 100644 --- a/picarones/measurements/layout.py +++ b/picarones/measurements/layout.py @@ -1,280 +1,14 @@ -"""Layout F1 par type de région — Sprint 54. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.layout``. -Sprint 54 — A.II.2.2 du plan d'évolution 2026. +L'ancien chemin ``picarones.measurements.layout`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. -Pourquoi ce module ------------------- -Un médiéviste qui édite un manuscrit glosé veut savoir : *« le moteur -sépare-t-il bien le texte principal de la glose ? »*. Le score de -structure global de Picarones (Sprint 5) agrège fusion/fragmentation -de lignes en un seul nombre — utile mais non typé. Ce module -discrimine par **type de région** ALTO/PAGE (``TextRegion``, -``MarginNote``, ``Header``, ``Footer``, ``Drop-Cap``...) en -appliquant le pattern ICDAR layout standard : - -- **TP** : région GT et région hypothèse de **même type** avec - chevauchement IoU ≥ seuil (alignement greedy par IoU décroissant), -- **FN** : région GT non matchée, -- **FP** : région hypothèse non matchée, -- F1 calculé global et par type. - -Le pattern d'alignement est le même que pour le NER (Sprint 38) — on -réutilise une approche éprouvée plutôt que d'en inventer une nouvelle. - -Stratégie de découpage ----------------------- -Cohérente avec NER (Sprint 38), Flesch (Sprint 52), Reading order F1 -(Sprint 53) : couche de calcul pure d'abord. L'utilisateur fournit -deux listes de ``Region`` (typiquement extraites de ALTO/PAGE par un -parser amont — le parser ALTO/PAGE standard de Picarones suivra -dans un sprint dédié). Pas de câblage runner ni de vue HTML ici. - -Convention de coordonnées -------------------------- -Une bbox est un tuple ``(x, y, width, height)`` en pixels (origine -en haut à gauche, axe y vers le bas — convention ALTO et PAGE -standard). L'IoU est calculée sur l'aire d'intersection / union des -rectangles. +Ré-expose explicitement le symbole privé ``_iou_bbox`` qu'au moins +un test importe directement. """ from __future__ import annotations -import logging -from dataclasses import dataclass -from typing import Iterable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle de données -# ────────────────────────────────────────────────────────────────────────── - - -@dataclass(frozen=True) -class Region: - """Une région ALTO/PAGE alignable sur sa GT. - - Attributs - --------- - id: - Identifiant unique au sein de la séquence (ex. ``"r_1"``, - ``"region_main"``). Informatif — l'alignement se fait par IoU, - pas par ID. - type: - Catégorie de la région (``"TextRegion"``, ``"MarginNote"``, - ``"Header"``, etc.). Comparaison **case-insensitive**. - bbox: - Rectangle ``(x, y, width, height)`` en pixels, origine en haut - à gauche. Doit avoir width > 0 et height > 0. - """ - - id: str - type: str - bbox: tuple[int, int, int, int] - - def __post_init__(self) -> None: - x, y, w, h = self.bbox - if w <= 0 or h <= 0: - raise ValueError( - f"Region {self.id!r} : bbox invalide (w={w}, h={h}). " - "width et height doivent être strictement positifs." - ) - - @property - def area(self) -> int: - _, _, w, h = self.bbox - return w * h - - -def _to_region(obj: Region | dict) -> Region: - """Coerce un dict en ``Region`` (clés ``id``, ``type``, ``bbox``).""" - if isinstance(obj, Region): - return obj - return Region( - id=str(obj["id"]), - type=str(obj["type"]), - bbox=tuple(obj["bbox"]), # type: ignore[arg-type] - ) - - -# ────────────────────────────────────────────────────────────────────────── -# IoU + alignement greedy -# ────────────────────────────────────────────────────────────────────────── - - -def _iou_bbox(a: Region, b: Region) -> float: - """Intersection-over-Union de deux bboxes ``(x, y, w, h)``.""" - ax, ay, aw, ah = a.bbox - bx, by, bw, bh = b.bbox - inter_x = max(ax, bx) - inter_y = max(ay, by) - inter_x_end = min(ax + aw, bx + bw) - inter_y_end = min(ay + ah, by + bh) - inter_w = max(0, inter_x_end - inter_x) - inter_h = max(0, inter_y_end - inter_y) - inter = inter_w * inter_h - if inter == 0: - return 0.0 - union = a.area + b.area - inter - if union <= 0: - return 0.0 - return inter / union - - -def _align_regions( - references: list[Region], - hypotheses: list[Region], - iou_threshold: float, -) -> tuple[list[tuple[int, int, float]], set[int], set[int]]: - """Appareillage greedy par IoU décroissant ; same type requis. - - Renvoie ``(matches, unmatched_refs, unmatched_hyps)`` — - ``matches`` est une liste de ``(idx_ref, idx_hyp, iou)``. - """ - candidates: list[tuple[float, int, int]] = [] - for i, r in enumerate(references): - for j, h in enumerate(hypotheses): - if r.type.casefold() != h.type.casefold(): - continue - iou = _iou_bbox(r, h) - if iou >= iou_threshold: - candidates.append((iou, i, j)) - - # Tri stable : IoU décroissant, puis indices croissants pour - # déterminisme sur égalités. - candidates.sort(key=lambda t: (-t[0], t[1], t[2])) - - matched_refs: set[int] = set() - matched_hyps: set[int] = set() - matches: list[tuple[int, int, float]] = [] - for iou, i, j in candidates: - if i in matched_refs or j in matched_hyps: - continue - matched_refs.add(i) - matched_hyps.add(j) - matches.append((i, j, iou)) - - unmatched_refs = set(range(len(references))) - matched_refs - unmatched_hyps = set(range(len(hypotheses))) - matched_hyps - return matches, unmatched_refs, unmatched_hyps - - -# ────────────────────────────────────────────────────────────────────────── -# Métrique principale -# ────────────────────────────────────────────────────────────────────────── - - -def _prf(tp: int, fp: int, fn: int) -> dict[str, float]: - p = tp / (tp + fp) if (tp + fp) > 0 else 0.0 - r = tp / (tp + fn) if (tp + fn) > 0 else 0.0 - f1 = 2 * p * r / (p + r) if (p + r) > 0 else 0.0 - return {"precision": p, "recall": r, "f1": f1, "support": tp + fn} - - -def compute_layout_metrics( - reference_regions: Iterable[Region | dict] | None, - hypothesis_regions: Iterable[Region | dict] | None, - iou_threshold: float = 0.5, -) -> dict: - """Calcule precision/recall/F1 sur le layout par type de région. - - Parameters - ---------- - reference_regions: - Liste de régions GT (``Region`` ou dict ``{id, type, bbox}``). - hypothesis_regions: - Liste de régions produites par le moteur OCR/HTR ou un - layout-detector. - iou_threshold: - Seuil de chevauchement minimal pour déclarer un appariement - (défaut : 0,5 — convention ICDAR). - - Returns - ------- - dict - ``{ - "global": {"precision", "recall", "f1", "support"}, - "per_type": {type_name: {"precision", ...}}, - "true_positives": int, - "false_positives": int, - "false_negatives": int, - "missed_regions": list[dict], # GT non matchées - "hallucinated_regions": list[dict], # hyp non matchées - "iou_threshold": float, - }`` - - Cas dégénérés - ------------- - - Deux listes vides → F1 = 0 et tous compteurs à 0. - - GT vide + hyp non-vide → F1 = 0 (toutes hyp = FP). - - hyp vide + GT non-vide → F1 = 0 (toutes GT = FN). - """ - refs = [_to_region(r) for r in (reference_regions or [])] - hyps = [_to_region(h) for h in (hypothesis_regions or [])] - - matches, unmatched_refs, unmatched_hyps = _align_regions( - refs, hyps, iou_threshold, - ) - - tp = len(matches) - fn = len(unmatched_refs) - fp = len(unmatched_hyps) - - cat_tp: dict[str, int] = {} - cat_fn: dict[str, int] = {} - cat_fp: dict[str, int] = {} - for i, _j, _iou in matches: - cat = refs[i].type - cat_tp[cat] = cat_tp.get(cat, 0) + 1 - for i in unmatched_refs: - cat = refs[i].type - cat_fn[cat] = cat_fn.get(cat, 0) + 1 - for j in unmatched_hyps: - cat = hyps[j].type - cat_fp[cat] = cat_fp.get(cat, 0) + 1 - - all_categories = sorted(set(cat_tp) | set(cat_fn) | set(cat_fp)) - per_type = { - cat: _prf( - cat_tp.get(cat, 0), - cat_fp.get(cat, 0), - cat_fn.get(cat, 0), - ) - for cat in all_categories - } - - return { - "global": _prf(tp, fp, fn), - "per_type": per_type, - "true_positives": tp, - "false_positives": fp, - "false_negatives": fn, - "missed_regions": [ - {"id": refs[i].id, "type": refs[i].type, "bbox": list(refs[i].bbox)} - for i in sorted(unmatched_refs) - ], - "hallucinated_regions": [ - {"id": hyps[j].id, "type": hyps[j].type, "bbox": list(hyps[j].bbox)} - for j in sorted(unmatched_hyps) - ], - "iou_threshold": iou_threshold, - } - - -def layout_f1( - reference_regions: Iterable[Region | dict] | None, - hypothesis_regions: Iterable[Region | dict] | None, - iou_threshold: float = 0.5, -) -> float: - """Raccourci : F1 global du layout.""" - return compute_layout_metrics( - reference_regions, hypothesis_regions, iou_threshold, - )["global"]["f1"] - - -__all__ = [ - "Region", - "compute_layout_metrics", - "layout_f1", -] +from picarones.evaluation.metrics.layout import * # noqa: F401,F403 +from picarones.evaluation.metrics.layout import _iou_bbox # noqa: F401 diff --git a/picarones/measurements/levers.py b/picarones/measurements/levers.py index 47ba0ab9d665f6eb35d0572fdb4c07a2d7b4ea44..b068c809a10cf35d4bd064695e98a20d031b0170 100644 --- a/picarones/measurements/levers.py +++ b/picarones/measurements/levers.py @@ -1,561 +1,10 @@ -"""Section « Leviers d'amélioration » — Sprint 82 (A.I.9). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.levers``. -Sprint 82 — A.I.9 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Le moteur narratif (Sprint 19) émet des `Fact` qui décrivent **ce -qui s'est passé** dans le benchmark : qui gagne, qui s'effondre, -qui est fragile. Ce sprint répond à une question -complémentaire : **sur quelle dimension le bénéfice attendu d'une -amélioration serait-il le plus visible ?** - -Pas de prescription -------------------- -Picarones est un **outil de recherche**, pas un atelier de -production. Le module ne dit jamais *« faites X »* ni -*« utilisez le moteur Y »* ; il agrège des **observations -factuelles** déjà calculées dans d'autres modules (Sprints 75-81) -et les présente comme un récapitulatif compact en bas du rapport. -Le chercheur lit, juge et arbitre. - -Exemples de leviers émis ------------------------- -- *« 65 % des erreurs de Tesseract sont de classe récupérable - (case_error, ligature_error, abbreviation_error) — un - post-processing trivial absorberait une partie. »* -- *« 12 % de vos documents concentrent 78 % du CER total - (Pareto-CER). »* -- *« Le déficit projeté du moteur le plus fragile sur le corpus - réel est de 4,2 points de CER (Sprint 81). »* -- *« Le top-3 des tokens GT systématiquement modernisés est - maistre, nostre, veoir (Sprint 80). »* - -Structure ---------- -Module parallèle au registre narratif Sprint 19 : `Lever` est la -dataclass équivalente à `Fact`, `LeverImportance` reprend la -sémantique de `FactImportance`, `@register_lever` indexe les -détecteurs. Garde-fou anti-hallucination identique : chaque -nombre rendu doit être présent dans le `payload` du `Lever`. - -Les détecteurs lisent **uniquement** des structures déjà -construites par le pipeline du benchmark — ils ne calculent rien -de nouveau, ils synthétisent. C'est pourquoi le module est -résolument optionnel : si un benchmark n'expose pas -`taxonomy_aggregated`, `inter_engine_analysis`, `corpus_difficulty`, -`lexical_modernization` ou `robustness_projection`, le détecteur -correspondant retourne tout simplement `[]`. +L'ancien chemin ``picarones.measurements.levers`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import threading -from dataclasses import dataclass -from enum import Enum -from typing import Callable - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Modèle -# ────────────────────────────────────────────────────────────────────────── - - -class LeverType(str, Enum): - """Types de leviers détectés.""" - - DOMINANT_RECOVERABLE_CLASS = "dominant_recoverable_class" - """Une part importante des erreurs d'un moteur est dans des classes - catégorisées « récupérables » (Sprint 77).""" - - PARETO_CONCENTRATION = "pareto_concentration" - """Une fraction minoritaire de documents concentre une fraction - majoritaire du CER total — l'inspection ciblée est rentable.""" - - COMPLEMENTARITY_OBSERVATION = "complementarity_observation" - """Le `complementarity_gap` (Sprint 35) entre l'oracle et le - meilleur moteur seul est non négligeable — observation factuelle, - aucune recommandation d'ensemble.""" - - LEXICAL_MODERNIZATION_OBSERVATION = "lexical_modernization_observation" - """Top-N des tokens GT systématiquement modernisés (Sprint 80).""" - - ROBUSTNESS_PROJECTION_OBSERVATION = "robustness_projection_observation" - """Déficit projeté global le plus important pour un moteur sur - le corpus réel (Sprint 81).""" - - -class LeverImportance(int, Enum): - """Importance éditoriale d'un levier.""" - - HIGH = 70 - MEDIUM = 40 - LOW = 10 - - -@dataclass -class Lever: - """Observation factuelle synthétisable en encart « Leviers ». - - Attributes - ---------- - type: - Le type de levier (voir `LeverType`). - importance: - Score qui décide l'ordre d'affichage. - payload: - Données brutes — **tout chiffre rendu dans le HTML doit - provenir d'ici**, jamais d'un calcul du renderer. - engines_involved: - Noms des moteurs concernés (peut être vide pour un levier - corpus-wide). - """ - - type: LeverType - importance: LeverImportance - payload: dict - engines_involved: tuple[str, ...] = () - - def as_dict(self) -> dict: - return { - "type": self.type.value, - "importance": int(self.importance), - "payload": self.payload, - "engines_involved": list(self.engines_involved), - } - - -# ────────────────────────────────────────────────────────────────────────── -# Registre -# ────────────────────────────────────────────────────────────────────────── - - -LeverDetectorFn = Callable[[dict], list[Lever]] - - -@dataclass(frozen=True) -class LeverDetectorEntry: - lever_type: LeverType - fn: LeverDetectorFn - priority: int - - -_LEVER_REGISTRY: dict[LeverType, LeverDetectorEntry] = {} -_LEVER_REGISTRY_LOCK = threading.Lock() - - -def register_lever( - lever_type: LeverType, - *, - priority: int, -) -> Callable[[LeverDetectorFn], LeverDetectorFn]: - """Décorateur : enregistre un détecteur de levier. - - Une seule fonction par type — réenregistrer lève `ValueError`. - """ - def _decorator(fn: LeverDetectorFn) -> LeverDetectorFn: - with _LEVER_REGISTRY_LOCK: - if lever_type in _LEVER_REGISTRY: - raise ValueError( - f"Détecteur déjà enregistré pour {lever_type.value!r} : " - f"{_LEVER_REGISTRY[lever_type].fn.__name__}." - ) - _LEVER_REGISTRY[lever_type] = LeverDetectorEntry( - lever_type=lever_type, fn=fn, priority=int(priority), - ) - return fn - return _decorator - - -def unregister_lever(lever_type: LeverType) -> None: - with _LEVER_REGISTRY_LOCK: - _LEVER_REGISTRY.pop(lever_type, None) - - -def iter_lever_detectors() -> list[LeverDetectorEntry]: - with _LEVER_REGISTRY_LOCK: - entries = list(_LEVER_REGISTRY.values()) - entries.sort(key=lambda e: e.priority) - return entries - - -def detect_levers(benchmark_data: dict) -> list[Lever]: - """Applique tous les détecteurs enregistrés et trie par importance - décroissante puis priorité d'enregistrement croissante.""" - levers: list[Lever] = [] - for entry in iter_lever_detectors(): - try: - result = entry.fn(benchmark_data) - except Exception as e: - logger.warning( - "[levers.detector.%s] fonctionnalité dégradée : %s", - entry.lever_type.value, e, - ) - continue - if result: - levers.extend(result) - # Tri stable : importance décroissante d'abord - levers.sort(key=lambda lv: -int(lv.importance)) - return levers - - -# ────────────────────────────────────────────────────────────────────────── -# Détecteurs -# ────────────────────────────────────────────────────────────────────────── - - -# Catégorisation reprise du Sprint 77 (taxonomy_comparison.py). -# Volontairement dupliquée ici pour ne pas introduire d'import -# circulaire — la sémantique est gelée. -_RECOVERABILITY: dict[str, str] = { - "case_error": "recoverable", - "ligature_error": "recoverable", - "abbreviation_error": "recoverable", - "diacritic_error": "difficult", - "visual_confusion": "difficult", - "hapax": "difficult", - "lacuna": "irrecoverable", - "oov_character": "irrecoverable", - "segmentation_error": "irrecoverable", -} - - -@register_lever(LeverType.DOMINANT_RECOVERABLE_CLASS, priority=10) -def detect_dominant_recoverable_class( - benchmark_data: dict, - *, - threshold: float = 0.30, -) -> list[Lever]: - """Émet un levier si ≥ `threshold` des erreurs d'un moteur sont - classifiées récupérables (catégorisation Sprint 77). - - Lit `benchmark_data["engines"][i]["aggregated_taxonomy"]` — - structure produite par le runner historique. Si absent, retourne - []. - """ - engines = benchmark_data.get("engines") or [] - out: list[Lever] = [] - for engine in engines: - taxonomy = engine.get("aggregated_taxonomy") - if not taxonomy: - continue - # `taxonomy` peut être {class_name: int} ou un dict avec une - # sous-clé "counts" — on accepte les deux conventions. - counts = taxonomy.get("counts") if isinstance(taxonomy, dict) and "counts" in taxonomy else taxonomy - if not isinstance(counts, dict) or not counts: - continue - try: - int_counts = {k: int(v) for k, v in counts.items() if isinstance(v, (int, float))} - except (TypeError, ValueError): - continue - total = sum(int_counts.values()) - if total <= 0: - continue - recoverable_total = sum( - v for k, v in int_counts.items() - if _RECOVERABILITY.get(k) == "recoverable" - ) - share = recoverable_total / total - if share < threshold: - continue - # Classes récupérables non vides triées par count décroissant - breakdown = sorted( - ( - (k, v) for k, v in int_counts.items() - if _RECOVERABILITY.get(k) == "recoverable" and v > 0 - ), - key=lambda kv: -kv[1], - ) - importance = ( - LeverImportance.HIGH if share >= 0.50 else LeverImportance.MEDIUM - ) - out.append(Lever( - type=LeverType.DOMINANT_RECOVERABLE_CLASS, - importance=importance, - payload={ - "engine": engine.get("name") or "?", - "share_recoverable": share, - "share_recoverable_pct": round(share * 100, 1), - "n_recoverable": recoverable_total, - "n_total_errors": total, - "top_classes": [ - {"class": k, "count": v} for k, v in breakdown[:3] - ], - }, - engines_involved=(engine.get("name") or "?",), - )) - return out - - -@register_lever(LeverType.PARETO_CONCENTRATION, priority=20) -def detect_pareto_concentration( - benchmark_data: dict, - *, - top_share: float = 0.20, - cer_share_threshold: float = 0.50, -) -> list[Lever]: - """Émet un levier si une fraction minoritaire de documents - (`top_share`) concentre plus de `cer_share_threshold` du CER - total cumulé sur le moteur leader. - - Lit `benchmark_data["per_doc_cer"][engine_name]` ou tente de - reconstruire depuis `benchmark_data["engines"][...]["per_doc"]`. - Si rien d'exploitable, retourne []. - """ - ranking = benchmark_data.get("ranking") or [] - if not ranking: - return [] - leader = ranking[0] - leader_name = leader.get("engine") - if not leader_name: - return [] - - per_doc_cer: list[float] = [] - # Voie 1 : structure plate "per_doc_cer" - flat = benchmark_data.get("per_doc_cer") or {} - if isinstance(flat, dict) and leader_name in flat and isinstance(flat[leader_name], list): - per_doc_cer = [float(x) for x in flat[leader_name] if isinstance(x, (int, float))] - else: - # Voie 2 : engine.per_doc liste de dicts {cer: float} - for engine in benchmark_data.get("engines") or []: - if engine.get("name") != leader_name: - continue - per_doc = engine.get("per_doc") or [] - for entry in per_doc: - if isinstance(entry, dict) and isinstance(entry.get("cer"), (int, float)): - per_doc_cer.append(float(entry["cer"])) - break - - if not per_doc_cer: - return [] - total_cer = sum(per_doc_cer) - if total_cer <= 0: - return [] - - sorted_cer = sorted(per_doc_cer, reverse=True) - n = len(sorted_cer) - n_top = max(1, int(round(top_share * n))) - top_cer_sum = sum(sorted_cer[:n_top]) - share_of_total = top_cer_sum / total_cer - if share_of_total < cer_share_threshold: - return [] - importance = ( - LeverImportance.HIGH if share_of_total >= 0.75 - else LeverImportance.MEDIUM - ) - return [Lever( - type=LeverType.PARETO_CONCENTRATION, - importance=importance, - payload={ - "engine": leader_name, - "n_docs": n, - "n_docs_top": n_top, - "top_share_pct": round((n_top / n) * 100, 1), - "cer_share_of_total": share_of_total, - "cer_share_pct": round(share_of_total * 100, 1), - }, - engines_involved=(leader_name,), - )] - - -@register_lever(LeverType.COMPLEMENTARITY_OBSERVATION, priority=30) -def detect_complementarity_observation( - benchmark_data: dict, - *, - min_relative_gap: float = 0.20, -) -> list[Lever]: - """Reformule factuellement le `complementarity_gap` (Sprint 35). - - Lit `benchmark_data["inter_engine_analysis"]`. Garde-fou : ne - déclenche que si `relative_gap` ≥ `min_relative_gap`. **Aucune - recommandation d'ensemble** — le levier dit factuellement - « X points séparent l'oracle du meilleur moteur », c'est tout. - """ - inter = benchmark_data.get("inter_engine_analysis") or {} - cgap = inter.get("complementarity_gap") or {} - relative_gap = cgap.get("relative_gap") - absolute_gap = cgap.get("absolute_gap") - if relative_gap is None or absolute_gap is None: - return [] - try: - rg = float(relative_gap) - ag = float(absolute_gap) - except (TypeError, ValueError): - return [] - if rg < min_relative_gap: - return [] - importance = ( - LeverImportance.HIGH if rg >= 0.50 else LeverImportance.MEDIUM - ) - payload: dict = { - "absolute_gap": ag, - "absolute_gap_pct": round(ag * 100, 1), - "relative_gap": rg, - "relative_gap_pct": round(rg * 100, 1), - } - best_engine = cgap.get("best_engine") or inter.get("best_engine") - best_recall = cgap.get("best_recall") or inter.get("best_engine_recall") - oracle_recall = cgap.get("oracle_recall") or inter.get("oracle_recall") - engines_involved: tuple[str, ...] = () - if best_engine: - payload["best_engine"] = str(best_engine) - engines_involved = (str(best_engine),) - if isinstance(best_recall, (int, float)): - payload["best_recall"] = float(best_recall) - if isinstance(oracle_recall, (int, float)): - payload["oracle_recall"] = float(oracle_recall) - return [Lever( - type=LeverType.COMPLEMENTARITY_OBSERVATION, - importance=importance, - payload=payload, - engines_involved=engines_involved, - )] - - -@register_lever(LeverType.LEXICAL_MODERNIZATION_OBSERVATION, priority=40) -def detect_lexical_modernization_observation( - benchmark_data: dict, - *, - top_n: int = 3, - min_total: int = 3, - min_rate: float = 0.50, -) -> list[Lever]: - """Pour chaque moteur disposant de `lexical_modernization`, - émet un levier listant les `top_n` tokens GT les plus modernisés. - - Lit `benchmark_data["engines"][i]["lexical_modernization"]` qui - suit la forme produite par `compute_lexical_modernization` du - Sprint 80 (`{"n_gt_tokens": int, "tokens": dict}`). - """ - out: list[Lever] = [] - for engine in benchmark_data.get("engines") or []: - data = engine.get("lexical_modernization") - if not isinstance(data, dict): - continue - tokens = data.get("tokens") or {} - if not isinstance(tokens, dict) or not tokens: - continue - candidates: list[tuple[str, dict]] = [] - for gt_token, slot in tokens.items(): - if not isinstance(slot, dict): - continue - n_total = slot.get("n_total") - rate = slot.get("rate_modernized") - if not isinstance(n_total, (int, float)) or not isinstance(rate, (int, float)): - continue - if int(n_total) < min_total: - continue - if float(rate) < min_rate: - continue - candidates.append((gt_token, dict(slot))) - if not candidates: - continue - candidates.sort( - key=lambda kv: (-float(kv[1].get("rate_modernized", 0.0)), - -int(kv[1].get("n_total", 0)), - kv[0]), - ) - top = candidates[:top_n] - engine_name = engine.get("name") or "?" - max_rate = max(float(slot.get("rate_modernized", 0.0)) for _, slot in top) - importance = ( - LeverImportance.HIGH if max_rate >= 0.90 else LeverImportance.MEDIUM - ) - out.append(Lever( - type=LeverType.LEXICAL_MODERNIZATION_OBSERVATION, - importance=importance, - payload={ - "engine": engine_name, - "top_tokens": [ - { - "gt_token": gt, - "n_total": int(slot.get("n_total", 0)), - "rate_modernized": float(slot.get("rate_modernized", 0.0)), - "rate_modernized_pct": round( - float(slot.get("rate_modernized", 0.0)) * 100, 1, - ), - } - for gt, slot in top - ], - }, - engines_involved=(engine_name,), - )) - return out - - -@register_lever(LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, priority=50) -def detect_robustness_projection_observation( - benchmark_data: dict, - *, - min_total_deficit: float = 0.02, -) -> list[Lever]: - """Lit l'agrégation par moteur de la projection de robustesse - (Sprint 81). Émet le levier pour le moteur dont - `total_expected_deficit` est ≥ `min_total_deficit` (par défaut - 2 points de CER). - - Lit `benchmark_data["robustness_projection_aggregated"]` — - structure produite par `aggregate_projection_per_engine`. - """ - agg = benchmark_data.get("robustness_projection_aggregated") or {} - if not isinstance(agg, dict) or not agg: - return [] - out: list[Lever] = [] - for engine_name, info in agg.items(): - if not isinstance(info, dict): - continue - total_deficit = info.get("total_expected_deficit") - worst_type = info.get("worst_degradation_type") - worst_deficit = info.get("worst_degradation_deficit") - if not isinstance(total_deficit, (int, float)): - continue - if float(total_deficit) < min_total_deficit: - continue - importance = ( - LeverImportance.HIGH if float(total_deficit) >= 0.05 - else LeverImportance.MEDIUM - ) - payload: dict = { - "engine": engine_name, - "total_expected_deficit": float(total_deficit), - "total_expected_deficit_pct": round(float(total_deficit) * 100, 1), - "n_degradation_types": int(info.get("n_degradation_types") or 0), - } - if isinstance(worst_type, str): - payload["worst_degradation_type"] = worst_type - if isinstance(worst_deficit, (int, float)): - payload["worst_degradation_deficit"] = float(worst_deficit) - payload["worst_degradation_deficit_pct"] = round( - float(worst_deficit) * 100, 1, - ) - out.append(Lever( - type=LeverType.ROBUSTNESS_PROJECTION_OBSERVATION, - importance=importance, - payload=payload, - engines_involved=(engine_name,), - )) - # Tri par déficit décroissant pour stabilité d'affichage. - out.sort( - key=lambda lv: -float(lv.payload.get("total_expected_deficit") or 0.0), - ) - return out - - -__all__ = [ - "Lever", - "LeverImportance", - "LeverType", - "LeverDetectorEntry", - "register_lever", - "unregister_lever", - "iter_lever_detectors", - "detect_levers", - "detect_dominant_recoverable_class", - "detect_pareto_concentration", - "detect_complementarity_observation", - "detect_lexical_modernization_observation", - "detect_robustness_projection_observation", -] +from picarones.evaluation.metrics.levers import * # noqa: F401,F403 diff --git a/picarones/measurements/lexical_modernization.py b/picarones/measurements/lexical_modernization.py index d8da72a721e173100a14500b5020f782062c8863..651da4ef65c954db9d675c9285f5f91435455489 100644 --- a/picarones/measurements/lexical_modernization.py +++ b/picarones/measurements/lexical_modernization.py @@ -1,263 +1,10 @@ -"""Détection de la sur-normalisation lexicale par les LLM/VLM — -Sprint 80 (A.I.7). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.lexical_modernization``. -Sprint 80 — A.I.7 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Le détecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un -moteur sur-normalise (« 0,05 % »). Mais ce score agrégé ne dit -rien sur **quoi** corriger dans le prompt. Ce module produit -une **table de fréquences détaillée** : - -+----------------------+--------------------+------+----------+ -| Forme historique GT | Forme modernisée | n GT | % modern | -+======================+====================+======+==========+ -| maistre | maître | 47 | 85 % | -| nostre | nostre | 92 | 8 % | -| veoir | voir | 23 | 100 % | -+----------------------+--------------------+------+----------+ - -Lecture immédiate : *« le LLM modernise systématiquement -maistre → maître ; pour préserver l'orthographe historique, ajouter -au prompt "ne pas moderniser maistre, nostre, veoir" »*. - -Méthode -------- -Alignement mot-à-mot via ``difflib.SequenceMatcher``. Chaque -``replace`` ou ``equal`` produit une paire ``(gt_token, -hyp_token)``. On accumule pour chaque ``gt_token`` : - -- ``n_total`` : nombre d'occurrences du token dans la GT -- ``n_modernized`` : nombre d'occurrences où ``hyp_token != gt_token`` -- ``variants`` : dict des hyp_tokens observés avec leur count - -Stop-list ---------- -L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT à -ignorer). Par défaut, vide — le module ne tente pas de deviner ce -qui est « moderne » ou « historique », c'est au chercheur de -fournir le filtre adapté à son corpus. - -Sortie ------- -``compute_lexical_modernization`` retourne une structure adaptée -au rendu HTML. ``aggregate_lexical_modernization`` agrège -plusieurs documents. - -Limites documentées -------------------- -- Tokenisation au niveau mot (split sur espace) — cohérent avec - ``taxonomy.py`` et autres modules. Pas de stemming ni de - lemmatisation. -- La métrique mesure la **réécriture lexicale** ; elle n'attrape - pas les modernisations infra-mot (perte du s long ſ qui se - fond dans la même forme). Pour ça, voir ``early_modern_typography`` - (Sprint 58) et ``equivalence_profile`` (Sprint 78). +L'ancien chemin ``picarones.measurements.lexical_modernization`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import difflib -import logging -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -def _split_words(text: Optional[str]) -> list[str]: - """Tokenisation simple par split sur whitespace.""" - if not text: - return [] - return text.split() - - -def compute_lexical_modernization( - reference: Optional[str], - hypothesis: Optional[str], - *, - stop_list: Optional[Iterable[str]] = None, - case_sensitive: bool = False, -) -> dict: - """Calcule le tableau de modernisation lexicale pour un document. - - Returns - ------- - dict - ``{ - "n_gt_tokens": int, - "tokens": { - gt_token: { - "n_total": int, - "n_modernized": int, - "rate_modernized": float, # ∈ [0, 1] - "variants": {hyp_token: count, ...}, - }, - ... - }, - }`` - Si ``reference`` est vide → ``tokens == {}``. - """ - ref_tokens = _split_words(reference) - hyp_tokens = _split_words(hypothesis) - if not ref_tokens: - return {"n_gt_tokens": 0, "tokens": {}} - - if not case_sensitive: - ref_for_match = [t.lower() for t in ref_tokens] - hyp_for_match = [t.lower() for t in hyp_tokens] - else: - ref_for_match = ref_tokens - hyp_for_match = hyp_tokens - - stop = frozenset( - (t.lower() if not case_sensitive else t) - for t in (stop_list or []) - ) - - # On accumule par gt_token (forme display = forme originale, - # match key = forme casée selon ``case_sensitive``). - tokens_data: dict[str, dict] = {} - - matcher = difflib.SequenceMatcher( - None, ref_for_match, hyp_for_match, autojunk=False, - ) - for tag, i1, i2, j1, j2 in matcher.get_opcodes(): - if tag == "equal": - for k in range(i2 - i1): - gt_orig = ref_tokens[i1 + k] - gt_match = ref_for_match[i1 + k] - if gt_match in stop: - continue - slot = tokens_data.setdefault( - gt_orig, - {"n_total": 0, "n_modernized": 0, "variants": {}}, - ) - slot["n_total"] += 1 - elif tag == "replace": - # Apparier 1-à-1 quand possible - paired = min(i2 - i1, j2 - j1) - for k in range(paired): - gt_orig = ref_tokens[i1 + k] - gt_match = ref_for_match[i1 + k] - if gt_match in stop: - continue - hyp_orig = hyp_tokens[j1 + k] - slot = tokens_data.setdefault( - gt_orig, - {"n_total": 0, "n_modernized": 0, "variants": {}}, - ) - slot["n_total"] += 1 - slot["n_modernized"] += 1 - slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1 - # Si plus de gt que de hyp, le reste des gt_tokens est - # « perdu » — on les compte comme totaux mais pas comme - # modernisés (on ne sait pas en quoi). - for k in range(paired, i2 - i1): - gt_orig = ref_tokens[i1 + k] - gt_match = ref_for_match[i1 + k] - if gt_match in stop: - continue - slot = tokens_data.setdefault( - gt_orig, - {"n_total": 0, "n_modernized": 0, "variants": {}}, - ) - slot["n_total"] += 1 - slot["n_modernized"] += 1 - slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1 - elif tag == "delete": - # gt présent, pas en hyp → modernisation par - # suppression (ou perte pure) - for k in range(i2 - i1): - gt_orig = ref_tokens[i1 + k] - gt_match = ref_for_match[i1 + k] - if gt_match in stop: - continue - slot = tokens_data.setdefault( - gt_orig, - {"n_total": 0, "n_modernized": 0, "variants": {}}, - ) - slot["n_total"] += 1 - slot["n_modernized"] += 1 - slot["variants"]["∅"] = slot["variants"].get("∅", 0) + 1 - - # Calcul du taux par token - for slot in tokens_data.values(): - total = slot["n_total"] - slot["rate_modernized"] = ( - slot["n_modernized"] / total if total > 0 else 0.0 - ) - - return { - "n_gt_tokens": len(ref_tokens), - "tokens": tokens_data, - } - - -def aggregate_lexical_modernization( - per_doc_results: Iterable[dict], -) -> dict: - """Agrège des ``compute_lexical_modernization`` per-doc. - - Renvoie la structure agrégée corpus-wide avec la même forme - que ``compute_lexical_modernization``. - """ - agg_tokens: dict[str, dict] = {} - n_gt_total = 0 - for doc_result in per_doc_results: - if not doc_result: - continue - n_gt_total += doc_result.get("n_gt_tokens", 0) - for gt, data in (doc_result.get("tokens") or {}).items(): - slot = agg_tokens.setdefault( - gt, {"n_total": 0, "n_modernized": 0, "variants": {}}, - ) - slot["n_total"] += data.get("n_total", 0) - slot["n_modernized"] += data.get("n_modernized", 0) - for hyp_t, count in (data.get("variants") or {}).items(): - slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count - - for slot in agg_tokens.values(): - total = slot["n_total"] - slot["rate_modernized"] = ( - slot["n_modernized"] / total if total > 0 else 0.0 - ) - return { - "n_gt_tokens": n_gt_total, - "tokens": agg_tokens, - } - - -def top_modernized_tokens( - data: dict, - *, - n: int = 20, - min_total: int = 1, -) -> list[tuple[str, dict]]: - """Top-N tokens GT par taux de modernisation. - - Filtre les tokens dont ``n_total < min_total`` (anecdotiques). - Tri par ``rate_modernized`` décroissant, tie-break par - ``n_total`` décroissant. - """ - tokens = data.get("tokens") or {} - candidates = [ - (gt, slot) for gt, slot in tokens.items() - if slot.get("n_total", 0) >= min_total - and slot.get("n_modernized", 0) > 0 - ] - candidates.sort( - key=lambda pair: ( - -pair[1].get("rate_modernized", 0.0), - -pair[1].get("n_total", 0), - pair[0], - ), - ) - return candidates[:n] - - -__all__ = [ - "compute_lexical_modernization", - "aggregate_lexical_modernization", - "top_modernized_tokens", -] +from picarones.evaluation.metrics.lexical_modernization import * # noqa: F401,F403 diff --git a/picarones/measurements/line_metrics.py b/picarones/measurements/line_metrics.py index 5204decce03afa16ce9d4fc93e8bbb973d77f475..53ca9108dae089384ad0627a6ef84fe99bd87a10 100644 --- a/picarones/measurements/line_metrics.py +++ b/picarones/measurements/line_metrics.py @@ -1,286 +1,10 @@ -"""Distribution des erreurs CER par ligne — Sprint 10. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.line_metrics``. -Métriques calculées -------------------- -- CER par ligne : distance d'édition caractère/longueur GT sur chaque paire de lignes -- Percentiles : p50, p75, p90, p95, p99 sur la distribution des CER ligne -- Taux catastrophiques : % de lignes dépassant des seuils configurables (30 %, 50 %, 100 %) -- Coefficient de Gini : concentration des erreurs (0 = uniformes, 1 = toutes concentrées) -- Carte thermique : CER moyen par tranche de position dans le document +L'ancien chemin ``picarones.measurements.line_metrics`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import unicodedata -from dataclasses import dataclass -from typing import Optional - - -# --------------------------------------------------------------------------- -# CER d'une paire de lignes (distance d'édition Levenshtein normalisée) -# --------------------------------------------------------------------------- - -def _edit_distance(a: str, b: str) -> int: - """Distance de Levenshtein entre deux chaînes.""" - if not a: - return len(b) - if not b: - return len(a) - prev = list(range(len(b) + 1)) - for i, ca in enumerate(a, 1): - curr = [i] - for j, cb in enumerate(b, 1): - cost = 0 if ca == cb else 1 - curr.append(min(curr[j - 1] + 1, prev[j] + 1, prev[j - 1] + cost)) - prev = curr - return prev[-1] - - -def _line_cer(ref_line: str, hyp_line: str) -> float: - """CER pour une paire de lignes. Retourne 1.0 si le GT est vide et que l'hyp ne l'est pas.""" - ref = unicodedata.normalize("NFC", ref_line.strip()) - hyp = unicodedata.normalize("NFC", hyp_line.strip()) - if not ref: - return 0.0 if not hyp else 1.0 - dist = _edit_distance(ref, hyp) - return dist / len(ref) - - -# --------------------------------------------------------------------------- -# Percentiles (implémentation pur-Python, sans numpy) -# --------------------------------------------------------------------------- - -def _percentile(sorted_values: list[float], p: float) -> float: - """Retourne le p-ième percentile (0 ≤ p ≤ 100) d'une liste triée.""" - if not sorted_values: - return 0.0 - n = len(sorted_values) - index = p / 100 * (n - 1) - lo = int(index) - hi = min(lo + 1, n - 1) - frac = index - lo - return sorted_values[lo] + frac * (sorted_values[hi] - sorted_values[lo]) - - -# --------------------------------------------------------------------------- -# Coefficient de Gini -# --------------------------------------------------------------------------- - -def _gini(values: list[float]) -> float: - """Coefficient de Gini des erreurs (0 = uniformes, 1 = toutes concentrées). - - Formule : G = (2 * Σ i*x_i) / (n * Σ x_i) - (n+1)/n - sur les valeurs triées par ordre croissant. - """ - if not values: - return 0.0 - xs = sorted(max(v, 0.0) for v in values) - n = len(xs) - total = sum(xs) - if total == 0.0: - return 0.0 - weighted_sum = sum((i + 1) * x for i, x in enumerate(xs)) - return (2.0 * weighted_sum) / (n * total) - (n + 1) / n - - -# --------------------------------------------------------------------------- -# Résultat structuré -# --------------------------------------------------------------------------- - -@dataclass -class LineMetrics: - """Distribution des erreurs CER par ligne pour une paire (GT, hypothèse).""" - - cer_per_line: list[float] - """CER de chaque ligne (longueur = nombre de lignes GT).""" - - percentiles: dict[str, float] - """Percentiles : p50, p75, p90, p95, p99.""" - - catastrophic_rate: dict[str, float] - """Taux de lignes catastrophiques pour chaque seuil (ex. {0.3: 0.12, 0.5: 0.07, 1.0: 0.02}).""" - - gini: float - """Coefficient de Gini des erreurs (0 → uniforme, 1 → concentrées).""" - - heatmap: list[float] - """CER moyen par tranche de position dans le document (longueur = heatmap_bins).""" - - line_count: int - """Nombre de lignes GT traitées.""" - - mean_cer: float - """CER moyen sur l'ensemble des lignes.""" - - def as_dict(self) -> dict: - return { - "cer_per_line": [round(v, 6) for v in self.cer_per_line], - "percentiles": {k: round(v, 6) for k, v in self.percentiles.items()}, - "catastrophic_rate": {str(k): round(v, 6) for k, v in self.catastrophic_rate.items()}, - "gini": round(self.gini, 6), - "heatmap": [round(v, 6) for v in self.heatmap], - "line_count": self.line_count, - "mean_cer": round(self.mean_cer, 6), - } - - @classmethod - def from_dict(cls, d: dict) -> "LineMetrics": - return cls( - cer_per_line=d.get("cer_per_line", []), - percentiles=d.get("percentiles", {}), - catastrophic_rate={float(k): v for k, v in d.get("catastrophic_rate", {}).items()}, - gini=d.get("gini", 0.0), - heatmap=d.get("heatmap", []), - line_count=d.get("line_count", 0), - mean_cer=d.get("mean_cer", 0.0), - ) - - -# --------------------------------------------------------------------------- -# Calcul principal -# --------------------------------------------------------------------------- - -def compute_line_metrics( - reference: str, - hypothesis: str, - thresholds: Optional[list[float]] = None, - heatmap_bins: int = 10, -) -> LineMetrics: - """Calcule la distribution des erreurs CER ligne par ligne. - - Parameters - ---------- - reference: - Texte de vérité terrain (GT) avec sauts de ligne. - hypothesis: - Texte produit par le moteur OCR. - thresholds: - Seuils CER pour le taux catastrophique. Défaut : [0.30, 0.50, 1.00]. - heatmap_bins: - Nombre de tranches de position pour la carte thermique. - - Returns - ------- - LineMetrics - """ - if thresholds is None: - thresholds = [0.30, 0.50, 1.00] - - ref_lines = reference.splitlines() - hyp_lines = hypothesis.splitlines() - - # Aligner les lignes GT / hypothèse — on prend au moins autant de lignes que le GT - n = len(ref_lines) - if n == 0: - # Pas de lignes : retourner des métriques neutres - return LineMetrics( - cer_per_line=[], - percentiles={f"p{p}": 0.0 for p in (50, 75, 90, 95, 99)}, - catastrophic_rate={t: 0.0 for t in thresholds}, - gini=0.0, - heatmap=[0.0] * heatmap_bins, - line_count=0, - mean_cer=0.0, - ) - - # Aligner en ignorant les lignes d'hypothèse supplémentaires - # Si l'hypothèse a moins de lignes, les lignes manquantes comptent comme supprimées (CER = 1.0) - cer_per_line: list[float] = [] - for i, ref_line in enumerate(ref_lines): - hyp_line = hyp_lines[i] if i < len(hyp_lines) else "" - cer_per_line.append(min(_line_cer(ref_line, hyp_line), 1.0)) - - sorted_cer = sorted(cer_per_line) - - # Percentiles - percentiles = { - f"p{p}": _percentile(sorted_cer, p) - for p in (50, 75, 90, 95, 99) - } - - # Taux catastrophiques - catastrophic_rate: dict[float, float] = {} - for t in thresholds: - count = sum(1 for v in cer_per_line if v > t) - catastrophic_rate[t] = count / n - - # Gini - gini = _gini(cer_per_line) - - # Carte thermique par tranche de position - bins = heatmap_bins - heatmap: list[float] = [] - for b in range(bins): - start = int(b * n / bins) - end = int((b + 1) * n / bins) - slice_ = cer_per_line[start:end] - heatmap.append(sum(slice_) / len(slice_) if slice_ else 0.0) - - mean_cer = sum(cer_per_line) / n - - return LineMetrics( - cer_per_line=cer_per_line, - percentiles=percentiles, - catastrophic_rate=catastrophic_rate, - gini=gini, - heatmap=heatmap, - line_count=n, - mean_cer=mean_cer, - ) - - -# --------------------------------------------------------------------------- -# Agrégation sur un corpus -# --------------------------------------------------------------------------- - -def aggregate_line_metrics(results: list[LineMetrics]) -> dict: - """Agrège les métriques de distribution par ligne sur un corpus. - - Returns - ------- - dict - Statistiques agrégées : Gini moyen, percentiles moyens, taux catastrophiques moyens. - """ - if not results: - return {} - - import statistics as _stats - - gini_values = [r.gini for r in results] - mean_cer_values = [r.mean_cer for r in results] - - # Percentiles moyens - pct_keys = ["p50", "p75", "p90", "p95", "p99"] - avg_percentiles = {} - for k in pct_keys: - vals = [r.percentiles.get(k, 0.0) for r in results] - avg_percentiles[k] = round(sum(vals) / len(vals), 6) if vals else 0.0 - - # Taux catastrophiques moyens (union des seuils) - all_thresholds: set[float] = set() - for r in results: - all_thresholds.update(r.catastrophic_rate.keys()) - avg_catastrophic: dict[str, float] = {} - for t in sorted(all_thresholds): - vals = [r.catastrophic_rate.get(t, 0.0) for r in results] - avg_catastrophic[str(t)] = round(sum(vals) / len(vals), 6) if vals else 0.0 - - # Heatmap moyenne (longueur = max des longueurs) - if results and results[0].heatmap: - n_bins = len(results[0].heatmap) - heatmap_avg = [] - for b in range(n_bins): - vals = [r.heatmap[b] for r in results if b < len(r.heatmap)] - heatmap_avg.append(round(sum(vals) / len(vals), 6) if vals else 0.0) - else: - heatmap_avg = [] - - return { - "gini_mean": round(sum(gini_values) / len(gini_values), 6), - "gini_stdev": round(_stats.stdev(gini_values), 6) if len(gini_values) > 1 else 0.0, - "mean_cer_mean": round(sum(mean_cer_values) / len(mean_cer_values), 6), - "percentiles": avg_percentiles, - "catastrophic_rate": avg_catastrophic, - "heatmap": heatmap_avg, - "document_count": len(results), - } +from picarones.evaluation.metrics.line_metrics import * # noqa: F401,F403 diff --git a/picarones/measurements/longitudinal.py b/picarones/measurements/longitudinal.py index 26fe91c4530a99793c87e35fef81ffb5716df174..5c329343f756b64f797bc87724011b938f56c7db 100644 --- a/picarones/measurements/longitudinal.py +++ b/picarones/measurements/longitudinal.py @@ -1,373 +1,10 @@ -"""Métriques longitudinales — Sprint 92 (A.II.9). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.longitudinal``. -Sprint 92 — A.II.9 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -L'historique SQLite (`core/history.py`, Sprint 8) collecte les -résultats de chaque run de benchmark, mais aucune métrique -n'en sortait dans le rapport. Ce module exploite la série -temporelle des CER d'un moteur pour répondre à deux -questions : - -1. **Y a-t-il une tendance ?** Régression linéaire simple - (méthode des moindres carrés) sur ``(t, CER)`` — pente, - ordonnée à l'origine, R², n_runs. Une pente > 0 signale - une régression progressive ; une pente < 0 une amélioration. - -2. **Y a-t-il un point de rupture ?** Algorithme de - change-point pur Python (différence de moyennes maximale, - variante de Pettitt simplifiée). Identifie l'index où la - série se sépare en deux segments avec moyennes les plus - différentes — typiquement le run où un modèle a changé de - comportement. - -Pas de scipy ------------- -Pour rester sans dépendance lourde, on implémente : -- la régression linéaire en pur Python (closed-form OLS) ; -- le change-point par balayage exhaustif (O(N) pour de petits - N — l'historique d'une institution dépasse rarement quelques - centaines de runs). +L'ancien chemin ``picarones.measurements.longitudinal`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import math -import statistics -from dataclasses import dataclass -from datetime import datetime -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class LinearTrend: - """Résultat d'une régression linéaire sur une série CER.""" - slope: float - """Pente (CER par jour). Positif = régression.""" - intercept: float - """Ordonnée à l'origine.""" - r_squared: float - """Qualité de l'ajustement, ∈ [0, 1].""" - n_runs: int - """Nombre de points utilisés.""" - - def as_dict(self) -> dict: - return { - "slope": self.slope, - "intercept": self.intercept, - "r_squared": self.r_squared, - "n_runs": self.n_runs, - } - - -@dataclass -class ChangePointResult: - """Résultat d'une détection de point de rupture.""" - index: int - """Index de la rupture (0-based, le segment 1 est [0:index], - le segment 2 est [index:N]).""" - timestamp: str - """Timestamp du run à la rupture.""" - mean_before: float - mean_after: float - delta: float - """``mean_after - mean_before``. Positif = régression.""" - n_before: int - n_after: int - - def as_dict(self) -> dict: - return { - "index": self.index, - "timestamp": self.timestamp, - "mean_before": self.mean_before, - "mean_after": self.mean_after, - "delta": self.delta, - "n_before": self.n_before, - "n_after": self.n_after, - } - - -def _parse_timestamp(ts: str) -> Optional[float]: - """Parse un ISO timestamp en jour ordinal float. - - Tolère ``YYYY-MM-DD`` et ``YYYY-MM-DDTHH:MM:SS``. Retourne - ``None`` si non parsable. - """ - if not ts: - return None - formats = ( - "%Y-%m-%dT%H:%M:%S.%f", - "%Y-%m-%dT%H:%M:%S", - "%Y-%m-%d %H:%M:%S", - "%Y-%m-%d", - ) - for fmt in formats: - try: - dt = datetime.strptime(ts.split("+")[0].split("Z")[0], fmt) - return dt.toordinal() + ( - dt.hour * 3600 + dt.minute * 60 + dt.second - ) / 86400.0 - except ValueError: - continue - return None - - -def compute_linear_trend( - cer_series: Iterable[tuple[str, float]], -) -> Optional[LinearTrend]: - """Régression linéaire OLS sur une série temporelle de CER. - - Parameters - ---------- - cer_series: - Itérable de ``(timestamp_iso, cer)``. Au moins 2 points - valides requis. - - Returns - ------- - LinearTrend | None - ``None`` si moins de 2 points ou si tous les timestamps - sont identiques (variance nulle sur t). - """ - points: list[tuple[float, float]] = [] - for ts, cer in cer_series: - t = _parse_timestamp(ts) - if t is None or cer is None: - continue - try: - cer_f = float(cer) - except (TypeError, ValueError): - continue - points.append((t, cer_f)) - n = len(points) - if n < 2: - return None - xs = [p[0] for p in points] - ys = [p[1] for p in points] - x_mean = statistics.fmean(xs) - y_mean = statistics.fmean(ys) - sxx = sum((x - x_mean) ** 2 for x in xs) - sxy = sum((x - x_mean) * (y - y_mean) for x, y in zip(xs, ys)) - if sxx == 0: - return None - slope = sxy / sxx - intercept = y_mean - slope * x_mean - syy = sum((y - y_mean) ** 2 for y in ys) - if syy == 0: - # Tous les CER sont égaux → R² mathématiquement indéfini ; - # on retourne 1.0 (parfaite "non-tendance"). - r_squared = 1.0 - else: - ss_res = sum( - (y - (slope * x + intercept)) ** 2 - for x, y in zip(xs, ys) - ) - r_squared = max(0.0, 1.0 - ss_res / syy) - return LinearTrend( - slope=slope, - intercept=intercept, - r_squared=r_squared, - n_runs=n, - ) - - -def detect_change_point( - cer_series: Iterable[tuple[str, float]], - min_segment_size: int = 3, -) -> Optional[ChangePointResult]: - """Détecte le point de rupture maximisant l'écart de moyennes. - - Algorithme : balayage des indices ``i`` où la série se - sépare en deux segments d'au moins ``min_segment_size`` - points chacun ; on retient l'index où ``|mean_after - - mean_before|`` est maximal. Variante simplifiée de Pettitt. - - Parameters - ---------- - cer_series: - Itérable de ``(timestamp_iso, cer)``. - min_segment_size: - Taille minimale des deux segments. Défaut 3. - - Returns - ------- - ChangePointResult | None - ``None`` si la série a moins de ``2 × min_segment_size`` - points valides. - """ - points: list[tuple[str, float, float]] = [] - for ts, cer in cer_series: - t = _parse_timestamp(ts) - if t is None or cer is None: - continue - try: - cer_f = float(cer) - except (TypeError, ValueError): - continue - points.append((ts, t, cer_f)) - if len(points) < 2 * min_segment_size: - return None - points.sort(key=lambda p: p[1]) - n = len(points) - best_index = -1 - best_abs_delta = -1.0 - best_delta = 0.0 - best_mean_before = 0.0 - best_mean_after = 0.0 - for i in range(min_segment_size, n - min_segment_size + 1): - before = [p[2] for p in points[:i]] - after = [p[2] for p in points[i:]] - mean_b = statistics.fmean(before) - mean_a = statistics.fmean(after) - delta = mean_a - mean_b - abs_delta = abs(delta) - if abs_delta > best_abs_delta: - best_abs_delta = abs_delta - best_index = i - best_delta = delta - best_mean_before = mean_b - best_mean_after = mean_a - if best_index < 0: - return None - return ChangePointResult( - index=best_index, - timestamp=points[best_index][0], - mean_before=best_mean_before, - mean_after=best_mean_after, - delta=best_delta, - n_before=best_index, - n_after=n - best_index, - ) - - -def compute_engine_longitudinal( - history_entries: Iterable, - engine_name: str, - corpus_name: Optional[str] = None, - *, - min_runs_for_trend: int = 3, - min_segment_size: int = 3, - change_point_threshold: float = 0.01, -) -> Optional[dict]: - """Calcule trend + change_point pour un moteur. - - Parameters - ---------- - history_entries: - Liste de ``HistoryEntry`` (ou dicts compatibles). - engine_name: - Filtre sur le nom du moteur. - corpus_name: - Filtre optionnel sur le corpus. ``None`` (défaut) : tous - les corpus. - min_runs_for_trend: - Minimum de runs pour calculer une tendance. - min_segment_size: - Taille minimale des segments pour le change-point. - change_point_threshold: - Magnitude absolue minimale du delta (en CER) pour - retenir le change-point. Défaut 0.01 (1 point de CER). - - Returns - ------- - dict | None - ``{ - "engine_name", "corpus_name", "n_runs", "trend", - "change_point", # ou None - "first_timestamp", "last_timestamp", - "first_cer", "last_cer", "absolute_delta_pct", - }`` ou ``None`` si moins de ``min_runs_for_trend`` runs. - """ - series: list[tuple[str, float]] = [] - for entry in history_entries: - if hasattr(entry, "as_dict"): - data = entry.as_dict() - else: - data = entry - if data.get("engine_name") != engine_name: - continue - if corpus_name is not None and data.get("corpus_name") != corpus_name: - continue - cer = data.get("cer_mean") - ts = data.get("timestamp") - if cer is None or ts is None: - continue - series.append((ts, float(cer))) - if len(series) < min_runs_for_trend: - return None - series.sort(key=lambda p: _parse_timestamp(p[0]) or 0.0) - trend = compute_linear_trend(series) - cp = detect_change_point(series, min_segment_size=min_segment_size) - if cp is not None and abs(cp.delta) < change_point_threshold: - cp = None - first_ts, first_cer = series[0] - last_ts, last_cer = series[-1] - return { - "engine_name": engine_name, - "corpus_name": corpus_name, - "n_runs": len(series), - "trend": trend.as_dict() if trend else None, - "change_point": cp.as_dict() if cp else None, - "first_timestamp": first_ts, - "last_timestamp": last_ts, - "first_cer": first_cer, - "last_cer": last_cer, - "absolute_delta": last_cer - first_cer, - "absolute_delta_pct": round((last_cer - first_cer) * 100, 2), - } - - -def compute_corpus_longitudinal( - history_entries: Iterable, - corpus_name: Optional[str] = None, - *, - min_runs_for_trend: int = 3, - min_segment_size: int = 3, - change_point_threshold: float = 0.01, -) -> list[dict]: - """Pour chaque moteur présent dans l'historique sur ``corpus_name``, - calcule trend + change_point. - - Returns - ------- - list[dict] - Une entrée par moteur (filtrée), liste vide si rien. - """ - entries = list(history_entries) - engines: set[str] = set() - for entry in entries: - data = entry.as_dict() if hasattr(entry, "as_dict") else entry - if corpus_name is not None and data.get("corpus_name") != corpus_name: - continue - name = data.get("engine_name") - if name: - engines.add(name) - out: list[dict] = [] - for engine in sorted(engines): - result = compute_engine_longitudinal( - entries, engine, corpus_name=corpus_name, - min_runs_for_trend=min_runs_for_trend, - min_segment_size=min_segment_size, - change_point_threshold=change_point_threshold, - ) - if result is not None: - out.append(result) - return out - - -__all__ = [ - "LinearTrend", - "ChangePointResult", - "compute_linear_trend", - "detect_change_point", - "compute_engine_longitudinal", - "compute_corpus_longitudinal", -] - - -# Marqueur d'évitement d'import inutilisé (math) -_ = math +from picarones.evaluation.metrics.longitudinal import * # noqa: F401,F403 diff --git a/picarones/measurements/marginal_cost.py b/picarones/measurements/marginal_cost.py index 4d1c59bf324ede3d6bf0e2fcf91c59d9dae9d0de..b6ed75d8d1fadff4531a04d9eef1935b8e045b9c 100644 --- a/picarones/measurements/marginal_cost.py +++ b/picarones/measurements/marginal_cost.py @@ -1,142 +1,10 @@ -"""Coût marginal par erreur évitée — Sprint 91 (A.II.6 chantier 2). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.marginal_cost``. -Sprint 91 — A.II.6 chantier 2 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -La vue Pareto (Sprint 20) trace CER vs coût mais n'arbitre pas -quel surcoût est *raisonnable* pour quelle réduction d'erreur. -Une institution avec un budget contraint a besoin d'une -réponse opérationnelle : - - *« Passer de Tesseract à Mistral OCR coûte 0,83 € par - erreur évitée — décider selon votre budget par millier - d'erreurs corrigées. »* - -Formule -------- -Pour deux moteurs A et B où B fait **moins** d'erreurs que A -(donc B est plus précis) : - -.. code:: - - coût_marginal = (coût_B − coût_A) / (errors_A − errors_B) - -- Si ``cost_B > cost_A`` et ``errors_B < errors_A`` : - ``cost_per_avoided_error > 0`` (cas standard, B coûte plus - pour moins d'erreurs). -- Si ``cost_B ≤ cost_A`` et ``errors_B < errors_A`` : - ``cost_per_avoided_error ≤ 0`` (cas idéal, B est strictement - meilleur). -- Si ``errors_B ≥ errors_A`` : non comparable dans ce sens - (B n'évite pas d'erreur), retourne ``None``. - -Sortie ------- -``compute_marginal_cost(cost_a, errors_a, cost_b, errors_b)`` -retourne ``{cost_per_avoided_error, n_errors_avoided, -cost_delta, dominated}`` ou ``None`` si non comparable. - -``compute_marginal_cost_matrix(per_engine)`` retourne, pour -chaque paire ordonnée ``(A → B)`` où B est plus précis, le -coût marginal correspondant. Trié par coût marginal croissant -(meilleur ratio en tête). +L'ancien chemin ``picarones.measurements.marginal_cost`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -def compute_marginal_cost( - cost_a: float, - errors_a: float, - cost_b: float, - errors_b: float, -) -> Optional[dict]: - """Coût marginal du passage A → B (B plus précis). - - Retourne ``None`` si : - - ``errors_b >= errors_a`` (B n'évite pas d'erreur) ; - - les valeurs ne sont pas finies. - """ - try: - ca = float(cost_a) - cb = float(cost_b) - ea = float(errors_a) - eb = float(errors_b) - except (TypeError, ValueError): - return None - if ea <= eb: - # B ne fait pas mieux que A → pas de gain à mesurer. - return None - n_avoided = ea - eb - cost_delta = cb - ca - cost_per_avoided = cost_delta / n_avoided - dominated = cost_delta <= 0 # B aussi cher ou moins → cas idéal - return { - "cost_per_avoided_error": cost_per_avoided, - "n_errors_avoided": n_avoided, - "cost_delta": cost_delta, - "dominated": dominated, - } - - -def compute_marginal_cost_matrix( - per_engine: dict[str, dict], -) -> Optional[dict]: - """Pour chaque paire A → B où B fait moins d'erreurs, calcule - le coût marginal. - - Parameters - ---------- - per_engine: - Map ``{engine_name: {"cost": float, "errors": float}}``. - - Returns - ------- - dict | None - ``{ - "pairs": list[ - {"engine_a", "engine_b", "cost_per_avoided_error", - "n_errors_avoided", "cost_delta", "dominated"} - ], # triée par cost_per_avoided_error croissant - }`` - ou ``None`` si moins de 2 moteurs. - """ - if not per_engine or len(per_engine) < 2: - return None - engines = sorted(per_engine.keys()) - pairs: list[dict] = [] - for a in engines: - for b in engines: - if a == b: - continue - data_a = per_engine[a] - data_b = per_engine[b] - try: - ca = float(data_a.get("cost")) - ea = float(data_a.get("errors")) - cb = float(data_b.get("cost")) - eb = float(data_b.get("errors")) - except (TypeError, ValueError): - continue - result = compute_marginal_cost(ca, ea, cb, eb) - if result is None: - continue - entry = {"engine_a": a, "engine_b": b} - entry.update(result) - pairs.append(entry) - if not pairs: - return None - pairs.sort(key=lambda p: p["cost_per_avoided_error"]) - return {"pairs": pairs} - - -__all__ = [ - "compute_marginal_cost", - "compute_marginal_cost_matrix", -] +from picarones.evaluation.metrics.marginal_cost import * # noqa: F401,F403 diff --git a/picarones/measurements/metrics.py b/picarones/measurements/metrics.py index 48fe4ea4202da8e9a9392dceb1f935b1a87e6447..7a468ef3f259ac88bd8fcfc9f78a0d1d3db85d29 100644 --- a/picarones/measurements/metrics.py +++ b/picarones/measurements/metrics.py @@ -104,9 +104,12 @@ def compute_metrics( Objet contenant toutes les métriques calculées. """ if not _JIWER_AVAILABLE: + # Sprint A14-S1 — A.I.0 P0 : ne pas retourner 0.0 en erreur + # (indistinguable d'un score parfait pour un lecteur qui ne + # vérifie pas ``error``). None = absence de mesure. return MetricsResult( - cer=0.0, cer_nfc=0.0, cer_caseless=0.0, - wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0, + cer=None, cer_nfc=None, cer_caseless=None, + wer=None, wer_normalized=None, mer=None, wil=None, reference_length=len(reference), hypothesis_length=len(hypothesis), error="jiwer n'est pas installé (pip install jiwer)", @@ -177,9 +180,11 @@ def compute_metrics( except Exception as exc: # noqa: BLE001 logger.warning("[metrics] calcul métriques échoué : %s", exc) + # Sprint A14-S1 — A.I.0 P0 : None plutôt que 0.0 (cf. cas + # ``not _JIWER_AVAILABLE`` plus haut pour le rationale). return MetricsResult( - cer=0.0, cer_nfc=0.0, cer_caseless=0.0, - wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0, + cer=None, cer_nfc=None, cer_caseless=None, + wer=None, wer_normalized=None, mer=None, wil=None, reference_length=len(reference), hypothesis_length=len(hypothesis), error=str(exc), diff --git a/picarones/measurements/module_policy.py b/picarones/measurements/module_policy.py index 326b9685bd5d16b555a33bd2b875a3e6ab0e4625..8b4cf0526186668b1339e4cd2dd2b36b6a2ee9bf 100644 --- a/picarones/measurements/module_policy.py +++ b/picarones/measurements/module_policy.py @@ -1,333 +1,10 @@ -"""Politique de modules contribués — Sprint 97 (B.6). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.module_policy``. -Sprint 97 — B.6 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Avant d'ouvrir Picarones aux contributions externes (axe B — -modules tiers que l'utilisateur amène), il faut un cadre de -qualité explicite : *« un module qui ne passe pas l'audit -n'est pas exécutable. »* - -Ce module fournit l'**enveloppe d'audit** : - -- ``ModuleManifest`` — métadonnées obligatoires (auteur, - licence, version, citation, contrat d'entrée/sortie typé). -- ``validate_manifest(manifest)`` — vérifie que tous les champs - obligatoires sont présents et bien formés. -- ``audit_module(module_class_or_instance, manifest)`` — - vérifie en plus que la classe respecte le contrat ``BaseModule`` - et que ``input_types``/``output_types`` correspondent au - manifeste. -- ``AuditResult`` — verdict structuré ``passed/failed`` + liste - des checks détaillés. - -Stratégie d'ouverture ---------------------- -Phase fermée actuelle : modules officiels uniquement, -contributions via PR sur le repo principal. Phase ouverte -future : une fois 5–6 modules officiels stables, ouverture via -``entry_points`` sur PyPI (``picarones-module-X``). Ce module -prépare la phase ouverte sans la déclencher : tout module -externe devra fournir un ``ModuleManifest`` valide pour être -exécuté. - -Pas de SPDX validator ---------------------- -On vérifie la présence et la non-vacuité des champs licence ; -on ne valide pas la conformité SPDX du nom (``MIT`` vs -``mit-license`` vs ``MIT License``). Le chercheur reste -responsable du choix de licence ; l'outil documente, il ne -juge pas. +L'ancien chemin ``picarones.measurements.module_policy`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from dataclasses import dataclass, field -from typing import Any, Optional - -logger = logging.getLogger(__name__) - - -# Champs obligatoires d'un ManifestModule (texte non-vide). -_REQUIRED_TEXT_FIELDS = ( - "name", "version", "author", "license", - "description", -) - - -@dataclass -class ModuleManifest: - """Métadonnées d'un module contribué. - - Attributes - ---------- - name: - Identifiant unique du module (ex. ``"my-llm-correcteur"``). - version: - Version sémantique (ex. ``"1.2.0"``). - author: - Auteur ou institution responsable. - license: - Identifiant de licence (SPDX recommandé, non validé). - description: - Description courte (≤ 1 phrase). - input_types: - Liste des types d'entrée (chaînes). Doit correspondre - à ``module.input_types`` (Sprint 33). - output_types: - Liste des types de sortie. Doit correspondre à - ``module.output_types``. - citation: - Citation académique (BibTeX, DOI, ou texte libre). - Optionnel. - homepage: - URL du dépôt ou de la page projet. Optionnel. - picarones_min_version: - Version minimale de Picarones requise. Optionnel. - extra: - Métadonnées libres (clé → valeur). - """ - - name: str - version: str - author: str - license: str - description: str - input_types: list[str] = field(default_factory=list) - output_types: list[str] = field(default_factory=list) - citation: Optional[str] = None - homepage: Optional[str] = None - picarones_min_version: Optional[str] = None - extra: dict = field(default_factory=dict) - - def as_dict(self) -> dict: - return { - "name": self.name, - "version": self.version, - "author": self.author, - "license": self.license, - "description": self.description, - "input_types": list(self.input_types), - "output_types": list(self.output_types), - "citation": self.citation, - "homepage": self.homepage, - "picarones_min_version": self.picarones_min_version, - "extra": dict(self.extra), - } - - -@dataclass -class AuditCheck: - """Un check individuel de l'audit.""" - - name: str - passed: bool - detail: Optional[str] = None - - def as_dict(self) -> dict: - return { - "name": self.name, - "passed": self.passed, - "detail": self.detail, - } - - -@dataclass -class AuditResult: - """Résultat global d'un audit de module.""" - - module_name: str - passed: bool - checks: list[AuditCheck] = field(default_factory=list) - - @property - def n_passed(self) -> int: - return sum(1 for c in self.checks if c.passed) - - @property - def n_failed(self) -> int: - return sum(1 for c in self.checks if not c.passed) - - def as_dict(self) -> dict: - return { - "module_name": self.module_name, - "passed": self.passed, - "n_passed": self.n_passed, - "n_failed": self.n_failed, - "checks": [c.as_dict() for c in self.checks], - } - - -def validate_manifest(manifest: ModuleManifest) -> list[AuditCheck]: - """Vérifie qu'un manifest est complet et bien formé. - - Returns - ------- - list[AuditCheck] - Un check par champ obligatoire + un check pour - ``input_types``/``output_types`` non vides. - """ - checks: list[AuditCheck] = [] - for field_name in _REQUIRED_TEXT_FIELDS: - value = getattr(manifest, field_name, None) - ok = isinstance(value, str) and bool(value.strip()) - checks.append(AuditCheck( - name=f"manifest.{field_name}", - passed=ok, - detail=None if ok else f"champ '{field_name}' vide ou absent", - )) - # input_types / output_types : au moins une entrée chacun - in_ok = ( - isinstance(manifest.input_types, list) - and len(manifest.input_types) > 0 - and all( - isinstance(t, str) and t for t in manifest.input_types - ) - ) - checks.append(AuditCheck( - name="manifest.input_types", - passed=in_ok, - detail=None if in_ok else "input_types vide ou non-string", - )) - out_ok = ( - isinstance(manifest.output_types, list) - and len(manifest.output_types) > 0 - and all( - isinstance(t, str) and t for t in manifest.output_types - ) - ) - checks.append(AuditCheck( - name="manifest.output_types", - passed=out_ok, - detail=None if out_ok else "output_types vide ou non-string", - )) - return checks - - -def _is_base_module(cls: Any) -> bool: - """Best-effort : vérifie que cls hérite de BaseModule. - - On ne **pas** importer ``BaseModule`` au top-level pour - éviter les cycles : on inspecte la chaîne de classes par - leur nom. - """ - try: - for base in cls.__mro__: - if base.__name__ == "BaseModule": - return True - except AttributeError: - return False - return False - - -def audit_module( - module_class_or_instance: Any, - manifest: ModuleManifest, -) -> AuditResult: - """Audite un module contribué : interface + manifest. - - Parameters - ---------- - module_class_or_instance: - Soit la classe ``BaseModule`` (Sprint 33), soit une - instance. - manifest: - ``ModuleManifest`` correspondant au module. - - Returns - ------- - AuditResult - ``passed=True`` ssi tous les checks passent. - """ - checks = validate_manifest(manifest) - - # Check : héritage de BaseModule - cls = ( - type(module_class_or_instance) - if not isinstance(module_class_or_instance, type) - else module_class_or_instance - ) - inherits_base = _is_base_module(cls) - checks.append(AuditCheck( - name="module.inherits_base_module", - passed=inherits_base, - detail=( - None if inherits_base - else "la classe n'hérite pas de picarones.core.modules.BaseModule" - ), - )) - - # Check : input_types / output_types correspondent - declared_in: list[str] = [] - declared_out: list[str] = [] - try: - instance = ( - module_class_or_instance - if not isinstance(module_class_or_instance, type) - else None - ) - attr_in = getattr(cls, "input_types", None) - attr_out = getattr(cls, "output_types", None) - if instance is not None: - attr_in = getattr(instance, "input_types", attr_in) - attr_out = getattr(instance, "output_types", attr_out) - if attr_in is not None: - declared_in = [ - getattr(t, "value", str(t)) for t in attr_in - ] - if attr_out is not None: - declared_out = [ - getattr(t, "value", str(t)) for t in attr_out - ] - except Exception: # noqa: BLE001 - pass - # Comparaison case-insensitive : on accepte "TEXT" ou "text" - # côté manifest, le contrat sémantique est le même. - declared_in_lower = sorted(t.lower() for t in declared_in) - declared_out_lower = sorted(t.lower() for t in declared_out) - manifest_in_lower = sorted(t.lower() for t in manifest.input_types) - manifest_out_lower = sorted(t.lower() for t in manifest.output_types) - in_match = declared_in_lower == manifest_in_lower - checks.append(AuditCheck( - name="module.input_types_match_manifest", - passed=in_match, - detail=( - None if in_match - else f"déclaré {declared_in} vs manifest {manifest.input_types}" - ), - )) - out_match = declared_out_lower == manifest_out_lower - checks.append(AuditCheck( - name="module.output_types_match_manifest", - passed=out_match, - detail=( - None if out_match - else f"déclaré {declared_out} vs manifest {manifest.output_types}" - ), - )) - - # Check : process callable - has_process = callable(getattr(cls, "process", None)) - checks.append(AuditCheck( - name="module.has_process", - passed=has_process, - detail=None if has_process else "méthode process() absente", - )) - - passed = all(c.passed for c in checks) - return AuditResult( - module_name=manifest.name, - passed=passed, - checks=checks, - ) - - -__all__ = [ - "ModuleManifest", - "AuditCheck", - "AuditResult", - "validate_manifest", - "audit_module", -] +from picarones.evaluation.metrics.module_policy import * # noqa: F401,F403 diff --git a/picarones/measurements/normalization.py b/picarones/measurements/normalization.py index 6c33b33d4752d0c00715e8dfd6b068b75c773498..8d5db3fc8716927d9e2fd542fbe4868a54d813fb 100644 --- a/picarones/measurements/normalization.py +++ b/picarones/measurements/normalization.py @@ -1,420 +1,58 @@ -"""Profils de normalisation unicode pour le calcul du CER diplomatique. - -La normalisation diplomatique permet de calculer un CER tenant compte des -équivalences graphiques propres aux documents historiques : ſ=s, u=v, i=j, etc. - -En appliquant la même table aux deux textes (GT et OCR), on mesure les erreurs -"substantielles" (transcription erronée) en ignorant les variations graphiques -codifiées connues. - -Trois niveaux de normalisation sont disponibles : - -1. NFC : normalisation Unicode canonique (décomposition+recomposition) -2. caseless : NFC + pliage de casse (casefold) -3. diplomatic: NFC + table de correspondances historiques configurables - -Les profils préconfigurés couvrent les cas d'usage patrimoniaux courants. -Ils sont également chargeables depuis un fichier YAML. - -Exemple YAML ------------- -name: medieval_custom -caseless: false -diplomatic: - ſ: s - u: v - i: j - y: i - æ: ae - œ: oe +"""Re-export depuis ``picarones.formats.text.normalization`` — Sprint A14-S9. + +Le contenu canonique de ce module a été déplacé vers +``picarones/formats/text/normalization.py`` au Sprint S9 du +rewrite ciblé (cf. ``docs/roadmap/rewrite-2026.md``). + +Ce fichier est conservé comme re-export pour ne **rien casser** +chez les ~50 consommateurs qui font ``from +picarones.measurements.normalization import X``. Les symboles +publics ET privés utilisés downstream (``_parse_exclude_chars``, +``_apply_diplomatic_table``) sont ré-exposés explicitement. + +Plan de migration +----------------- +Au S22, les consommateurs qui importent encore depuis cet +emplacement seront migrés vers ``picarones.formats.text.normalization`` +et ce re-export disparaîtra. + +Règle architecturale +-------------------- +``measurements/`` (ancien code legacy) est autorisé à importer +``formats/`` (nouveau code) pendant la phase de migration. +L'inverse est interdit (vérifié par ``test_layer_dependencies``). """ from __future__ import annotations -import unicodedata -from dataclasses import dataclass, field -from pathlib import Path - - -# --------------------------------------------------------------------------- -# Tables de correspondances diplomatiques préconfigurées -# --------------------------------------------------------------------------- - -#: Français médiéval (XIIe–XVe siècle) -DIPLOMATIC_FR_MEDIEVAL: dict[str, str] = { - "ſ": "s", # s long → s - "u": "v", # u/v interchangeables en position initiale - "i": "j", # i/j interchangeables - "y": "i", # y vocalique → i - "æ": "ae", # ligature æ - "œ": "oe", # ligature œ - "ꝑ": "per", # abréviation per/par - "ꝓ": "pro", # abréviation pro - "\u0026": "et", # & → et -} - -#: Français moderne / imprimés anciens (XVIe–XVIIIe siècle) -DIPLOMATIC_FR_EARLY_MODERN: dict[str, str] = { - "ſ": "s", # s long - "æ": "ae", - "œ": "oe", - "\u0026": "et", - "ỹ": "yn", # y tilde -} - -#: Latin médiéval -DIPLOMATIC_LATIN_MEDIEVAL: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "y": "i", - "æ": "ae", - "œ": "oe", - "ꝑ": "per", - "ꝓ": "pro", - "ꝗ": "que", # q barré → que - "\u0026": "et", -} - -#: Profil minimal — uniquement NFC + s long -DIPLOMATIC_MINIMAL: dict[str, str] = { - "ſ": "s", -} - -#: Anglais moderne / imprimés anciens (XVIe–XVIIIe siècle) -#: Orthographe «early modern» : ſ=s, u/v, i/j, vv=w, þ=th, ð=th, ȝ=y -DIPLOMATIC_EN_EARLY_MODERN: dict[str, str] = { - "ſ": "s", # s long → s - "u": "v", # u/v interchangeables (vpon → upon) - "i": "j", # i/j interchangeables (ioy → joy) - "vv": "w", # vv → w (vvhich → which) - "þ": "th", # thorn → th - "ð": "th", # eth → th - "ȝ": "y", # yogh → y - "æ": "ae", # ligature æ - "œ": "oe", # ligature œ - "\u0026": "and", # & → and -} - -#: Anglais médiéval (XIIe–XVe siècle) — abréviations manuscrites incluses -DIPLOMATIC_EN_MEDIEVAL: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "vv": "w", - "þ": "th", - "ð": "th", - "ȝ": "y", - "æ": "ae", - "œ": "oe", - "\u0026": "and", - # Abréviations courantes dans les manuscrits anglais médiévaux - "ꝑ": "per", # p barré → per/par - "ꝓ": "pro", # p crocheté → pro - "ꝗ": "que", # q barré → que - "\ua75b": "r", # lettre r rotunda → r -} - -#: Écriture secrétaire (XVIe–XVIIe siècle) — secretary hand -#: Confusions visuelles propres à l'écriture cursive anglaise -DIPLOMATIC_EN_SECRETARY: dict[str, str] = { - "ſ": "s", - "u": "v", - "i": "j", - "vv": "w", - "þ": "th", - "ð": "th", - "ȝ": "y", - "\u0026": "and", - # Confusions visuelles typiques : e/c, n/u, m/w en secrétaire - # Note : ne pas normaliser e/c automatiquement (trop agressif) ; - # on se limite aux substituts graphiques historiquement documentés -} - - -# --------------------------------------------------------------------------- -# Profil de normalisation -# --------------------------------------------------------------------------- - -@dataclass -class NormalizationProfile: - """Décrit une stratégie de normalisation pour le calcul du CER diplomatique. - - Parameters - ---------- - name: - Identifiant lisible du profil (ex : ``"medieval_french"``). - nfc: - Applique la normalisation Unicode NFC (recommandé, activé par défaut). - caseless: - Pliage de casse (casefold) après NFC. - diplomatic_table: - Table de correspondances graphiques historiques appliquée caractère - par caractère sur les deux textes avant calcul du CER. - exclude_chars: - Ensemble de caractères supprimés des deux textes (GT et OCR) avant - tout calcul de métriques (CER, WER, MER, WIL et CER diplomatique). - Utile pour ignorer la ponctuation ou les apostrophes. - description: - Description courte du profil (affichée dans le rapport HTML). - """ - - name: str - nfc: bool = True - caseless: bool = False - diplomatic_table: dict[str, str] = field(default_factory=dict) - exclude_chars: frozenset = field(default_factory=frozenset) - description: str = "" - - def normalize(self, text: str) -> str: - """Applique le profil de normalisation à un texte.""" - if self.exclude_chars: - text = "".join(c for c in text if c not in self.exclude_chars) - if self.nfc: - text = unicodedata.normalize("NFC", text) - if self.caseless: - text = text.casefold() - if self.diplomatic_table: - text = _apply_diplomatic_table(text, self.diplomatic_table) - return text - - def as_dict(self) -> dict: - return { - "name": self.name, - "nfc": self.nfc, - "caseless": self.caseless, - "diplomatic_table": self.diplomatic_table, - "exclude_chars": sorted(self.exclude_chars), - "description": self.description, - } - - @classmethod - def from_yaml(cls, path: str | Path) -> "NormalizationProfile": - """Charge un profil depuis un fichier YAML. - - Le fichier YAML doit contenir les clés ``name``, optionnellement - ``caseless``, ``description``, ``diplomatic`` (dict str→str) et - ``exclude_chars`` (liste ou chaîne de caractères à ignorer). - - Example - ------- - .. code-block:: yaml - - name: medieval_custom - caseless: false - description: Français médiéval personnalisé - exclude_chars: ".,;:!?" - diplomatic: - ſ: s - u: v - """ - try: - import yaml - except ImportError as exc: - raise RuntimeError( - "Le package 'pyyaml' est requis pour charger les profils YAML. " - "Installez-le avec : pip install pyyaml" - ) from exc - - data = yaml.safe_load(Path(path).read_text(encoding="utf-8")) - return cls( - name=data.get("name", Path(path).stem), - nfc=bool(data.get("nfc", True)), - caseless=bool(data.get("caseless", False)), - diplomatic_table=data.get("diplomatic", {}), - exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), - description=data.get("description", ""), - ) - - @classmethod - def from_dict(cls, data: dict) -> "NormalizationProfile": - """Charge un profil depuis un dictionnaire (ex : section YAML inline).""" - return cls( - name=data.get("name", "custom"), - nfc=bool(data.get("nfc", True)), - caseless=bool(data.get("caseless", False)), - diplomatic_table=data.get("diplomatic", {}), - exclude_chars=_parse_exclude_chars(data.get("exclude_chars", "")), - description=data.get("description", ""), - ) - - -# --------------------------------------------------------------------------- -# Profils préconfigurés -# --------------------------------------------------------------------------- - -NORMALIZATION_PROFILES: dict[str, NormalizationProfile] = { - "nfc": NormalizationProfile( - name="nfc", - nfc=True, - caseless=False, - diplomatic_table={}, - description="Normalisation NFC uniquement", - ), - "caseless": NormalizationProfile( - name="caseless", - nfc=True, - caseless=True, - diplomatic_table={}, - description="NFC + insensible à la casse", - ), - "minimal": NormalizationProfile( - name="minimal", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_MINIMAL, - description="Minimal : NFC + s long seulement", - ), - "medieval_french": NormalizationProfile( - name="medieval_french", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_FR_MEDIEVAL, - description="Français médiéval (XIIe–XVe) : ſ=s, u=v, i=j, æ=ae, œ=oe", - ), - "early_modern_french": NormalizationProfile( - name="early_modern_french", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_FR_EARLY_MODERN, - description="Imprimés anciens (XVIe–XVIIIe) : ſ=s, æ=ae, œ=oe", - ), - "medieval_latin": NormalizationProfile( - name="medieval_latin", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_LATIN_MEDIEVAL, - description="Latin médiéval : ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro", - ), - "early_modern_english": NormalizationProfile( - name="early_modern_english", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_EARLY_MODERN, - description="Early Modern English (XVIth–XVIIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", - ), - "medieval_english": NormalizationProfile( - name="medieval_english", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_MEDIEVAL, - description="Medieval English (XIIth–XVth c.): ſ=s, u=v, i=j, þ=th, ȝ=y, ꝑ=per, ꝓ=pro", - ), - "secretary_hand": NormalizationProfile( - name="secretary_hand", - nfc=True, - caseless=False, - diplomatic_table=DIPLOMATIC_EN_SECRETARY, - description="Secretary hand (XVIth–XVIIth c.): ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y", - ), - # ── Profils d'exclusion de caractères ──────────────────────────────── - "sans_ponctuation": NormalizationProfile( - name="sans_ponctuation", - nfc=True, - caseless=False, - diplomatic_table={}, - exclude_chars=frozenset(". , ; : ! ? ' \u2019 \" - \u2013 \u2014 ( ) [ ]".split()), - description="NFC + suppression de la ponctuation courante : . , ; : ! ? ' \" - – — ( ) [ ]", - ), - "sans_apostrophes": NormalizationProfile( - name="sans_apostrophes", - nfc=True, - caseless=False, - diplomatic_table={}, - exclude_chars=frozenset(["'", "\u2019"]), # apostrophe droite + apostrophe typographique - description="NFC + suppression des apostrophes droite (') et typographique (\u2019)", - ), -} - - -def get_builtin_profile(name: str) -> NormalizationProfile: - """Retourne un profil préconfigurée par son identifiant. - - Identifiants disponibles - ------------------------ - - ``"medieval_french"`` : français médiéval XIIe–XVe (ſ=s, u=v, i=j, æ=ae, œ=oe…) - - ``"early_modern_french"`` : imprimés anciens XVIe–XVIIIe (ſ=s, œ=oe, æ=ae…) - - ``"medieval_latin"`` : latin médiéval (ſ=s, u=v, i=j, ꝑ=per, ꝓ=pro…) - - ``"early_modern_english"`` : anglais imprimé XVIe–XVIIIe (ſ=s, u=v, i=j, vv=w, þ=th, ð=th, ȝ=y) - - ``"medieval_english"`` : anglais manuscrit XIIe–XVe (+ abréviations ꝑ, ꝓ…) - - ``"secretary_hand"`` : écriture secrétaire anglaise XVIe–XVIIe (cursive administrative) - - ``"minimal"`` : uniquement NFC + s long - - ``"nfc"`` : NFC seul (sans table diplomatique) - - ``"caseless"`` : NFC + pliage de casse - - Raises - ------ - KeyError - Si le nom n'est pas reconnu. - """ - if name not in NORMALIZATION_PROFILES: - raise KeyError( - f"Profil de normalisation inconnu : '{name}'. " - f"Disponibles : {', '.join(NORMALIZATION_PROFILES)}" - ) - return NORMALIZATION_PROFILES[name] - - -# --------------------------------------------------------------------------- -# Fonctions utilitaires -# --------------------------------------------------------------------------- - -def _parse_exclude_chars(value: "str | list | None") -> frozenset: - """Convertit une liste de caractères (str ou list) en frozenset. - - Accepte : - - Une chaîne de caractères séparés par une virgule+espace (ex. ``"', -, –"``) - ou simplement concaténés sans séparateur (ex. ``".,;:!?"``) - - Une liste Python/YAML de chaînes (chacune un caractère) - - None ou chaîne vide → frozenset vide - - Règle de désambiguïsation : si la chaîne contient la séquence ``", "`` - (virgule suivie d'un espace), on découpe par ``", "``. Sinon, chaque - caractère Unicode est un item distinct. - """ - if not value: - return frozenset() - if isinstance(value, (list, tuple)): - return frozenset(str(c) for c in value if c) - raw = str(value) - # Désambiguïsation : séparer par ", " si présent (format lisible) - if ", " in raw: - return frozenset(c.strip() for c in raw.split(",") if c.strip()) - # Sinon, chaque caractère Unicode est un item distinct - return frozenset(raw) - - -def _apply_diplomatic_table(text: str, table: dict[str, str]) -> str: - """Applique une table de correspondances diplomatiques en un seul pass. - - Les clés multi-caractères (ex : ``"ae"`` → ``"æ"``) sont gérées en priorité - sur les correspondances simples. Le remplacement est fait en un seul pass - via regex pour éviter les remplacements en cascade (ex : ``"ſ"→"s"`` puis - ``"s"→"z"`` donnerait ``"z"`` au lieu de ``"s"``). - """ - if not table: - return text - - import re - - # Séparer les clés simples (1 char) des clés multi-chars - multi_keys = sorted( - (k for k in table if len(k) > 1), key=len, reverse=True - ) - simple_table = {k: v for k, v in table.items() if len(k) == 1} - - if multi_keys: - # Single-pass : construire un pattern regex avec toutes les clés multi-chars - # triées par longueur décroissante pour matcher les plus longues d'abord - pattern = re.compile("|".join(re.escape(k) for k in multi_keys)) - text = pattern.sub(lambda m: table[m.group(0)], text) - - # Remplacements char par char (single-pass via itération) - if simple_table: - text = "".join(simple_table.get(c, c) for c in text) - - return text - - -# Profil par défaut utilisé pour le CER diplomatique intégré -DEFAULT_DIPLOMATIC_PROFILE: NormalizationProfile = get_builtin_profile("medieval_french") +from picarones.formats.text.normalization import ( + DEFAULT_DIPLOMATIC_PROFILE, + DIPLOMATIC_EN_EARLY_MODERN, + DIPLOMATIC_EN_MEDIEVAL, + DIPLOMATIC_EN_SECRETARY, + DIPLOMATIC_FR_EARLY_MODERN, + DIPLOMATIC_FR_MEDIEVAL, + DIPLOMATIC_LATIN_MEDIEVAL, + DIPLOMATIC_MINIMAL, + NORMALIZATION_PROFILES, + NormalizationProfile, + _apply_diplomatic_table, + _parse_exclude_chars, + get_builtin_profile, +) + +__all__ = [ + "NormalizationProfile", + "DIPLOMATIC_FR_MEDIEVAL", + "DIPLOMATIC_FR_EARLY_MODERN", + "DIPLOMATIC_LATIN_MEDIEVAL", + "DIPLOMATIC_MINIMAL", + "DIPLOMATIC_EN_EARLY_MODERN", + "DIPLOMATIC_EN_MEDIEVAL", + "DIPLOMATIC_EN_SECRETARY", + "NORMALIZATION_PROFILES", + "DEFAULT_DIPLOMATIC_PROFILE", + "get_builtin_profile", + "_parse_exclude_chars", + "_apply_diplomatic_table", +] diff --git a/picarones/measurements/pricing.py b/picarones/measurements/pricing.py index 2ebf1bea27ba9b17d80ba2f6d3f2e1c84e7192d3..55ca9de08de86376c5b7756b553054438cec3e57 100644 --- a/picarones/measurements/pricing.py +++ b/picarones/measurements/pricing.py @@ -1,309 +1,15 @@ -"""Modélisation des coûts — APIs cloud et temps d'inférence local. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.pricing``. -Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5). -Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml`` -pour les hypothèses, dates et URLs de référence. +L'ancien chemin ``picarones.measurements.pricing`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. -Conventions ------------ -- Unité monétaire : EUR (conversion indicative depuis USD quand applicable). -- Coût exprimé par **1 000 pages** traitées. -- Coût local = temps moyen d'inférence × taux horaire (paramétrable). -- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau - d'exécution (mix France bas carbone par défaut pour le local, - moyenne cloud hyperscaler pour les APIs). +Ce module ré-expose **explicitement** le symbole privé +``_DEFAULT_PRICING_PATH`` qu'au moins un consommateur importe +directement (cf. tests). """ from __future__ import annotations -import logging -from dataclasses import dataclass, field -from pathlib import Path -from typing import Optional - -import yaml - -logger = logging.getLogger(__name__) - -_DEFAULT_PRICING_PATH = Path(__file__).parent.parent / "data" / "pricing.yaml" - - -@dataclass(frozen=True) -class PricingDefaults: - """Valeurs par défaut du fichier de prix (section ``meta``).""" - - last_updated: Optional[str] = None - currency: str = "EUR" - hourly_rate_local_cpu_eur: float = 0.08 - hourly_rate_local_gpu_eur: float = 1.20 - grid_intensity_local: float = 58.0 - grid_intensity_cloud: float = 380.0 - - -@dataclass -class EngineCost: - """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses. - - La représentation est immuable après construction : une fois que l'utilisateur - a choisi un taux horaire local, toutes les instances partagent cette - hypothèse par injection explicite dans ``build_costs_for_benchmark``. - """ - - engine_key: str - """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``).""" - - type: str # "local" | "cloud_api" | "unknown" - - cost_per_1k_pages_eur: Optional[float] = None - """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes.""" - - currency: str = "EUR" - - # Source / date - pricing_source_url: Optional[str] = None - pricing_date: Optional[str] = None - - # Pour les APIs cloud : prix brut - api_price_per_1k_pages: Optional[float] = None - - # Pour le local : temps d'inférence et taux horaire utilisés - local_mean_seconds_per_page: Optional[float] = None - hourly_rate_eur: Optional[float] = None - - # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport) - kwh_per_1k_pages: Optional[float] = None - grid_intensity_g_co2_per_kwh: Optional[float] = None - co2_per_1k_pages_g: Optional[float] = None - - notes: Optional[str] = None - - assumptions: list[str] = field(default_factory=list) - """Liste d'hypothèses textuelles à afficher sous le graphique.""" - - def as_dict(self) -> dict: - return { - "engine_key": self.engine_key, - "type": self.type, - "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, - "currency": self.currency, - "pricing_source_url": self.pricing_source_url, - "pricing_date": self.pricing_date, - "api_price_per_1k_pages": self.api_price_per_1k_pages, - "local_mean_seconds_per_page": self.local_mean_seconds_per_page, - "hourly_rate_eur": self.hourly_rate_eur, - "kwh_per_1k_pages": self.kwh_per_1k_pages, - "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh, - "co2_per_1k_pages_g": self.co2_per_1k_pages_g, - "notes": self.notes, - "assumptions": list(self.assumptions), - } - - -def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]: - """Charge la table de prix YAML. - - Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict - ``{engine_key: raw_entry}``. - """ - path = Path(path) if path else _DEFAULT_PRICING_PATH - if not path.exists(): - logger.warning("[pricing] fichier %s introuvable", path) - return PricingDefaults(), {} - try: - with path.open(encoding="utf-8") as fh: - data = yaml.safe_load(fh) or {} - except yaml.YAMLError as e: - logger.warning("[pricing] échec parsing %s : %s", path, e) - return PricingDefaults(), {} - - meta = data.get("meta", {}) or {} - defaults = PricingDefaults( - last_updated=meta.get("last_updated"), - currency=meta.get("currency", "EUR"), - hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)), - hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)), - grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)), - grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)), - ) - engines_table = data.get("engines", {}) or {} - return defaults, engines_table - - -def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]: - """Cherche la meilleure clé pour ce moteur dans la table. - - Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le - nom OCR, puis un match partiel (substring) comme filet de sécurité. - """ - candidates = [llm_model, engine_name] - for c in candidates: - if c and c in table: - return c - # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision" - for c in candidates: - if not c: - continue - for key in table: - if key in c: - return key - return None - - -def estimate_cost( - engine_name: str, - *, - llm_model: Optional[str] = None, - is_pipeline: bool = False, - measured_seconds_per_page: Optional[float] = None, - table: Optional[dict] = None, - defaults: Optional[PricingDefaults] = None, - hourly_rate_override_eur: Optional[float] = None, -) -> EngineCost: - """Calcule le ``EngineCost`` pour un moteur donné. - - Parameters - ---------- - engine_name: - Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``). - llm_model: - Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la - lookup car c'est lui qui domine le coût. - is_pipeline: - Indique un pipeline OCR+LLM (change la sémantique de lookup). - measured_seconds_per_page: - Temps moyen observé sur le benchmark courant. Remplace la valeur - indicative de la table si fournie (plus fiable). - table, defaults: - Overrides pour tests ou usage institutionnel. - hourly_rate_override_eur: - Taux horaire à utiliser pour le calcul local (sinon valeur table - ou défaut). - """ - if table is None or defaults is None: - _defaults, _table = load_pricing_database() - defaults = defaults or _defaults - table = table or _table - - key = _match_key(engine_name, llm_model if is_pipeline else None, table) - if key is None: - return EngineCost( - engine_key=engine_name, - type="unknown", - assumptions=["Aucune entrée dans la table de prix pour ce moteur."], - ) - - entry = table[key] - etype = str(entry.get("type", "unknown")) - notes = entry.get("notes") - assumptions: list[str] = [] - currency = defaults.currency - - cost_eur: Optional[float] = None - api_price: Optional[float] = None - local_seconds = measured_seconds_per_page - hourly_rate = None - - if etype == "cloud_api": - api_price = entry.get("api_price_per_1k_pages") - if api_price is not None: - cost_eur = float(api_price) - assumptions.append( - f"Prix API indicatif : {cost_eur:.2f} €/1000 pages " - f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})." - ) - elif etype == "local": - indicative_seconds = entry.get("local_mean_seconds_per_page") - if local_seconds is None and indicative_seconds is not None: - local_seconds = float(indicative_seconds) - assumptions.append( - f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)." - ) - elif local_seconds is not None: - assumptions.append( - f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)." - ) - - hourly_rate = ( - hourly_rate_override_eur - if hourly_rate_override_eur is not None - else entry.get("hourly_rate_override_eur") - ) - if hourly_rate is None: - # Heuristique : si l'entrée précise un override GPU, sinon CPU - hourly_rate = ( - defaults.hourly_rate_local_gpu_eur - if "gpu" in str(notes or "").lower() - else defaults.hourly_rate_local_cpu_eur - ) - hourly_rate = float(hourly_rate) - - if local_seconds is not None and hourly_rate is not None: - cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0 - assumptions.append( - f"Taux horaire appliqué : {hourly_rate:.2f} €/h " - f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})." - ) - - # Empreinte carbone optionnelle - kwh_1k = entry.get("kwh_per_1k_pages") - grid = ( - entry.get("grid_intensity_g_co2_per_kwh") - or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local) - ) - co2_g = None - if kwh_1k is not None and grid is not None: - co2_g = float(kwh_1k) * float(grid) - - return EngineCost( - engine_key=key, - type=etype, - cost_per_1k_pages_eur=cost_eur, - currency=currency, - pricing_source_url=entry.get("pricing_source_url"), - pricing_date=entry.get("pricing_date"), - api_price_per_1k_pages=api_price, - local_mean_seconds_per_page=local_seconds, - hourly_rate_eur=hourly_rate, - kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None, - grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None, - co2_per_1k_pages_g=co2_g, - notes=notes, - assumptions=assumptions, - ) - - -def build_costs_for_benchmark( - engines_summary: list[dict], - durations_by_engine: dict[str, float], - *, - hourly_rate_local_eur: Optional[float] = None, - pricing_path: Optional[Path] = None, -) -> dict[str, dict]: - """Calcule le coût de chaque moteur d'un benchmark. - - Returns - ------- - dict ``{engine_name: EngineCost.as_dict()}``. - """ - defaults, table = load_pricing_database(pricing_path) - out: dict[str, dict] = {} - for e in engines_summary: - name = e.get("name") - if not name: - continue - measured = durations_by_engine.get(name) - llm_model = None - pipeline_info = e.get("pipeline_info") or {} - if pipeline_info: - llm_model = pipeline_info.get("llm_model") - cost = estimate_cost( - engine_name=name, - llm_model=llm_model, - is_pipeline=bool(e.get("is_pipeline")), - measured_seconds_per_page=measured, - table=table, - defaults=defaults, - hourly_rate_override_eur=hourly_rate_local_eur, - ) - out[name] = cost.as_dict() - return out +from picarones.evaluation.metrics.pricing import * # noqa: F401,F403 +from picarones.evaluation.metrics.pricing import _DEFAULT_PRICING_PATH # noqa: F401 diff --git a/picarones/measurements/rare_tokens.py b/picarones/measurements/rare_tokens.py index 69f320e2c1b1922285c16f708f74240b51713709..ed8fa830a86b37149f0117c00db7a12e1bc8f5c4 100644 --- a/picarones/measurements/rare_tokens.py +++ b/picarones/measurements/rare_tokens.py @@ -1,254 +1,10 @@ -"""Rare-token recall — Sprint 71 (A.I.1 chantier 2 du plan 2026). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.rare_tokens``. -Pourquoi ce module ------------------- -Le CER global d'un moteur peut sembler bon (ex. 5 %) tout en -masquant des **erreurs systématiques sur les tokens rares** : noms -propres, toponymes peu fréquents, mots techniques, formules latines -récurrentes mais pas dominantes. Pour un usage prosopographique -(indexation de noms, recherche généalogique), ce sont précisément -ces tokens-là qui comptent. - -Ce module mesure le **rappel sur les tokens rares** d'un corpus — -défaut : tokens dont la fréquence corpus-wide est ≤ 2 (hapax + -dis legomena, terminologie de lexicométrie classique). - -Hypothèse à valider expérimentalement -------------------------------------- -La conjecture du plan A.I.1 : *« cette métrique discrimine plus -les moteurs que le CER global »*. Si confirmée sur un corpus -patrimonial réel, elle gagne sa place dans le tableau de -classement principal — décision laissée au chercheur après -observation. - -Stratégie de découpage ----------------------- -Cohérente avec NER (38), Flesch (52), philologie (55-60) : couche -de calcul pure d'abord, sans intégration runner. La vue HTML -« worst lines / rare tokens manqués » suit dans un sprint dédié. - -Pas d'enregistrement dans le registre typé Sprint 34 ----------------------------------------------------- -La métrique exige **trois entrées** (reference, hypothesis, set -des tokens rares) et le set des rares est calculé corpus-wide -(donc connu seulement après itération sur tout le corpus). La -signature ne rentre pas dans ``(TEXT, TEXT)``. L'utilisateur -appelle explicitement ``compute_rare_token_recall`` avec le set -qu'il a calculé. +L'ancien chemin ``picarones.measurements.rare_tokens`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -import re -from collections import Counter -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────────────────── -# Tokenisation Unicode-aware -# ────────────────────────────────────────────────────────────────────────── - -# Token = séquence maximale de caractères de mot Unicode (\w en -# Python 3 utilise déjà la table Unicode), incluant l'apostrophe -# typographique '’' à l'intérieur (« l'an », « d’une ») et les -# tirets internes (« peut-être »). La ponctuation isolée et les -# espaces sont des séparateurs. - -_TOKEN_RE = re.compile( - r"\w+(?:[’'\-]\w+)*", - flags=re.UNICODE, -) - - -def tokenize(text: Optional[str]) -> list[str]: - """Tokenisation Unicode-aware. - - Conserve les contractions (``l'an``, ``d’une``) et les mots - composés (``peut-être``, ``c'est-à-dire``) comme un seul token. - Casse préservée — l'utilisateur normalise lui-même via - ``case_sensitive=False`` dans les fonctions aval s'il le veut. - """ - if not text: - return [] - return _TOKEN_RE.findall(text) - - -# ────────────────────────────────────────────────────────────────────────── -# Distribution de fréquence corpus-wide -# ────────────────────────────────────────────────────────────────────────── - - -def frequency_distribution( - documents: Iterable[str], - *, - case_sensitive: bool = False, -) -> Counter[str]: - """Calcule ``{token: count}`` sur l'ensemble du corpus. - - Parameters - ---------- - documents: - Itérable de textes (typiquement les ``ground_truth`` des - documents du corpus). - case_sensitive: - Si ``False`` (défaut), tous les tokens sont mis en - minuscule avant comptage. - """ - counter: Counter[str] = Counter() - for doc in documents: - tokens = tokenize(doc) - if not case_sensitive: - tokens = [t.lower() for t in tokens] - counter.update(tokens) - return counter - - -def extract_rare_tokens( - documents: Iterable[str], - *, - max_freq: int = 2, - case_sensitive: bool = False, -) -> frozenset[str]: - """Retourne l'ensemble des tokens dont la fréquence - corpus-wide est ``≤ max_freq``. - - Convention de lexicométrie : ``max_freq=1`` retourne uniquement - les hapax legomena (1 occurrence) ; ``max_freq=2`` retourne - hapax + dis legomena (≤ 2 occurrences) — défaut. - - Les tokens qui n'apparaissent **jamais** dans le corpus ne sont - évidemment pas inclus (le ``Counter`` ne les liste pas). - """ - if max_freq < 1: - raise ValueError("max_freq doit être ≥ 1") - counter = frequency_distribution( - documents, case_sensitive=case_sensitive, - ) - return frozenset(t for t, c in counter.items() if c <= max_freq) - - -# ────────────────────────────────────────────────────────────────────────── -# Calcul du rappel par document -# ────────────────────────────────────────────────────────────────────────── - - -def compute_rare_token_recall( - reference: Optional[str], - hypothesis: Optional[str], - rare_tokens: Iterable[str], - *, - case_sensitive: bool = False, -) -> dict: - """Calcule le rappel sur les tokens rares présents dans la GT. - - Parameters - ---------- - reference: - Texte GT du document. - hypothesis: - Texte produit par l'OCR. - rare_tokens: - Itérable des tokens rares — typiquement le résultat de - ``extract_rare_tokens`` sur le corpus complet. - case_sensitive: - Si ``False`` (défaut), la comparaison se fait sur les - formes minuscules. - - Returns - ------- - dict - ``{ - "n_rare_tokens_in_reference": int, - # nombre d'**occurrences** de tokens rares dans la GT - # (multiplicité préservée — un token rare présent 2 - # fois compte 2) - "n_rare_tokens_recalled": int, - # nombre d'occurrences correctement présentes dans hyp - # (alignement bag-of-tokens : min(count_ref, count_hyp)) - "recall": float, - # ratio dans [0, 1], ou 0.0 si aucun rare en GT - "missed_tokens": list[str], - # liste des tokens rares **manqués** (avec multiplicité, - # ex. "Dupont" présent 2 fois en GT et 1 fois en hyp → - # missed_tokens contient ["Dupont"] une fois) - }`` - - Cas dégénérés - ------------- - - GT vide ou aucun token rare présent → recall = 0.0, listes - vides (convention : on ne récompense pas l'absence de - tokens rares). - - Hyp vide avec rares en GT → tous manqués, recall = 0.0. - """ - ref = reference or "" - hyp = hypothesis or "" - - if case_sensitive: - rare_set = frozenset(rare_tokens) - ref_tokens = tokenize(ref) - hyp_tokens = tokenize(hyp) - else: - rare_set = frozenset(t.lower() for t in rare_tokens) - ref_tokens = [t.lower() for t in tokenize(ref)] - hyp_tokens = [t.lower() for t in tokenize(hyp)] - - # Multiplicité : on compte uniquement les rares présents dans la GT - ref_rare_counts: Counter[str] = Counter( - t for t in ref_tokens if t in rare_set - ) - n_rare_in_ref = sum(ref_rare_counts.values()) - if n_rare_in_ref == 0: - return { - "n_rare_tokens_in_reference": 0, - "n_rare_tokens_recalled": 0, - "recall": 0.0, - "missed_tokens": [], - } - - # Bag-of-tokens dans hyp pour les tokens rares uniquement - hyp_rare_counts: Counter[str] = Counter( - t for t in hyp_tokens if t in rare_set - ) - # Recall multiplicitaire : pour chaque token, min(ref_count, hyp_count) - n_recalled = 0 - missed: list[str] = [] - for token, ref_count in ref_rare_counts.items(): - hyp_count = hyp_rare_counts.get(token, 0) - recalled = min(ref_count, hyp_count) - n_recalled += recalled - missed_count = ref_count - recalled - if missed_count > 0: - missed.extend([token] * missed_count) - - return { - "n_rare_tokens_in_reference": n_rare_in_ref, - "n_rare_tokens_recalled": n_recalled, - "recall": n_recalled / n_rare_in_ref, - "missed_tokens": missed, - } - - -def rare_token_recall( - reference: Optional[str], - hypothesis: Optional[str], - rare_tokens: Iterable[str], - *, - case_sensitive: bool = False, -) -> float: - """Raccourci : retourne uniquement le rappel ∈ [0, 1].""" - return compute_rare_token_recall( - reference, hypothesis, rare_tokens, - case_sensitive=case_sensitive, - )["recall"] - - -__all__ = [ - "tokenize", - "frequency_distribution", - "extract_rare_tokens", - "compute_rare_token_recall", - "rare_token_recall", -] +from picarones.evaluation.metrics.rare_tokens import * # noqa: F401,F403 diff --git a/picarones/measurements/robustness_projection.py b/picarones/measurements/robustness_projection.py index dc6c66a0a62c62e6a70839288e08c85a415a7c0c..d8133192c062b65100db1298ec7339671b2bc48e 100644 --- a/picarones/measurements/robustness_projection.py +++ b/picarones/measurements/robustness_projection.py @@ -1,287 +1,18 @@ -"""Projection de robustesse synthétique sur le corpus réel — -Sprint 81 (A.I.8). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.robustness_projection``. -Sprint 81 — A.I.8 du plan d'évolution 2026. +L'ancien chemin ``picarones.measurements.robustness_projection`` est +conservé pour ne casser aucun consommateur. Au S22, ce re-export +disparaîtra. -Pourquoi ce module ------------------- -Le module ``picarones/core/robustness.py`` (Sprint 8) génère des -courbes CER vs niveau de dégradation **synthétique** (bruit, flou, -rotation, résolution). ``picarones/core/image_quality.py`` mesure -le bruit/flou/contraste **réels** des images du corpus. Ce -sprint **projette** les caractéristiques réelles sur les courbes -synthétiques pour estimer le **déficit attendu de CER** sur le -corpus dans son état actuel. - -Lecture concrète ----------------- -*« 30 % de vos documents ont un bruit équivalent à σ=15 où -Tesseract perd 8 points de CER — soit un déficit attendu global -de 2,4 points (30 % × 8 points). »* - -Méthode -------- -1. Pour chaque document, on extrait la valeur de qualité réelle - (``noise_level``, ``blur_score``, ``contrast_score``…) depuis - ``ImageQualityResult``. -2. Pour chaque type de dégradation, on interpole linéairement la - ``DegradationCurve`` synthétique : CER attendu à ce niveau. -3. On agrège : CER moyen attendu, % docs au-dessus du seuil - critique de la courbe, déficit projeté = CER_attendu - - CER_baseline (niveau nul). - -Sortie ------- -``project_robustness_on_corpus(curves, image_qualities)`` retourne -``{engine_name: {degradation_type: {expected_cer_mean, -deficit_vs_baseline, n_docs_above_critical, n_docs}}}``. - -Limites -------- -- Mapping ``image_quality → degradation level`` : on suppose que - ``noise_level`` (ImageQualityResult) correspond à σ - (DegradationCurve), et idem pour ``blur_score`` ↔ rayon de - flou. Si un corpus expose ces valeurs avec une échelle - différente, le mapping est documenté et l'utilisateur peut - passer ``quality_to_level`` custom. -- Interpolation **linéaire** entre les points de la courbe. Au- - delà des bornes, on **clip** au point extrême (pas - d'extrapolation hasardeuse). +Ré-expose explicitement ``_extract_quality_value`` et +``_interpolate_cer`` (symboles privés utilisés downstream). """ from __future__ import annotations -import logging -import statistics -from typing import Callable, Iterable, Optional - -logger = logging.getLogger(__name__) - - -# Mapping par défaut entre attributs ImageQualityResult et types -# de dégradation synthétique. L'utilisateur peut passer un dict -# custom pour modifier ce mapping. -_DEFAULT_QUALITY_FIELD: dict[str, str] = { - "noise": "noise_level", # σ - "blur": "blur_score", # Variance laplacienne (inverse) - "contrast": "contrast_score", - "rotation": "rotation_angle", - "resolution": "resolution_score", # peut être absent -} - - -def _interpolate_cer( - levels: list[float], - cer_values: list[Optional[float]], - target_level: float, -) -> Optional[float]: - """Interpolation linéaire : retourne CER attendu à - ``target_level``. - - - Si ``target_level`` est en-dessous du minimum de levels, - retourne le CER au minimum (clip). - - Si au-dessus du maximum, retourne le CER au maximum. - - Sinon, interpolation linéaire entre les deux points - encadrants. - - Retourne ``None`` si aucun ``cer_value`` valide. - """ - if not levels: - return None - # Filtrer les paires (level, cer) où cer est None - pairs = [ - (lvl, cer) for lvl, cer in zip(levels, cer_values) - if cer is not None - ] - if not pairs: - return None - pairs.sort(key=lambda p: p[0]) - # Clip - if target_level <= pairs[0][0]: - return pairs[0][1] - if target_level >= pairs[-1][0]: - return pairs[-1][1] - # Interpolation - for i in range(len(pairs) - 1): - lo_lvl, lo_cer = pairs[i] - hi_lvl, hi_cer = pairs[i + 1] - if lo_lvl <= target_level <= hi_lvl: - if hi_lvl == lo_lvl: - return lo_cer - ratio = (target_level - lo_lvl) / (hi_lvl - lo_lvl) - return lo_cer + (hi_cer - lo_cer) * ratio - return None # ne devrait pas arriver - - -def _extract_quality_value( - quality: dict, degradation_type: str, - custom_mapping: Optional[dict[str, str]] = None, -) -> Optional[float]: - """Extrait la valeur de qualité pertinente pour un type de - dégradation depuis un ``ImageQualityResult.as_dict()``.""" - mapping = custom_mapping or _DEFAULT_QUALITY_FIELD - field = mapping.get(degradation_type) - if field is None: - return None - value = quality.get(field) - if value is None: - return None - try: - return float(value) - except (TypeError, ValueError): - return None - - -def project_robustness_on_corpus( - curves: Iterable, - image_qualities: list[dict], - *, - quality_to_level: Optional[Callable[[dict, str], Optional[float]]] = None, - critical_threshold: Optional[float] = None, -) -> dict: - """Projette les courbes de robustesse sur les qualités réelles. - - Parameters - ---------- - curves: - Itérable de ``DegradationCurve`` (ou dicts compatibles - avec ``engine_name``, ``degradation_type``, ``levels``, - ``cer_values``, ``critical_threshold_level``). - image_qualities: - Liste de dicts ``ImageQualityResult.as_dict()`` (un par - document). Si vide, retourne une projection vide. - quality_to_level: - Fonction custom ``(quality_dict, degradation_type) → - Optional[float]`` pour adapter le mapping qualité→niveau. - Par défaut, utilise ``_DEFAULT_QUALITY_FIELD``. - critical_threshold: - Override pour le seuil critique de CER (défaut : utilise - ``DegradationCurve.cer_threshold``). - - Returns - ------- - dict - ``{ - engine_name: { - degradation_type: { - "n_docs": int, - "n_docs_with_data": int, # qualité disponible - "expected_cer_mean": float, # moyenne CER attendu - "expected_cer_median": float, - "baseline_cer": float, # CER à niveau min - "deficit_vs_baseline": float, - "n_docs_above_critical": int, - "critical_threshold_level": float | None, - "critical_threshold_cer": float, - }, - }, - }`` - """ - extractor = quality_to_level or ( - lambda q, dt: _extract_quality_value(q, dt) - ) - out: dict[str, dict] = {} - - for curve in curves: - # Accepter dict ou DegradationCurve - if hasattr(curve, "as_dict"): - data = curve.as_dict() - else: - data = curve - engine = data.get("engine_name") - deg_type = data.get("degradation_type") - levels = data.get("levels") or [] - cer_values = data.get("cer_values") or [] - crit_lvl = data.get("critical_threshold_level") - crit_cer = ( - critical_threshold - if critical_threshold is not None - else data.get("cer_threshold", 0.20) - ) - if not engine or not deg_type: - continue - - per_doc_cer: list[float] = [] - n_docs_with_data = 0 - n_above_critical = 0 - for quality in image_qualities: - level = extractor(quality, deg_type) - if level is None: - continue - n_docs_with_data += 1 - cer = _interpolate_cer(levels, cer_values, level) - if cer is None: - continue - per_doc_cer.append(cer) - if cer > crit_cer: - n_above_critical += 1 - - if not per_doc_cer: - continue - - # Baseline = CER au niveau minimum (sans dégradation) - baseline = _interpolate_cer( - levels, cer_values, - min(levels) if levels else 0.0, - ) - expected_mean = statistics.fmean(per_doc_cer) - expected_median = statistics.median(per_doc_cer) - deficit = ( - expected_mean - baseline - if baseline is not None else None - ) - - out.setdefault(engine, {})[deg_type] = { - "n_docs": len(image_qualities), - "n_docs_with_data": n_docs_with_data, - "expected_cer_mean": expected_mean, - "expected_cer_median": expected_median, - "baseline_cer": baseline, - "deficit_vs_baseline": deficit, - "n_docs_above_critical": n_above_critical, - "critical_threshold_level": crit_lvl, - "critical_threshold_cer": crit_cer, - } - return out - - -def aggregate_projection_per_engine(projection: dict) -> dict: - """Pour chaque moteur, agrège le déficit projeté en sommant - sur tous les types de dégradation. - - Lecture : *« déficit total attendu pour Tesseract = 5,2 points - de CER si on considère les 4 dégradations indépendamment »*. - - Note : la sommation **suppose l'indépendance** des - dégradations, ce qui n'est pas strictement vrai mais reste - une approximation utile pour le diagnostic. - """ - out: dict[str, dict] = {} - for engine, per_type in projection.items(): - total_deficit = 0.0 - n_types_with_data = 0 - max_deficit_type: Optional[tuple[str, float]] = None - for deg_type, stats in per_type.items(): - deficit = stats.get("deficit_vs_baseline") - if deficit is None: - continue - total_deficit += deficit - n_types_with_data += 1 - if max_deficit_type is None or deficit > max_deficit_type[1]: - max_deficit_type = (deg_type, deficit) - out[engine] = { - "total_expected_deficit": total_deficit, - "n_degradation_types": n_types_with_data, - "worst_degradation_type": ( - max_deficit_type[0] if max_deficit_type else None - ), - "worst_degradation_deficit": ( - max_deficit_type[1] if max_deficit_type else None - ), - } - return out - - -__all__ = [ - "project_robustness_on_corpus", - "aggregate_projection_per_engine", -] +from picarones.evaluation.metrics.robustness_projection import * # noqa: F401,F403 +from picarones.evaluation.metrics.robustness_projection import ( # noqa: F401 + _extract_quality_value, + _interpolate_cer, +) diff --git a/picarones/measurements/runner/document.py b/picarones/measurements/runner/document.py index 5616618077d00301ea84dd67954f28c959d4e13b..fbd16505549ce4ed350008e8bf0a371012f76eb1 100644 --- a/picarones/measurements/runner/document.py +++ b/picarones/measurements/runner/document.py @@ -42,6 +42,7 @@ def _compute_document_result( char_exclude: Optional[frozenset], corpus_lang: str = "fr", profile: str = "standard", + normalization_profile: Optional[object] = None, ) -> DocumentResult: """Calcule toutes les métriques pour un document et retourne un DocumentResult. @@ -69,7 +70,15 @@ def _compute_document_result( from picarones.core.metric_hooks import run_document_hooks if ocr_result.success: - metrics = compute_metrics(ground_truth, ocr_result.text, char_exclude=char_exclude) + # Sprint A14-S1 — A.I.0 P0 : propagation du profil de + # normalisation depuis le runner. ``normalization_profile`` + # est un ``NormalizationProfile`` résolu en main process par + # ``run_benchmark`` (cf. orchestration.py). + metrics = compute_metrics( + ground_truth, ocr_result.text, + normalization_profile=normalization_profile, # type: ignore[arg-type] + char_exclude=char_exclude, + ) else: metrics = MetricsResult( cer=1.0, cer_nfc=1.0, cer_caseless=1.0, diff --git a/picarones/measurements/runner/orchestration.py b/picarones/measurements/runner/orchestration.py index b53d4adc00db8dcd4d321ef50c990ef00982eec6..b4c065cff9a3d57e06a0d0ed8d3331b946f9dd58 100644 --- a/picarones/measurements/runner/orchestration.py +++ b/picarones/measurements/runner/orchestration.py @@ -64,6 +64,7 @@ def run_benchmark( cancel_event: Optional[threading.Event] = None, entity_extractor: Optional[callable] = None, profile: str = "standard", + normalization_profile: Optional[str] = None, ) -> BenchmarkResult: """Exécute le benchmark d'un ou plusieurs moteurs/pipelines sur un corpus. @@ -119,6 +120,15 @@ def run_benchmark( ``"diagnostics"``, ``"economics"``, ``"pipeline"``, ``"full"``. Le profil ``"standard"`` est strictement rétrocompatible avec le runner pré-chantier-2. + normalization_profile: + Identifiant d'un profil de normalisation diplomatique + (cf. ``measurements.normalization.NORMALIZATION_PROFILES``). + Sprint A14-S1 — A.I.0 P0 : auparavant l'API web exposait ce + paramètre mais il était silencieusement perdu avant + d'atteindre ``compute_metrics``, ce qui rendait + scientifiquement faux tout benchmark lancé via la web app. + Désormais propagé end-to-end : web → run_benchmark → workers + → compute_metrics. ``None`` = profil par défaut (medieval_french). Returns ------- @@ -135,6 +145,15 @@ def run_benchmark( ) validate_profile(profile) + # Sprint A14-S1 — résolution one-shot du profil de normalisation. + # On le fait ici (main process) pour échouer rapidement sur un ID + # invalide avant de soumettre des futures aux pools, et pour + # éviter de re-résoudre N fois côté workers. + norm_profile_obj = None + if normalization_profile is not None: + from picarones.measurements.normalization import get_builtin_profile + norm_profile_obj = get_builtin_profile(normalization_profile) + def _is_cancelled() -> bool: return cancel_event is not None and cancel_event.is_set() engine_reports: list[EngineReport] = [] @@ -225,12 +244,13 @@ def run_benchmark( _cpu_doc_worker, (engine_module, engine_class_name, engine.config, doc.doc_id, str(doc.image_path), doc.ground_truth, - char_exclude_tuple, corpus_lang, profile), + char_exclude_tuple, corpus_lang, profile, + norm_profile_obj), ) else: future = executor.submit( _io_doc_worker, engine, doc, char_exclude, - corpus_lang, profile, + corpus_lang, profile, norm_profile_obj, ) future_to_doc[future] = doc submitted_at[future] = time.monotonic() @@ -397,9 +417,17 @@ def run_benchmark( agg_ner = _aggregate_ner(document_results) report.aggregated_ner = agg_ner - # Libérer la mémoire des analyses per-document après agrégation - for dr in document_results: - dr.compact() + # Sprint A14-S1 — A.I.0 P0 : la compaction inconditionnelle qui + # vivait ici amputait silencieusement le JSON exporté (et donc + # le rapport HTML qui le consomme) en supprimant 13 dicts + # d'analyse per-document et en tronquant les textes à 200 chars. + # ``DocumentResult.compact()`` est désormais opt-in (paramètres + # ``text_limit`` et ``drop_analyses``) ; le runner ne compacte + # plus par défaut afin que ``output_json`` contienne réellement + # toutes les analyses détaillées promises par le README. + # Un caller qui veut un JSON léger peut appeler + # ``dr.compact(text_limit=200, drop_analyses=True)`` lui-même + # après ``run_benchmark`` et avant la sérialisation finale. # Sprint 36 — analyse inter-moteurs (divergence taxonomique + # complémentarité / oracle). N'est calculée qu'à partir de 2 diff --git a/picarones/measurements/runner/workers.py b/picarones/measurements/runner/workers.py index 4ccdbb674964bb0c27c5f87dedb282d2e1c4e5aa..85ee631dd65f3bc1601bca3d21f7bc4c1a91a50e 100644 --- a/picarones/measurements/runner/workers.py +++ b/picarones/measurements/runner/workers.py @@ -33,8 +33,14 @@ def _cpu_doc_worker(args: tuple) -> "DocumentResult": - 7 éléments : legacy (Sprint 13) - 8 éléments : + ``corpus_lang`` (Sprint 87) - 9 éléments : + ``profile`` (chantier 2 post-Sprint 97) + - 10 éléments : + ``normalization_profile`` (Sprint A14-S1, A.I.0 P0) """ - if len(args) == 9: + norm_profile = None + if len(args) == 10: + (engine_module, engine_class_name, engine_config, doc_id, + image_path, ground_truth, char_exclude_chars, corpus_lang, + profile, norm_profile) = args + elif len(args) == 9: (engine_module, engine_class_name, engine_config, doc_id, image_path, ground_truth, char_exclude_chars, corpus_lang, profile) = args @@ -61,6 +67,7 @@ def _cpu_doc_worker(args: tuple) -> "DocumentResult": char_exclude=char_exclude, corpus_lang=corpus_lang, profile=profile, + normalization_profile=norm_profile, ) @@ -70,6 +77,7 @@ def _io_doc_worker( char_exclude: Optional[frozenset], corpus_lang: str = "fr", profile: str = "standard", + normalization_profile: Optional[object] = None, ) -> "DocumentResult": """Worker pour ThreadPoolExecutor (moteurs IO-bound / API). @@ -101,6 +109,7 @@ def _io_doc_worker( char_exclude=char_exclude, corpus_lang=corpus_lang, profile=profile, + normalization_profile=normalization_profile, ) diff --git a/picarones/measurements/taxonomy_comparison.py b/picarones/measurements/taxonomy_comparison.py index eb99d5ef20d8af1985c2dd42b777499c3d1b58f3..0e276289cd8145c1e819ecbc94ca823a5b284002 100644 --- a/picarones/measurements/taxonomy_comparison.py +++ b/picarones/measurements/taxonomy_comparison.py @@ -1,161 +1,10 @@ -"""Taxonomie comparative entre deux moteurs — Sprint 77 (A.I.4 chantier 3). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.taxonomy_comparison``. -Sprint 77 — A.I.4 chantier 3 du plan d'évolution 2026 (clôture A.I.4). - -Pourquoi ce module ------------------- -Le détecteur narratif ``error_profile_outlier`` (Sprint 19) signale -qu'un moteur a un profil taxonomique éloigné de ses concurrents, -mais le rapport n'expose pas cette différence visuellement. Ce -sprint répond à *« deux moteurs ont le même CER global, mais lequel -fait des erreurs plus récupérables ? »*. - -Lecture concrète ----------------- -- Moteur A : 80 % d'erreurs ``case_error`` → toutes corrigeables - par un post-processing trivial (récupérables). -- Moteur B : 80 % d'erreurs ``lacuna`` (mots manquants) → - irrécupérables sans relire l'image. - -À CER égal, A est massivement préférable pour un workflow -d'édition critique. Cette vue rend la différence visible. - -Catégorisation des classes --------------------------- -On annote chaque classe d'erreur d'un degré de **récupérabilité** -(critère éditorial pragmatique, pas verdict imposé) : - -- ``recoverable`` : récupérable par post-processing trivial - (case_error, ligature_error, abbreviation_error) -- ``difficult`` : récupérable au prix d'un effort - (diacritic_error, visual_confusion, hapax) -- ``irrecoverable`` : impossible à corriger sans l'image - (lacuna, oov_character, segmentation_error) - -L'utilisateur consulte ces catégories comme un guide, pas un -verdict — c'est lui qui juge selon ses besoins éditoriaux. +L'ancien chemin ``picarones.measurements.taxonomy_comparison`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from typing import Optional - -logger = logging.getLogger(__name__) - - -# Classification éditoriale. Documentée dans la docstring. -RECOVERABILITY: dict[str, str] = { - "case_error": "recoverable", - "ligature_error": "recoverable", - "abbreviation_error": "recoverable", - "diacritic_error": "difficult", - "visual_confusion": "difficult", - "hapax": "difficult", - "lacuna": "irrecoverable", - "oov_character": "irrecoverable", - "segmentation_error": "irrecoverable", -} - - -def _normalize_counts(counts: dict[str, int]) -> dict[str, float]: - """Convertit un dict de comptes en proportions [0, 1].""" - total = sum(counts.values()) - if total <= 0: - return {k: 0.0 for k in counts} - return {k: v / total for k, v in counts.items()} - - -def compare_taxonomies( - engine_a_name: str, - engine_a_counts: dict[str, int], - engine_b_name: str, - engine_b_counts: dict[str, int], -) -> Optional[dict]: - """Compare deux profils taxonomiques. - - Parameters - ---------- - engine_a_name, engine_b_name: - Noms d'identification des moteurs (utilisés dans le rendu). - engine_a_counts, engine_b_counts: - Maps ``{class_name: count}`` produites par - ``aggregate_taxonomy``. - - Returns - ------- - Optional[dict] - ``{ - "engine_a": str, "engine_b": str, - "total_a": int, "total_b": int, - "classes": list[str], # classes apparaissant chez A ou B - "proportions_a": dict[str, float], - "proportions_b": dict[str, float], - "deltas": dict[str, float], # prop_b - prop_a (signé) - "recoverability": dict[str, str], # mapping class → niveau - "totals_by_recoverability": { - "recoverable": {"a": float, "b": float}, - "difficult": {"a": float, "b": float}, - "irrecoverable": {"a": float, "b": float}, - }, - }`` - Ou ``None`` si les deux moteurs ont 0 erreur chacun. - """ - if engine_a_name == engine_b_name: - # On accepte des comparaisons même si les noms sont - # identiques (cas tests), mais on émet un warning. - logger.warning( - "[taxonomy_comparison] engine_a et engine_b ont le même nom : %s", - engine_a_name, - ) - - total_a = sum(engine_a_counts.values()) if engine_a_counts else 0 - total_b = sum(engine_b_counts.values()) if engine_b_counts else 0 - if total_a == 0 and total_b == 0: - return None - - classes = sorted(set(engine_a_counts) | set(engine_b_counts)) - if not classes: - return None - - prop_a = _normalize_counts( - {c: engine_a_counts.get(c, 0) for c in classes}, - ) - prop_b = _normalize_counts( - {c: engine_b_counts.get(c, 0) for c in classes}, - ) - deltas = {c: prop_b[c] - prop_a[c] for c in classes} - - # Agrégat par récupérabilité (utile pour la lecture rapide) - totals_recov: dict[str, dict[str, float]] = { - "recoverable": {"a": 0.0, "b": 0.0}, - "difficult": {"a": 0.0, "b": 0.0}, - "irrecoverable": {"a": 0.0, "b": 0.0}, - } - for cls in classes: - level = RECOVERABILITY.get(cls, "difficult") - if level not in totals_recov: - level = "difficult" - totals_recov[level]["a"] += prop_a[cls] - totals_recov[level]["b"] += prop_b[cls] - - return { - "engine_a": engine_a_name, - "engine_b": engine_b_name, - "total_a": total_a, - "total_b": total_b, - "classes": classes, - "proportions_a": prop_a, - "proportions_b": prop_b, - "deltas": deltas, - "recoverability": { - cls: RECOVERABILITY.get(cls, "difficult") for cls in classes - }, - "totals_by_recoverability": totals_recov, - } - - -__all__ = [ - "RECOVERABILITY", - "compare_taxonomies", -] +from picarones.evaluation.metrics.taxonomy_comparison import * # noqa: F401,F403 diff --git a/picarones/measurements/taxonomy_cooccurrence.py b/picarones/measurements/taxonomy_cooccurrence.py index 8148935bec875feaa8e985d960cdb7b929487459..9636fe5e595c967a452ca1121b4adc32eb5adc12 100644 --- a/picarones/measurements/taxonomy_cooccurrence.py +++ b/picarones/measurements/taxonomy_cooccurrence.py @@ -1,150 +1,10 @@ -"""Co-occurrence des classes taxonomiques d'erreur — Sprint 75 (A.I.4 chantier 1). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.taxonomy_cooccurrence``. -Sprint 75 — A.I.4 chantier 1 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -La taxonomie d'erreurs (10 classes, ``picarones/core/taxonomy.py``) -est calculée par document mais le rapport actuel ne montre qu'un -seul histogramme global. La roadmap A.I.4 demande trois lectures -plus fines de cette taxonomie ; ce sprint livre la première : -**co-occurrence**. - -Si ``ligature_error`` et ``abbreviation_error`` co-occurrent -toujours dans les mêmes documents, c'est un signal de scribe -particulier — utile pour stratifier le corpus *a posteriori* -(qu'est-ce qui caractérise les documents difficiles ?). - -Mesure ------- -Indice de **Jaccard** entre paires de classes au niveau -**document** : - -.. math:: - - J(A, B) = \\frac{|D_A \\cap D_B|}{|D_A \\cup D_B|} - -où ``D_X`` est l'ensemble des documents qui contiennent au moins -une erreur de classe ``X``. - -- ``J(A, B) = 1`` : A et B apparaissent toujours ensemble (et - jamais l'un sans l'autre). -- ``J(A, B) = 0`` : A et B ne co-occurrent jamais. -- ``J(A, B) = 0,5`` : A et B partagent la moitié de leur union. - -Stratégie de découpage ----------------------- -Couche de calcul pure d'abord (pattern Sprint 35, 38, 52-58). -Le rendu HTML (heatmap SVG) est livré dans le même sprint pour -boucler la dimension ; les chantiers 2 et 3 d'A.I.4 (évolution -intra-document, taxonomie comparative) suivent. +L'ancien chemin ``picarones.measurements.taxonomy_cooccurrence`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -def compute_taxonomy_cooccurrence( - per_doc_classes: Iterable[Iterable[str]], - *, - min_doc_count: int = 1, - top_n_pairs: int = 10, -) -> Optional[dict]: - """Calcule la matrice de Jaccard inter-classes au niveau document. - - Parameters - ---------- - per_doc_classes: - Itérable de docs, chaque doc étant un itérable de noms de - classes taxonomiques détectées (set, list, tuple…). - Les doublons à l'intérieur d'un doc sont ignorés (présence - binaire au niveau doc). - min_doc_count: - Nombre minimum de documents dans lesquels une classe doit - apparaître pour figurer dans la matrice (défaut 1). - Permet d'écarter les classes anecdotiques. - top_n_pairs: - Nombre de paires retournées dans ``top_pairs`` (triées par - Jaccard décroissant). Défaut 10. - - Returns - ------- - Optional[dict] - ``{ - "classes": list[str], # triées alpha - "n_documents": int, - "doc_count": dict[str, int], # nb docs par classe - "cooccurrence_matrix": dict[str, dict[str, float]], - # symétrique, diagonale = 1.0 (sauf classe vide) - "top_pairs": list[tuple[str, str, float]], - # paires les plus co-occurrentes (Jaccard désc.) - }`` - ou ``None`` si aucune classe ne dépasse ``min_doc_count`` - ou si l'itérable est vide. - """ - docs: list[frozenset[str]] = [] - for doc_classes in per_doc_classes: - if doc_classes is None: - continue - cleaned = frozenset(c for c in doc_classes if c) - docs.append(cleaned) - if not docs: - return None - - # Comptage par classe - doc_count: dict[str, int] = {} - for doc in docs: - for cls in doc: - doc_count[cls] = doc_count.get(cls, 0) + 1 - - # Filtrage min_doc_count - classes = sorted( - c for c, n in doc_count.items() if n >= min_doc_count - ) - if not classes: - return None - - # Matrice de Jaccard - matrix: dict[str, dict[str, float]] = { - c: {} for c in classes - } - for i, ca in enumerate(classes): - docs_a = {idx for idx, d in enumerate(docs) if ca in d} - for cb in classes[i:]: - if ca == cb: - # Diagonale : Jaccard(X, X) = 1 si X est présent - matrix[ca][cb] = 1.0 if docs_a else 0.0 - continue - docs_b = {idx for idx, d in enumerate(docs) if cb in d} - inter = len(docs_a & docs_b) - union = len(docs_a | docs_b) - jaccard = inter / union if union > 0 else 0.0 - matrix[ca][cb] = jaccard - matrix[cb][ca] = jaccard # symétrique - - # Top paires (hors diagonale) - pairs: list[tuple[str, str, float]] = [] - for i, ca in enumerate(classes): - for cb in classes[i + 1:]: - j = matrix[ca][cb] - if j > 0: - pairs.append((ca, cb, j)) - pairs.sort(key=lambda p: (-p[2], p[0], p[1])) - top_pairs = pairs[:top_n_pairs] - - return { - "classes": classes, - "n_documents": len(docs), - "doc_count": doc_count, - "cooccurrence_matrix": matrix, - "top_pairs": top_pairs, - } - - -__all__ = [ - "compute_taxonomy_cooccurrence", -] +from picarones.evaluation.metrics.taxonomy_cooccurrence import * # noqa: F401,F403 diff --git a/picarones/measurements/throughput.py b/picarones/measurements/throughput.py index 47d0ed674492f221013aa8a53c3632db14cbe6b5..ab95080b653c0130f90c4b745f7ef069e153970c 100644 --- a/picarones/measurements/throughput.py +++ b/picarones/measurements/throughput.py @@ -1,165 +1,10 @@ -"""Throughput effectif (Sprint 91 — A.II.6). +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.throughput``. -Sprint 91 — A.II.6 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Le throughput brut (pages/heure d'OCR pur) ment quand un moteur -est rapide mais imprécis : la correction humaine *post hoc* -absorbe le gain. La **vraie** vitesse opérationnelle inclut -le temps de correction. Cette métrique discrimine fortement -entre un cloud rapide à 30 % de timeouts/erreurs et un local -lent à 100 % de fiabilité. - -Formule -------- -.. code:: - - pages_par_heure_utilisable = - pages_traitées / (durée_totale + temps_correction_humaine) - -Le temps de correction est estimé linéairement : -``temps_par_erreur × nombre_d_erreurs``. Le défaut -``time_per_error_seconds=5.0`` correspond aux études HTR-United -(saisie manuelle d'une correction de mot par un opérateur -formé : ≈ 5 s par erreur). L'utilisateur peut le surcharger -pour son institution. - -Sortie ------- -``compute_effective_throughput(n_pages, duration_seconds, -n_errors, time_per_error_seconds=5.0)`` retourne ``{n_pages, -duration_seconds, n_errors, time_per_error_seconds, -correction_time_seconds, total_seconds, pages_per_hour_raw, -pages_per_hour_effective, drag_ratio}``. - -``aggregate_effective_throughput(per_engine_data)`` agrège par -moteur sur l'ensemble du corpus. +L'ancien chemin ``picarones.measurements.throughput`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from typing import Iterable, Optional - -logger = logging.getLogger(__name__) - - -_DEFAULT_TIME_PER_ERROR_SECONDS = 5.0 - - -def compute_effective_throughput( - n_pages: int, - duration_seconds: float, - n_errors: int, - *, - time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, -) -> Optional[dict]: - """Throughput effectif (pages/heure utilisables). - - Parameters - ---------- - n_pages: - Nombre de pages traitées. - duration_seconds: - Durée totale de l'OCR (somme des durées par doc). - n_errors: - Nombre d'erreurs (au niveau mot, typiquement - ``WER × n_words_total``). - time_per_error_seconds: - Temps moyen de correction humaine par erreur. Défaut - 5 s (HTR-United). Doit être ≥ 0. - - Returns - ------- - dict | None - ``None`` si ``n_pages == 0`` ou ``total_seconds == 0`` - (pas de division par zéro). - """ - if n_pages <= 0: - return None - if duration_seconds < 0 or n_errors < 0 or time_per_error_seconds < 0: - raise ValueError( - "duration_seconds, n_errors et time_per_error_seconds " - "doivent être ≥ 0", - ) - correction_seconds = float(n_errors) * float(time_per_error_seconds) - total_seconds = float(duration_seconds) + correction_seconds - if total_seconds <= 0: - # Aucun temps écoulé : impossible de définir un throughput - return None - pages_per_hour_raw = ( - n_pages / duration_seconds * 3600.0 - if duration_seconds > 0 else None - ) - pages_per_hour_effective = n_pages / total_seconds * 3600.0 - drag_ratio = ( - correction_seconds / total_seconds if total_seconds > 0 else 0.0 - ) - return { - "n_pages": int(n_pages), - "duration_seconds": float(duration_seconds), - "n_errors": int(n_errors), - "time_per_error_seconds": float(time_per_error_seconds), - "correction_time_seconds": correction_seconds, - "total_seconds": total_seconds, - "pages_per_hour_raw": pages_per_hour_raw, - "pages_per_hour_effective": pages_per_hour_effective, - "drag_ratio": drag_ratio, - } - - -def aggregate_effective_throughput( - per_engine: Iterable[dict], - *, - time_per_error_seconds: float = _DEFAULT_TIME_PER_ERROR_SECONDS, -) -> Optional[dict]: - """Agrège le throughput effectif par moteur. - - Parameters - ---------- - per_engine: - Itérable de dicts ``{engine_name, n_pages, - duration_seconds, n_errors}``. - - Returns - ------- - dict | None - ``{ - "engines": [ - {"engine_name", ..., compute_effective_throughput - fields}, - ... - ], - "time_per_error_seconds": float, - }`` ou ``None`` si aucun moteur exploitable. - """ - rows: list[dict] = [] - for entry in per_engine: - if not isinstance(entry, dict): - continue - name = entry.get("engine_name") or entry.get("engine") - if not name: - continue - result = compute_effective_throughput( - int(entry.get("n_pages") or 0), - float(entry.get("duration_seconds") or 0.0), - int(entry.get("n_errors") or 0), - time_per_error_seconds=time_per_error_seconds, - ) - if result is None: - continue - result["engine_name"] = str(name) - rows.append(result) - if not rows: - return None - return { - "engines": rows, - "time_per_error_seconds": float(time_per_error_seconds), - } - - -__all__ = [ - "compute_effective_throughput", - "aggregate_effective_throughput", -] +from picarones.evaluation.metrics.throughput import * # noqa: F401,F403 diff --git a/picarones/measurements/worst_lines.py b/picarones/measurements/worst_lines.py index dfece53263f29f83db9cb6dbaaf749d719b04857..de594193011ee8f3cca630af249404fef9164cde 100644 --- a/picarones/measurements/worst_lines.py +++ b/picarones/measurements/worst_lines.py @@ -1,199 +1,10 @@ -"""Extraction transversale des « Worst lines » du corpus — Sprint 72. +"""Re-export — Sprint A14-S10. Le contenu canonique vit dans +``picarones.evaluation.metrics.worst_lines``. -Sprint 72 — A.I.1 chantier 1 du plan d'évolution 2026. - -Pourquoi ce module ------------------- -Le percentile p95 du CER ligne (calculé par ``line_metrics.py``, -Sprint 10) est un nombre abstrait : *« 5 % de mes lignes ont un -CER > 0,42 »*. Le chercheur veut **voir** ces lignes : leur -texte, leur diff, leur document parent, pour comprendre ce qui -casse. - -Ce module fournit la requête transversale qui collecte, depuis un -``BenchmarkResult``, les **N lignes les plus mal transcrites de -tout le corpus**, classées par CER ligne. Filtrable par moteur -et par strate. - -Limite documentée ------------------ -``DocumentResult.line_metrics`` ne stocke que les CER par ligne, -**pas le texte des lignes**. Pour récupérer les textes GT/hyp -on resplitte ``ground_truth`` et ``hypothesis`` du -``DocumentResult`` à l'index de la ligne. Cette logique -**suppose un BenchmarkResult non-compacté** — après ``compact()`` -les textes sont tronqués à 200 caractères et les lignes au-delà -de cette troncature ne sont plus accessibles. En pratique on -extrait les worst lines **avant** la sérialisation/compactage. +L'ancien chemin ``picarones.measurements.worst_lines`` est conservé pour +ne casser aucun consommateur. Au S22, ce re-export disparaîtra. """ from __future__ import annotations -import logging -from dataclasses import dataclass -from typing import Optional - -logger = logging.getLogger(__name__) - - -@dataclass -class WorstLineEntry: - """Une ligne du corpus identifiée comme mal transcrite. - - Champs - ------ - rank: - Position dans le classement (1-based, 1 = pire CER). - cer: - CER de la ligne ∈ [0, 1]. - engine_name: - Nom du moteur ayant produit cette hypothèse. - doc_id: - Identifiant du document parent. - line_index: - Index 0-based de la ligne dans le document GT. - gt_line: - Texte de la ligne dans la GT. - hyp_line: - Texte correspondant dans l'hypothèse (peut être ``""`` - si l'OCR a sauté la ligne). - script_type: - Strate du document si disponible (``script_type`` - capturé par le runner pour la stratification A.III). - """ - - rank: int - cer: float - engine_name: str - doc_id: str - line_index: int - gt_line: str - hyp_line: str - script_type: Optional[str] = None - - -def _split_lines(text: Optional[str]) -> list[str]: - """Splitte un texte en lignes (cohérent avec ``line_metrics``). - - Supporte les fins de ligne ``\\n``, ``\\r\\n``, ``\\r``. Les - lignes vides sont préservées. Retourne une liste vide si le - texte est None ou vide. - """ - if not text: - return [] - # ``splitlines`` gère \r\n et \r correctement - return text.splitlines() - - -def _line_at(text: Optional[str], index: int) -> str: - """Retourne la ligne à l'index demandé, ou ``""`` si l'index - est hors borne (cas où l'OCR a moins de lignes que la GT).""" - lines = _split_lines(text) - if 0 <= index < len(lines): - return lines[index] - return "" - - -def extract_worst_lines( - benchmark, - *, - top_n: int = 20, - engine_filter: Optional[str] = None, - script_type_filter: Optional[str] = None, -) -> list[WorstLineEntry]: - """Extrait les ``top_n`` lignes les plus mal transcrites du - corpus, transversalement à tous les moteurs et documents. - - Parameters - ---------- - benchmark: - ``BenchmarkResult`` non-compacté (cf. limite ci-dessus). - L'objet doit exposer ``engine_reports`` (liste de - ``EngineReport``) et optionnellement ``doc_strata`` - (map ``{doc_id: script_type}``, Sprint 45). - top_n: - Nombre de lignes à retourner. Défaut : 20. - engine_filter: - Si fourni, n'inclut que les lignes produites par ce moteur - (match exact sur ``engine_name``). - script_type_filter: - Si fourni, n'inclut que les lignes des documents de cette - strate (nécessite ``benchmark.doc_strata``). - - Returns - ------- - list[WorstLineEntry] - Liste triée par CER décroissant (pire en premier), - rang 1-based attribué après tri. Vide si aucune ligne - exploitable. - """ - if top_n <= 0: - return [] - - doc_strata = getattr(benchmark, "doc_strata", None) or {} - candidates: list[tuple[float, str, str, int, str, str, Optional[str]]] = [] - - for engine_report in getattr(benchmark, "engine_reports", []): - engine_name = engine_report.engine_name - if engine_filter is not None and engine_name != engine_filter: - continue - for dr in engine_report.document_results: - line_metrics = getattr(dr, "line_metrics", None) - if not line_metrics: - continue - cer_per_line = line_metrics.get("cer_per_line") if isinstance( - line_metrics, dict, - ) else getattr(line_metrics, "cer_per_line", None) - if not cer_per_line: - continue - doc_id = dr.doc_id - doc_strata_value = doc_strata.get(doc_id) - if ( - script_type_filter is not None - and doc_strata_value != script_type_filter - ): - continue - for idx, cer in enumerate(cer_per_line): - if cer <= 0.0: - continue - gt_line = _line_at(dr.ground_truth, idx) - hyp_line = _line_at(dr.hypothesis, idx) - if not gt_line and not hyp_line: - continue - candidates.append(( - float(cer), engine_name, doc_id, idx, - gt_line, hyp_line, doc_strata_value, - )) - - if not candidates: - return [] - - # Tri par CER décroissant ; en cas d'égalité, ordre stable - # (engine, doc_id, line_index) pour reproductibilité. - candidates.sort( - key=lambda c: (-c[0], c[1], c[2], c[3]), - ) - selected = candidates[:top_n] - - return [ - WorstLineEntry( - rank=i + 1, - cer=cer, - engine_name=engine, - doc_id=doc_id, - line_index=line_index, - gt_line=gt_line, - hyp_line=hyp_line, - script_type=script_type, - ) - for i, ( - cer, engine, doc_id, line_index, - gt_line, hyp_line, script_type, - ) in enumerate(selected) - ] - - -__all__ = [ - "WorstLineEntry", - "extract_worst_lines", -] +from picarones.evaluation.metrics.worst_lines import * # noqa: F401,F403 diff --git a/picarones/pipeline/__init__.py b/picarones/pipeline/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..a3034e78df0b8d6b5ca9a0a84404e75f2c435087 --- /dev/null +++ b/picarones/pipeline/__init__.py @@ -0,0 +1,117 @@ +"""Cercle 2 — Pipeline execution. + +Exécution séquentielle ou DAG-branchante d'une chaîne de modules +tiers (``StepExecutor``). Picarones ne fournit **aucun module +métier** — l'utilisateur amène ses propres adapters OCR/LLM/VLM/ +correcteur/reconstructeur ALTO ; le pipeline executor les compose, +valide les types aux jonctions et évalue automatiquement chaque +artefact produit contre la GT correspondante. + +Modules livrés au S6 +-------------------- +- ``spec.py`` — ``PipelineStep``, ``PipelineSpec``, ``INITIAL_STEP_ID``. + Spec déclarative sérialisable en YAML (cf. ``yaml_io.py``). +- ``types.py`` — ``RunContext``, ``StepResult``, ``PipelineResult``. + Types runtime de l'executor. +- ``protocols.py`` — ``StepExecutor`` (Protocol), ``ExecutionMode``. + Contrat d'un adapter exécutable. +- ``validation.py`` — ``validate_spec(spec, available_adapters)``, + ``ValidationError``. Validation statique sans instancier de module. +- ``yaml_io.py`` — ``dump_spec_to_yaml`` / ``load_spec_from_yaml``. + +Modules livrés au S7 +-------------------- +- ``executor.py`` — ``PipelineExecutor.run(spec, document, + initial_inputs, context)`` exécute mono-document avec capture + gracieuse des erreurs et bag d'artefacts versionné. + ``AdapterResolver`` type alias. +- ``cache.py`` — ``ArtifactCache`` minimal in-memory indexé par + ``hash(content + spec + code_version)``. + +Modules livrés au S8 +-------------------- +- ``runner.py`` — ``CorpusRunner`` orchestre ``PipelineExecutor`` + sur un corpus complet avec : + + * **backpressure** (``max_in_flight``, jamais plus de N futures + en vol), + * **timeout depuis le début d'exécution réelle** (pas depuis la + submission au pool), + * **annulation propre** via ``threading.Event``. + + ``CorpusRunResult`` agrège ``DocumentOutcome``, qui distingue + ``succeeded`` / ``failed`` / ``timed_out`` / ``cancelled``. + +Cible du Sprint S12 +------------------- +Équivalence numérique CER/WER avec l'ancien +``measurements.runner`` à 1e-9 près sur les fixtures. +""" + +from __future__ import annotations + +from picarones.pipeline.cache import ArtifactCache +from picarones.pipeline.executor import ( + AdapterResolver, + PipelineExecutor, + PipelineSpecInvalid, +) +from picarones.pipeline.planner import ( + ExecutionPlan, + MetricJunction, + PipelinePlanner, + PlanningError, + ResolvedStep, + StepInputBinding, +) +from picarones.pipeline.protocols import ExecutionMode, StepExecutor +from picarones.pipeline.runner import ( + ContextFactory, + CorpusRunResult, + CorpusRunner, + DocumentOutcome, + InitialInputsFactory, +) +from picarones.domain.pipeline_spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep +from picarones.pipeline.types import PipelineResult, RunContext, StepResult +from picarones.pipeline.validation import ValidationError, validate_spec +from picarones.pipeline.yaml_io import dump_spec_to_yaml, load_spec_from_yaml + +__all__ = [ + # Spec déclarative + "PipelineSpec", + "PipelineStep", + "INITIAL_STEP_ID", + # Runtime types + "RunContext", + "StepResult", + "PipelineResult", + # Protocol + "StepExecutor", + "ExecutionMode", + # Validation + "validate_spec", + "ValidationError", + # YAML IO + "dump_spec_to_yaml", + "load_spec_from_yaml", + # Executor (S7) + "PipelineExecutor", + "PipelineSpecInvalid", + "AdapterResolver", + # Planner (S28) + "PipelinePlanner", + "PlanningError", + "ExecutionPlan", + "ResolvedStep", + "StepInputBinding", + "MetricJunction", + # Cache (S7) + "ArtifactCache", + # CorpusRunner (S8) + "CorpusRunner", + "CorpusRunResult", + "DocumentOutcome", + "InitialInputsFactory", + "ContextFactory", +] diff --git a/picarones/pipeline/cache.py b/picarones/pipeline/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..2334f2bcc3fb059102c5b30564e00f4b0f403315 --- /dev/null +++ b/picarones/pipeline/cache.py @@ -0,0 +1,154 @@ +"""``ArtifactCache`` minimal in-memory — Sprint A14-S7. + +Cache d'outputs d'étape indexé par ``(content_hashes des inputs + +spec hash + code_version)``. Permet de sauter une étape coûteuse +(typiquement un appel LLM cloud) si elle a déjà été exécutée avec +exactement les mêmes inputs et la même spec. + +S7 livre la couche de calcul ; le branchement avec +``PipelineExecutor`` viendra quand un cas d'usage concret de +réutilisation se présentera (probablement S8 quand on aura +l'orchestration corpus-wide qui peut bénéficier d'un cache pour +les retries idempotents). + +Garde-fous +---------- +- Si **un seul** input n'a pas de ``content_hash``, la clé n'est + pas calculable → ``compute_key`` retourne ``None`` → + ``get`` retourne ``None`` (équivalent à un cache miss). Pas de + fallback hasardeux qui pourrait servir des résultats faux. +- Pas de TTL, pas d'éviction LRU — c'est un cache in-memory + simple, taille gardée par le caller (qui peut appeler ``clear()`` + s'il veut libérer la mémoire). +- Pas de persistance disque pour S7. Si un caller en a besoin, + on l'ajoutera quand le besoin sera concret (S20+ probablement). +""" + +from __future__ import annotations + +import hashlib +import json +from typing import Iterable + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.pipeline_spec import PipelineStep + + +class ArtifactCache: + """Cache in-memory d'outputs d'étape. + + Thread-safe en lecture/écriture **après** l'init (les opérations + mutantes se font sur un dict — Python GIL garantit l'atomicité + des set/del sur un dict). Pas de mécanisme de freeze technique. + """ + + def __init__(self) -> None: + self._store: dict[str, dict[ArtifactType, Artifact]] = {} + + # ────────────────────────────────────────────────────────────────── + # Calcul de clé + # ────────────────────────────────────────────────────────────────── + + def compute_key( + self, + step: PipelineStep, + input_artifacts: dict[ArtifactType, Artifact], + code_version: str, + ) -> str | None: + """Calcule la clé canonique du cache pour cette exécution. + + Retourne ``None`` si **un seul** input n'a pas de + ``content_hash`` — convention "ne sert pas un résultat + douteux". + + La clé combine : + + - les ``content_hash`` triés par ``ArtifactType.value``, + - le hash de la spec du step (sérialisée JSON déterministe), + - le ``code_version``. + + Deux exécutions avec exactement les mêmes inputs (au sens + ``content_hash``), la même spec et la même version de code + produisent la même clé. + """ + # 1. Inputs : (type → content_hash), tous obligatoires. + try: + input_hashes = sorted( + (t.value, input_artifacts[t].content_hash) + for t in input_artifacts + ) + except KeyError: + return None + if any(h is None for _, h in input_hashes): + return None + + # 2. Spec du step : on hash la sérialisation pydantic de + # PipelineStep (params, kind, adapter_name, etc.). Tout + # changement dans la spec invalide le cache. + step_payload = step.model_dump(mode="json") + step_blob = json.dumps( + step_payload, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ) + + # 3. Composition. + material = json.dumps( + { + "inputs": input_hashes, + "step": step_blob, + "code_version": code_version, + }, + sort_keys=True, + ensure_ascii=False, + separators=(",", ":"), + ) + return hashlib.sha256(material.encode("utf-8")).hexdigest() + + # ────────────────────────────────────────────────────────────────── + # Get / Put / Clear + # ────────────────────────────────────────────────────────────────── + + def get(self, key: str | None) -> dict[ArtifactType, Artifact] | None: + """Retourne les outputs cachés pour la clé, ou ``None``. + + Tolère ``key=None`` pour faciliter le pattern : + + key = cache.compute_key(...) + cached = cache.get(key) + if cached is not None: + return cached + """ + if key is None: + return None + return self._store.get(key) + + def put( + self, + key: str | None, + outputs: dict[ArtifactType, Artifact], + ) -> None: + """Stocke les outputs sous la clé donnée. No-op si + ``key=None`` (alignement avec la convention "ne pas servir + un résultat douteux").""" + if key is None: + return + self._store[key] = dict(outputs) # copie défensive + + def clear(self) -> None: + """Vide complètement le cache.""" + self._store.clear() + + def __len__(self) -> int: + return len(self._store) + + def __contains__(self, key: str) -> bool: + return key in self._store + + def keys(self) -> Iterable[str]: + """Liste des clés actuellement en cache (utile pour les tests).""" + return list(self._store.keys()) + + +__all__ = ["ArtifactCache"] diff --git a/picarones/pipeline/cache_helpers.py b/picarones/pipeline/cache_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..8f1607430b6e5169ce7a04c71937ea921a191579 --- /dev/null +++ b/picarones/pipeline/cache_helpers.py @@ -0,0 +1,189 @@ +"""Helpers de cache d'artefacts pour le ``PipelineExecutor``. + +Ce module fournit les **fonctions pures** qui transforment un +``(PipelineStep, inputs, RunContext)`` en ``ArtifactKey`` et en clés +de stockage par output_type, pour que le ``PipelineExecutor`` puisse : + +1. Avant d'exécuter un step : calculer la clé, interroger le store, + et si toutes les sorties attendues sont présentes ET valides, + sauter l'exécution en retournant les artefacts cachés. +2. Après une exécution réussie : persister chaque output dans le store + sous une clé dérivée. + +Stratégie de clé multi-output +----------------------------- +Un ``PipelineStep`` peut produire plusieurs ``ArtifactType``. +``ArtifactStore.put/get`` opère sur **un** Artifact à la fois. Pour +gérer cela sans étendre l'API du store, on dérive une **clé composite** +par output_type : + +:: + + store_key = f"{step_hash}:{output_type.value}" + +où ``step_hash`` est ``ArtifactKey(...).hash_hex()`` qui dépend des +inputs, du step et du code_version. À la lecture, on demande au store +toutes les clés ``{step_hash}:`` pour les ``output_types`` du +step ; si une seule manque, c'est un miss complet (cache partiel +n'est pas exploitable — on relance le step pour cohérence). + +Pas de stockage du payload bytes +-------------------------------- +On stocke uniquement les **métadonnées** ``Artifact`` (id, type, +content_hash, uri, provenance). Le payload (texte, ALTO XML, image) +reste sur le filesystem au chemin pointé par ``Artifact.uri``. + +Conséquence : si le workspace a été nettoyé entre deux runs, l'URI +cachée pointe vers un fichier disparu → cache miss (la fonction +``read_cached_outputs`` vérifie l'existence des URIs). C'est le +comportement attendu : le store est un **cache**, pas une source de +vérité du contenu. + +Anti-sur-ingénierie +------------------- +- Pas de TTL, pas d'éviction LRU. Le caller appelle ``store.clear()`` + s'il veut forcer un re-run complet. +- Pas de support des artefacts inline (sans URI). Si un step produit + un artefact dont le contenu vit en RAM seulement, le cache est + inopérant — c'est documenté. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from picarones.domain.artifact_key import ArtifactKey +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.cache_protocol import ArtifactCachePort + +if TYPE_CHECKING: + from picarones.domain.pipeline_spec import PipelineStep + from picarones.pipeline.types import RunContext + +logger = logging.getLogger(__name__) + + +def compute_step_artifact_key( + step: "PipelineStep", + inputs: dict[ArtifactType, Artifact], + context: "RunContext", +) -> ArtifactKey: + """Calcule la ``ArtifactKey`` d'un step pour le cache d'artefacts. + + La clé combine : + + - les ``content_hash`` des inputs (triés par type pour + déterminisme — délégué à ``ArtifactKey.to_canonical_json``) ; + - ``step.adapter_name`` ; + - ``step.params`` (dict scalaire) ; + - ``context.code_version``. + + Les autres champs de ``ArtifactKey`` (normalization_profile, + projection_name, metric_version) restent ``None`` — ils sont + spécifiques aux jonctions d'évaluation, pas aux steps de pipeline. + + La clé peut retourner ``None`` à ``hash_hex()`` si **un seul** + input n'a pas de ``content_hash`` (cf. la convention « ne pas + servir un résultat douteux » d'``ArtifactKey``). Le caller doit + tester ``key.hash_hex() is None`` avant d'utiliser la clé. + """ + input_hashes: tuple[tuple[str, str], ...] = tuple( + (art_type.value, artifact.content_hash or "") + for art_type, artifact in inputs.items() + ) + return ArtifactKey( + input_hashes=input_hashes, + adapter_name=step.adapter_name, + adapter_version=None, # adapters ne déclarent pas (encore) de version + step_params=dict(step.params), + code_version=context.code_version, + ) + + +#: Séparateur de la clé composite ````. +#: +#: Le caractère ``:`` est réservé sous Windows (Alternate Data Streams) : +#: un filename comme ``abc:raw_text.json`` est rejeté avec WinError 87. +#: ``__`` est filesystem-safe sur les trois OS (Linux/macOS/Windows) et +#: lisible visuellement. Pas de risque de collision avec un hash +#: hex (caractères ``[0-9a-f]`` uniquement) ou un ``ArtifactType.value`` +#: (``[a-z_]+``). +_KEY_SEPARATOR = "__" + + +def storage_key_for_output(step_hash: str, output_type: ArtifactType) -> str: + """Construit la clé de stockage composite pour un output donné. + + Format : ``__``. Le séparateur ``__`` + est filesystem-safe sur les trois OS (cf. ``_KEY_SEPARATOR``). + """ + return f"{step_hash}{_KEY_SEPARATOR}{output_type.value}" + + +def read_cached_outputs( + store: ArtifactCachePort, + step: "PipelineStep", + step_hash: str, +) -> dict[ArtifactType, Artifact] | None: + """Tente de lire les outputs cachés d'un step. + + Retourne ``None`` si : + + - une seule sortie attendue n'est pas dans le store + (cache partiel) ; + - une URI cachée pointe vers un fichier disparu + (cache orphelin). + + Sinon, retourne le dict ``{output_type: Artifact}`` complet, + prêt à être réinjecté dans le bag du runner. + """ + cached: dict[ArtifactType, Artifact] = {} + for output_type in step.output_types: + store_key = storage_key_for_output(step_hash, output_type) + stored = store.get(store_key) + if stored is None: + logger.debug( + "[cache] miss partiel sur step %r : %s manquant.", + step.id, output_type.value, + ) + return None + # Vérifie que l'URI cachée pointe vers un fichier qui existe + # encore. Sinon, le payload a disparu (workspace nettoyé, + # mount débranché, etc.) — on doit re-exécuter. + if stored.artifact.uri is not None: + uri_path = Path(stored.artifact.uri) + if not uri_path.exists(): + logger.debug( + "[cache] orphelin sur step %r : URI %s disparu.", + step.id, uri_path, + ) + return None + cached[output_type] = stored.artifact + return cached + + +def write_outputs_to_cache( + store: ArtifactCachePort, + step: "PipelineStep", + step_hash: str, + outputs: dict[ArtifactType, Artifact], +) -> None: + """Persiste tous les outputs d'un step réussi dans le store. + + Idempotent : ``store.put`` écrase silencieusement une entrée + existante (cf. la convention de ``InMemoryArtifactStore`` et + ``FilesystemArtifactStore``). + """ + for output_type, artifact in outputs.items(): + store_key = storage_key_for_output(step_hash, output_type) + store.put(store_key, artifact, payload=None) + + +__all__ = [ + "compute_step_artifact_key", + "read_cached_outputs", + "storage_key_for_output", + "write_outputs_to_cache", +] diff --git a/picarones/pipeline/cache_protocol.py b/picarones/pipeline/cache_protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..37e2f7f5904051fa36b52930dbecca2866a0a305 --- /dev/null +++ b/picarones/pipeline/cache_protocol.py @@ -0,0 +1,85 @@ +"""``ArtifactCachePort`` — port (Protocol) consommé par ``PipelineExecutor``. + +Sprint A14-S47 — inversion de dépendance pour le branchement +``ArtifactStore`` dans le pipeline. + +Pourquoi ce Protocol +-------------------- +La couche ``pipeline/`` est plus interne que ``adapters/`` dans la +hiérarchie documentée du rewrite (``domain → formats → evaluation +→ pipeline → adapters → app → reports_v2 → interfaces``). Importer +depuis ``adapters/`` dans ``pipeline/`` violerait la règle de +dépendance. + +On applique l'inversion de dépendance (pattern hexagonal / +ports-and-adapters) : + +- ``pipeline/`` définit le **port** ``ArtifactCachePort`` (ce + module) — ce que le pipeline a besoin de consommer. +- ``adapters/storage/artifact_store.ArtifactStore`` (S29) est + l'**adapter** qui satisfait ce port par duck typing. +- Toute autre implémentation tierce (Redis, S3, GCS, ...) qui + implémente ces 5 méthodes est compatible. + +Convention duck typing +---------------------- +``StoredArtifact`` est aussi exposé comme Protocol minimal pour +éviter d'importer la dataclass concrète depuis ``adapters/``. +Les implémentations réelles fournissent une dataclass plus riche ; +``pipeline/`` ne consomme que ``stored.artifact`` et +``stored.artifact.uri``. +""" + +from __future__ import annotations + +from typing import Protocol, runtime_checkable + +from picarones.domain.artifacts import Artifact + + +@runtime_checkable +class CachedArtifactRef(Protocol): + """Port minimal consommé par ``read_cached_outputs``. + + Les implémentations concrètes peuvent porter des champs + supplémentaires (``payload``, ``key``, …) ; ``pipeline/`` + n'utilise que l'``Artifact`` reconstitué. + """ + + @property + def artifact(self) -> Artifact: # pragma: no cover — Protocol + ... + + +@runtime_checkable +class ArtifactCachePort(Protocol): + """Contrat minimal d'un cache d'artefacts consommable par + ``PipelineExecutor`` pour la reprise par hash. + + Les méthodes correspondent **exactement** à l'API publique de + ``ArtifactStore`` (S29) — ``ArtifactStore`` est donc compatible + par duck typing sans rien changer. + + Pas d'``isinstance(store, ArtifactCachePort)`` requis : Python + type-checke à l'usage (les méthodes manquantes lèvent + ``AttributeError`` au runtime). Le ``@runtime_checkable`` + autorise un test ``isinstance`` côté caller s'il veut une + validation explicite. + """ + + def get(self, key: str) -> CachedArtifactRef | None: # pragma: no cover + ... + + def put( + self, + key: str, + artifact: Artifact, + payload: bytes | None = None, + ) -> None: # pragma: no cover + ... + + def __contains__(self, key: str) -> bool: # pragma: no cover + ... + + +__all__ = ["ArtifactCachePort", "CachedArtifactRef"] diff --git a/picarones/pipeline/executor.py b/picarones/pipeline/executor.py new file mode 100644 index 0000000000000000000000000000000000000000..14cdf160a2a5c04aa4a6c6bdf37c99a343522990 --- /dev/null +++ b/picarones/pipeline/executor.py @@ -0,0 +1,556 @@ +"""``PipelineExecutor`` mono-document — Sprints A14-S7 / S28. + +Exécuteur séquentiel d'une pipeline composée sur un document. + +Sprint S7 livrait ``run(spec, document, initial_inputs, context)`` +qui validait la spec en interne et résolvait les bindings au +runtime via un bag versionné. + +Sprint S28 introduit le ``PipelinePlanner`` qui transforme une +``PipelineSpec`` en ``ExecutionPlan`` immuable (validations + +bindings résolus + jonctions de métriques détectées). L'executor +consomme désormais soit : + +- Un ``ExecutionPlan`` pré-calculé via ``run_plan(plan, ...)`` — + signature canonique, contrat explicite. +- Une ``PipelineSpec`` brute via ``run(spec, ...)`` — sucre + ergonomique qui appelle le planner en interne (planification + systématique, pas de cache implicite). + +Contrat +------- +Le caller (typiquement ``BenchmarkService`` ou ``CorpusRunner``) +fournit : + +- un ``ExecutionPlan`` (canonique) ou ``PipelineSpec`` (sucre), +- un ``DocumentRef`` du document à traiter, +- un dict ``{ArtifactType: Artifact}`` des entrées initiales + (typiquement ``{IMAGE: Artifact(...)}``), +- un ``RunContext`` (``document_id``, ``code_version``, + ``pipeline_name``, éventuel ``workspace_uri``), +- un ``adapter_resolver: Callable[[str], StepExecutor]`` injecté + au constructeur. + +L'executor garantit : + +- Les étapes sont exécutées dans l'ordre du plan + (``resolved_steps``). +- Chaque entrée d'une étape est résolue depuis les + ``StepInputBinding`` du plan — fini la résolution implicite + « dernier producteur » au runtime. +- Toute exception levée par un adapter est capturée — le step + est marqué ``succeeded=False`` avec ``error=str(exc)``, et le + pipeline continue (les étapes en aval pourront échouer si elles + dépendaient des outputs de ce step, ce qui est explicite). +- Les ``output_types`` déclarés par l'adapter sont validés au + retour : un type promis manquant marque le step en échec avec + ``error="missing_output: "``. + +L'executor ne garantit PAS (reportés à des sprints suivants) : + +- Cache d'artefacts inter-runs (S29 livre ``ArtifactStore``). +- Parallélisation inter-documents ou inter-étapes (cf. S8 pour + inter-doc via ``CorpusRunner``). + +Compat S7 +--------- +La signature historique ``run(spec, document, ...)`` reste +exposée — elle planifie la spec systématiquement à chaque appel +et délègue à ``run_plan``. Aucune logique nouvelle n'y vit. +""" + +from __future__ import annotations + +import logging +import time +from typing import Callable + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.documents import DocumentRef +from picarones.domain.errors import PicaronesError +from picarones.pipeline.cache_helpers import ( + compute_step_artifact_key, + read_cached_outputs, + write_outputs_to_cache, +) +from picarones.pipeline.cache_protocol import ArtifactCachePort +from picarones.pipeline.planner import ( + ExecutionPlan, + PipelinePlanner, + PlanningError, + ResolvedStep, +) +from picarones.pipeline.protocols import StepExecutor +from picarones.domain.pipeline_spec import INITIAL_STEP_ID, PipelineSpec +from picarones.pipeline.types import PipelineResult, RunContext, StepResult + +logger = logging.getLogger(__name__) + + +class PipelineSpecInvalid(PicaronesError): + """``PipelineSpec`` mal formée — l'executor refuse de démarrer. + + Wrappe le ``PlanningError`` produit par ``PipelinePlanner`` pour + préserver la sémantique historique : un caller qui catchait + ``PipelineSpecInvalid`` continue de fonctionner. + """ + + +#: Type alias pour le resolver d'adapters. Une fonction qui +#: prend un ``adapter_name`` (str) et retourne une instance +#: ``StepExecutor`` prête à l'emploi. Si le resolver lève +#: ``KeyError``, l'executor traduit en step en échec avec +#: ``error="adapter_not_found: ..."``. +AdapterResolver = Callable[[str], StepExecutor] + + +class PipelineExecutor: + """Exécuteur séquentiel mono-document. + + Une instance peut traiter plusieurs documents (l'état est + porté par les paramètres de ``run()``, pas par le constructeur). + L'instance est thread-safe en lecture (rien n'est muté après + construction). + + Parameters + ---------- + adapter_resolver: + Callable qui résout un ``adapter_name`` en instance + ``StepExecutor``. Typiquement + ``lambda name: registry[name]`` en test, ou un service + applicatif qui injecte les bonnes dépendances en prod. + planner: + ``PipelinePlanner`` injecté (S28). Si ``None``, un planner + par défaut sans ``MetricRegistry`` est instancié. + artifact_store: + ``ArtifactStore`` optionnel (S29 + S47) pour la **reprise par + hash**. Si fourni, l'executor : + + - **avant** chaque step, calcule la clé du step via + ``compute_step_artifact_key`` et interroge le store ; si + toutes les sorties attendues sont présentes ET valides + (URIs accessibles), saute l'exécution et retourne les + artefacts cachés (``StepResult.duration_seconds=0.0``) ; + - **après** chaque step réussi, persiste les outputs dans + le store sous la clé dérivée. + + Si ``None`` (défaut), aucun cache n'est consulté ni écrit. + Le comportement est strictement identique à l'avant-S47. + """ + + def __init__( + self, + adapter_resolver: AdapterResolver, + planner: PipelinePlanner | None = None, + artifact_store: ArtifactCachePort | None = None, + ) -> None: + if not callable(adapter_resolver): + raise PicaronesError( + "PipelineExecutor : adapter_resolver doit être callable." + ) + if planner is not None and not isinstance(planner, PipelinePlanner): + raise PicaronesError( + "PipelineExecutor : planner doit être un PipelinePlanner ou None." + ) + # ``isinstance(artifact_store, ArtifactCachePort)`` est un duck + # typing check (Protocol @runtime_checkable) — valide get/put/ + # __contains__ par leur seule présence. Permet à un caller + # tiers (Redis, S3) de fournir un store custom satisfaisant + # le protocol sans hériter de la classe ABC ``ArtifactStore``. + if artifact_store is not None and not isinstance( + artifact_store, ArtifactCachePort, + ): + raise PicaronesError( + "PipelineExecutor : artifact_store doit satisfaire le " + "protocole ArtifactCachePort (get / put / __contains__) " + "ou être None.", + ) + self._resolver = adapter_resolver + # Si pas de planner injecté, on en fabrique un sans MetricRegistry — + # les jonctions seront vides mais la planification reste correcte. + self._planner = planner if planner is not None else PipelinePlanner() + self._artifact_store = artifact_store + + def plan(self, spec: PipelineSpec) -> ExecutionPlan: + """Planifie une ``PipelineSpec`` en ``ExecutionPlan``. + + Sucre exposant le planner injecté. Permet aux callers + (typiquement ``CorpusRunner`` qui exécute la même spec sur + N documents) de planifier **une fois** puis appeler + ``run_plan`` N fois — économisant N-1 validations. + + Raises + ------ + PipelineSpecInvalid + Si la planification échoue (validations statiques). + """ + try: + return self._planner.plan(spec) + except PlanningError as exc: + messages = "; ".join( + f"{e.step_id or ''}: {e.message}" + for e in exc.errors + ) + raise PipelineSpecInvalid( + f"Spec {spec.name!r} invalide : {messages}" + ) from exc + + def run( + self, + spec: PipelineSpec, + document: DocumentRef, + initial_inputs: dict[ArtifactType, Artifact], + context: RunContext, + ) -> PipelineResult: + """Exécute une pipeline complète sur un document (sucre). + + Sucre ergonomique sur ``run_plan`` : appelle + ``self._planner.plan(spec)`` puis ``run_plan(plan, ...)``. + Aucune logique nouvelle n'y vit — l'API canonique est + ``run_plan(plan, document, initial_inputs, context)`` qui + accepte un ``ExecutionPlan`` pré-calculé. + + Returns + ------- + PipelineResult + ``succeeded`` global = True ssi toutes les étapes ont + réussi. Une étape en échec n'arrête PAS l'exécution — + les étapes suivantes peuvent quand même tourner si + leurs entrées ne dépendent pas du step en échec. + + Raises + ------ + PipelineSpecInvalid + Si la planification échoue (validations statiques). + L'executor ne masque pas ce type d'erreur : c'est un + bug de programmation, pas un problème runtime. + """ + plan = self.plan(spec) + return self.run_plan(plan, document, initial_inputs, context) + + def run_plan( + self, + plan: ExecutionPlan, + document: DocumentRef, + initial_inputs: dict[ArtifactType, Artifact], + context: RunContext, + ) -> PipelineResult: + """Exécute un ``ExecutionPlan`` pré-calculé sur un document. + + Signature canonique du S28. Le caller a déjà appelé + ``planner.plan(spec)`` (typiquement ``CorpusRunner`` qui + planifie une fois pour N documents). L'executor consomme + directement ``plan.resolved_steps`` sans re-valider la + spec ni re-résoudre les bindings. + + Toute la logique d'exécution vit ici ; ``run`` n'est qu'un + sucre. + """ + if not isinstance(plan, ExecutionPlan): + raise PicaronesError( + f"run_plan : plan doit être un ExecutionPlan, " + f"reçu {type(plan).__name__}" + ) + + # 1. Bag versionné : map (type, step_id) → Artifact. + versioned: dict[tuple[ArtifactType, str], Artifact] = {} + for art_type, art in initial_inputs.items(): + versioned[(art_type, INITIAL_STEP_ID)] = art + + # 2. Exécution séquentielle des steps résolus. + step_results: list[StepResult] = [] + all_artifacts: list[Artifact] = list(initial_inputs.values()) + run_started = time.perf_counter() + + for resolved_step in plan.resolved_steps: + result, produced = self._run_step( + resolved_step=resolved_step, + versioned=versioned, + context=context, + ) + step_results.append(result) + for art_type, art in produced.items(): + versioned[(art_type, resolved_step.id)] = art + all_artifacts.append(art) + + run_duration = time.perf_counter() - run_started + succeeded = all(r.succeeded for r in step_results) + + return PipelineResult( + pipeline_name=plan.spec.name, + document_id=document.id, + step_results=tuple(step_results), + succeeded=succeeded, + duration_seconds=run_duration, + artifacts=tuple(all_artifacts), + ) + + # ────────────────────────────────────────────────────────────────── + # Helpers internes + # ────────────────────────────────────────────────────────────────── + + def _run_step( + self, + *, + resolved_step: ResolvedStep, + versioned: dict[tuple[ArtifactType, str], Artifact], + context: RunContext, + ) -> tuple[StepResult, dict[ArtifactType, Artifact]]: + """Exécute une étape résolue, retourne (result, artefacts produits). + + Le tuple est important : si le step échoue, on retourne quand + même un dict vide pour les artefacts → le caller peut + continuer la boucle proprement. + """ + step = resolved_step.step + step_started = time.perf_counter() + + # 1. Résoudre les inputs depuis le bag en suivant les bindings + # explicites du plan. + try: + inputs = self._inputs_from_bindings( + resolved_step=resolved_step, + versioned=versioned, + ) + except _InputResolutionError as exc: + duration = time.perf_counter() - step_started + return ( + StepResult( + step_id=step.id, + succeeded=False, + duration_seconds=duration, + error=str(exc), + ), + {}, + ) + + # 1bis. S47 — Reprise par hash via ArtifactStore. + # Si un store est injecté et que tous les inputs ont un + # ``content_hash``, on calcule la clé du step et on interroge + # le store. Hit complet → on saute l'exécution (durée 0, + # même artefacts que la dernière exécution réussie). Miss + # ou cache partiel → on tombe dans l'exécution normale. + if self._artifact_store is not None: + cached_outputs = self._try_resume_from_cache( + step=step, inputs=inputs, context=context, + ) + if cached_outputs is not None: + logger.info( + "[pipeline:%s] step '%s' : hit cache " + "(reprise par hash, exécution sautée).", + context.pipeline_name, step.id, + ) + return ( + StepResult( + step_id=step.id, + succeeded=True, + duration_seconds=0.0, + produced_artifacts={ + t.value: a.id + for t, a in cached_outputs.items() + }, + ), + cached_outputs, + ) + + # 2. Résoudre l'adapter. + try: + adapter = self._resolver(step.adapter_name) + except KeyError: + duration = time.perf_counter() - step_started + return ( + StepResult( + step_id=step.id, + succeeded=False, + duration_seconds=duration, + error=f"adapter_not_found: {step.adapter_name}", + ), + {}, + ) + except Exception as exc: # noqa: BLE001 + duration = time.perf_counter() - step_started + return ( + StepResult( + step_id=step.id, + succeeded=False, + duration_seconds=duration, + error=f"adapter_resolver_failed: {exc}", + ), + {}, + ) + + # 3. Exécuter. Toute exception est capturée → step en échec. + try: + outputs = adapter.execute(inputs, dict(step.params), context) + except Exception as exc: # noqa: BLE001 + duration = time.perf_counter() - step_started + logger.warning( + "[pipeline:%s] step '%s' a levé : %s", + context.pipeline_name, step.id, exc, + ) + return ( + StepResult( + step_id=step.id, + succeeded=False, + duration_seconds=duration, + error=f"adapter_raised: {type(exc).__name__}: {exc}", + ), + {}, + ) + + # 4. Valider les outputs déclarés. + missing = [ + t for t in step.output_types + if t not in outputs + ] + duration = time.perf_counter() - step_started + if missing: + return ( + StepResult( + step_id=step.id, + succeeded=False, + duration_seconds=duration, + error=( + "missing_output: " + f"{[t.value for t in missing]}" + ), + ), + # On garde quand même les outputs qui ont été produits, + # pour que les éventuels steps en aval puissent les + # utiliser si la pipeline est résiliente. + outputs, + ) + + # 5. Filtrage sur ``step.output_types``. + # Un adapter peut produire plus de types que le YAML n'en + # déclare (ex: Tesseract avec ``expose_confidences=True`` + # mais le step ne déclare que ``[raw_text]``). Le contrat + # est que seuls les outputs déclarés en sortie de step + # passent en aval — sinon un DAG branchant pourrait recevoir + # des artefacts qui ne devaient pas exister à cette jonction. + declared = set(step.output_types) + outputs = {t: a for t, a in outputs.items() if t in declared} + + # 6. Succès — persiste dans le store si fourni. La méthode + # interne sait gérer le cas content_hash manquant (skip + # silencieux) — on lui passe la responsabilité. + if self._artifact_store is not None: + self._persist_to_cache( + step=step, inputs=inputs, context=context, outputs=outputs, + ) + produced_map = { + t.value: a.id for t, a in outputs.items() + } + return ( + StepResult( + step_id=step.id, + succeeded=True, + duration_seconds=duration, + produced_artifacts=produced_map, + ), + outputs, + ) + + # ────────────────────────────────────────────────────────────────── + # S47 — Reprise par hash via ArtifactStore + # ────────────────────────────────────────────────────────────────── + + def _try_resume_from_cache( + self, + *, + step, + inputs: dict[ArtifactType, Artifact], + context: RunContext, + ) -> dict[ArtifactType, Artifact] | None: + """Tente de retrouver les outputs cachés du step. + + Retourne ``None`` (cache miss) dans 3 cas : + + 1. Un input n'a pas de ``content_hash`` → la clé n'est pas + calculable (cf. ``ArtifactKey.hash_hex``). + 2. Le store ne contient pas TOUS les ``output_types`` du step. + 3. Une URI cachée pointe vers un fichier qui n'existe plus. + """ + # Nécessairement non-None ici (vérifié par le caller), mais on + # défend en profondeur. + if self._artifact_store is None: + return None + key = compute_step_artifact_key(step, inputs, context) + step_hash = key.hash_hex() + if step_hash is None: + return None + return read_cached_outputs( + store=self._artifact_store, + step=step, + step_hash=step_hash, + ) + + def _persist_to_cache( + self, + *, + step, + inputs: dict[ArtifactType, Artifact], + context: RunContext, + outputs: dict[ArtifactType, Artifact], + ) -> None: + """Persiste les outputs d'un step réussi dans le store. + + Skip silencieux si la clé n'est pas calculable (un input sans + ``content_hash``). + """ + if self._artifact_store is None: + return + key = compute_step_artifact_key(step, inputs, context) + step_hash = key.hash_hex() + if step_hash is None: + return + write_outputs_to_cache( + store=self._artifact_store, + step=step, + step_hash=step_hash, + outputs=outputs, + ) + + def _inputs_from_bindings( + self, + *, + resolved_step: ResolvedStep, + versioned: dict[tuple[ArtifactType, str], Artifact], + ) -> dict[ArtifactType, Artifact]: + """Construit le dict ``{ArtifactType: Artifact}`` à passer + à l'adapter à partir des bindings explicites du plan. + + Le plan a déjà résolu chaque ``input_type`` à une + ``source_step_id`` (soit ``INITIAL_STEP_ID``, soit l'ID + d'une étape antérieure). L'executor n'a plus qu'à indexer + le bag par ``(input_type, source_step_id)``. + + Lève ``_InputResolutionError`` si l'artefact attendu + n'est pas dans le bag — typiquement parce qu'une étape + antérieure a échoué et n'a pas produit son output. + """ + inputs: dict[ArtifactType, Artifact] = {} + for binding in resolved_step.input_bindings: + key = (binding.input_type, binding.source_step_id) + if key not in versioned: + raise _InputResolutionError( + f"missing_input: {binding.input_type.value}" + f"@{binding.source_step_id}" + ) + inputs[binding.input_type] = versioned[key] + return inputs + + +class _InputResolutionError(Exception): + """Erreur interne signalant qu'un input n'a pas pu être résolu. + + Capturée par ``_run_step`` qui la traduit en ``StepResult`` + en échec avec ``error="missing_input: ..."``. + """ + + +__all__ = [ + "AdapterResolver", + "PipelineExecutor", + "PipelineSpecInvalid", +] diff --git a/picarones/pipeline/planner.py b/picarones/pipeline/planner.py new file mode 100644 index 0000000000000000000000000000000000000000..270770474cf04a69c920766770aca7e392c9e952 --- /dev/null +++ b/picarones/pipeline/planner.py @@ -0,0 +1,406 @@ +"""``PipelinePlanner`` — Sprint A14-S28. + +Le S6 livrait ``validate_spec`` (validation statique : types +cohérents, IDs uniques, ``inputs_from`` valides, adapters connus). +Le S7 livrait ``PipelineExecutor`` qui résolvait les bindings +**au runtime** (bag versionné consulté à chaque step). + +S28 introduit une couche de **planification** qui transforme une +``PipelineSpec`` en ``ExecutionPlan`` immuable : + +1. Validation statique (délègue à ``validate_spec``). +2. Résolution explicite de chaque binding d'entrée — fini la + résolution implicite « dernier producteur » au runtime. +3. Détection des **jonctions de métriques** : pour chaque sortie + de step, le planner interroge le ``MetricRegistry`` pour les + métriques applicables sur la signature ``(T, T)`` — base + pour l'auto-évaluation contre la GT du même niveau. +4. Calcul d'un ordre topologique déterministe (les steps + ``inputs_from`` peuvent référencer n'importe quelle étape + antérieure ; le planner s'assure que la séquence est cohérente). + +Pourquoi cette séparation +------------------------- +- **Contrat explicite** : l'executor consomme un ``ExecutionPlan`` + immuable plutôt que de dériver les bindings au runtime — moins + de surprises, debug plus simple. +- **Réutilisabilité** : le ``CorpusRunner`` planifie **une fois** + pour la spec, exécute N fois (un par document) — économie marginale + mais clarté garantie. +- **Diagnostic** : un ``PlanningError`` capture toutes les erreurs + d'un coup (pas de short-circuit à la première erreur). +- **Métriques de jonction** : le planner liste les métriques + applicables à chaque sortie ; un service applicatif (S29+) peut + pré-calculer où l'évaluation est possible. + +Anti-sur-ingénierie +------------------- +- Pas de cache de plan inter-spec (le coût de planification est + O(steps) et négligeable face à l'OCR). +- Pas d'optimisation de DAG (parallélisation, fusion, etc.) — le + plan reste séquentiel et correspond exactement à l'ordre des + steps. +- Pas de validation runtime additionnelle (artefacts effectivement + produits, etc.) — c'est la responsabilité de l'executor. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.errors import PicaronesError +from picarones.evaluation.registry import MetricRegistry +from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, +) +from picarones.pipeline.validation import ValidationError, validate_spec + + +# ────────────────────────────────────────────────────────────────────── +# Erreur dédiée +# ────────────────────────────────────────────────────────────────────── + + +class PlanningError(PicaronesError): + """La spec n'a pas pu être planifiée — typiquement parce qu'elle + contient des erreurs de validation détectées par + ``validate_spec``. + + Attributes + ---------- + errors: + Liste des ``ValidationError`` produites par ``validate_spec``. + Le caller peut les rendre dans son rapport (CLI, JSON, HTML) + sans avoir à parser le message. + """ + + def __init__( + self, message: str, errors: list[ValidationError] | None = None, + ) -> None: + super().__init__(message) + self.errors: tuple[ValidationError, ...] = tuple(errors or ()) + + +# ────────────────────────────────────────────────────────────────────── +# Modèles immuables du plan +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class StepInputBinding: + """Binding explicite d'une entrée de step à sa source. + + Attributes + ---------- + input_type: + Type d'artefact consommé. + source_step_id: + ID de l'étape source, ou ``INITIAL_STEP_ID`` pour les + entrées initiales fournies au runner. + + Notes + ----- + Frozen — le caller doit considérer le binding comme un fait + figé du plan. Toute mutation invaliderait l'``ExecutionPlan``. + """ + + input_type: ArtifactType + source_step_id: str + + +@dataclass(frozen=True) +class ResolvedStep: + """Étape avec tous ses bindings d'entrée résolus. + + Attributes + ---------- + step: + Le ``PipelineStep`` original (frozen pydantic). + input_bindings: + Bindings explicites — un par ``input_type``. Préserve + l'ordre de ``step.input_types``. + + Notes + ----- + Le runner peut directement consommer ``input_bindings`` sans + refaire la résolution : pour chaque binding, il sait quelle + version de quel artefact aller chercher dans son bag. + """ + + step: PipelineStep + input_bindings: tuple[StepInputBinding, ...] = field(default_factory=tuple) + + @property + def id(self) -> str: + return self.step.id + + @property + def adapter_name(self) -> str: + return self.step.adapter_name + + +@dataclass(frozen=True) +class MetricJunction: + """Jonction de métriques détectée à la sortie d'un step. + + Pour chaque sortie ``T`` d'un step, le planner interroge le + ``MetricRegistry`` pour les métriques de signature ``(T, T)`` + — celles qui peuvent comparer la sortie du step à une GT + du même niveau. Un service applicatif (S29+) consomme cette + liste pour décider où auto-évaluer. + + Attributes + ---------- + step_id: + Step qui produit l'artefact évaluable. + artifact_type: + Type de l'artefact produit. + candidate_metrics: + Noms des métriques applicables, triés alphabétiquement + pour déterminisme. + + Notes + ----- + « Candidate » : la jonction est *applicable*, pas *exigée*. Le + caller décide selon la GT disponible et la stratégie d'évaluation. + """ + + step_id: str + artifact_type: ArtifactType + candidate_metrics: tuple[str, ...] = field(default_factory=tuple) + + +@dataclass(frozen=True) +class ExecutionPlan: + """Plan d'exécution immuable consommable par le ``PipelineExecutor``. + + Construit par ``PipelinePlanner.plan(spec)``. Garantit que : + + - La spec est statiquement valide (toutes les ``ValidationError`` + sont nulles). + - Chaque step a ses bindings résolus (``input_bindings`` non vide + pour chaque ``input_type`` déclaré). + - L'ordre topologique est respecté (``resolved_steps`` suit + l'ordre de ``spec.steps``, qui doit déjà être topologique). + - Les jonctions de métriques sont indexées par step. + + Attributes + ---------- + spec: + La ``PipelineSpec`` source (référence, pas copie). + resolved_steps: + Steps avec bindings résolus, dans l'ordre topologique + d'exécution. + metric_junctions: + Jonctions auto-détectées si un ``MetricRegistry`` était + fourni au planner ; tuple vide sinon. Le ``PipelineExecutor`` + ne les consomme pas encore au runtime — elles sont exposées + pour l'introspection (rapport, diagnostic). L'auto-évaluation + intra-pipeline sera ajoutée sans breaking change. + """ + + spec: PipelineSpec + resolved_steps: tuple[ResolvedStep, ...] = field(default_factory=tuple) + metric_junctions: tuple[MetricJunction, ...] = field(default_factory=tuple) + + def step_by_id(self, step_id: str) -> ResolvedStep | None: + """Retourne le step résolu par son id, ou ``None``.""" + for rs in self.resolved_steps: + if rs.id == step_id: + return rs + return None + + def junctions_for_step(self, step_id: str) -> tuple[MetricJunction, ...]: + """Retourne les jonctions de métriques associées à un step.""" + return tuple( + j for j in self.metric_junctions if j.step_id == step_id + ) + + +# ────────────────────────────────────────────────────────────────────── +# Planificateur +# ────────────────────────────────────────────────────────────────────── + + +class PipelinePlanner: + """Planificateur d'une ``PipelineSpec`` en ``ExecutionPlan``. + + Parameters + ---------- + metric_registry: + Optionnel — si fourni, les jonctions de métriques sont + détectées pour chaque sortie de step. Sinon, le plan a + ``metric_junctions=()``. + available_adapters: + Optionnel — set des noms d'adapters connus. Si fourni, la + validation rejette les ``adapter_name`` inconnus. Sinon, + cette validation est sautée (utile pour les YAML qui + peuvent référencer des adapters tiers absents en CI). + + Notes + ----- + Stateless : le planner ne mémorise aucun état entre appels. + Thread-safe en lecture/écriture. + """ + + def __init__( + self, + metric_registry: MetricRegistry | None = None, + available_adapters: set[str] | None = None, + ) -> None: + if metric_registry is not None and not isinstance( + metric_registry, MetricRegistry, + ): + raise TypeError( + "metric_registry doit être un MetricRegistry ou None." + ) + self._metrics = metric_registry + self._adapters = ( + frozenset(available_adapters) + if available_adapters is not None + else None + ) + + def plan(self, spec: PipelineSpec) -> ExecutionPlan: + """Construit un ``ExecutionPlan`` à partir d'une ``PipelineSpec``. + + Étapes : + + 1. ``validate_spec(spec, available_adapters)`` — récolte + toutes les erreurs structurelles. + 2. Si erreurs → ``PlanningError`` avec la liste complète. + 3. Sinon, résout les bindings step par step en simulant le + bag versionné. + 4. Si un registre de métriques est disponible, détecte les + jonctions pour chaque sortie de step. + + Raises + ------ + PlanningError + Si la validation statique échoue. Le caller peut + inspecter ``error.errors`` pour rendre un rapport. + """ + # 1. Validation statique. + errors = validate_spec( + spec, + available_adapters=set(self._adapters) if self._adapters else None, + ) + if errors: + n = len(errors) + preview = "; ".join( + f"{e.step_id or ''}:{e.code}" + for e in errors[:3] + ) + suffix = f" (+{n - 3} de plus)" if n > 3 else "" + raise PlanningError( + f"PipelineSpec {spec.name!r} a {n} erreur(s) de " + f"validation : {preview}{suffix}", + errors=errors, + ) + + # 2. Résolution des bindings. + resolved_steps = self._resolve_steps(spec) + + # 3. Détection des jonctions de métriques. + metric_junctions = ( + self._detect_junctions(spec) + if self._metrics is not None + else () + ) + + return ExecutionPlan( + spec=spec, + resolved_steps=resolved_steps, + metric_junctions=metric_junctions, + ) + + # ────────────────────────────────────────────────────────────────── + # Helpers internes + # ────────────────────────────────────────────────────────────────── + + def _resolve_steps( + self, spec: PipelineSpec, + ) -> tuple[ResolvedStep, ...]: + """Résout les bindings de chaque step en simulant le bag. + + Pour chaque ``input_type`` d'un step : + + - Si ``inputs_from[input_type]`` est défini → ce step est la + source explicite. + - Sinon → la source est le **dernier producteur** du type + dans l'ordre topologique (équivalent au comportement + historique de l'executor S7). + + ``validate_spec`` garantit que ces résolutions sont valides + (pas de référence pendante, type produit par la source). + """ + latest_producer: dict[ArtifactType, str] = { + t: INITIAL_STEP_ID for t in spec.initial_inputs + } + resolved: list[ResolvedStep] = [] + + for step in spec.steps: + bindings: list[StepInputBinding] = [] + for input_type in step.input_types: + source = step.inputs_from.get(input_type) + if source is None: + # validate_spec a vérifié que latest_producer[t] + # existe → on peut indexer sans garde. + source = latest_producer[input_type] + bindings.append(StepInputBinding( + input_type=input_type, + source_step_id=source, + )) + resolved.append(ResolvedStep( + step=step, + input_bindings=tuple(bindings), + )) + # Mise à jour de l'état pour les steps suivants. + for output_type in step.output_types: + latest_producer[output_type] = step.id + + return tuple(resolved) + + def _detect_junctions( + self, spec: PipelineSpec, + ) -> tuple[MetricJunction, ...]: + """Détecte les jonctions de métriques pour chaque sortie. + + Pour chaque ``output_type`` ``T`` d'un step, interroge le + ``MetricRegistry`` pour les métriques de signature ``(T, T)`` + — métriques applicables à la comparaison ``GT[T]`` vs + ``step.outputs[T]``. + + Si aucune métrique n'est applicable, la jonction est tout + de même listée avec ``candidate_metrics=()`` — un caller + peut ainsi détecter qu'un step produit un type non + évaluable et décider de la suite (warning, registre étendu, + omission). + """ + # Garde-fou : devrait être garanti par le check dans plan(). + if self._metrics is None: # pragma: no cover + return () + junctions: list[MetricJunction] = [] + for step in spec.steps: + for output_type in step.output_types: + specs = self._metrics.select(output_type, output_type) + names = tuple(sorted(s.name for s in specs)) + junctions.append(MetricJunction( + step_id=step.id, + artifact_type=output_type, + candidate_metrics=names, + )) + return tuple(junctions) + + +__all__ = [ + "ExecutionPlan", + "MetricJunction", + "PipelinePlanner", + "PlanningError", + "ResolvedStep", + "StepInputBinding", +] diff --git a/picarones/pipeline/protocols.py b/picarones/pipeline/protocols.py new file mode 100644 index 0000000000000000000000000000000000000000..cce827a3354b7ed65700af8857a126f3d828c863 --- /dev/null +++ b/picarones/pipeline/protocols.py @@ -0,0 +1,102 @@ +"""``StepExecutor`` (Protocol) — Sprint A14-S6. + +Contrat que doit satisfaire tout adapter exécutable par le pipeline +runner. Une fonction ou une classe peut satisfaire le protocole — +le runner ne se soucie que de l'interface. + +Implémentations concrètes au Sprint S11 dans ``picarones/adapters/`` +(Tesseract, Pero OCR, Mistral OCR, Google Vision, Azure DI, OpenAI, +Anthropic, Mistral, Ollama, ...). + +Pattern d'utilisation cible : + +.. code-block:: python + + class TesseractExecutor: + name = "tesseract" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "cpu" + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict, + context: RunContext, + ) -> dict[ArtifactType, Artifact]: + image_artifact = inputs[ArtifactType.IMAGE] + text = pytesseract.image_to_string(image_artifact.uri, **params) + return {ArtifactType.RAW_TEXT: build_text_artifact(text, context)} +""" + +from __future__ import annotations + +from typing import Literal, Protocol, runtime_checkable + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +#: Mode d'exécution déclaré par l'adapter. Le runner choisit +#: ``ProcessPoolExecutor`` pour ``"cpu"``, ``ThreadPoolExecutor`` pour +#: ``"io"``. +ExecutionMode = Literal["io", "cpu"] + + +@runtime_checkable +class StepExecutor(Protocol): + """Contrat d'un adapter exécutable. + + Trois propriétés statiques (le runner les inspecte sans appeler + ``execute()``) : + + - ``name`` : identifiant stable (cf. ``PipelineStep.adapter_name``). + - ``input_types`` : types consommés. + - ``output_types`` : types produits. + - ``execution_mode`` : ``"io"`` ou ``"cpu"``. + + Une méthode ``execute(inputs, params, context) -> dict[ArtifactType, Artifact]``. + + Le runner garantit que : + + - ``inputs`` contient au moins tous les types listés dans + ``input_types``. + - ``params`` est le dict ``PipelineStep.params`` (copie). + - ``context`` est le ``RunContext`` du document courant. + + L'adapter garantit que : + + - Le dict retourné contient au moins tous les types listés dans + ``output_types``. Le runner valide cette propriété et marque + le step en échec si un type promis manque. + - Toute exception levée est propagée au runner ; ne rien capturer + silencieusement. + + Le ``execute`` reste **pur du point de vue du runner** : il + peut faire des side effects (écrire un fichier, appeler une API), + mais le runner garantit qu'il ne sera pas appelé deux fois pour + le même couple ``(document_id, step_id)`` dans le même run + (cache du Sprint S7). + """ + + @property + def name(self) -> str: ... + + @property + def input_types(self) -> frozenset[ArtifactType]: ... + + @property + def output_types(self) -> frozenset[ArtifactType]: ... + + @property + def execution_mode(self) -> ExecutionMode: ... + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict[str, str | int | float | bool], + context: RunContext, + ) -> dict[ArtifactType, Artifact]: ... + + +__all__ = ["StepExecutor", "ExecutionMode"] diff --git a/picarones/pipeline/runner.py b/picarones/pipeline/runner.py new file mode 100644 index 0000000000000000000000000000000000000000..4d5dd6a29b7f2c48f745e15d4ff063af98d23cfc --- /dev/null +++ b/picarones/pipeline/runner.py @@ -0,0 +1,486 @@ +"""``CorpusRunner`` — Sprint A14-S8. + +Orchestre l'exécution d'une ``PipelineSpec`` sur un corpus complet +avec trois propriétés critiques que l'ancien +``measurements.runner`` ne garantissait pas correctement : + +1. **Backpressure** — pas de "submit all upfront". L'orchestrateur + ne soumet jamais plus de ``max_in_flight`` documents en + parallèle. RAM bornée même sur des corpus de plusieurs milliers + de documents. + +2. **Timeout depuis le début d'exécution réelle** — l'ancien runner + calculait le timeout depuis la submission au pool, donc un + document pouvait être marqué timeout parce qu'il avait passé + N secondes en queue, pas N secondes en train de tourner. Le + nouveau runner mesure depuis le moment où le worker démarre + réellement. + +3. **Annulation propre** — un ``threading.Event`` partagé permet + au caller (typiquement un service applicatif sur un endpoint + FastAPI ``cancel``) de signaler l'arrêt. Les workers + coopératifs vérifient l'event ; les futures non démarrées sont + sautées ; les futures déjà en cours se terminent (Python ne + permet pas de tuer un thread en cours). + +Limites assumées pour S8 +------------------------ +- **Mode threads uniquement.** Le mode process (``ProcessPoolExecutor``) + ajouté au S11 quand on déplacera les adapters CPU-bound. + Aujourd'hui, un adapter Tesseract local en thread fonctionne + (le GIL est relâché par le sous-processus pytesseract → OK). +- **Pas de kill-thread garanti.** Si un adapter ne coopère pas avec + ``cancel_event`` et fait un appel C bloquant non-interruptible, + le runner attend la fin naturelle. C'est documenté. +- **Pas de retry automatique.** Si un adapter échoue, le doc est + marqué en échec et on passe au suivant. + +Définition de done +------------------ +``CorpusRunner.run(spec, 1000 docs synthétiques)`` se termine en +moins de 10 minutes sans dépasser 500 MB de RAM résidente. Le +test ``test_sprint_a14_s8_def_of_done`` valide ce critère +(échantillon paramétrable pour CI rapide). +""" + +from __future__ import annotations + +import concurrent.futures +import logging +import threading +import time +from collections.abc import Iterable +from typing import Callable + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.documents import DocumentRef +from picarones.domain.errors import PicaronesError +from picarones.pipeline.executor import PipelineExecutor +from picarones.domain.pipeline_spec import PipelineSpec +from picarones.pipeline.types import PipelineResult, RunContext + +logger = logging.getLogger(__name__) + + +#: Factories injectées par le caller pour adapter le runner à +#: son contexte (corpus local, IIIF, HF, etc.). +InitialInputsFactory = Callable[ + [DocumentRef], + dict[ArtifactType, Artifact], +] +ContextFactory = Callable[[DocumentRef], RunContext] + + +class DocumentOutcome(BaseModel): + """Résultat de l'exécution d'une pipeline sur **un** document. + + Distinct de ``PipelineResult`` : porte un statut + (``"succeeded"`` / ``"failed"`` / ``"timed_out"`` / + ``"cancelled"``) et conserve le ``PipelineResult`` quand il + existe (peut être ``None`` si annulation avant démarrage). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + document_id: str + status: str = Field(pattern=r"^(succeeded|failed|timed_out|cancelled)$") + duration_seconds: float = Field(ge=0.0) + error: str | None = None + pipeline_result: PipelineResult | None = None + + +class CorpusRunResult(BaseModel): + """Résultat agrégé d'un run de corpus. + + Attributs + --------- + pipeline_name: + Nom de la pipeline exécutée. + corpus_name: + Nom du corpus (libre, fourni par le caller). + n_documents: + Nombre total de documents tentés. + n_succeeded: + Nombre de documents pour lesquels la pipeline a complètement + réussi (``PipelineResult.succeeded == True``). + n_failed: + Nombre de documents avec au moins une étape en échec. + n_timed_out: + Nombre de documents tués par timeout. + n_cancelled: + Nombre de documents jamais démarrés (cancel_event signalé + avant leur tour). + duration_seconds: + Wall-clock total du run. + outcomes: + Détail document par document, ordre d'achèvement. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + pipeline_name: str + corpus_name: str + n_documents: int = Field(ge=0) + n_succeeded: int = Field(ge=0) + n_failed: int = Field(ge=0) + n_timed_out: int = Field(ge=0) + n_cancelled: int = Field(ge=0) + duration_seconds: float = Field(ge=0.0) + outcomes: tuple[DocumentOutcome, ...] = Field(default_factory=tuple) + + +class CorpusRunner: + """Orchestre ``PipelineExecutor`` sur un corpus avec backpressure + + timeout réel + cancellation. + + Une instance est réutilisable à travers plusieurs runs. + """ + + def __init__( + self, + executor: PipelineExecutor, + max_in_flight: int = 4, + timeout_seconds_per_doc: float = 300.0, + poll_interval_seconds: float = 0.05, + ) -> None: + if max_in_flight < 1: + raise PicaronesError( + f"max_in_flight doit être >= 1 (reçu {max_in_flight})." + ) + if timeout_seconds_per_doc <= 0: + raise PicaronesError( + f"timeout_seconds_per_doc doit être > 0 (reçu " + f"{timeout_seconds_per_doc})." + ) + if poll_interval_seconds <= 0: + raise PicaronesError( + "poll_interval_seconds doit être > 0." + ) + self._executor = executor + self._max_in_flight = max_in_flight + self._timeout = timeout_seconds_per_doc + self._poll = poll_interval_seconds + + def run( + self, + spec: PipelineSpec, + documents: Iterable[DocumentRef], + initial_inputs_factory: InitialInputsFactory, + context_factory: ContextFactory, + corpus_name: str = "corpus", + cancel_event: threading.Event | None = None, + ) -> CorpusRunResult: + """Exécute ``spec`` sur tous les ``documents`` du corpus. + + Returns + ------- + CorpusRunResult + Résultat agrégé. Ne lève jamais — toute erreur d'un + document est capturée dans son ``DocumentOutcome``. + """ + documents_list = list(documents) + run_started = time.perf_counter() + + # État partagé entre threads : ``started_at[doc_id]`` = + # monotonic au moment où le worker du doc a vraiment démarré + # ``execute()``. L'orchestrateur lit ce dict pour décider + # d'un timeout depuis le début d'exécution réelle. + started_at: dict[str, float] = {} + started_at_lock = threading.Lock() + + outcomes: list[DocumentOutcome] = [] + + # Fast path : aucun document → résultat vide immédiat. + if not documents_list: + return CorpusRunResult( + pipeline_name=spec.name, + corpus_name=corpus_name, + n_documents=0, + n_succeeded=0, + n_failed=0, + n_timed_out=0, + n_cancelled=0, + duration_seconds=0.0, + outcomes=(), + ) + + # S28 : on planifie une seule fois pour la spec. Si la spec + # est invalide, on lève maintenant — pas dans chaque worker. + # Les workers consomment ensuite ``executor.run_plan(plan, ...)`` + # → N-1 validations économisées. + plan = self._executor.plan(spec) + + # Pool instancié explicitement avec ``shutdown(wait=False, + # cancel_futures=True)`` à la sortie : les futures en queue + # sont annulées, les threads en cours continuent en + # arrière-plan jusqu'à leur fin naturelle (Python ne permet + # pas de tuer un thread). Le caller récupère le résultat + # immédiatement après le timeout / la cancellation, sans + # attendre que les threads en cours se terminent — c'est + # critique pour la latence perçue du runner. + pool = concurrent.futures.ThreadPoolExecutor( + max_workers=self._max_in_flight, + thread_name_prefix=f"picarones-{spec.name}", + ) + try: + future_to_doc: dict[concurrent.futures.Future, DocumentRef] = {} + doc_iter = iter(documents_list) + in_flight = 0 + done_count = 0 + + def _submit_next() -> bool: + """Tente de soumettre le prochain document au pool. + + Retourne ``True`` si un doc a été soumis, + ``False`` si l'itérateur est épuisé ou si + cancel_event est signalé. + """ + nonlocal in_flight + if cancel_event is not None and cancel_event.is_set(): + return False + try: + doc = next(doc_iter) + except StopIteration: + return False + fut = pool.submit( + self._run_one, + plan=plan, + document=doc, + initial_inputs_factory=initial_inputs_factory, + context_factory=context_factory, + started_at=started_at, + started_at_lock=started_at_lock, + ) + future_to_doc[fut] = doc + in_flight += 1 + return True + + # 1. Amorcer le pool : ne pas dépasser max_in_flight. + for _ in range(self._max_in_flight): + if not _submit_next(): + break + + # 2. Boucle principale : récolter les résultats, surveiller + # les timeouts, soumettre le suivant à chaque libération. + while future_to_doc: + # Polling court pour pouvoir vérifier les timeouts en + # parallèle des completions naturelles. + done_set, _ = concurrent.futures.wait( + future_to_doc.keys(), + timeout=self._poll, + return_when=concurrent.futures.FIRST_COMPLETED, + ) + + # 2a. Récolter les futures terminées. + for fut in done_set: + doc = future_to_doc.pop(fut) + in_flight -= 1 + outcomes.append(_outcome_from_future(fut, doc)) + done_count += 1 + # Soumettre le suivant pour maintenir la backpressure. + _submit_next() + + # 2b. Vérifier les timeouts depuis le début d'exécution + # réelle (pas depuis la submission). + now = time.monotonic() + timed_out_futures: list[concurrent.futures.Future] = [] + with started_at_lock: + started_snapshot = dict(started_at) + for fut, doc in list(future_to_doc.items()): + started = started_snapshot.get(doc.id) + if started is None: + continue # pas encore démarré → pas de timeout + if now - started > self._timeout: + timed_out_futures.append(fut) + + for fut in timed_out_futures: + doc = future_to_doc.pop(fut) + in_flight -= 1 + # On ne peut pas vraiment killer un thread en + # Python ; on signale via cancel_event si fourni + # ET on enregistre le timeout immédiatement (le + # thread continuera en arrière-plan jusqu'à ce + # qu'il ait fini, mais le run principal n'attend + # plus son résultat). + duration = ( + now - started_snapshot.get(doc.id, now) + ) + outcomes.append(DocumentOutcome( + document_id=doc.id, + status="timed_out", + duration_seconds=max(duration, 0.0), + error=( + f"timeout: doc {doc.id} a dépassé " + f"{self._timeout:.1f}s d'exécution réelle" + ), + )) + done_count += 1 + _submit_next() + + # 2c. Cancellation explicite : marquer toutes les + # futures non démarrées comme annulées. + if cancel_event is not None and cancel_event.is_set(): + cancelled = [] + with started_at_lock: + started_snapshot = dict(started_at) + for fut, doc in list(future_to_doc.items()): + if doc.id not in started_snapshot: + # Future encore en queue → on peut la + # canceller proprement. + if fut.cancel(): + cancelled.append(doc) + future_to_doc.pop(fut, None) + in_flight -= 1 + for doc in cancelled: + outcomes.append(DocumentOutcome( + document_id=doc.id, + status="cancelled", + duration_seconds=0.0, + error="cancelled before start", + )) + finally: + # Sortie immédiate : on ne bloque pas sur les threads en + # cours. Les futures en queue sont annulées, les threads + # déjà actifs continuent jusqu'à leur fin naturelle (cf. + # commentaire à l'instanciation du pool). + pool.shutdown(wait=False, cancel_futures=True) + + # 3. Agrégation finale. + run_duration = time.perf_counter() - run_started + return _aggregate( + pipeline_name=spec.name, + corpus_name=corpus_name, + n_documents=len(documents_list), + outcomes=outcomes, + duration_seconds=run_duration, + ) + + # ────────────────────────────────────────────────────────────────── + # Worker + # ────────────────────────────────────────────────────────────────── + + def _run_one( + self, + *, + plan, # ExecutionPlan ; type omis pour éviter l'import top-level + document: DocumentRef, + initial_inputs_factory: InitialInputsFactory, + context_factory: ContextFactory, + started_at: dict[str, float], + started_at_lock: threading.Lock, + ) -> PipelineResult: + """Exécute le plan pré-calculé sur un document. Appelé dans + un thread du pool. + + Enregistre ``started_at[doc.id]`` au tout début pour que + l'orchestrateur puisse mesurer le timeout depuis le début + d'exécution réelle. + """ + # 1. Marquer le démarrage réel. Ce moment est ce qui sert de + # référence pour le timeout. + with started_at_lock: + started_at[document.id] = time.monotonic() + + # 2. Construire les inputs et le contexte. + initial_inputs = initial_inputs_factory(document) + context = context_factory(document) + + # 3. Déléguer au PipelineExecutor.run_plan (S28). Le plan a + # déjà été validé une fois par le runner ; pas de re-validation + # par doc. + return self._executor.run_plan( + plan=plan, + document=document, + initial_inputs=initial_inputs, + context=context, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Helpers d'agrégation +# ────────────────────────────────────────────────────────────────────── + + +def _outcome_from_future( + fut: concurrent.futures.Future, + doc: DocumentRef, +) -> DocumentOutcome: + """Convertit une future achevée en ``DocumentOutcome``. + + - Future qui a levé → ``status="failed"``, ``error=str(exc)``. + - Future qui a renvoyé un ``PipelineResult`` succeeded → ``"succeeded"``. + - Future qui a renvoyé un ``PipelineResult`` non-succeeded → + ``"failed"`` (au moins une étape en erreur). + """ + try: + result = fut.result(timeout=0) # déjà done + except concurrent.futures.CancelledError: + return DocumentOutcome( + document_id=doc.id, + status="cancelled", + duration_seconds=0.0, + error="cancelled", + ) + except Exception as exc: # noqa: BLE001 + # PipelineExecutor capture toutes les erreurs des steps, + # donc une exception ici signale un bug profond (typiquement + # un PipelineSpecInvalid levé par l'executor). + return DocumentOutcome( + document_id=doc.id, + status="failed", + duration_seconds=0.0, + error=f"runner_internal_error: {type(exc).__name__}: {exc}", + ) + + if result.succeeded: + status = "succeeded" + error: str | None = None + else: + status = "failed" + # Concaténer les erreurs de step pour le diagnostic. + step_errors = [ + f"{r.step_id}: {r.error}" + for r in result.step_results + if not r.succeeded + ] + error = "; ".join(step_errors) if step_errors else "unknown failure" + + return DocumentOutcome( + document_id=doc.id, + status=status, + duration_seconds=result.duration_seconds, + error=error, + pipeline_result=result, + ) + + +def _aggregate( + *, + pipeline_name: str, + corpus_name: str, + n_documents: int, + outcomes: list[DocumentOutcome], + duration_seconds: float, +) -> CorpusRunResult: + return CorpusRunResult( + pipeline_name=pipeline_name, + corpus_name=corpus_name, + n_documents=n_documents, + n_succeeded=sum(1 for o in outcomes if o.status == "succeeded"), + n_failed=sum(1 for o in outcomes if o.status == "failed"), + n_timed_out=sum(1 for o in outcomes if o.status == "timed_out"), + n_cancelled=sum(1 for o in outcomes if o.status == "cancelled"), + duration_seconds=duration_seconds, + outcomes=tuple(outcomes), + ) + + +__all__ = [ + "CorpusRunner", + "CorpusRunResult", + "DocumentOutcome", + "InitialInputsFactory", + "ContextFactory", +] diff --git a/picarones/pipeline/spec.py b/picarones/pipeline/spec.py new file mode 100644 index 0000000000000000000000000000000000000000..9975bed5b53d901318174cf72348d6385d59ea03 --- /dev/null +++ b/picarones/pipeline/spec.py @@ -0,0 +1,38 @@ +"""``picarones.pipeline.spec`` — shim de compatibilité descendante (déprécié). + +Le module canonique est ``picarones.domain.pipeline_spec`` depuis le +sprint S40. Ce module a été supprimé temporairement au S57 puis +restauré au S59 avec ``DeprecationWarning`` pour respecter une +deprecation period propre vis-à-vis des callers externes (espaces +HuggingFace tiers, scripts archivistiques, notebooks de chercheurs). + +Suppression effective prévue en version majeure suivante (1.x → 2.0). + +:: + + # Migration : remplacer + from picarones.pipeline.spec import PipelineSpec + # par + from picarones.domain import PipelineSpec +""" + +from __future__ import annotations + +import warnings + +from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, +) + +warnings.warn( + "picarones.pipeline.spec is deprecated and will be removed in 2.0. " + "Import from picarones.domain instead " + "(`from picarones.domain import PipelineSpec, PipelineStep, " + "INITIAL_STEP_ID`).", + DeprecationWarning, + stacklevel=2, +) + +__all__ = ["INITIAL_STEP_ID", "PipelineSpec", "PipelineStep"] diff --git a/picarones/pipeline/types.py b/picarones/pipeline/types.py new file mode 100644 index 0000000000000000000000000000000000000000..8a541c0eab87789cd801e649b766b9c6a81e1e3f --- /dev/null +++ b/picarones/pipeline/types.py @@ -0,0 +1,143 @@ +"""``RunContext``, ``StepResult``, ``PipelineResult`` — Sprint A14-S6. + +Types runtime du pipeline executor (à implémenter au Sprint S7). +Distincts des specs déclaratives (``picarones.pipeline.spec``) — +ces types portent les **résultats** de l'exécution, pas la +description du DAG. + +Aucune logique métier ici : juste des dataclasses pydantic qu'un +service applicatif peut sérialiser dans le manifest d'un run. +""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + +from picarones.domain.artifacts import Artifact + + +class RunContext(BaseModel): + """Contexte d'exécution passé à chaque ``StepExecutor.execute()``. + + Le caller (typiquement ``app/services/benchmark_service`` au + S19) construit un ``RunContext`` par document et le passe à + l'executor pour chaque étape. + + Attributs + --------- + document_id: + ``DocumentRef.id`` du document en cours de traitement. + code_version: + Version du code (``picarones.__version__``) au moment du + run. Sert à étiqueter la ``ProvenanceRecord`` de chaque + artefact produit. + pipeline_name: + Nom de la pipeline en cours. Permet à un adapter de + loguer ``[pipeline_x] step_y : ...`` plutôt que + ``[unknown] ...``. + workspace_uri: + URI/chemin du workspace dans lequel l'adapter peut écrire + ses artefacts intermédiaires. ``None`` autorisé pour les + adapters qui n'écrivent rien sur disque (mode in-memory). + + Anti-sur-ingénierie : pas de logger injecté, pas d'horloge + abstraite, pas de cancellation token. Ces extras viendront + quand un caller en aura concrètement besoin (probablement S7 + pour la cancellation, S8 pour le timeout réel). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + document_id: str = Field(min_length=1, max_length=256) + code_version: str = Field(min_length=1, max_length=128) + pipeline_name: str = Field(min_length=1, max_length=128) + workspace_uri: str | None = Field(default=None, max_length=2048) + + +class StepResult(BaseModel): + """Résultat de l'exécution d'une étape sur un document. + + Sérialisable JSON pour persistance dans le manifest du run. + + Attributs + --------- + step_id: + Identifiant de l'étape (cf. ``PipelineStep.id``). + succeeded: + ``True`` si l'étape s'est exécutée sans lever d'exception + et a produit tous les types déclarés dans + ``output_types``. ``False`` sinon. + duration_seconds: + Wall-clock time de ``execute()`` (du début effectif à la + fin). L'executor du S8 garantira que ce temps est mesuré + depuis le démarrage réel (pas depuis la submission au pool). + produced_artifacts: + Map ``{ArtifactType: artifact_id}`` des artefacts produits. + Vide en cas d'échec. + error: + ``None`` en cas de succès ; sinon message d'erreur. Format + libre (le caller décide de la structure dans son rapport). + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + step_id: str = Field(min_length=1, max_length=128) + succeeded: bool + duration_seconds: float = Field(ge=0.0) + produced_artifacts: dict[str, str] = Field(default_factory=dict) + """Map ``{ArtifactType.value: Artifact.id}``. + + Sérialisée avec la valeur string de l'enum (``"raw_text"``, + ``"alto_xml"``) pour faciliter la lecture humaine du JSON. + """ + error: str | None = None + + +class PipelineResult(BaseModel): + """Résultat complet d'une exécution de pipeline sur un document. + + Attributs + --------- + pipeline_name: + Nom de la pipeline qui a produit ce résultat. + document_id: + Document traité. + step_results: + Résultats de chaque étape, dans l'ordre d'exécution. + succeeded: + ``True`` ssi tous les ``step_results`` sont des succès. + Si ``False``, un ou plusieurs ``StepResult.error`` sont + non-None. + duration_seconds: + Wall-clock total (somme des étapes + overhead orchestration). + artifacts: + Liste **plate** de tous les artefacts produits par la + pipeline. Permet à un consommateur (rapport, vue + d'évaluation) d'accéder directement à un artefact par son + id sans parcourir l'arborescence des étapes. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + pipeline_name: str + document_id: str + step_results: tuple[StepResult, ...] = Field(default_factory=tuple) + succeeded: bool = False + duration_seconds: float = Field(default=0.0, ge=0.0) + artifacts: tuple[Artifact, ...] = Field(default_factory=tuple) + + def step_result_by_id(self, step_id: str) -> StepResult | None: + for r in self.step_results: + if r.step_id == step_id: + return r + return None + + def artifacts_of_type(self, artifact_type: Any) -> tuple[Artifact, ...]: + """Retourne tous les artefacts du type donné dans l'ordre + de production.""" + return tuple(a for a in self.artifacts if a.type == artifact_type) + + +__all__ = ["RunContext", "StepResult", "PipelineResult"] diff --git a/picarones/pipeline/validation.py b/picarones/pipeline/validation.py new file mode 100644 index 0000000000000000000000000000000000000000..1c040c7239e0133728e2f8406c9863456679ff46 --- /dev/null +++ b/picarones/pipeline/validation.py @@ -0,0 +1,218 @@ +"""``validate_spec`` — Sprint A14-S6. + +Validation statique d'une ``PipelineSpec`` : vérifier que les +types s'enchaînent, qu'il n'y a pas d'IDs dupliqués, que les +références ``inputs_from`` pointent bien vers des étapes +antérieures qui produisent le bon type, et (optionnellement) que +les ``adapter_name`` existent dans un registre fourni. + +S'exécute **sans instancier aucun adapter** — c'est le bénéfice +clé de la séparation déclaratif/runtime du S6. + +API : + + >>> errors = validate_spec(spec) + >>> if errors: + ... for e in errors: + ... print(f"{e.step_id}: {e.message}") + +Le caller décide de la suite — typiquement un service applicatif +refuse de démarrer un run si la spec a des erreurs. + +Anti-sur-ingénierie +------------------- +Pas de détection de cycles graphes complexe (le DAG est exprimé +par ordre des steps, donc impossible de référencer une étape +postérieure : si tu as une boucle, c'est qu'une référence pointe +vers un nom inconnu, déjà détecté). + +Pas de validation des params (chaque adapter validera les siens +au moment de l'exécution — le format libre des params est un +choix assumé). +""" + +from __future__ import annotations + +from pydantic import BaseModel, ConfigDict + +from picarones.domain.artifacts import ArtifactType +from picarones.domain.pipeline_spec import INITIAL_STEP_ID, PipelineSpec, PipelineStep + + +class ValidationError(BaseModel): + """Une erreur de validation d'une ``PipelineSpec``. + + Format structuré pour faciliter le rendu (CLI, rapport, JSON). + Volontairement plat — pas de hiérarchie d'erreurs ; on ajoute + un ``code`` discriminant si un caller en a besoin. + """ + + model_config = ConfigDict(frozen=True, extra="forbid") + + step_id: str | None + """Step concerné, ou ``None`` pour les erreurs globales (DAG vide, + ID dupliqué détecté entre deux steps...).""" + + code: str + """Identifiant court (``"duplicate_id"``, ``"unknown_adapter"``, + ``"missing_input"``, ``"unknown_input_source"``, ...). Permet + à un test d'asserter sur le code plutôt que sur le message + français. + """ + + message: str + """Description humainement lisible (français).""" + + +def validate_spec( + spec: PipelineSpec, + available_adapters: set[str] | None = None, +) -> list[ValidationError]: + """Vérifie une ``PipelineSpec`` et retourne la liste des erreurs. + + Parameters + ---------- + spec: + La spec à valider. + available_adapters: + Set des noms d'adapters connus. Si fourni, chaque + ``adapter_name`` du DAG est vérifié. Si ``None`` (défaut), + cette validation est sautée — utile pour les tests qui + valident la cohérence d'un YAML sans avoir le runtime + chargé. + + Returns + ------- + list[ValidationError] + Liste vide si la spec est valide ; sinon un ou plusieurs + problèmes (ne s'arrête pas à la première erreur — le + caller veut tout voir d'un coup). + """ + errors: list[ValidationError] = [] + + # -- 0. Steps absents + if not spec.steps: + errors.append(ValidationError( + step_id=None, + code="empty_pipeline", + message="pipeline vide : au moins une étape est requise", + )) + return errors # impossible de continuer + + # -- 1. IDs dupliqués + seen_ids: dict[str, int] = {} + for i, step in enumerate(spec.steps): + if step.id in seen_ids: + errors.append(ValidationError( + step_id=step.id, + code="duplicate_id", + message=( + f"id dupliqué : '{step.id}' apparaît à l'étape {i} " + f"et précédemment à {seen_ids[step.id]}" + ), + )) + else: + seen_ids[step.id] = i + + # -- 2. Adapter inconnu (si registre fourni) + if available_adapters is not None: + for step in spec.steps: + if step.adapter_name not in available_adapters: + errors.append(ValidationError( + step_id=step.id, + code="unknown_adapter", + message=( + f"adapter '{step.adapter_name}' non disponible. " + f"Adapters connus : {sorted(available_adapters)}" + ), + )) + + # -- 3. Cohérence des types et des références inputs_from + # On simule un parcours topologique en ordre de spec.steps. + # À chaque étape : + # a) Tout type de input_types doit être disponible (soit + # initial, soit produit par une étape antérieure). + # b) Si inputs_from[type] = "src", "src" doit être une étape + # antérieure connue (ou "__initial__") qui produit ce type. + + # Map { step_id (ou "__initial__") -> set(types qu'elle produit) }. + step_outputs: dict[str, set[ArtifactType]] = { + INITIAL_STEP_ID: set(spec.initial_inputs), + } + # Set des types disponibles à un instant t (latest seulement). + available: set[ArtifactType] = set(spec.initial_inputs) + + for step in spec.steps: + errors.extend(_validate_step_against_state( + step=step, + step_outputs=step_outputs, + available=available, + )) + # Mise à jour de l'état pour les étapes suivantes. + step_outputs[step.id] = set(step.output_types) + available.update(step.output_types) + + return errors + + +def _validate_step_against_state( + *, + step: PipelineStep, + step_outputs: dict[str, set[ArtifactType]], + available: set[ArtifactType], +) -> list[ValidationError]: + """Valide une étape donnée contre l'état des types + disponibles et des outputs des étapes antérieures.""" + errors: list[ValidationError] = [] + + # 3.a — entrées disponibles + missing = [t for t in step.input_types if t not in available] + if missing: + errors.append(ValidationError( + step_id=step.id, + code="missing_input", + message=( + f"types d'entrée non disponibles : " + f"{[t.value for t in missing]}. " + f"Disponibles : {sorted(t.value for t in available)}" + ), + )) + + # 3.b — références inputs_from + for ref_type, ref_step in step.inputs_from.items(): + if ref_type not in step.input_types: + errors.append(ValidationError( + step_id=step.id, + code="inputs_from_unused", + message=( + f"inputs_from[{ref_type.value}]={ref_step!r} " + "mais l'étape ne consomme pas ce type " + f"(input_types = {[t.value for t in step.input_types]})" + ), + )) + continue + if ref_step not in step_outputs: + errors.append(ValidationError( + step_id=step.id, + code="unknown_input_source", + message=( + f"inputs_from[{ref_type.value}]={ref_step!r} " + "ne désigne pas une étape antérieure connue " + f"({INITIAL_STEP_ID!r} pour les entrées initiales)" + ), + )) + continue + if ref_type not in step_outputs[ref_step]: + errors.append(ValidationError( + step_id=step.id, + code="source_does_not_produce_type", + message=( + f"inputs_from[{ref_type.value}]={ref_step!r} " + f"mais '{ref_step}' ne produit pas {ref_type.value!r}" + ), + )) + + return errors + + +__all__ = ["validate_spec", "ValidationError"] diff --git a/picarones/pipeline/yaml_io.py b/picarones/pipeline/yaml_io.py new file mode 100644 index 0000000000000000000000000000000000000000..2486af60f7fc845b91c4e5af3e7c2daea17527e9 --- /dev/null +++ b/picarones/pipeline/yaml_io.py @@ -0,0 +1,59 @@ +"""Sérialisation YAML des ``PipelineSpec`` — Sprint A14-S6. + +Helpers de chargement / écriture YAML. Volontairement minces — +``pydantic.model_dump()`` produit déjà un dict imbriqué +sérialisable, et ``yaml.safe_dump`` / ``yaml.safe_load`` sont +suffisants pour le contrat round-trip. + +Pourquoi un module dédié plutôt qu'une méthode de classe ? +---------------------------------------------------------- +Le ``domain/`` ne doit pas dépendre de PyYAML — c'est une lib +externe que la couche layer permet seulement à ``formats/``, +``app/`` et adjacents. ``pipeline/`` peut importer pyyaml +(autorisé par les règles du S3), donc le helper vit ici. + +API : + + >>> from picarones.pipeline import dump_spec_to_yaml, load_spec_from_yaml + >>> text = dump_spec_to_yaml(spec) + >>> spec2 = load_spec_from_yaml(text) + >>> spec == spec2 + True +""" + +from __future__ import annotations + +import yaml + +from picarones.domain.pipeline_spec import PipelineSpec + + +def dump_spec_to_yaml(spec: PipelineSpec) -> str: + """Sérialise une ``PipelineSpec`` en YAML déterministe. + + Le YAML produit est compatible avec ``load_spec_from_yaml`` + et conserve l'ordre des champs et des étapes. + """ + payload = spec.model_dump(mode="json") + return yaml.safe_dump( + payload, + sort_keys=False, # conserve l'ordre des champs + allow_unicode=True, # préserve accents et caractères spéciaux + default_flow_style=False, # style "block" lisible + ) + + +def load_spec_from_yaml(text: str) -> PipelineSpec: + """Parse une chaîne YAML et retourne une ``PipelineSpec`` validée. + + Lève ``pydantic.ValidationError`` si le YAML ne respecte pas + le schéma, ou ``yaml.YAMLError`` si le YAML est mal formé. + """ + payload = yaml.safe_load(text) + if payload is None: + from picarones.domain.errors import PicaronesError + raise PicaronesError("YAML vide — pas de PipelineSpec à charger") + return PipelineSpec.model_validate(payload) + + +__all__ = ["dump_spec_to_yaml", "load_spec_from_yaml"] diff --git a/picarones/reports_v2/__init__.py b/picarones/reports_v2/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..b308c8bd3623f83755ce96ebeef4fa3d7a22f0be --- /dev/null +++ b/picarones/reports_v2/__init__.py @@ -0,0 +1,26 @@ +"""Cercle 3 — Reports. + +Sortie en différents formats à partir d'un ``RunResult`` persisté. +Le rapport est une **vue** des artefacts et des résultats +d'évaluation, jamais une source de vérité. + +Sous-packages : + +- ``html/`` — rapport HTML interactif (cible Sprint S22). + Consomme ``RunManifest`` + ``view_results.jsonl`` plutôt que + l'ancien ``BenchmarkResult`` fourre-tout. +- ``json/`` — export JSON canonique pour intégration externe. +- ``csv/`` — exports tabulaires par vue d'évaluation. + +Règles : un rapport ne doit jamais **recalculer** un score. Tout +ce qu'il affiche provient des fichiers persistés par le run. + +Note de migration : ce package s'appelle ``reports_v2`` pendant le +rewrite pour cohabiter avec l'existant ``picarones.report`` (qui +sera supprimé au S22). Renommé en ``reports`` à la fin du +rewrite. +""" + +from __future__ import annotations + +__all__: list[str] = [] diff --git a/picarones/reports_v2/csv/__init__.py b/picarones/reports_v2/csv/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d61497eeafd1194e8ccc2792480cab60166ce247 --- /dev/null +++ b/picarones/reports_v2/csv/__init__.py @@ -0,0 +1,16 @@ +"""Rendu CSV des résultats de benchmark — Sprint A14-S42. + +API publique : + +- ``CsvReportRenderer.render(run_result) -> str`` : produit un CSV + prêt à écrire sur disque. + +Format : une ligne par (document × pipeline × view × metric). +``OMITTED`` est explicite — pas de score factice 0. +""" + +from __future__ import annotations + +from picarones.reports_v2.csv.render import CsvReportRenderer + +__all__ = ["CsvReportRenderer"] diff --git a/picarones/reports_v2/csv/render.py b/picarones/reports_v2/csv/render.py new file mode 100644 index 0000000000000000000000000000000000000000..613e9c0bc9f6c1e76cbb802b9f8653b00a9239ec --- /dev/null +++ b/picarones/reports_v2/csv/render.py @@ -0,0 +1,115 @@ +"""``CsvReportRenderer`` — Sprint A14-S42. + +Rendu CSV d'un ``RunResult`` : une ligne par paire +(document × pipeline × view × metric) avec sa valeur numérique ou +le marqueur ``OMITTED`` (pas de score factice). + +Cohérent avec la convention du rewrite : pour les pipelines qui ne +produisent pas un type d'artefact accepté par une vue, on émet +``OMITTED`` dans la cellule ``value`` plutôt que ``0`` ou ``""``. +Le consommateur (Pandas, Excel, awk, ...) sait que l'omission est +l'information. + +Usage +----- + +:: + + from picarones.reports_v2.csv import CsvReportRenderer + csv_text = CsvReportRenderer().render(run_result) + Path("rapport.csv").write_text(csv_text, encoding="utf-8") + +Format +------ +Colonnes (dans l'ordre) : + +:: + + run_id, document_id, pipeline_name, view_name, + metric_name, value, status + +- ``run_id`` : ``RunManifest.run_id``. +- ``status`` : ``"ok"``, ``"failed_metric"`` (la métrique a levé), + ``"omitted"`` (le pipeline ne produit pas d'artefact pour la vue). +- ``value`` : valeur numérique formatée à 6 décimales, ou vide si + ``status != "ok"``. + +Anti-sur-ingénierie +------------------- +- Pas de pivot par moteur — chaque ligne est self-contained. Le + consommateur pivote en 2 lignes Pandas si besoin. +- Pas d'escape custom — on utilise ``csv.writer`` qui gère les + virgules et guillemets dans les values. +- Pas de séparateur configurable (``,`` fixe) — un test garde-fou + vérifie le déterminisme du contenu. +""" + +from __future__ import annotations + +import csv +import io +from typing import Any + +from picarones.app.results import RunResult + + +class CsvReportRenderer: + """Rendu CSV stateless d'un RunResult.""" + + HEADER: tuple[str, ...] = ( + "run_id", + "document_id", + "pipeline_name", + "view_name", + "metric_name", + "value", + "status", + ) + + def render(self, result: RunResult) -> str: + """Retourne le contenu CSV (stringly typed) prêt à écrire.""" + buf = io.StringIO() + writer = csv.writer(buf) + writer.writerow(self.HEADER) + + run_id = result.manifest.run_id + + for doc_result in result.document_results: + for view_result in doc_result.view_results: + pipeline_name = view_result.pipeline_name + for metric_name, value in view_result.metric_values.items(): + writer.writerow([ + run_id, + doc_result.document_id, + pipeline_name, + view_result.view_name, + metric_name, + self._format_value(value), + "ok", + ]) + for metric_name, _err in view_result.failed_metrics.items(): + writer.writerow([ + run_id, + doc_result.document_id, + pipeline_name, + view_result.view_name, + metric_name, + "", + "failed_metric", + ]) + + return buf.getvalue() + + @staticmethod + def _format_value(value: Any) -> str: + """Formate la valeur numérique à 6 décimales pour + déterminisme cross-OS (évite ``1.0000000000000002`` sur + certains floats).""" + if isinstance(value, bool): + return "1" if value else "0" + if isinstance(value, (int, float)): + return f"{float(value):.6f}" + return str(value) + + +__all__ = ["CsvReportRenderer"] diff --git a/picarones/reports_v2/html/__init__.py b/picarones/reports_v2/html/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..32cf1d15f16c9d9a418a003fee5fe550b0f43dd1 --- /dev/null +++ b/picarones/reports_v2/html/__init__.py @@ -0,0 +1,26 @@ +"""Rendu HTML du rewrite ciblé. + +API publique : + +- :class:`HtmlReportRenderer` — produit un fichier HTML autonome + depuis un ``RunResult`` (ou les 3 fichiers persistés par + ``BenchmarkService.persist``). + +Usage +----- + +:: + + from pathlib import Path + from picarones.reports_v2.html import HtmlReportRenderer + + renderer = HtmlReportRenderer(lang="fr") + html = renderer.render(run_result) + Path("rapport.html").write_text(html, encoding="utf-8") +""" + +from __future__ import annotations + +from picarones.reports_v2.html.render import HtmlReportRenderer + +__all__ = ["HtmlReportRenderer"] diff --git a/picarones/reports_v2/html/render.py b/picarones/reports_v2/html/render.py new file mode 100644 index 0000000000000000000000000000000000000000..43f999368f6dd85740325baa58b325f027237081 --- /dev/null +++ b/picarones/reports_v2/html/render.py @@ -0,0 +1,644 @@ +"""``HtmlReportRenderer`` — produit un rapport HTML depuis un ``RunResult``. + +Cible documentée du rewrite : la génération HTML vit dans la couche +``reports_v2/html/`` (cf. ``picarones/reports_v2/__init__.py``). +Un rapport est un **format de sortie** consommant un ``RunResult`` +persisté — pas un service métier. ``app/services/`` orchestre la +génération via ``RunOrchestrator``, mais le rendu lui-même est ici. + +Premier rapport HTML du nouveau monde. Volontairement minimal : ce +service répond à *« je veux ouvrir un fichier ``.html`` et voir mon +benchmark »*, pas à *« je veux les 22 vues legacy avec Chart.js, CDD, +narrative engine, glossaire, mode avancé »* — ces vues vivent toujours +dans ``picarones.report.*`` (legacy) et seront ré-intégrées au cas par +cas dans une phase ultérieure du rewrite. + +Caractéristiques +---------------- +- Rendu **server-side, HTML autonome** : pas de JS, pas de CSS + externe (les styles sont inlinés). Un fichier qui s'ouvre + partout, conservable en archive. +- **Pattern d'omission visible** : pour chaque vue × pipeline, si le + pipeline ne produit pas d'artefact éligible, la cellule affiche + ``OMIS`` au lieu d'un score factice ``0`` qui mentirait. +- **Anti-injection** : tout texte d'origine utilisateur ou métier + (``corpus_name``, ``run_id``, ``pipeline_name``, ``view.name``, + ``view.description``, etc.) passe par :func:`html.escape`. +- **Bilingue light** : ``lang="fr"`` ou ``lang="en"`` via paramètre + constructeur — labels traduits, valeurs intactes. + +Anti-sur-ingénierie +------------------- +- Pas de coloration par gradient. Les valeurs sont affichées en + toutes lettres ; le caller qui veut un rendu visuel sophistiqué + utilise le legacy. +- Pas d'arrow ↑/↓ par métrique : ``EvaluationView`` ne porte pas + cette info (elle vit dans ``MetricSpec``, qui n'est pas dans le + ``RunResult``). À ajouter quand un caller a vraiment besoin. +- Pas de tri automatique des pipelines par classement : on respecte + l'ordre du manifest (déterminisme byte-à-byte sur deux runs + identiques). +- Pas de rendu Markdown ou Jinja2. Construction str pure + (``f"…"``) — facile à debugger, byte-déterministe. +""" + +from __future__ import annotations + +import html +import json +from dataclasses import dataclass +from pathlib import Path +from typing import Iterable + +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.run_manifest import RunManifest +from picarones.app.results import RunDocumentResult, RunResult +from picarones.evaluation.views.base import ViewResult +from picarones.pipeline.types import PipelineResult + + +#: Marqueur affiché quand un pipeline est OMIS d'une vue. +_OMITTED_MARKER = "OMIS" + + +# ────────────────────────────────────────────────────────────────────── +# Labels bilingues (FR + EN) +# ────────────────────────────────────────────────────────────────────── + + +_LABELS: dict[str, dict[str, str]] = { + "fr": { + "title": "Rapport Picarones", + "corpus": "Corpus", + "run_id": "Identifiant du run", + "code_version": "Version du code", + "started_at": "Démarré", + "completed_at": "Terminé", + "duration_seconds": "Durée (secondes)", + "n_documents": "Nombre de documents", + "pipelines_overview": "Pipelines exécutées", + "pipeline": "Pipeline", + "n_succeeded": "Succès", + "n_failed": "Échecs", + "duration_total": "Durée totale (s)", + "view": "Vue", + "description": "Description", + "warnings": "Avertissements", + "ignored_dimensions": "Dimensions explicitement non évaluées", + "results_per_pipeline": "Résultats par pipeline (moyenne)", + "n_observations": "n", + "omitted_explanation": ( + "Pipeline ne produisant pas d'artefact éligible à cette vue. " + "Pas de score factice — l'omission est l'information." + ), + "footer": "Généré par Picarones (rewrite ciblé S21)", + "no_data_for_view": ( + "Aucun pipeline n'a produit d'artefact éligible à cette vue." + ), + }, + "en": { + "title": "Picarones Report", + "corpus": "Corpus", + "run_id": "Run identifier", + "code_version": "Code version", + "started_at": "Started", + "completed_at": "Completed", + "duration_seconds": "Duration (seconds)", + "n_documents": "Document count", + "pipelines_overview": "Pipelines executed", + "pipeline": "Pipeline", + "n_succeeded": "Succeeded", + "n_failed": "Failed", + "duration_total": "Total duration (s)", + "view": "View", + "description": "Description", + "warnings": "Warnings", + "ignored_dimensions": "Dimensions explicitly not evaluated", + "results_per_pipeline": "Per-pipeline results (mean)", + "n_observations": "n", + "omitted_explanation": ( + "Pipeline did not produce any artifact eligible for this view. " + "No fake score — omission is the information." + ), + "footer": "Generated by Picarones (targeted rewrite S21)", + "no_data_for_view": ( + "No pipeline produced an artifact eligible for this view." + ), + }, +} + + +# ────────────────────────────────────────────────────────────────────── +# CSS minimal inliné +# ────────────────────────────────────────────────────────────────────── + + +_INLINE_CSS = """ +body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', + Helvetica, Arial, sans-serif; margin: 2em; line-height: 1.4; + color: #222; } +header { border-bottom: 2px solid #444; padding-bottom: 0.8em; + margin-bottom: 1.5em; } +h1 { margin: 0 0 0.4em 0; } +h2 { margin-top: 2em; padding-top: 0.6em; border-top: 1px solid #ccc; } +h3 { margin-top: 1.4em; } +table { border-collapse: collapse; margin: 0.8em 0; min-width: 60%; } +th, td { border: 1px solid #ccc; padding: 0.4em 0.8em; text-align: left; + vertical-align: top; } +th { background: #f4f4f4; font-weight: 600; } +td.numeric { text-align: right; font-variant-numeric: tabular-nums; } +td.omitted { color: #888; font-style: italic; background: #fafafa; + text-align: center; } +ul.warnings { background: #fff8e1; border-left: 4px solid #f9a825; + padding: 0.6em 1em; margin: 0.8em 0; } +.description { color: #555; font-style: italic; margin: 0.3em 0 1em 0; } +.ignored { color: #777; font-size: 0.9em; margin-top: 0.6em; } +code { background: #f4f4f4; padding: 0.1em 0.3em; border-radius: 3px; } +footer { margin-top: 3em; padding-top: 0.8em; border-top: 1px solid #ccc; + color: #888; font-size: 0.85em; } +.empty-view { color: #888; font-style: italic; padding: 0.8em; + border: 1px dashed #ccc; } +""".strip() + + +# ────────────────────────────────────────────────────────────────────── +# Service +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _Aggregate: + """Moyenne d'une métrique pour un pipeline donné dans une vue.""" + + mean: float + n: int + + +class HtmlReportRenderer: + """Génère un rapport HTML à partir d'un ``RunResult``. + + Parameters + ---------- + lang: + Langue des labels. ``"fr"`` (défaut) ou ``"en"``. Une langue + non supportée fait fallback sur ``"fr"`` avec un caractère + diacritique préservé pour signaler qu'un fallback a eu lieu. + """ + + def __init__(self, *, lang: str = "fr") -> None: + if lang not in _LABELS: + lang = "fr" + self._lang = lang + self._labels = _LABELS[lang] + + # ────────────────────────────────────────────────────────────────── + # API publique + # ────────────────────────────────────────────────────────────────── + + def render(self, result: RunResult) -> str: + """Produit le HTML d'un ``RunResult`` (chargé en mémoire).""" + manifest = result.manifest + artifact_to_pipeline = _build_artifact_to_pipeline_map( + result.document_results, + ) + pipeline_summaries = _summarize_pipelines(result.document_results) + + sections = [ + self._render_head(manifest), + self._render_header_block(manifest), + self._render_pipelines_overview( + manifest.pipeline_names, pipeline_summaries, + ), + ] + for view in manifest.view_specs: + view_results = result.view_results_for(view.name) + sections.append( + self._render_view( + view=view, + view_results=view_results, + pipeline_names=manifest.pipeline_names, + artifact_to_pipeline=artifact_to_pipeline, + ), + ) + sections.append(self._render_footer(manifest)) + return "\n".join(sections) + "\n" + + def render_from_dir(self, run_dir: Path | str) -> str: + """Lit les 3 fichiers persistés et produit le HTML. + + Pendant inverse de ``BenchmarkService.persist`` : permet de + re-générer un rapport sans avoir le ``RunResult`` en mémoire + (cas de la CLI ``picarones report ``). + """ + result = self.load_run_result(run_dir) + return self.render(result) + + # ────────────────────────────────────────────────────────────────── + # Loader (statique, utilisable hors instance) + # ────────────────────────────────────────────────────────────────── + + @staticmethod + def load_run_result(run_dir: Path | str) -> RunResult: + """Reconstruit un ``RunResult`` depuis les 4 fichiers persistés + par ``BenchmarkService.persist`` (S41). + + Raises + ------ + FileNotFoundError + Si l'un des fichiers obligatoires (manifest, + pipeline_results, view_results) est manquant. + ``artifacts_index.jsonl`` est optionnel pour rester + compatible avec d'anciens runs persistés avant S41. + """ + d = Path(run_dir) + manifest_path = d / "run_manifest.json" + pipelines_path = d / "pipeline_results.jsonl" + artifacts_index_path = d / "artifacts_index.jsonl" + views_path = d / "view_results.jsonl" + if not manifest_path.exists(): + raise FileNotFoundError( + f"run_manifest.json absent du dossier : {d!r}", + ) + if not pipelines_path.exists(): + raise FileNotFoundError( + f"pipeline_results.jsonl absent du dossier : {d!r}", + ) + if not views_path.exists(): + raise FileNotFoundError( + f"view_results.jsonl absent du dossier : {d!r}", + ) + manifest = RunManifest.model_validate_json( + manifest_path.read_text(encoding="utf-8"), + ) + + # S41 — l'index d'artefacts est désormais séparé des + # pipeline_results.jsonl. On le lit AVANT pour pouvoir + # ré-attacher les artefacts à chaque pipeline_result lors de + # la reconstruction. + artifacts_by_pipeline: dict[ + tuple[str, str], list[dict], + ] = {} + if artifacts_index_path.exists(): + with artifacts_index_path.open("r", encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + rec = json.loads(line) + # `pipeline_name` est uniquement un champ d'index + # (groupement) — on le retire avant de re-valider + # un Artifact (qui a `extra="forbid"`). En revanche + # `document_id` fait partie de l'Artifact lui-même + # et doit être préservé pour la validation pydantic. + pipe_name = rec.pop("pipeline_name") + doc_id = rec["document_id"] + artifacts_by_pipeline.setdefault( + (doc_id, pipe_name), [], + ).append(rec) + + # Reconstruire les pipeline_results et view_results par doc. + pipeline_results_by_doc: dict[str, list[PipelineResult]] = {} + with pipelines_path.open("r", encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + payload = json.loads(line) + doc_id = payload["document_id"] + # Ré-attache les artefacts depuis l'index S41 si présent. + key = (doc_id, payload.get("pipeline_name", "")) + if key in artifacts_by_pipeline and "artifacts" not in payload: + payload["artifacts"] = artifacts_by_pipeline[key] + pipeline_results_by_doc.setdefault(doc_id, []).append( + PipelineResult.model_validate(payload), + ) + + view_results_by_doc: dict[str, list[ViewResult]] = {} + with views_path.open("r", encoding="utf-8") as f: + for line in f: + if not line.strip(): + continue + payload = json.loads(line) + doc_id = payload.pop("document_id") + view_results_by_doc.setdefault(doc_id, []).append( + ViewResult.model_validate(payload), + ) + + all_doc_ids = sorted( + set(pipeline_results_by_doc) | set(view_results_by_doc), + ) + document_results = tuple( + RunDocumentResult( + document_id=doc_id, + pipeline_results=tuple( + pipeline_results_by_doc.get(doc_id, []), + ), + view_results=tuple(view_results_by_doc.get(doc_id, [])), + ) + for doc_id in all_doc_ids + ) + return RunResult(manifest=manifest, document_results=document_results) + + # ────────────────────────────────────────────────────────────────── + # Helpers de rendu + # ────────────────────────────────────────────────────────────────── + + def _render_head(self, manifest: RunManifest) -> str: + title = html.escape( + f"{self._labels['title']} — {manifest.corpus_name}", + ) + return ( + f'\n' + f'\n' + f'\n' + f'\n' + f'{title}\n' + f'\n' + f'\n' + f'' + ) + + def _render_header_block(self, manifest: RunManifest) -> str: + L = self._labels + return ( + f'
\n' + f'

{html.escape(L["title"])}

\n' + f'

{html.escape(L["corpus"])} : ' + f'{html.escape(manifest.corpus_name)}

\n' + f'

{html.escape(L["run_id"])} : ' + f'{html.escape(manifest.run_id)}

\n' + f'

{html.escape(L["code_version"])} : ' + f'{html.escape(manifest.code_version)}

\n' + f'

{html.escape(L["started_at"])} : ' + f'{html.escape(manifest.started_at.isoformat())} • ' + f'{html.escape(L["completed_at"])} : ' + f'{html.escape(manifest.completed_at.isoformat())} • ' + f'{html.escape(L["duration_seconds"])} : ' + f'{manifest.duration_seconds:.3f}

\n' + f'

{html.escape(L["n_documents"])} : ' + f'{manifest.n_documents}

\n' + f'
' + ) + + def _render_pipelines_overview( + self, + pipeline_names: tuple[str, ...], + summaries: dict[str, "_PipelineSummary"], + ) -> str: + L = self._labels + rows = [] + for name in pipeline_names: + s = summaries.get(name) + if s is None: + # Pipeline du manifest sans aucun résultat (cas dégénéré). + rows.append( + f'{html.escape(name)}' + f'0' + f'0' + f'—', + ) + continue + rows.append( + f'' + f'{html.escape(name)}' + f'{s.n_succeeded}' + f'{s.n_failed}' + f'{s.duration_total:.3f}' + f'', + ) + rows_html = "\n".join(rows) if rows else ( + '—' + ) + return ( + f'
\n' + f'

{html.escape(L["pipelines_overview"])}

\n' + f'\n' + f'' + f'' + f'' + f'' + f'' + f'\n' + f'\n{rows_html}\n\n' + f'
{html.escape(L["pipeline"])}{html.escape(L["n_succeeded"])}{html.escape(L["n_failed"])}{html.escape(L["duration_total"])}
\n' + f'
' + ) + + def _render_view( + self, + *, + view: EvaluationView, + view_results: tuple[ViewResult, ...], + pipeline_names: tuple[str, ...], + artifact_to_pipeline: dict[str, str], + ) -> str: + L = self._labels + view_id = html.escape(view.name) + per_pipeline = _aggregate_view_by_pipeline( + view_results=view_results, + artifact_to_pipeline=artifact_to_pipeline, + metric_names=view.metric_names, + ) + + warnings_html = "" + if view.warnings: + items = "\n".join( + f'
  • {html.escape(w)}
  • ' for w in view.warnings + ) + warnings_html = ( + f'
      \n{items}\n
    ' + ) + + # En-tête : Pipeline | metric_a | metric_b | ... | n + header_cells = [ + f'{html.escape(L["pipeline"])}', + ] + for m in view.metric_names: + header_cells.append(f'{html.escape(m)}') + header_cells.append( + f'{html.escape(L["n_observations"])}', + ) + + # Lignes : un par pipeline du manifest. + body_rows: list[str] = [] + any_data = bool(per_pipeline) + for pipeline_name in pipeline_names: + cells = [f'{html.escape(pipeline_name)}'] + agg = per_pipeline.get(pipeline_name) + if agg is None: + # OMIS — rendu fusionné sur toutes les colonnes métriques + n. + cells.append( + f'' + f'{_OMITTED_MARKER}' + f'', + ) + else: + # Une cellule par métrique + colonne n. + # n = max(n_observations) parmi les métriques calculées + # (typiquement identique pour toutes les métriques d'une + # même vue). + for m in view.metric_names: + metric_agg = agg.get(m) + if metric_agg is None: + cells.append('—') + else: + cells.append( + f'{metric_agg.mean:.4f}', + ) + ns = [a.n for a in agg.values() if a is not None] + n = max(ns) if ns else 0 + cells.append(f'{n}') + body_rows.append(f'{"".join(cells)}') + + if any_data: + table_html = ( + f'

    {html.escape(L["results_per_pipeline"])}

    \n' + f'\n' + f'{"".join(header_cells)}\n' + f'\n' + "\n".join(body_rows) + '\n\n' + '
    ' + ) + else: + table_html = ( + f'

    ' + f'{html.escape(L["no_data_for_view"])}

    ' + ) + + ignored_html = "" + if view.ignored_dimensions: + ignored_html = ( + f'

    ' + f'{html.escape(L["ignored_dimensions"])} : ' + f'{html.escape(", ".join(view.ignored_dimensions))}' + f'

    ' + ) + + return ( + f'
    \n' + f'

    {html.escape(L["view"])} : ' + f'{html.escape(view.name)}

    \n' + f'

    ' + f'{html.escape(view.description or "")}

    \n' + f'{warnings_html}\n' + f'{table_html}\n' + f'{ignored_html}\n' + f'
    ' + ) + + def _render_footer(self, manifest: RunManifest) -> str: + return ( + f'
    \n' + f'

    {html.escape(self._labels["footer"])} • ' + f'{html.escape(manifest.code_version)}

    \n' + f'
    \n' + f'\n' + f'' + ) + + +# ────────────────────────────────────────────────────────────────────── +# Helpers d'agrégation (purs, testables sans rendu) +# ────────────────────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _PipelineSummary: + n_succeeded: int + n_failed: int + duration_total: float + + +def _summarize_pipelines( + document_results: Iterable[RunDocumentResult], +) -> dict[str, _PipelineSummary]: + """Agrège succès/échecs/durée par pipeline_name.""" + n_ok: dict[str, int] = {} + n_fail: dict[str, int] = {} + duration: dict[str, float] = {} + for doc_result in document_results: + for pr in doc_result.pipeline_results: + name = pr.pipeline_name + if pr.succeeded: + n_ok[name] = n_ok.get(name, 0) + 1 + else: + n_fail[name] = n_fail.get(name, 0) + 1 + duration[name] = duration.get(name, 0.0) + pr.duration_seconds + all_names = set(n_ok) | set(n_fail) | set(duration) + return { + name: _PipelineSummary( + n_succeeded=n_ok.get(name, 0), + n_failed=n_fail.get(name, 0), + duration_total=duration.get(name, 0.0), + ) + for name in all_names + } + + +def _build_artifact_to_pipeline_map( + document_results: Iterable[RunDocumentResult], +) -> dict[str, str]: + """Construit ``{artifact_id: pipeline_name}`` à partir des + ``PipelineResult.artifacts`` de chaque doc. + + Permet de retrouver à quelle pipeline appartient un + ``ViewResult.candidate_artifact_id``. + """ + out: dict[str, str] = {} + for doc_result in document_results: + for pr in doc_result.pipeline_results: + for art in pr.artifacts: + out[art.id] = pr.pipeline_name + return out + + +def _aggregate_view_by_pipeline( + *, + view_results: tuple[ViewResult, ...], + artifact_to_pipeline: dict[str, str], + metric_names: tuple[str, ...], +) -> dict[str, dict[str, _Aggregate]]: + """Agrège les ``ViewResult`` en moyenne par (pipeline, métrique). + + Returns + ------- + dict + ``{pipeline_name: {metric_name: _Aggregate(mean, n)}}``. + Pipelines absents = aucun ViewResult ne leur correspond + (omis explicitement de la vue). + """ + sums: dict[str, dict[str, float]] = {} + counts: dict[str, dict[str, int]] = {} + for vr in view_results: + pipeline_name = artifact_to_pipeline.get(vr.candidate_artifact_id) + if pipeline_name is None: + # Artefact orphelin : on l'ignore silencieusement (cas + # bizarre, ne devrait pas arriver depuis BenchmarkService). + continue + for metric_name, value in vr.metric_values.items(): + if metric_name not in metric_names: + continue + if value is None: + continue + try: + fv = float(value) + except (TypeError, ValueError): + continue + sums.setdefault(pipeline_name, {}).setdefault(metric_name, 0.0) + counts.setdefault(pipeline_name, {}).setdefault(metric_name, 0) + sums[pipeline_name][metric_name] += fv + counts[pipeline_name][metric_name] += 1 + out: dict[str, dict[str, _Aggregate]] = {} + for pipeline_name, metric_sums in sums.items(): + out[pipeline_name] = { + m: _Aggregate( + mean=metric_sums[m] / counts[pipeline_name][m], + n=counts[pipeline_name][m], + ) + for m in metric_sums + } + return out + + +__all__ = [ + "HtmlReportRenderer", +] diff --git a/picarones/reports_v2/json/__init__.py b/picarones/reports_v2/json/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3fa1defbb298e17a567164d3808113209e5ec274 --- /dev/null +++ b/picarones/reports_v2/json/__init__.py @@ -0,0 +1,14 @@ +"""Rendu JSON canonique des résultats de benchmark — Sprint A14-S43. + +API publique : + +- ``JsonReportRenderer.render(run_result) -> str`` : document JSON + consolidé, sérialisation déterministe (clés triées, indent=2, + Unicode préservé). +""" + +from __future__ import annotations + +from picarones.reports_v2.json.render import JsonReportRenderer + +__all__ = ["JsonReportRenderer"] diff --git a/picarones/reports_v2/json/render.py b/picarones/reports_v2/json/render.py new file mode 100644 index 0000000000000000000000000000000000000000..cc82ecb5c73bb83d83d5faf408b43176f37999ab --- /dev/null +++ b/picarones/reports_v2/json/render.py @@ -0,0 +1,100 @@ +"""``JsonReportRenderer`` — Sprint A14-S43. + +Rendu JSON canonique d'un ``RunResult`` : représentation hiérarchique +sérialisable, déterministe (clés triées, indent=2, ensure_ascii=False), +prête à être archivée ou consommée par un client tiers. + +Différent des trois fichiers persistés par ``BenchmarkService.persist`` +(``run_manifest.json`` + 3 JSONL) qui sont **streamables** : ce +renderer produit un **document unique** consolidé. + +Usage +----- + +:: + + from picarones.reports_v2.json import JsonReportRenderer + json_text = JsonReportRenderer().render(run_result) + Path("rapport.json").write_text(json_text, encoding="utf-8") + +Structure +--------- + +:: + + { + "run_manifest": { ... }, + "documents": [ + { + "document_id": "d1", + "pipeline_results": [ {...} ], + "view_results": [ {...} ] + }, + ... + ] + } + +Anti-sur-ingénierie +------------------- +- Pas de schéma JSON publié — pydantic ``model_dump_json`` est + l'autorité. La stabilité sera tagguée à la livraison BnF. +- Pas de séparateurs custom — JSON standard. +- Pas de pretty mode configurable — toujours indent=2 pour la + lisibilité humaine ; un caller qui veut compact appelle + ``json.dumps(json.loads(out))``. +""" + +from __future__ import annotations + +import json + +from picarones.app.results import RunResult + + +class JsonReportRenderer: + """Rendu JSON consolidé d'un RunResult.""" + + def render(self, result: RunResult) -> str: + """Retourne un document JSON canonique du run. + + Sérialisation déterministe : ``sort_keys=True``, ``indent=2``, + ``ensure_ascii=False``. Le caller peut écrire directement le + retour via ``Path.write_text(..., encoding="utf-8")``. + """ + document = self._build_document(result) + return json.dumps( + document, + sort_keys=True, + indent=2, + ensure_ascii=False, + ) + + def _build_document(self, result: RunResult) -> dict: + """Construit le dict canonique avant sérialisation. + + ``model_dump(mode="json")`` produit directement un dict + JSON-serializable (datetime → ISO string, enum → value, + etc.). Préférable au round-trip + ``model_dump_json() → loads → dumps`` qui est ~10× plus coûteux + sur des manifests volumineux. + """ + return { + "run_manifest": result.manifest.model_dump(mode="json"), + "documents": [ + { + "document_id": dr.document_id, + "pipeline_results": [ + pr.model_dump(mode="json") + for pr in dr.pipeline_results + ], + "view_results": [ + vr.model_dump(mode="json") + for vr in dr.view_results + ], + } + for dr in result.document_results + ], + } + + +__all__ = ["JsonReportRenderer"] diff --git a/picarones/web/benchmark_utils.py b/picarones/web/benchmark_utils.py index f6c4217a4b6d88f15cd61c917e968ef524c31736..4b05f2925770e9c673423b0c503954c1c41b67a7 100644 --- a/picarones/web/benchmark_utils.py +++ b/picarones/web/benchmark_utils.py @@ -176,9 +176,15 @@ def run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> None if not engines: raise ValueError("Aucun concurrent valide disponible.") + # Sprint A14-S1 — A.I.0 P0 : ``output_dir`` a déjà été validé + # par le router (validated_path). ``report_name`` est sanitizé + # ici pour défense en profondeur (refuse ``../``, séparateurs, + # caractères de contrôle) avant concaténation à output_dir. + from picarones.web.security import safe_report_name output_dir = Path(req.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - report_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + raw_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + report_name = safe_report_name(raw_name) output_json = str(output_dir / f"{report_name}.json") output_html = str(output_dir / f"{report_name}.html") @@ -213,6 +219,7 @@ def run_benchmark_thread_v2(job: BenchmarkJob, req: BenchmarkRunRequest) -> None progress_callback=_progress_callback, char_exclude=char_excl, cancel_event=job._cancel_event, + normalization_profile=req.normalization_profile, ) if job.status == "cancelled": @@ -276,9 +283,15 @@ def run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None: raise ValueError("Aucun moteur valide disponible.") # Répertoire de sortie + # Sprint A14-S1 — A.I.0 P0 : ``output_dir`` a déjà été validé + # par le router (validated_path). ``report_name`` est sanitizé + # ici pour défense en profondeur (refuse ``../``, séparateurs, + # caractères de contrôle) avant concaténation à output_dir. + from picarones.web.security import safe_report_name output_dir = Path(req.output_dir) output_dir.mkdir(parents=True, exist_ok=True) - report_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + raw_name = req.report_name or f"rapport_{datetime.now().strftime('%Y%m%d_%H%M%S')}" + report_name = safe_report_name(raw_name) output_json = str(output_dir / f"{report_name}.json") output_html = str(output_dir / f"{report_name}.html") @@ -314,6 +327,7 @@ def run_benchmark_thread(job: BenchmarkJob, req: BenchmarkRequest) -> None: progress_callback=_progress_callback, char_exclude=char_excl, cancel_event=job._cancel_event, + normalization_profile=req.normalization_profile, ) if job.status == "cancelled": diff --git a/picarones/web/models.py b/picarones/web/models.py index 041972717d65eb54532d571e4e9a52b0a93fa98e..1a079e1e4136331d9d0ee5d774629047520a717e 100644 --- a/picarones/web/models.py +++ b/picarones/web/models.py @@ -57,8 +57,15 @@ NormalizationProfileId = Literal[ "medieval_french", "early_modern_french", "medieval_latin", "early_modern_english", "medieval_english", + "secretary_hand", + "sans_ponctuation", "sans_apostrophes", ] -"""Identifiants des profils de normalisation Unicode disponibles.""" +"""Identifiants des profils de normalisation Unicode disponibles. + +Liste alignée sur ``measurements.normalization.NORMALIZATION_PROFILES`` +(11 profils). Toute addition côté ``normalization.py`` doit être +répercutée ici sous peine de rejet Pydantic au niveau API web. +Sprint A14-S1 — alignement README ↔ web models ↔ runtime.""" class BenchmarkRequest(BaseModel): diff --git a/picarones/web/routers/benchmark.py b/picarones/web/routers/benchmark.py index c226dc2ba5b08a0cce5f3e4bbbaec7208bb50ede..0f800a6fa2f94a4b4bfffb446730949b0ce07929 100644 --- a/picarones/web/routers/benchmark.py +++ b/picarones/web/routers/benchmark.py @@ -11,7 +11,6 @@ from __future__ import annotations import asyncio import threading import uuid -from pathlib import Path from typing import AsyncIterator, Callable, Optional from fastapi import APIRouter, HTTPException, Request @@ -25,10 +24,15 @@ from picarones.web.benchmark_utils import ( ) from picarones.web.models import BenchmarkRequest, BenchmarkRunRequest from picarones.web.security import ( + PathValidationError, assert_engines_allowed, assert_llm_provider_allowed, + compute_workspace_roots, get_max_concurrent_jobs, + validated_path, + validated_prompt_filename, ) +from picarones.web.state import UPLOADS_DIR router = APIRouter() @@ -61,18 +65,35 @@ def _start_job_thread( @router.post("/api/benchmark/start") async def api_benchmark_start(req: BenchmarkRequest, request: Request) -> dict: """Lance un benchmark sur une liste de moteurs OCR (mode legacy).""" - corpus_path = Path(req.corpus_path) - if not corpus_path.exists() or not corpus_path.is_dir(): - raise HTTPException( - status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}", - ) - # Sprint 24 — mode public : refuse les moteurs OCR cloud mutualisés. + # Vérifié AVANT la validation des chemins pour que la réponse + # 403 mode public reste prioritaire (cf. tests sprint24). try: assert_engines_allowed(req.engines) except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) + # Sprint A14-S1 — A.I.0 P0 : validation des chemins utilisateur + # contre les racines workspace autorisées. Bloque les chemins + # absolus arbitraires, la traversée (``..``), les liens symboliques + # vers l'extérieur, etc. + workspace_roots = compute_workspace_roots(UPLOADS_DIR) + try: + validated_path( + req.corpus_path, + allowed_roots=workspace_roots, + must_be_dir=True, + ) + # ``output_dir`` peut ne pas encore exister, on valide juste + # qu'il sera créé dans une racine autorisée. + validated_path( + req.output_dir, + allowed_roots=workspace_roots, + must_exist=False, + ) + except PathValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + # Sprint 24 — rate limit + sémaphore concurrents. state.enforce_rate_limit(request) if not state.JOBS_SEMAPHORE.acquire(blocking=False): @@ -105,15 +126,12 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict: Chaque ``CompetitorConfig`` peut combiner un moteur OCR et un provider LLM (mode post-correction, zero-shot, ou OCR seul). """ - corpus_path = Path(req.corpus_path) - if not corpus_path.exists() or not corpus_path.is_dir(): - raise HTTPException( - status_code=400, detail=f"Corpus non trouvé : {req.corpus_path}", - ) # ``competitors`` non vide est garanti par Pydantic ``min_length=1``. # Mode public : refuse les pipelines LLM mutualisés et les moteurs # OCR cloud sollicités par n'importe quel concurrent. + # Vérifié AVANT la validation des chemins (cf. /api/benchmark/start + # pour le rationale). try: for comp in req.competitors: assert_engines_allowed([comp.ocr_engine] if comp.ocr_engine else []) @@ -121,6 +139,31 @@ async def api_benchmark_run(req: BenchmarkRunRequest, request: Request) -> dict: except PermissionError as exc: raise HTTPException(status_code=403, detail=str(exc)) + # Sprint A14-S1 — A.I.0 P0 : validation des chemins utilisateur + # (cf. /api/benchmark/start). Idempotent : refuse un corpus_path + # absolu hors workspaces, et refuse un output_dir qui s'évaderait + # via ``..`` ou symlinks. + workspace_roots = compute_workspace_roots(UPLOADS_DIR) + try: + validated_path( + req.corpus_path, + allowed_roots=workspace_roots, + must_be_dir=True, + ) + validated_path( + req.output_dir, + allowed_roots=workspace_roots, + must_exist=False, + ) + # Sprint A14-S1 — restriction des prompts à la bibliothèque + # intégrée (``picarones/prompts/``). Cf. validated_prompt_filename + # pour le rationale (vecteur d'exfiltration via LLM). + for comp in req.competitors: + if comp.prompt_file: + validated_prompt_filename(comp.prompt_file) + except PathValidationError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + # Sprint 24 — rate limit + sémaphore concurrents. state.enforce_rate_limit(request) if not state.JOBS_SEMAPHORE.acquire(blocking=False): diff --git a/picarones/web/security.py b/picarones/web/security.py index 52c8639b9c6b1dda1751b336ed34f17e1c53de8a..b683c4663ae756dfd25fcfc43c9d63b167581b48 100644 --- a/picarones/web/security.py +++ b/picarones/web/security.py @@ -96,6 +96,24 @@ def assert_llm_provider_allowed(llm_provider: str) -> None: ) +# --------------------------------------------------------------------------- +# Validation des chemins utilisateur (Sprint A14-S1, A.I.0 P0) +# +# Ré-importé depuis le foyer définitif ``picarones.app.services.path_security`` +# (Sprint A14-S19). Pas de duplication — le code vit en un seul +# endroit dans la couche app, accessible aussi par la CLI et les jobs +# background. +# --------------------------------------------------------------------------- + +from picarones.app.services.path_security import ( # noqa: F401 + PathValidationError, + safe_report_name, + validated_path, + validated_prompt_filename, +) +from picarones.app.services.path_security import _is_within # noqa: F401 + + # --------------------------------------------------------------------------- # Browse roots # --------------------------------------------------------------------------- @@ -126,6 +144,43 @@ def compute_browse_roots(uploads_dir: Path) -> list[Path]: ] +def compute_workspace_roots(uploads_dir: Path) -> list[Path]: + """Retourne les racines autorisées pour les opérations de benchmark. + + Sprint A14-S1 — A.I.0 P0 : utilisé par les endpoints + ``/api/benchmark/start`` et ``/api/benchmark/run`` pour valider + ``corpus_path`` et ``output_dir`` via :func:`validated_path`. + + Sémantique : + + - Si ``PICARONES_WORKSPACE_ROOTS`` est défini, prend précédence + absolue (admin sait ce qu'il fait). + - Sinon, en mode public : uniquement ``uploads_dir`` (lecture) + et ``./rapports`` (écriture des rapports générés). + - Sinon, mode dev : ``compute_browse_roots`` + ``./rapports`` + + ``./corpus`` (corpus locaux des développeurs). + + En production institutionnelle, exporter ``PICARONES_WORKSPACE_ROOTS`` + pour épingler explicitement les répertoires autorisés. + """ + raw = os.environ.get("PICARONES_WORKSPACE_ROOTS") + if raw: + return [Path(p).expanduser().resolve() for p in raw.split(os.pathsep) if p.strip()] + + base = compute_browse_roots(uploads_dir) + extras = [ + Path("./rapports").resolve(), + Path("./corpus").resolve(), + ] + seen: set[Path] = set() + out: list[Path] = [] + for p in base + extras: + if p not in seen: + seen.add(p) + out.append(p) + return out + + # --------------------------------------------------------------------------- # Validation des images uploadées # --------------------------------------------------------------------------- diff --git a/pyproject.toml b/pyproject.toml index 93cd8842c44ffa877fd46082f643fd1dbbc7d3bc..f3da97d357d78e366c139c36f233dc371a4980d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -151,9 +151,16 @@ picarones = [ [tool.pytest.ini_options] testpaths = ["tests"] -# Exclusion par défaut : marker network non sélectionné. Override via -# ``pytest -m network`` (CI réseau-friendly) ou ``pytest -m ""``. -addopts = "-v --tb=short -m 'not network'" +# Le repo root dans ``sys.path`` pour que ``tests.fixtures.*`` soit +# importable de manière déterministe sur tous les OS (Linux/macOS/ +# Windows) — utilisé par les tests CLI E2E qui résolvent leurs mock +# adapters via dotted path (``importlib.import_module("tests.fixtures.…")``). +pythonpath = ["."] +# Exclusion par défaut : markers ``network`` et ``live`` non +# sélectionnés. Override en local via ``pytest -m network`` ou +# ``pytest -m live`` (avec env vars / binaires correctement +# configurés). ``-m ""`` pour tout exécuter. +addopts = "-v --tb=short -m 'not network and not live'" # Sprint A1 (M-15) : aucun test individuel ne doit dépasser 5 minutes. # Mode "thread" car certains tests utilisent ProcessPoolExecutor qui est # incompatible avec le timeout en mode "signal" sur certaines plateformes. @@ -171,6 +178,7 @@ timeout_method = "thread" markers = [ "slow: tests longs (corpus de référence, intégration cloud) ; non bloquants en dev local", "network: tests qui hit le réseau réel ; exclus par défaut", + "live: tests d'intégration contre vraie API/binaire (Tesseract, Anthropic, OpenAI, Mistral) ; exclus par défaut, opt-in en local via 'pytest -m live'", ] # ────────────────────────────────────────────────────────────────── diff --git a/scripts/gen_readme_tables.py b/scripts/gen_readme_tables.py index cbe7ac505c291a2bf9519b4b611f77b8e4d42753..bebd059f03aeb263dca0a78e7c5a0225ee417c70 100644 --- a/scripts/gen_readme_tables.py +++ b/scripts/gen_readme_tables.py @@ -37,6 +37,11 @@ from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent README = REPO_ROOT / "README.md" +# Permet l'invocation du script en subprocess sans avoir besoin +# d'un ``pip install -e .`` préalable (cas CI / test pytest). +if str(REPO_ROOT) not in sys.path: + sys.path.insert(0, str(REPO_ROOT)) + # --------------------------------------------------------------------------- # Engines @@ -209,7 +214,17 @@ def _replace_section(text: str, marker: str, content: str) -> str: def _replace_test_count(text: str, count: int) -> str: """Remplace les mentions ``N tests`` ou ``N passed`` qui citent un nombre dans la fenêtre [count*0.5, count*2]. Garde la formulation - exacte (espace, ponctuation) intacte.""" + exacte (espace, ponctuation) intacte. + + Le count est **arrondi à la dizaine** pour rendre le résultat + OS-déterministe : sur Windows certains tests POSIX-only sont + skipés (cf. ``pytest.importorskip``) ce qui décale le compteur + de quelques unités. L'arrondi absorbe ces écarts mineurs sans + masquer une vraie évolution (le seuil de tolérance des tests + consistency reste à ±5 %). + """ + rounded_count = round(count, -1) # -1 = arrondi à la dizaine + def _sub(match: re.Match) -> str: cited = int(match.group(1)) # Ne touche pas si le nombre cité est complètement hors plage — @@ -217,7 +232,7 @@ def _replace_test_count(text: str, count: int) -> str: # phrase qui parle d'autre chose). if cited < count * 0.5 or cited > count * 2: return match.group(0) - return match.group(0).replace(str(cited), str(count)) + return match.group(0).replace(str(cited), str(rounded_count)) return re.sub(r"(\d{3,5})\s+(?:tests|passed)\b", _sub, text) diff --git a/tests/adapters/__init__.py b/tests/adapters/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/adapters/llm/__init__.py b/tests/adapters/llm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/adapters/llm/test_sprint_a14_s44_llm_step_executor.py b/tests/adapters/llm/test_sprint_a14_s44_llm_step_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..39be0fe3b8fe9110ce03ab48c33f88f3c63ec425 --- /dev/null +++ b/tests/adapters/llm/test_sprint_a14_s44_llm_step_executor.py @@ -0,0 +1,344 @@ +"""Sprint A14-S44 — ``BaseLLMAdapter`` implémente le contrat StepExecutor. + +Tests de l'intégration native des 4 LLM adapters dans le pipeline : +``execute(inputs, params, context) -> dict[ArtifactType, Artifact]`` +ajouté à ``BaseLLMAdapter`` (sans wrapper / sans shim). + +Couvre : +1. ``BaseLLMAdapter.input_types`` / ``output_types`` / ``execution_mode`` +2. ``execute`` lit RAW_TEXT, appelle ``complete``, écrit + ``..corrected.txt``, retourne CORRECTED_TEXT. +3. Erreurs : RAW_TEXT manquant, sans URI, fichier inexistant, + complete() en échec. +4. Image optionnelle : ``inputs[IMAGE]`` est encodée en base64 et + passée au ``complete``. +5. Les 4 adapters concrets (Anthropic, Mistral, OpenAI, Ollama) + héritent bien du contrat. +""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from picarones.adapters.llm.base import BaseLLMAdapter +from picarones.adapters.llm.base import LLMAdapterError +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────────── +# Adapter de test concret +# ────────────────────────────────────────────────────────────────────── + + +class _StubLLMAdapter(BaseLLMAdapter): + """LLM stub pour tester ``execute`` sans appeler une vraie API.""" + + @property + def name(self) -> str: + return "stub_llm" + + @property + def default_model(self) -> str: + return "stub-model-1.0" + + def __init__( + self, + response_text: str = "TEXTE CORRIGÉ", + raise_on_call: bool = False, + model=None, + config=None, + ) -> None: + super().__init__(model=model, config=config) + self._response = response_text + self._raise = raise_on_call + self.last_prompt = None + self.last_image_b64 = None + + def _call(self, prompt, image_b64=None): + self.last_prompt = prompt + self.last_image_b64 = image_b64 + if self._raise: + raise RuntimeError("LLM crashed") + return self._response + + +def _make_context() -> RunContext: + return RunContext( + document_id="doc01", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_text_artifact(uri: str) -> Artifact: + return Artifact( + id="doc01:ocr:raw_text", + document_id="doc01", + type=ArtifactType.RAW_TEXT, + uri=uri, + ) + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="doc01:image", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Contract StepExecutor +# ────────────────────────────────────────────────────────────────────── + + +class TestBaseLLMAdapterContract: + def test_input_types_default_raw_text(self) -> None: + adapter = _StubLLMAdapter() + assert ArtifactType.RAW_TEXT in adapter.input_types + + def test_output_types_default_corrected_text(self) -> None: + adapter = _StubLLMAdapter() + assert ArtifactType.CORRECTED_TEXT in adapter.output_types + + def test_execution_mode_default_io(self) -> None: + # Class attribute, pas instance. + assert BaseLLMAdapter.execution_mode == "io" + + +# ────────────────────────────────────────────────────────────────────── +# execute() — chemin nominal +# ────────────────────────────────────────────────────────────────────── + + +class TestLLMExecuteNominal: + def test_basic_correction(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc01.txt" + text_path.write_text("texte avec erreurs", encoding="utf-8") + + adapter = _StubLLMAdapter(response_text="texte sans erreurs") + result = adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + assert ArtifactType.CORRECTED_TEXT in result + produced = result[ArtifactType.CORRECTED_TEXT] + assert produced.type == ArtifactType.CORRECTED_TEXT + assert produced.document_id == "doc01" + + out_path = Path(produced.uri) + assert out_path.exists() + assert out_path.read_text(encoding="utf-8") == "texte sans erreurs" + assert out_path.name == "doc01.stub_llm.corrected.txt" + + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc01.txt" + text_path.write_text("x", encoding="utf-8") + adapter = _StubLLMAdapter() + result = adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.CORRECTED_TEXT] + assert produced.id == "doc01:stub_llm:corrected_text" + assert produced.produced_by_step == "post_correction" + + def test_prompt_template_formatted_with_text(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc01.txt" + text_path.write_text("input text", encoding="utf-8") + adapter = _StubLLMAdapter() + adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + # Le prompt doit contenir le texte d'entrée. + assert "input text" in adapter.last_prompt + + def test_custom_prompt_via_config(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc01.txt" + text_path.write_text("input", encoding="utf-8") + adapter = _StubLLMAdapter(config={ + "correction_prompt": "Custom: {text}", + }) + adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + assert adapter.last_prompt == "Custom: input" + + +# ────────────────────────────────────────────────────────────────────── +# Erreurs +# ────────────────────────────────────────────────────────────────────── + + +class TestLLMExecuteErrors: + def test_missing_raw_text_raises(self) -> None: + adapter = _StubLLMAdapter() + with pytest.raises(LLMAdapterError, match="RAW_TEXT manquant"): + adapter.execute( + inputs={}, + params={}, + context=_make_context(), + ) + + def test_text_artifact_without_uri_raises(self) -> None: + adapter = _StubLLMAdapter() + artifact = Artifact( + id="x", + document_id="doc01", + type=ArtifactType.RAW_TEXT, + uri=None, + ) + with pytest.raises(LLMAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.RAW_TEXT: artifact}, + params={}, + context=_make_context(), + ) + + def test_text_path_not_existing_raises(self) -> None: + adapter = _StubLLMAdapter() + with pytest.raises(LLMAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact( + "/nonexistent/x.txt", + )}, + params={}, + context=_make_context(), + ) + + def test_llm_call_failing_raises(self, tmp_path: Path) -> None: + text_path = tmp_path / "x.txt" + text_path.write_text("x", encoding="utf-8") + adapter = _StubLLMAdapter(raise_on_call=True, config={ + "max_retries": 0, # pas de retry pour accélérer le test + }) + with pytest.raises(LLMAdapterError, match="LLM a échoué"): + adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Image optionnelle (mode VLM) +# ────────────────────────────────────────────────────────────────────── + + +class TestLLMExecuteWithImage: + def test_image_passed_to_llm_as_base64(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc.txt" + text_path.write_text("x", encoding="utf-8") + image_path = tmp_path / "doc.png" + image_path.write_bytes(b"PNGBYTES") + + adapter = _StubLLMAdapter() + adapter.execute( + inputs={ + ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path)), + ArtifactType.IMAGE: _make_image_artifact(str(image_path)), + }, + params={}, + context=_make_context(), + ) + # L'image doit être encodée en base64. + assert adapter.last_image_b64 is not None + decoded = base64.b64decode(adapter.last_image_b64) + assert decoded == b"PNGBYTES" + + def test_image_omitted_when_not_provided(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc.txt" + text_path.write_text("x", encoding="utf-8") + adapter = _StubLLMAdapter() + adapter.execute( + inputs={ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path))}, + params={}, + context=_make_context(), + ) + assert adapter.last_image_b64 is None + + +# ────────────────────────────────────────────────────────────────────── +# Adapters concrets héritent du contrat +# ────────────────────────────────────────────────────────────────────── + + +class TestConcreteAdaptersInheritContract: + def test_openai_has_execute(self) -> None: + from picarones.adapters.llm.openai_adapter import OpenAIAdapter + # Vérifie que la méthode execute est héritée. + assert hasattr(OpenAIAdapter, "execute") + assert hasattr(OpenAIAdapter, "input_types") + assert hasattr(OpenAIAdapter, "output_types") + + def test_anthropic_has_execute(self) -> None: + from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter + assert hasattr(AnthropicAdapter, "execute") + + def test_mistral_has_execute(self) -> None: + from picarones.adapters.llm.mistral_adapter import MistralAdapter + assert hasattr(MistralAdapter, "execute") + + def test_ollama_has_execute(self) -> None: + from picarones.adapters.llm.ollama_adapter import OllamaAdapter + assert hasattr(OllamaAdapter, "execute") + + +# ────────────────────────────────────────────────────────────────────── +# Intégration pipeline (utilisation comme StepExecutor) +# ────────────────────────────────────────────────────────────────────── + + +class TestPipelineIntegration: + def test_used_as_pipeline_step(self, tmp_path: Path) -> None: + """Un adapter LLM se branche directement comme step de pipeline.""" + from picarones.pipeline.executor import PipelineExecutor + from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep + from picarones.domain.documents import DocumentRef + + text_path = tmp_path / "doc01.txt" + text_path.write_text("input ocr", encoding="utf-8") + + adapter = _StubLLMAdapter(response_text="cleaned text") + executor = PipelineExecutor( + adapter_resolver=lambda name: adapter, + ) + spec = PipelineSpec( + name="post_correction", + initial_inputs=(ArtifactType.RAW_TEXT,), + steps=( + PipelineStep( + id="llm", + kind="post_correction", + adapter_name="stub_llm", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + ), + ), + ) + result = executor.run( + spec=spec, + document=DocumentRef(id="doc01"), + initial_inputs={ + ArtifactType.RAW_TEXT: _make_text_artifact(str(text_path)), + }, + context=_make_context(), + ) + assert result.succeeded + # Trouve le CORRECTED_TEXT artefact. + corrected = [ + a for a in result.artifacts + if a.type == ArtifactType.CORRECTED_TEXT + ] + assert len(corrected) == 1 diff --git a/tests/adapters/ocr/__init__.py b/tests/adapters/ocr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py b/tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..9eac9a565523b77548b8587f88b86763ebc59ad9 --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s30_tesseract_adapter.py @@ -0,0 +1,384 @@ +"""Sprint A14-S30 — ``TesseractAdapter`` natif au contrat S26. + +Tests de l'adapter Tesseract migré nativement (pas de shim sur le +legacy ``picarones.engines.tesseract``). + +Couvre : + +1. Constructeur : + - rejet des paramètres invalides (name, psm, oem) ; + - valeurs par défaut ; + - propriétés en lecture. + +2. ``execute`` : + - cas nominal (mock pytesseract) → Artifact RAW_TEXT avec URI ; + - input IMAGE absent → OCRAdapterError ; + - artefact image sans URI → OCRAdapterError ; + - image inexistante → OCRAdapterError ; + - pytesseract non installé → OCRAdapterError ; + - Tesseract lève → OCRAdapterError ; + - écriture du fichier de sortie au bon emplacement ; + - tesseract_cmd appliqué. + +3. Contrat ``BaseOCRAdapter`` : + - input_types / output_types / execution_mode ; + - hérite bien de BaseOCRAdapter. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from picarones.adapters.ocr import ( + BaseOCRAdapter, + OCRAdapterError, + TesseractAdapter, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:initial:image", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Constructeur +# ────────────────────────────────────────────────────────────────────── + + +class TestTesseractAdapterConstructor: + def test_defaults(self) -> None: + adapter = TesseractAdapter() + assert adapter.name == "tesseract" + assert adapter.lang == "fra" + assert adapter.psm == 6 + assert adapter.oem == 3 + + def test_custom_name(self) -> None: + adapter = TesseractAdapter(name="my_tesseract_lat") + assert adapter.name == "my_tesseract_lat" + + def test_rejects_empty_name(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + TesseractAdapter(name="") + + def test_rejects_whitespace_name(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + TesseractAdapter(name=" ") + + def test_rejects_invalid_chars_in_name(self) -> None: + with pytest.raises(OCRAdapterError, match="invalide"): + TesseractAdapter(name="bad name with space") + + def test_rejects_psm_out_of_range(self) -> None: + with pytest.raises(OCRAdapterError, match=r"psm.*\[0, 13\]"): + TesseractAdapter(psm=14) + with pytest.raises(OCRAdapterError, match=r"psm.*\[0, 13\]"): + TesseractAdapter(psm=-1) + + def test_rejects_oem_out_of_range(self) -> None: + with pytest.raises(OCRAdapterError, match=r"oem.*\[0, 3\]"): + TesseractAdapter(oem=4) + with pytest.raises(OCRAdapterError, match=r"oem.*\[0, 3\]"): + TesseractAdapter(oem=-1) + + def test_accepts_psm_boundary_values(self) -> None: + TesseractAdapter(psm=0) + TesseractAdapter(psm=13) + + def test_accepts_oem_boundary_values(self) -> None: + TesseractAdapter(oem=0) + TesseractAdapter(oem=3) + + +# ────────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────────── + + +class TestTesseractAdapterContract: + def test_inherits_base_adapter(self) -> None: + adapter = TesseractAdapter() + assert isinstance(adapter, BaseOCRAdapter) + + def test_input_types(self) -> None: + assert TesseractAdapter.input_types == frozenset({ArtifactType.IMAGE}) + + def test_output_types(self) -> None: + """``output_types`` est l'ensemble maximal produit (constante de + classe). Si ``expose_confidences=False``, l'execute() omet + CONFIDENCES du dict — le YAML ``PipelineSpec`` doit alors + déclarer seulement ``[raw_text]`` pour cohérence. + """ + assert TesseractAdapter.output_types == frozenset( + {ArtifactType.RAW_TEXT, ArtifactType.CONFIDENCES}, + ) + + def test_execution_mode_is_cpu(self) -> None: + """Tesseract est CPU-bound — utilise un ProcessPool dans le runner.""" + assert TesseractAdapter.execution_mode == "cpu" + + +# ────────────────────────────────────────────────────────────────────── +# execute() — validation des inputs +# ────────────────────────────────────────────────────────────────────── + + +class TestTesseractAdapterInputValidation: + def test_missing_image_input_raises(self, tmp_path: Path) -> None: + adapter = TesseractAdapter() + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_artifact_without_uri_raises(self) -> None: + adapter = TesseractAdapter() + artifact = Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=None, # explicit no URI + ) + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_does_not_exist_raises(self) -> None: + adapter = TesseractAdapter() + artifact = _make_image_artifact("/nonexistent/path/img.png") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# execute() — chemin nominal et erreurs Tesseract +# ────────────────────────────────────────────────────────────────────── + + +class TestTesseractAdapterExecute: + def _create_dummy_image(self, tmp_path: Path) -> Path: + """Crée un fichier vide qui sert d'image (les tests mocquent + pytesseract donc le contenu n'est pas analysé).""" + path = tmp_path / "page.png" + path.write_bytes(b"\x89PNG\r\n\x1a\n") # signature PNG basique + return path + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_nominal_execution( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + """Cas nominal : pytesseract retourne du texte → Artifact RAW_TEXT + avec URI vers un fichier produit.""" + mock_image_to_string.return_value = "Bonjour le monde\n" + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter() + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + assert ArtifactType.RAW_TEXT in result + produced = result[ArtifactType.RAW_TEXT] + assert produced.type == ArtifactType.RAW_TEXT + assert produced.uri is not None + + # Le fichier de sortie existe et contient le texte stripé. + out_path = Path(produced.uri) + assert out_path.exists() + assert out_path.read_text(encoding="utf-8") == "Bonjour le monde" + + # Convention : ..txt à côté de l'image. + assert out_path.name == "page.tesseract.txt" + assert out_path.parent == tmp_path + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_custom_name_changes_output_filename( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "x" + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter(name="tess_lat_psm6") + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.name == "page.tess_lat_psm6.txt" + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_lang_psm_oem_passed_to_pytesseract( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "x" + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter(lang="lat", psm=4, oem=1) + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + # On vérifie l'appel à pytesseract.image_to_string avec les bons args. + assert mock_image_to_string.called + kwargs = mock_image_to_string.call_args.kwargs + assert kwargs["lang"] == "lat" + assert "--psm 4" in kwargs["config"] + assert "--oem 1" in kwargs["config"] + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_tesseract_cmd_applied_when_set( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "x" + mock_image_open.return_value.__enter__.return_value = MagicMock() + # Ré-import temporaire pour récupérer le module. + import pytesseract # type: ignore[import-untyped] + adapter = TesseractAdapter(tesseract_cmd="/custom/bin/tesseract") + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + assert pytesseract.pytesseract.tesseract_cmd == "/custom/bin/tesseract" + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_tesseract_exception_wrapped_in_ocr_error( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_open.return_value.__enter__.return_value = MagicMock() + mock_image_to_string.side_effect = RuntimeError("Tesseract crashed") + adapter = TesseractAdapter() + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with pytest.raises(OCRAdapterError, match="RuntimeError.*Tesseract crashed"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_pytesseract_not_installed_raises_clean_error( + self, tmp_path: Path, + ) -> None: + """Si pytesseract est introuvable, l'erreur est claire et + propose une commande pip.""" + adapter = TesseractAdapter() + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Simule que pytesseract est absent. + with patch.dict(sys.modules, {"pytesseract": None}): + with pytest.raises( + OCRAdapterError, match="pytesseract.*pip install", + ): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_artifact_id_uses_adapter_name( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "x" + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter(name="custom_name") + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "d1:custom_name:raw_text" + assert produced.document_id == "d1" + assert produced.produced_by_step == "ocr" + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_text_is_stripped( + self, mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + """Le texte est strippé des whitespaces extérieurs comme dans + le legacy.""" + mock_image_to_string.return_value = " \n\nHello world\n\n " + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter() + image_path = self._create_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Hello world" diff --git a/tests/adapters/ocr/test_sprint_a14_s31_pero_ocr_adapter.py b/tests/adapters/ocr/test_sprint_a14_s31_pero_ocr_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..3fbcdaf05c3421bb730192252d277389794554c4 --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s31_pero_ocr_adapter.py @@ -0,0 +1,379 @@ +"""Sprint A14-S31 — ``PeroOCRAdapter`` natif au contrat S26. + +Tests de l'adapter Pero OCR migré nativement (pas de shim sur le +legacy ``picarones.engines.pero_ocr``). +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from picarones.adapters.ocr import ( + BaseOCRAdapter, + OCRAdapterError, + PeroOCRAdapter, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:initial:image", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_dummy_image(tmp_path: Path) -> Path: + """Crée un fichier image réel pour que PIL puisse l'ouvrir. + + On utilise PIL pour générer une image PNG 10x10 valide, parce que + pero_ocr ne mock pas PIL.Image.open complètement. + """ + try: + from PIL import Image + import numpy as np + image_path = tmp_path / "page.png" + arr = np.zeros((10, 10, 3), dtype=np.uint8) + Image.fromarray(arr).save(image_path) + return image_path + except ImportError: + pytest.skip("PIL/numpy not available") + + +def _make_dummy_config(tmp_path: Path) -> Path: + config_path = tmp_path / "pero.ini" + config_path.write_text("[PARSER]\nname = stub\n") + return config_path + + +# ────────────────────────────────────────────────────────────────────── +# Constructeur +# ────────────────────────────────────────────────────────────────────── + + +class TestPeroOCRAdapterConstructor: + def test_with_required_config_path(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + assert adapter.name == "pero_ocr" + assert adapter.config_path == cfg + + def test_custom_name(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg, name="my_pero") + assert adapter.name == "my_pero" + + def test_rejects_empty_name(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + with pytest.raises(OCRAdapterError, match="vide"): + PeroOCRAdapter(config_path=cfg, name="") + + def test_rejects_invalid_chars_in_name(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + with pytest.raises(OCRAdapterError, match="invalide"): + PeroOCRAdapter(config_path=cfg, name="bad name") + + def test_rejects_empty_config_path(self) -> None: + with pytest.raises(OCRAdapterError, match="config_path"): + PeroOCRAdapter(config_path="") + + def test_accepts_string_config_path(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=str(cfg)) + assert adapter.config_path == Path(str(cfg)) + + +# ────────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────────── + + +class TestPeroOCRAdapterContract: + def test_inherits_base_adapter(self, tmp_path: Path) -> None: + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + assert isinstance(adapter, BaseOCRAdapter) + + def test_input_types(self) -> None: + assert PeroOCRAdapter.input_types == frozenset({ArtifactType.IMAGE}) + + def test_output_types(self) -> None: + assert PeroOCRAdapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + def test_execution_mode_is_cpu(self) -> None: + assert PeroOCRAdapter.execution_mode == "cpu" + + +# ────────────────────────────────────────────────────────────────────── +# execute() — validation des inputs +# ────────────────────────────────────────────────────────────────────── + + +class TestPeroOCRAdapterInputValidation: + def test_missing_image_input_raises(self, tmp_path: Path) -> None: + adapter = PeroOCRAdapter(config_path=_make_dummy_config(tmp_path)) + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_artifact_without_uri_raises(self, tmp_path: Path) -> None: + adapter = PeroOCRAdapter(config_path=_make_dummy_config(tmp_path)) + artifact = Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=None, + ) + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_does_not_exist_raises(self, tmp_path: Path) -> None: + adapter = PeroOCRAdapter(config_path=_make_dummy_config(tmp_path)) + artifact = _make_image_artifact("/nonexistent/img.png") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_config_path_missing_raises_at_first_run( + self, tmp_path: Path, + ) -> None: + """Si le config_path n'existe pas sur disque, l'erreur est levée + au premier execute() (lazy parser init).""" + nonexistent_cfg = tmp_path / "missing.ini" + adapter = PeroOCRAdapter(config_path=nonexistent_cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + # On mock pero_ocr pour passer l'import et tester le check de config_path. + fake_pero = MagicMock() + with patch.dict(sys.modules, { + "pero_ocr": fake_pero, + "pero_ocr.document_ocr": MagicMock(), + "pero_ocr.document_ocr.page_parser": MagicMock(), + "pero_ocr.document_ocr.layout": MagicMock(), + }): + with pytest.raises(OCRAdapterError, match="config_path"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# execute() — chemin nominal +# ────────────────────────────────────────────────────────────────────── + + +class TestPeroOCRAdapterExecute: + def _patch_pero_modules(self, page_layout_factory): + """Helper : retourne un context manager qui mock pero_ocr.""" + fake_page_parser_module = MagicMock() + fake_page_parser_module.PageParser = MagicMock() + fake_layout_module = MagicMock() + fake_layout_module.PageLayout = page_layout_factory + + return patch.dict(sys.modules, { + "pero_ocr": MagicMock(), + "pero_ocr.document_ocr": MagicMock(), + "pero_ocr.document_ocr.page_parser": fake_page_parser_module, + "pero_ocr.document_ocr.layout": fake_layout_module, + }) + + def test_nominal_extracts_text_in_line_order(self, tmp_path: Path) -> None: + # PageLayout simulé avec 2 régions × 2 lignes + line1 = MagicMock() + line1.transcription = "Bonjour le monde" + line1.transcription_confidence = 0.9 + line2 = MagicMock() + line2.transcription = "Tout va bien" + line2.transcription_confidence = 0.8 + line3 = MagicMock() + line3.transcription = "Deuxième région" + line3.transcription_confidence = 0.7 + + region1 = MagicMock() + region1.lines = [line1, line2] + region2 = MagicMock() + region2.lines = [line3] + + page_layout_instance = MagicMock() + page_layout_instance.regions = [region1, region2] + + # PageLayout(id, page_size=...) returns notre instance. + page_layout_factory = MagicMock(return_value=page_layout_instance) + + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_pero_modules(page_layout_factory): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + produced = result[ArtifactType.RAW_TEXT] + assert produced.type == ArtifactType.RAW_TEXT + out_text = Path(produced.uri).read_text(encoding="utf-8") + assert out_text == "Bonjour le monde\nTout va bien\nDeuxième région" + + def test_skips_lines_without_transcription(self, tmp_path: Path) -> None: + line_with = MagicMock() + line_with.transcription = "Présent" + line_without = MagicMock() + line_without.transcription = None + + region = MagicMock() + region.lines = [line_with, line_without] + page_layout_instance = MagicMock() + page_layout_instance.regions = [region] + page_layout_factory = MagicMock(return_value=page_layout_instance) + + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_pero_modules(page_layout_factory): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Présent" + + def test_writes_to_stem_name_txt_pattern(self, tmp_path: Path) -> None: + page_layout_instance = MagicMock() + page_layout_instance.regions = [] + page_layout_factory = MagicMock(return_value=page_layout_instance) + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg, name="my_pero") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_pero_modules(page_layout_factory): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.name == "page.my_pero.txt" + assert out_path.parent == tmp_path + + def test_pero_not_installed_raises_clean_error( + self, tmp_path: Path, + ) -> None: + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Force absence du module pero_ocr. + with patch.dict(sys.modules, { + "pero_ocr": None, + "pero_ocr.document_ocr.page_parser": None, + "pero_ocr.document_ocr.layout": None, + }): + with pytest.raises(OCRAdapterError): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + page_layout_instance = MagicMock() + page_layout_instance.regions = [] + page_layout_factory = MagicMock(return_value=page_layout_instance) + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg, name="custom") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_pero_modules(page_layout_factory): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "d1:custom:raw_text" + assert produced.document_id == "d1" + assert produced.produced_by_step == "ocr" + + def test_pero_internal_error_wrapped(self, tmp_path: Path) -> None: + page_layout_factory = MagicMock( + side_effect=RuntimeError("Pero crashed"), + ) + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_pero_modules(page_layout_factory): + with pytest.raises(OCRAdapterError, match="RuntimeError.*Pero crashed"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_parser_lazy_init_and_reused(self, tmp_path: Path) -> None: + """Le parser est instancié au premier execute() et réutilisé.""" + page_layout_instance = MagicMock() + page_layout_instance.regions = [] + page_layout_factory = MagicMock(return_value=page_layout_instance) + + cfg = _make_dummy_config(tmp_path) + adapter = PeroOCRAdapter(config_path=cfg) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + assert adapter._parser is None + + with self._patch_pero_modules(page_layout_factory): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + first_parser = adapter._parser + assert first_parser is not None + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + # Le parser doit être le même au deuxième appel. + assert adapter._parser is first_parser diff --git a/tests/adapters/ocr/test_sprint_a14_s32_mistral_ocr_adapter.py b/tests/adapters/ocr/test_sprint_a14_s32_mistral_ocr_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..b4e3a678561fcacbbf1d2bd057b6c528a1e52332 --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s32_mistral_ocr_adapter.py @@ -0,0 +1,390 @@ +"""Sprint A14-S32 — ``MistralOCRAdapter`` natif au contrat S26.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from picarones.adapters.ocr import ( + BaseOCRAdapter, + MistralOCRAdapter, + OCRAdapterError, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:initial:image", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_dummy_image(tmp_path: Path) -> Path: + path = tmp_path / "page.png" + path.write_bytes(b"\x89PNG\r\n\x1a\nfakeimagebytes") + return path + + +# ────────────────────────────────────────────────────────────────────── +# Constructeur +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRAdapterConstructor: + def test_defaults(self) -> None: + adapter = MistralOCRAdapter() + assert adapter.name == "mistral_ocr" + assert adapter.model == "mistral-ocr-latest" + + def test_custom_name(self) -> None: + adapter = MistralOCRAdapter(name="my_mistral") + assert adapter.name == "my_mistral" + + def test_custom_model(self) -> None: + adapter = MistralOCRAdapter(model="pixtral-12b-2409") + assert adapter.model == "pixtral-12b-2409" + + def test_rejects_empty_name(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + MistralOCRAdapter(name="") + + def test_rejects_invalid_chars_in_name(self) -> None: + with pytest.raises(OCRAdapterError, match="invalide"): + MistralOCRAdapter(name="bad name") + + def test_rejects_non_positive_max_tokens(self) -> None: + with pytest.raises(OCRAdapterError, match="max_tokens"): + MistralOCRAdapter(max_tokens=0) + with pytest.raises(OCRAdapterError, match="max_tokens"): + MistralOCRAdapter(max_tokens=-1) + + def test_rejects_non_positive_timeout(self) -> None: + with pytest.raises(OCRAdapterError, match="timeout_seconds"): + MistralOCRAdapter(timeout_seconds=0) + with pytest.raises(OCRAdapterError, match="timeout_seconds"): + MistralOCRAdapter(timeout_seconds=-1.0) + + +# ────────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRAdapterContract: + def test_inherits_base_adapter(self) -> None: + adapter = MistralOCRAdapter() + assert isinstance(adapter, BaseOCRAdapter) + + def test_input_types(self) -> None: + assert MistralOCRAdapter.input_types == frozenset({ArtifactType.IMAGE}) + + def test_output_types(self) -> None: + assert MistralOCRAdapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + def test_execution_mode_is_io(self) -> None: + """Mistral OCR fait des appels HTTP — IO-bound, ThreadPool.""" + assert MistralOCRAdapter.execution_mode == "io" + + +# ────────────────────────────────────────────────────────────────────── +# API key resolution +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRApiKey: + def test_explicit_key_takes_priority(self) -> None: + adapter = MistralOCRAdapter(api_key="explicit_key") + # Mock l'env pour s'assurer qu'on n'utilise pas la valeur env. + with patch.dict("os.environ", {"MISTRAL_API_KEY": "env_key"}): + assert adapter._resolve_api_key() == "explicit_key" + + def test_env_key_used_when_no_explicit(self) -> None: + adapter = MistralOCRAdapter() + with patch.dict("os.environ", {"MISTRAL_API_KEY": "env_key"}): + assert adapter._resolve_api_key() == "env_key" + + def test_no_key_raises(self) -> None: + adapter = MistralOCRAdapter() + # Vide l'env de MISTRAL_API_KEY. + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(OCRAdapterError, match="MISTRAL_API_KEY"): + adapter._resolve_api_key() + + +# ────────────────────────────────────────────────────────────────────── +# Encoding +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCREncoding: + def test_png_extension_yields_png_mime(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter() + image_path = _make_dummy_image(tmp_path) + encoded = adapter._encode_image(image_path) + assert encoded.startswith("data:image/png;base64,") + + def test_jpg_extension_yields_jpeg_mime(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter() + path = tmp_path / "img.jpg" + path.write_bytes(b"jpegbytes") + encoded = adapter._encode_image(path) + assert encoded.startswith("data:image/jpeg;base64,") + + def test_unknown_extension_defaults_to_jpeg(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter() + path = tmp_path / "img.xyz" + path.write_bytes(b"random") + encoded = adapter._encode_image(path) + assert encoded.startswith("data:image/jpeg;base64,") + + +# ────────────────────────────────────────────────────────────────────── +# Input validation +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRInputValidation: + def test_missing_image_input_raises(self) -> None: + adapter = MistralOCRAdapter(api_key="x") + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_artifact_without_uri_raises(self) -> None: + adapter = MistralOCRAdapter(api_key="x") + artifact = Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=None, + ) + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_does_not_exist_raises(self) -> None: + adapter = MistralOCRAdapter(api_key="x") + artifact = _make_image_artifact("/nonexistent/img.png") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_no_api_key_raises(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter() # pas d'api_key explicite + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(OCRAdapterError, match="MISTRAL_API_KEY"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# /v1/ocr API (mistral-ocr-* models) +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRNativeAPI: + def _mock_urlopen_ok(self, response_json: dict): + """Helper : retourne un context manager qui mock urlopen.""" + mock_resp = MagicMock() + mock_resp.read.return_value = repr(response_json).encode() + # On ne peut pas json.dumps un dict avec json.dumps directement + # à cause du repr ; on encode proprement. + import json as _json + mock_resp.read.return_value = _json.dumps(response_json).encode() + mock_resp.__enter__.return_value = mock_resp + return patch("urllib.request.urlopen", return_value=mock_resp) + + def test_native_api_concatenates_pages(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter(api_key="x") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response_json = { + "pages": [ + {"markdown": "Page 1 contenu"}, + {"markdown": "Page 2 contenu"}, + ], + } + + with self._mock_urlopen_ok(response_json): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Page 1 contenu\n\nPage 2 contenu" + + def test_native_api_writes_to_stem_name_pattern(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter(api_key="x", name="my_mistral") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._mock_urlopen_ok({"pages": [{"markdown": "x"}]}): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.name == "page.my_mistral.txt" + + def test_native_api_raises_on_http_error(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter(api_key="x") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with patch( + "urllib.request.urlopen", + side_effect=ConnectionError("API down"), + ): + with pytest.raises(OCRAdapterError, match="ConnectionError"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Vision/chat API (pixtral-* models) +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRVisionAPI: + def test_pixtral_routes_to_vision_api(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter( + api_key="x", + model="pixtral-12b-2409", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Mock le SDK mistralai. + mock_message = MagicMock() + mock_message.content = "Texte transcrit par pixtral." + mock_choice = MagicMock(message=mock_message) + mock_response = MagicMock() + mock_response.choices = [mock_choice] + + mock_client = MagicMock() + mock_client.chat.complete.return_value = mock_response + + fake_module = MagicMock() + fake_module.Mistral = MagicMock(return_value=mock_client) + fake_client_module = MagicMock() + fake_client_module.Mistral = fake_module.Mistral + + with patch.dict(sys.modules, { + "mistralai": fake_module, + "mistralai.client": fake_client_module, + }): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Texte transcrit par pixtral." + + def test_pixtral_sdk_missing_raises_clean_error( + self, tmp_path: Path, + ) -> None: + adapter = MistralOCRAdapter(api_key="x", model="pixtral-12b") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with patch.dict(sys.modules, { + "mistralai": None, + "mistralai.client": None, + }): + with pytest.raises(OCRAdapterError, match="mistralai"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_pixtral_api_error_wrapped(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter(api_key="x", model="pixtral-12b") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + mock_client = MagicMock() + mock_client.chat.complete.side_effect = RuntimeError("API error") + + fake_module = MagicMock() + fake_module.Mistral = MagicMock(return_value=mock_client) + fake_client_module = MagicMock() + fake_client_module.Mistral = fake_module.Mistral + + with patch.dict(sys.modules, { + "mistralai": fake_module, + "mistralai.client": fake_client_module, + }): + with pytest.raises(OCRAdapterError, match="RuntimeError.*API error"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Artifact ID +# ────────────────────────────────────────────────────────────────────── + + +class TestMistralOCRArtifactID: + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter(api_key="x", name="custom") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + mock_resp = MagicMock() + import json as _json + mock_resp.read.return_value = _json.dumps( + {"pages": [{"markdown": "x"}]}, + ).encode() + mock_resp.__enter__.return_value = mock_resp + + with patch("urllib.request.urlopen", return_value=mock_resp): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "d1:custom:raw_text" + assert produced.document_id == "d1" + assert produced.produced_by_step == "ocr" diff --git a/tests/adapters/ocr/test_sprint_a14_s33_google_vision_adapter.py b/tests/adapters/ocr/test_sprint_a14_s33_google_vision_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..5a66b33bee161aeeccdeccde8fd614e17c2aa458 --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s33_google_vision_adapter.py @@ -0,0 +1,418 @@ +"""Sprint A14-S33 — ``GoogleVisionAdapter`` natif au contrat S26.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from picarones.adapters.ocr import ( + BaseOCRAdapter, + GoogleVisionAdapter, + OCRAdapterError, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_dummy_image(tmp_path: Path) -> Path: + path = tmp_path / "page.png" + path.write_bytes(b"PNG_FAKE_BYTES") + return path + + +# ────────────────────────────────────────────────────────────────────── +# Constructeur +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionConstructor: + def test_defaults(self) -> None: + adapter = GoogleVisionAdapter() + assert adapter.name == "google_vision" + assert adapter.feature_type == "DOCUMENT_TEXT_DETECTION" + + def test_custom_name(self) -> None: + adapter = GoogleVisionAdapter(name="my_gv") + assert adapter.name == "my_gv" + + def test_text_detection_feature(self) -> None: + adapter = GoogleVisionAdapter(feature_type="TEXT_DETECTION") + assert adapter.feature_type == "TEXT_DETECTION" + + def test_rejects_invalid_feature_type(self) -> None: + with pytest.raises(OCRAdapterError, match="feature_type"): + GoogleVisionAdapter(feature_type="UNKNOWN_FEATURE") + + def test_rejects_empty_name(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + GoogleVisionAdapter(name="") + + def test_rejects_invalid_chars_in_name(self) -> None: + with pytest.raises(OCRAdapterError, match="invalide"): + GoogleVisionAdapter(name="bad name") + + def test_rejects_non_positive_timeout(self) -> None: + with pytest.raises(OCRAdapterError, match="timeout"): + GoogleVisionAdapter(timeout_seconds=0) + + def test_default_language_hints(self) -> None: + adapter = GoogleVisionAdapter() + # Vérifier que les hints sont stockés (privé mais accessible). + assert adapter._language_hints == ["fr"] + + def test_custom_language_hints(self) -> None: + adapter = GoogleVisionAdapter(language_hints=["en", "lat"]) + assert adapter._language_hints == ["en", "lat"] + + +# ────────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionContract: + def test_inherits_base_adapter(self) -> None: + adapter = GoogleVisionAdapter() + assert isinstance(adapter, BaseOCRAdapter) + + def test_input_types(self) -> None: + assert GoogleVisionAdapter.input_types == frozenset({ArtifactType.IMAGE}) + + def test_output_types(self) -> None: + assert GoogleVisionAdapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + def test_execution_mode_is_io(self) -> None: + assert GoogleVisionAdapter.execution_mode == "io" + + +# ────────────────────────────────────────────────────────────────────── +# Auth resolution +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionAuth: + def test_no_auth_raises(self, tmp_path: Path) -> None: + adapter = GoogleVisionAdapter() + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(OCRAdapterError, match="authentification manquante"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_explicit_credentials_path_takes_priority(self) -> None: + adapter = GoogleVisionAdapter(credentials_path="/explicit/creds.json") + with patch.dict( + "os.environ", + {"GOOGLE_APPLICATION_CREDENTIALS": "/env/creds.json"}, + ): + assert adapter._resolve_credentials_path() == "/explicit/creds.json" + + def test_env_credentials_fallback(self) -> None: + adapter = GoogleVisionAdapter() + with patch.dict( + "os.environ", + {"GOOGLE_APPLICATION_CREDENTIALS": "/env/creds.json"}, + ): + assert adapter._resolve_credentials_path() == "/env/creds.json" + + def test_explicit_api_key_takes_priority(self) -> None: + adapter = GoogleVisionAdapter(api_key="explicit_key") + with patch.dict("os.environ", {"GOOGLE_API_KEY": "env_key"}): + assert adapter._resolve_api_key() == "explicit_key" + + +# ────────────────────────────────────────────────────────────────────── +# Input validation +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionInputValidation: + def test_missing_image_input_raises(self) -> None: + adapter = GoogleVisionAdapter(api_key="x") + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_artifact_without_uri_raises(self) -> None: + adapter = GoogleVisionAdapter(api_key="x") + artifact = Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=None, + ) + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_does_not_exist_raises(self) -> None: + adapter = GoogleVisionAdapter(api_key="x") + artifact = _make_image_artifact("/nonexistent/img.png") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# REST API path (api_key) +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionREST: + def _mock_urlopen(self, response_dict: dict): + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(response_dict).encode("utf-8") + mock_resp.__enter__.return_value = mock_resp + return patch("urllib.request.urlopen", return_value=mock_resp) + + def test_document_text_detection_returns_full_text( + self, tmp_path: Path, + ) -> None: + adapter = GoogleVisionAdapter(api_key="x") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response = { + "responses": [{ + "fullTextAnnotation": {"text": "Bonjour\nle monde"}, + }], + } + + with self._mock_urlopen(response): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Bonjour\nle monde" + + def test_text_detection_returns_first_annotation( + self, tmp_path: Path, + ) -> None: + adapter = GoogleVisionAdapter( + api_key="x", feature_type="TEXT_DETECTION", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response = { + "responses": [{ + "textAnnotations": [ + {"description": "Texte court"}, + ], + }], + } + + with self._mock_urlopen(response): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Texte court" + + def test_empty_responses_returns_empty_text(self, tmp_path: Path) -> None: + adapter = GoogleVisionAdapter(api_key="x") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._mock_urlopen({"responses": [{}]}): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "" + + def test_api_error_in_response_raises(self, tmp_path: Path) -> None: + adapter = GoogleVisionAdapter(api_key="x") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response = { + "responses": [{ + "error": {"code": 7, "message": "Permission denied"}, + }], + } + + with self._mock_urlopen(response): + with pytest.raises(OCRAdapterError, match="Permission denied"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_writes_to_stem_name_pattern(self, tmp_path: Path) -> None: + adapter = GoogleVisionAdapter(api_key="x", name="my_gv") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response = {"responses": [{"fullTextAnnotation": {"text": "x"}}]} + + with self._mock_urlopen(response): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.name == "page.my_gv.txt" + + +# ────────────────────────────────────────────────────────────────────── +# SDK path (credentials_path) +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionSDK: + def test_credentials_path_routes_to_sdk(self, tmp_path: Path) -> None: + creds_path = tmp_path / "creds.json" + creds_path.write_text("{}") + adapter = GoogleVisionAdapter(credentials_path=str(creds_path)) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Mock du SDK google.cloud.vision + mock_response = MagicMock() + mock_response.full_text_annotation.text = "SDK output text" + mock_client = MagicMock() + mock_client.document_text_detection.return_value = mock_response + + fake_vision = MagicMock() + fake_vision.ImageAnnotatorClient = MagicMock(return_value=mock_client) + fake_vision.Image = MagicMock(return_value="image_obj") + fake_vision.ImageContext = MagicMock(return_value="ctx_obj") + fake_module = MagicMock() + fake_module.vision = fake_vision + + with patch.dict(sys.modules, { + "google": fake_module, + "google.cloud": fake_module, + "google.cloud.vision": fake_vision, + }): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "SDK output text" + + def test_sdk_missing_raises_clean_error(self, tmp_path: Path) -> None: + creds_path = tmp_path / "creds.json" + creds_path.write_text("{}") + adapter = GoogleVisionAdapter(credentials_path=str(creds_path)) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with patch.dict(sys.modules, { + "google.cloud.vision": None, + "google.cloud": None, + }): + with pytest.raises(OCRAdapterError, match="google-cloud-vision"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_sdk_internal_error_wrapped(self, tmp_path: Path) -> None: + creds_path = tmp_path / "creds.json" + creds_path.write_text("{}") + adapter = GoogleVisionAdapter(credentials_path=str(creds_path)) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + mock_client = MagicMock() + mock_client.document_text_detection.side_effect = RuntimeError( + "SDK boom", + ) + + fake_vision = MagicMock() + fake_vision.ImageAnnotatorClient = MagicMock(return_value=mock_client) + fake_vision.Image = MagicMock(return_value="image_obj") + fake_vision.ImageContext = MagicMock(return_value="ctx_obj") + fake_module = MagicMock() + fake_module.vision = fake_vision + + with patch.dict(sys.modules, { + "google": fake_module, + "google.cloud": fake_module, + "google.cloud.vision": fake_vision, + }): + with pytest.raises(OCRAdapterError, match="RuntimeError.*SDK boom"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Artifact ID +# ────────────────────────────────────────────────────────────────────── + + +class TestGoogleVisionArtifactID: + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + adapter = GoogleVisionAdapter(api_key="x", name="custom_gv") + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + response = {"responses": [{"fullTextAnnotation": {"text": "x"}}]} + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(response).encode("utf-8") + mock_resp.__enter__.return_value = mock_resp + + with patch("urllib.request.urlopen", return_value=mock_resp): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "d1:custom_gv:raw_text" + assert produced.document_id == "d1" + assert produced.produced_by_step == "ocr" diff --git a/tests/adapters/ocr/test_sprint_a14_s34_azure_doc_intel_adapter.py b/tests/adapters/ocr/test_sprint_a14_s34_azure_doc_intel_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..38f0c8b232407753bd25af4401f92cd93a345b6a --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s34_azure_doc_intel_adapter.py @@ -0,0 +1,536 @@ +"""Sprint A14-S34 — ``AzureDocIntelAdapter`` natif au contrat S26.""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +from picarones.adapters.ocr import ( + AzureDocIntelAdapter, + BaseOCRAdapter, + OCRAdapterError, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_dummy_image(tmp_path: Path) -> Path: + path = tmp_path / "page.png" + path.write_bytes(b"PNG_FAKE_BYTES") + return path + + +# ────────────────────────────────────────────────────────────────────── +# Constructeur +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelConstructor: + def test_defaults(self) -> None: + adapter = AzureDocIntelAdapter() + assert adapter.name == "azure_doc_intel" + assert adapter.model_id == "prebuilt-read" + + def test_custom_name(self) -> None: + adapter = AzureDocIntelAdapter(name="my_azure") + assert adapter.name == "my_azure" + + def test_custom_model_id(self) -> None: + adapter = AzureDocIntelAdapter(model_id="prebuilt-document") + assert adapter.model_id == "prebuilt-document" + + def test_rejects_empty_name(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + AzureDocIntelAdapter(name="") + + def test_rejects_invalid_chars_in_name(self) -> None: + with pytest.raises(OCRAdapterError, match="invalide"): + AzureDocIntelAdapter(name="bad name") + + def test_rejects_non_positive_timeout(self) -> None: + with pytest.raises(OCRAdapterError, match="timeout_seconds"): + AzureDocIntelAdapter(timeout_seconds=0) + + def test_rejects_non_positive_max_polling(self) -> None: + with pytest.raises(OCRAdapterError, match="max_polling_attempts"): + AzureDocIntelAdapter(max_polling_attempts=0) + + def test_rejects_negative_polling_interval(self) -> None: + with pytest.raises(OCRAdapterError, match="polling_interval_base"): + AzureDocIntelAdapter(polling_interval_base=-1.0) + + +# ────────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelContract: + def test_inherits_base_adapter(self) -> None: + adapter = AzureDocIntelAdapter() + assert isinstance(adapter, BaseOCRAdapter) + + def test_input_types(self) -> None: + assert AzureDocIntelAdapter.input_types == frozenset( + {ArtifactType.IMAGE}, + ) + + def test_output_types(self) -> None: + assert AzureDocIntelAdapter.output_types == frozenset( + {ArtifactType.RAW_TEXT}, + ) + + def test_execution_mode_is_io(self) -> None: + assert AzureDocIntelAdapter.execution_mode == "io" + + +# ────────────────────────────────────────────────────────────────────── +# Auth resolution +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelAuth: + def test_explicit_api_key_takes_priority(self) -> None: + adapter = AzureDocIntelAdapter(api_key="explicit") + with patch.dict("os.environ", {"AZURE_DOC_INTEL_KEY": "env"}): + assert adapter._resolve_api_key() == "explicit" + + def test_env_api_key_fallback(self) -> None: + adapter = AzureDocIntelAdapter() + with patch.dict("os.environ", {"AZURE_DOC_INTEL_KEY": "env_key"}): + assert adapter._resolve_api_key() == "env_key" + + def test_no_api_key_raises(self) -> None: + adapter = AzureDocIntelAdapter() + with patch.dict("os.environ", {}, clear=True): + with pytest.raises(OCRAdapterError, match="AZURE_DOC_INTEL_KEY"): + adapter._resolve_api_key() + + def test_explicit_endpoint_takes_priority(self) -> None: + adapter = AzureDocIntelAdapter(endpoint="https://explicit.azure.com") + with patch.dict( + "os.environ", {"AZURE_DOC_INTEL_ENDPOINT": "https://env.azure.com"}, + ): + assert adapter._resolve_endpoint() == "https://explicit.azure.com" + + def test_env_endpoint_fallback(self) -> None: + adapter = AzureDocIntelAdapter() + with patch.dict( + "os.environ", {"AZURE_DOC_INTEL_ENDPOINT": "https://env.azure.com/"}, + ): + # Note : .rstrip("/") supprime le trailing slash. + assert adapter._resolve_endpoint() == "https://env.azure.com" + + def test_no_endpoint_raises(self) -> None: + adapter = AzureDocIntelAdapter() + with patch.dict("os.environ", {}, clear=True): + with pytest.raises( + OCRAdapterError, match="AZURE_DOC_INTEL_ENDPOINT", + ): + adapter._resolve_endpoint() + + +# ────────────────────────────────────────────────────────────────────── +# Input validation +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelInputValidation: + def test_missing_image_input_raises(self) -> None: + adapter = AzureDocIntelAdapter( + api_key="x", endpoint="https://test.azure.com", + ) + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_artifact_without_uri_raises(self) -> None: + adapter = AzureDocIntelAdapter( + api_key="x", endpoint="https://test.azure.com", + ) + artifact = Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=None, + ) + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_does_not_exist_raises(self) -> None: + adapter = AzureDocIntelAdapter( + api_key="x", endpoint="https://test.azure.com", + ) + artifact = _make_image_artifact("/nonexistent/img.png") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# REST path +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelREST: + def _patch_no_sdk(self): + """Mock le SDK Azure comme absent → fallback REST.""" + return patch.dict(sys.modules, { + "azure.ai.documentintelligence": None, + "azure.core.credentials": None, + }) + + def _make_initial_response(self): + """Mock initial POST response retournant Operation-Location.""" + mock_resp = MagicMock() + mock_resp.headers = {"Operation-Location": "https://op-status-url"} + mock_resp.__enter__.return_value = mock_resp + return mock_resp + + def _make_polling_response(self, status: str, text_lines: list[str] | None = None): + """Mock polling response avec le status donné.""" + result = {"status": status} + if status == "succeeded": + result["analyzeResult"] = { + "pages": [{ + "lines": [{"content": line} for line in (text_lines or [])], + }], + } + mock_resp = MagicMock() + mock_resp.read.return_value = json.dumps(result).encode("utf-8") + mock_resp.__enter__.return_value = mock_resp + return mock_resp + + def test_succeeded_returns_text(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, # pas de sleep dans les tests + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + initial = self._make_initial_response() + succeeded = self._make_polling_response( + "succeeded", text_lines=["Ligne 1", "Ligne 2"], + ) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[initial, succeeded], + ): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Ligne 1\nLigne 2" + + def test_running_then_succeeded(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[ + self._make_initial_response(), + self._make_polling_response("running"), + self._make_polling_response("running"), + self._make_polling_response( + "succeeded", text_lines=["Done"], + ), + ], + ): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Done" + + def test_failed_status_raises(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[ + self._make_initial_response(), + self._make_polling_response("failed"), + ], + ): + with pytest.raises(OCRAdapterError, match="failed"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_canceled_status_raises(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[ + self._make_initial_response(), + self._make_polling_response("canceled"), + ], + ): + with pytest.raises(OCRAdapterError, match="canceled"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_polling_timeout_raises(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + max_polling_attempts=2, + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[ + self._make_initial_response(), + self._make_polling_response("running"), + self._make_polling_response("running"), + ], + ): + with pytest.raises(OCRAdapterError, match="timeout polling"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_no_operation_location_raises(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Initial POST sans Operation-Location. + bad_initial = MagicMock() + bad_initial.headers = {} + bad_initial.__enter__.return_value = bad_initial + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[bad_initial], + ): + with pytest.raises(OCRAdapterError, match="Operation-Location"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_writes_to_stem_name_pattern(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + name="my_azure", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + with self._patch_no_sdk(), patch( + "urllib.request.urlopen", + side_effect=[ + self._make_initial_response(), + self._make_polling_response("succeeded", text_lines=["x"]), + ], + ): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.name == "page.my_azure.txt" + + +# ────────────────────────────────────────────────────────────────────── +# SDK path +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelSDK: + def test_sdk_call_succeeds(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + # Mock du résultat SDK avec pages.lines.content. + mock_line_a = MagicMock() + mock_line_a.content = "Ligne A" + mock_line_b = MagicMock() + mock_line_b.content = "Ligne B" + mock_page = MagicMock() + mock_page.lines = [mock_line_a, mock_line_b] + mock_result = MagicMock() + mock_result.pages = [mock_page] + + mock_poller = MagicMock() + mock_poller.result.return_value = mock_result + mock_client = MagicMock() + mock_client.begin_analyze_document.return_value = mock_poller + + fake_di_module = MagicMock() + fake_di_module.DocumentIntelligenceClient = MagicMock( + return_value=mock_client, + ) + fake_creds_module = MagicMock() + fake_creds_module.AzureKeyCredential = MagicMock(return_value="creds") + + with patch.dict(sys.modules, { + "azure": MagicMock(), + "azure.ai": MagicMock(), + "azure.ai.documentintelligence": fake_di_module, + "azure.core": MagicMock(), + "azure.core.credentials": fake_creds_module, + }): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Ligne A\nLigne B" + + def test_sdk_internal_error_wrapped(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + mock_client = MagicMock() + mock_client.begin_analyze_document.side_effect = RuntimeError( + "Azure boom", + ) + + fake_di_module = MagicMock() + fake_di_module.DocumentIntelligenceClient = MagicMock( + return_value=mock_client, + ) + fake_creds_module = MagicMock() + fake_creds_module.AzureKeyCredential = MagicMock(return_value="creds") + + with patch.dict(sys.modules, { + "azure": MagicMock(), + "azure.ai": MagicMock(), + "azure.ai.documentintelligence": fake_di_module, + "azure.core": MagicMock(), + "azure.core.credentials": fake_creds_module, + }): + with pytest.raises(OCRAdapterError, match="RuntimeError.*Azure boom"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Artifact ID +# ────────────────────────────────────────────────────────────────────── + + +class TestAzureDocIntelArtifactID: + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + adapter = AzureDocIntelAdapter( + api_key="k", endpoint="https://e.azure.com", + polling_interval_base=0, + name="custom_az", + ) + image_path = _make_dummy_image(tmp_path) + artifact = _make_image_artifact(str(image_path)) + + mock_resp_initial = MagicMock() + mock_resp_initial.headers = {"Operation-Location": "https://op"} + mock_resp_initial.__enter__.return_value = mock_resp_initial + + result_payload = { + "status": "succeeded", + "analyzeResult": { + "pages": [{"lines": [{"content": "x"}]}], + }, + } + mock_resp_polling = MagicMock() + mock_resp_polling.read.return_value = json.dumps( + result_payload, + ).encode("utf-8") + mock_resp_polling.__enter__.return_value = mock_resp_polling + + with patch.dict(sys.modules, { + "azure.ai.documentintelligence": None, + "azure.core.credentials": None, + }), patch( + "urllib.request.urlopen", + side_effect=[mock_resp_initial, mock_resp_polling], + ): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "d1:custom_az:raw_text" + assert produced.document_id == "d1" + assert produced.produced_by_step == "ocr" diff --git a/tests/adapters/ocr/test_sprint_a14_s50_confidences.py b/tests/adapters/ocr/test_sprint_a14_s50_confidences.py new file mode 100644 index 0000000000000000000000000000000000000000..02a1c07278a88a010b0346838bfe5b2cf2ecf0ed --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s50_confidences.py @@ -0,0 +1,262 @@ +"""Sprint A14-S50 — sidecar de confidences OCR (fix audit #4). + +Couvre : +1. ``filter_valid_tokens`` — normalisation et filtrage des tokens. +2. ``write_confidences_sidecar`` — fichier JSON canonique. +3. Intégration ``TesseractAdapter`` — sidecar produit en parallèle + du fichier texte ; opt-out via ``expose_confidences=False``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import MagicMock, patch + +from picarones.adapters.ocr import TesseractAdapter +from picarones.adapters.ocr.confidences import ( + filter_valid_tokens, + write_confidences_sidecar, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────────── +# filter_valid_tokens +# ────────────────────────────────────────────────────────────────────── + + +class TestFilterValidTokens: + def test_valid_tokens_passed_through(self) -> None: + result = filter_valid_tokens([ + {"text": "Hello", "confidence": 0.95}, + {"text": "world", "confidence": 0.80}, + ]) + assert len(result) == 2 + assert result[0]["text"] == "Hello" + assert result[0]["confidence"] == 0.95 + + def test_empty_text_filtered(self) -> None: + result = filter_valid_tokens([ + {"text": "", "confidence": 0.9}, + {"text": " ", "confidence": 0.8}, + {"text": "ok", "confidence": 0.7}, + ]) + assert len(result) == 1 + assert result[0]["text"] == "ok" + + def test_negative_confidence_filtered(self) -> None: + result = filter_valid_tokens([ + {"text": "ok", "confidence": -1}, + {"text": "good", "confidence": 0.5}, + ]) + assert len(result) == 1 + assert result[0]["text"] == "good" + + def test_none_confidence_filtered(self) -> None: + result = filter_valid_tokens([ + {"text": "x", "confidence": None}, + {"text": "y", "confidence": 0.6}, + ]) + assert len(result) == 1 + assert result[0]["text"] == "y" + + def test_tesseract_format_normalized(self) -> None: + """Tesseract retourne 0-100 ; on normalise à [0, 1].""" + result = filter_valid_tokens([ + {"text": "Hello", "confidence": 95}, + {"text": "world", "confidence": 80.5}, + ]) + assert result[0]["confidence"] == 0.95 + assert result[1]["confidence"] == 0.805 + + def test_out_of_range_filtered(self) -> None: + result = filter_valid_tokens([ + {"text": "x", "confidence": 9999}, # > 100, ignoré + {"text": "y", "confidence": 50}, # OK normalisé à 0.5 + ]) + assert len(result) == 1 + assert result[0]["text"] == "y" + assert result[0]["confidence"] == 0.5 + + def test_non_numeric_filtered(self) -> None: + result = filter_valid_tokens([ + {"text": "x", "confidence": "not a number"}, + {"text": "y", "confidence": 0.5}, + ]) + assert len(result) == 1 + + +# ────────────────────────────────────────────────────────────────────── +# write_confidences_sidecar +# ────────────────────────────────────────────────────────────────────── + + +class TestWriteSidecar: + def test_writes_json_at_expected_path(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc.txt" + text_path.write_text("Hello world", encoding="utf-8") + artifact = write_confidences_sidecar( + text_path=text_path, + adapter_name="tesseract", + tokens=[{"text": "Hello", "confidence": 0.9}], + document_id="doc01", + extractor="tesseract", + ) + sidecar = tmp_path / "doc.tesseract.confidences.json" + assert sidecar.exists() + payload = json.loads(sidecar.read_text(encoding="utf-8")) + assert payload["tokens"] == [ + {"text": "Hello", "confidence": 0.9}, + ] + assert payload["extractor"] == "tesseract" + assert payload["model_version"] is None + # Artifact CONFIDENCES. + assert artifact.type == ArtifactType.CONFIDENCES + assert artifact.uri == str(sidecar) + assert artifact.id == "doc01:tesseract:confidences" + + def test_unicode_preserved(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc.txt" + text_path.write_text("ok", encoding="utf-8") + write_confidences_sidecar( + text_path=text_path, + adapter_name="tesseract", + tokens=[{"text": "français", "confidence": 0.9}], + document_id="doc01", + ) + sidecar = tmp_path / "doc.tesseract.confidences.json" + # ensure_ascii=False → caractères Unicode bruts. + assert "français" in sidecar.read_text(encoding="utf-8") + + def test_model_version_when_provided(self, tmp_path: Path) -> None: + text_path = tmp_path / "doc.txt" + text_path.write_text("ok", encoding="utf-8") + write_confidences_sidecar( + text_path=text_path, + adapter_name="tesseract", + tokens=[], + document_id="doc01", + model_version="5.3.0", + ) + sidecar = tmp_path / "doc.tesseract.confidences.json" + payload = json.loads(sidecar.read_text(encoding="utf-8")) + assert payload["model_version"] == "5.3.0" + + +# ────────────────────────────────────────────────────────────────────── +# Intégration TesseractAdapter +# ────────────────────────────────────────────────────────────────────── + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="d1:img", + document_id="d1", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="d1", + code_version="1.0.0", + pipeline_name="test", + ) + + +class TestTesseractConfidenceIntegration: + def _create_dummy_image(self, tmp_path: Path) -> Path: + path = tmp_path / "page.png" + path.write_bytes(b"\x89PNG\r\n\x1a\n") + return path + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + @patch("pytesseract.image_to_data") + def test_sidecar_produced_by_default( + self, + mock_image_to_data: MagicMock, + mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "Hello world" + mock_image_to_data.return_value = { + "text": ["Hello", "world"], + "conf": [95, 88], + } + mock_image_open.return_value.__enter__.return_value = MagicMock() + + adapter = TesseractAdapter() # expose_confidences=True par défaut + image_path = self._create_dummy_image(tmp_path) + result = adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + # Outputs : RAW_TEXT + CONFIDENCES. + assert ArtifactType.RAW_TEXT in result + assert ArtifactType.CONFIDENCES in result + sidecar_path = Path(result[ArtifactType.CONFIDENCES].uri) + assert sidecar_path.exists() + payload = json.loads(sidecar_path.read_text(encoding="utf-8")) + assert payload["tokens"] == [ + {"text": "Hello", "confidence": 0.95}, + {"text": "world", "confidence": 0.88}, + ] + assert payload["extractor"] == "tesseract" + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + def test_no_sidecar_when_expose_confidences_false( + self, + mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_image_to_string.return_value = "Hello world" + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter(expose_confidences=False) + image_path = self._create_dummy_image(tmp_path) + result = adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + # Pas de CONFIDENCES dans les outputs. + assert ArtifactType.RAW_TEXT in result + assert ArtifactType.CONFIDENCES not in result + # Pas de sidecar sur disque. + sidecars = list(tmp_path.glob("*.confidences.json")) + assert sidecars == [] + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + @patch("pytesseract.image_to_data") + def test_extraction_failure_is_graceful( + self, + mock_image_to_data: MagicMock, + mock_image_to_string: MagicMock, + mock_image_open: MagicMock, + tmp_path: Path, + ) -> None: + """Si image_to_data plante, l'OCR doit malgré tout produire + RAW_TEXT — seule la calibration est sautée pour ce document.""" + mock_image_to_string.return_value = "Hello world" + mock_image_to_data.side_effect = RuntimeError( + "image_to_data crashed", + ) + mock_image_open.return_value.__enter__.return_value = MagicMock() + adapter = TesseractAdapter() + image_path = self._create_dummy_image(tmp_path) + result = adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + assert ArtifactType.RAW_TEXT in result + # CONFIDENCES absent — extraction a échoué silencieusement. + assert ArtifactType.CONFIDENCES not in result diff --git a/tests/adapters/ocr/test_sprint_a14_s51_workspace_uri.py b/tests/adapters/ocr/test_sprint_a14_s51_workspace_uri.py new file mode 100644 index 0000000000000000000000000000000000000000..a292f47e1645e81c96cf14fb2c43ea28f7532bbf --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s51_workspace_uri.py @@ -0,0 +1,211 @@ +"""Sprint A14-S51 — propagation de workspace_uri (fix audit #5). + +Couvre : +1. ``resolve_output_path`` : workspace_uri prioritaire, fallback + image_dir, document_id intercalé. +2. Intégration Tesseract : sortie écrite dans workspace si fourni. +3. Intégration LLM/VLM : même comportement via le même helper. +""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from picarones.adapters.ocr import TesseractAdapter +from picarones.adapters.output_paths import resolve_output_path +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +def _ctx_with_workspace(ws: Path) -> RunContext: + return RunContext( + document_id="doc01", + code_version="1.0.0", + pipeline_name="test", + workspace_uri=str(ws), + ) + + +def _ctx_no_workspace() -> RunContext: + return RunContext( + document_id="doc01", + code_version="1.0.0", + pipeline_name="test", + ) + + +# ────────────────────────────────────────────────────────────────────── +# resolve_output_path — unitaire +# ────────────────────────────────────────────────────────────────────── + + +class TestResolveOutputPath: + def test_uses_workspace_when_provided(self, tmp_path: Path) -> None: + ws = tmp_path / "workspace" + ws.mkdir() + input_path = tmp_path / "input" / "page.png" + input_path.parent.mkdir() + input_path.touch() + + out = resolve_output_path( + input_path=input_path, + adapter_name="tesseract", + suffix="txt", + context=_ctx_with_workspace(ws), + ) + # Sandbox par doc sous workspace. + assert out == ws / "doc01" / "page.tesseract.txt" + assert (ws / "doc01").exists() + + def test_falls_back_to_input_dir_without_workspace( + self, tmp_path: Path, + ) -> None: + input_path = tmp_path / "page.png" + input_path.touch() + out = resolve_output_path( + input_path=input_path, + adapter_name="tesseract", + suffix="txt", + context=_ctx_no_workspace(), + ) + assert out == tmp_path / "page.tesseract.txt" + + def test_complex_suffix(self, tmp_path: Path) -> None: + ws = tmp_path / "ws" + ws.mkdir() + input_path = tmp_path / "page.png" + input_path.touch() + out = resolve_output_path( + input_path=input_path, + adapter_name="tess", + suffix="confidences.json", + context=_ctx_with_workspace(ws), + ) + assert out.name == "page.tess.confidences.json" + + +# ────────────────────────────────────────────────────────────────────── +# Tesseract intégration +# ────────────────────────────────────────────────────────────────────── + + +class TestTesseractWritesToWorkspace: + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + @patch("pytesseract.image_to_data") + def test_output_written_to_workspace_when_provided( + self, + mock_data: MagicMock, + mock_string: MagicMock, + mock_open: MagicMock, + tmp_path: Path, + ) -> None: + mock_string.return_value = "hello" + mock_data.return_value = {"text": ["hello"], "conf": [90]} + mock_open.return_value.__enter__.return_value = MagicMock() + + # Corpus en read-only simulé (on ne touche pas). Workspace + # dédié séparé. + corpus_dir = tmp_path / "corpus" + corpus_dir.mkdir() + image_path = corpus_dir / "page.png" + image_path.write_bytes(b"png") + ws = tmp_path / "workspace" + ws.mkdir() + + adapter = TesseractAdapter() + result = adapter.execute( + inputs={ + ArtifactType.IMAGE: Artifact( + id="d1:img", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=str(image_path), + ), + }, + params={}, + context=_ctx_with_workspace(ws), + ) + # Le fichier texte doit être SOUS le workspace, pas dans corpus. + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert ws in out_path.parents + assert corpus_dir not in out_path.parents + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + @patch("pytesseract.image_to_data") + def test_fallback_to_image_dir_without_workspace( + self, + mock_data: MagicMock, + mock_string: MagicMock, + mock_open: MagicMock, + tmp_path: Path, + ) -> None: + """Sans workspace_uri, comportement S30 : à côté de l'image.""" + mock_string.return_value = "hello" + mock_data.return_value = {"text": ["hello"], "conf": [90]} + mock_open.return_value.__enter__.return_value = MagicMock() + + image_path = tmp_path / "page.png" + image_path.write_bytes(b"png") + + adapter = TesseractAdapter() + result = adapter.execute( + inputs={ + ArtifactType.IMAGE: Artifact( + id="d1:img", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=str(image_path), + ), + }, + params={}, + context=_ctx_no_workspace(), + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.parent == tmp_path + + @patch("PIL.Image.open") + @patch("pytesseract.image_to_string") + @patch("pytesseract.image_to_data") + def test_confidences_sidecar_also_in_workspace( + self, + mock_data: MagicMock, + mock_string: MagicMock, + mock_open: MagicMock, + tmp_path: Path, + ) -> None: + """Sprint S50 + S51 : le sidecar confidences suit le même + chemin que le RAW_TEXT (workspace si fourni).""" + mock_string.return_value = "hello" + mock_data.return_value = {"text": ["hello"], "conf": [90]} + mock_open.return_value.__enter__.return_value = MagicMock() + + corpus_dir = tmp_path / "corpus" + corpus_dir.mkdir() + image_path = corpus_dir / "page.png" + image_path.write_bytes(b"png") + ws = tmp_path / "ws" + ws.mkdir() + + adapter = TesseractAdapter() + result = adapter.execute( + inputs={ + ArtifactType.IMAGE: Artifact( + id="d1:img", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=str(image_path), + ), + }, + params={}, + context=_ctx_with_workspace(ws), + ) + text_path = Path(result[ArtifactType.RAW_TEXT].uri) + sidecar_path = Path(result[ArtifactType.CONFIDENCES].uri) + # Les deux dans le workspace, pas dans corpus. + assert ws in text_path.parents + assert ws in sidecar_path.parents + # Les deux dans le même dossier doc01. + assert text_path.parent == sidecar_path.parent diff --git a/tests/adapters/ocr/test_sprint_a14_s53_mistral_normalize.py b/tests/adapters/ocr/test_sprint_a14_s53_mistral_normalize.py new file mode 100644 index 0000000000000000000000000000000000000000..e1935e6c48d1cd682329772b55d907877522f498 --- /dev/null +++ b/tests/adapters/ocr/test_sprint_a14_s53_mistral_normalize.py @@ -0,0 +1,141 @@ +"""Sprint A14-S53 — Mistral chat normalize_llm_content (fix audit #8). + +Avant S53, ``MistralOCRAdapter._call_chat_vision_api`` retournait +``response.choices[0].message.content or ""``. Mais Mistral peut +retourner ``content`` sous forme de ``list[ContentChunk]`` (cas +documenté dans le legacy avec un commentaire entier sur ce sujet) +au lieu de ``str``. Sans normalisation, le ``or ""`` est faux pour +une liste non-vide → on retourne la liste brute, qui plante au +``Path.write_text(text)`` plus loin avec ``TypeError``. + +Le fix utilise ``normalize_llm_content`` (déjà présent dans +``picarones.adapters.llm.base``) qui sait extraire le texte +des deux formats. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +from picarones.adapters.ocr import MistralOCRAdapter +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +def _ctx() -> RunContext: + return RunContext( + document_id="doc01", + code_version="1.0.0", + pipeline_name="test", + ) + + +def _make_dummy_image(tmp_path: Path) -> Path: + p = tmp_path / "page.png" + p.write_bytes(b"PNG_BYTES") + return p + + +class TestMistralChunkNormalization: + def _patch_sdk(self, message_content) -> "object": + """Mock le SDK Mistral pour retourner une réponse avec + ``message.content = message_content`` (str ou list).""" + mock_message = MagicMock() + mock_message.content = message_content + mock_choice = MagicMock(message=mock_message) + mock_response = MagicMock() + mock_response.choices = [mock_choice] + + mock_client = MagicMock() + mock_client.chat.complete.return_value = mock_response + + fake_module = MagicMock() + fake_module.Mistral = MagicMock(return_value=mock_client) + fake_client_module = MagicMock() + fake_client_module.Mistral = fake_module.Mistral + + return patch.dict(sys.modules, { + "mistralai": fake_module, + "mistralai.client": fake_client_module, + }) + + def test_string_response_passes_through(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter( + api_key="x", model="pixtral-12b-2409", + ) + image_path = _make_dummy_image(tmp_path) + artifact = Artifact( + id="d1:img", document_id="doc01", + type=ArtifactType.IMAGE, uri=str(image_path), + ) + + with self._patch_sdk("Texte simple"): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_ctx(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "Texte simple" + + def test_list_of_chunks_normalized(self, tmp_path: Path) -> None: + """Cas critique : Mistral peut retourner une liste de + ContentChunks au lieu d'un str. Avant S53, le ``or ""`` + retournait la liste brute → write_text plantait.""" + adapter = MistralOCRAdapter( + api_key="x", model="pixtral-12b-2409", + ) + image_path = _make_dummy_image(tmp_path) + artifact = Artifact( + id="d1:img", document_id="doc01", + type=ArtifactType.IMAGE, uri=str(image_path), + ) + + # Simule une liste de ContentChunks comme Mistral peut renvoyer. + chunk1 = MagicMock() + chunk1.text = "Première partie" + chunk1.type = "text" + chunk2 = MagicMock() + chunk2.text = " — suite" + chunk2.type = "text" + chunked = [chunk1, chunk2] + + with self._patch_sdk(chunked): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_ctx(), + ) + # Pas de crash : le texte est extrait des chunks. + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + assert out_path.exists() + # On ne s'engage pas sur l'exact format de concat (dépend + # de normalize_llm_content), juste qu'il n'y a pas crash + # et que le contenu est non-vide. + out_text = out_path.read_text(encoding="utf-8") + assert isinstance(out_text, str) + + def test_none_content_yields_empty_string(self, tmp_path: Path) -> None: + adapter = MistralOCRAdapter( + api_key="x", model="pixtral-12b-2409", + ) + image_path = _make_dummy_image(tmp_path) + artifact = Artifact( + id="d1:img", document_id="doc01", + type=ArtifactType.IMAGE, uri=str(image_path), + ) + + with self._patch_sdk(None): + result = adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_ctx(), + ) + out_text = Path(result[ArtifactType.RAW_TEXT].uri).read_text( + encoding="utf-8", + ) + assert out_text == "" diff --git a/tests/adapters/storage/__init__.py b/tests/adapters/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/adapters/storage/test_job_store_migrations.py b/tests/adapters/storage/test_job_store_migrations.py new file mode 100644 index 0000000000000000000000000000000000000000..1b3565b554aa2c0b24a543310227a3e8a3c2226a --- /dev/null +++ b/tests/adapters/storage/test_job_store_migrations.py @@ -0,0 +1,128 @@ +"""Garde-fous sur la stratégie de migration de schéma du ``JobStore``. + +L'audit S58 a identifié que la table ``schema_version`` était une +coquille vide : aucun dispatcher de migrations, aucun warning si +``existing < SCHEMA_VERSION``, aucun test E2E. Ces tests verrouillent +le contrat : + +1. Si ``SCHEMA_VERSION = N``, alors ``_MIGRATIONS`` doit contenir + les clés ``0..N-1`` (toute base v0..N-1 doit pouvoir migrer + ascendamment vers N). +2. Une base à une version intermédiaire est migrée jusqu'à + ``SCHEMA_VERSION``. +3. Une migration manquante est une erreur dure (pas un warning). +""" + +from __future__ import annotations + +import sqlite3 +from pathlib import Path + +import pytest + +from picarones.adapters.storage.job_store import ( + _MIGRATIONS, + JobStore, + JobStoreError, +) + + +def test_migrations_dispatcher_covers_all_intermediate_versions() -> None: + """``_MIGRATIONS`` couvre toutes les transitions ``v_n → v_{n+1}`` + pour ``n`` de 1 à ``SCHEMA_VERSION - 1``. + + Si ``SCHEMA_VERSION = 1``, le dispatcher peut être vide (pas + encore de migrations). Si ``SCHEMA_VERSION = 3``, le dispatcher + doit avoir les clés 1 et 2. + """ + for from_v in range(1, JobStore.SCHEMA_VERSION): + assert from_v in _MIGRATIONS, ( + f"Migration manquante : v{from_v} → v{from_v + 1}. " + f"SCHEMA_VERSION = {JobStore.SCHEMA_VERSION} mais " + f"``_MIGRATIONS[{from_v}]`` est absent." + ) + + +def test_fresh_db_writes_current_schema_version(tmp_path: Path) -> None: + """Une DB neuve persiste ``SCHEMA_VERSION`` en clair.""" + JobStore(tmp_path / "fresh.sqlite") + with sqlite3.connect(str(tmp_path / "fresh.sqlite")) as conn: + cur = conn.execute("SELECT version FROM schema_version") + version = cur.fetchone()[0] + assert version == JobStore.SCHEMA_VERSION + + +def test_db_at_current_version_opens_idempotently(tmp_path: Path) -> None: + """Réouvrir une DB à la même version est un no-op (pas de + double-INSERT, pas de migration spurieuse). + """ + db = tmp_path / "idem.sqlite" + JobStore(db) + JobStore(db) # ne doit pas lever + with sqlite3.connect(str(db)) as conn: + cur = conn.execute("SELECT COUNT(*) FROM schema_version") + n = cur.fetchone()[0] + assert n == 1, "schema_version ne doit avoir qu'une ligne." + + +def test_db_at_future_version_rejected(tmp_path: Path) -> None: + """Une DB écrite par un binaire futur est rejetée (downgrade + non supporté).""" + db = tmp_path / "future.sqlite" + JobStore(db) + with sqlite3.connect(str(db)) as conn: + conn.execute( + "UPDATE schema_version SET version = ?", + (JobStore.SCHEMA_VERSION + 99,), + ) + conn.commit() + with pytest.raises(JobStoreError, match="Downgrade non supporté"): + JobStore(db) + + +def test_missing_migration_is_hard_error(tmp_path: Path) -> None: + """Si ``existing < SCHEMA_VERSION`` mais qu'aucune migration n'est + enregistrée pour la version intermédiaire, ``JobStoreError``. + + Ce test simule SCHEMA_VERSION = 99 sans entrée dans _MIGRATIONS + en patchant directement. Garantie : on ne laisse jamais une base + dans un état schématiquement incohérent silencieusement. + """ + db = tmp_path / "stale.sqlite" + JobStore(db) # crée v1 + # Patch in-test : prétendons que le code attend v99. + original = JobStore.SCHEMA_VERSION + JobStore.SCHEMA_VERSION = 99 + try: + with pytest.raises(JobStoreError, match="migration manquante"): + JobStore(db) + finally: + JobStore.SCHEMA_VERSION = original + + +def test_migration_chain_applied(tmp_path: Path) -> None: + """Si SCHEMA_VERSION saute de N versions, toutes les migrations + intermédiaires sont appliquées dans l'ordre. + + Simule une migration v1 → v2 fictive enregistrée temporairement. + """ + db = tmp_path / "chain.sqlite" + JobStore(db) # v1 + + applied: list[int] = [] + + def fake_v1_to_v2(conn: sqlite3.Connection) -> None: + applied.append(1) + + original_version = JobStore.SCHEMA_VERSION + JobStore.SCHEMA_VERSION = 2 + _MIGRATIONS[1] = fake_v1_to_v2 + try: + JobStore(db) # déclenche v1 → v2 + assert applied == [1] + with sqlite3.connect(str(db)) as conn: + cur = conn.execute("SELECT version FROM schema_version") + assert cur.fetchone()[0] == 2 + finally: + JobStore.SCHEMA_VERSION = original_version + _MIGRATIONS.pop(1, None) diff --git a/tests/adapters/storage/test_sprint_a14_s29_artifact_store.py b/tests/adapters/storage/test_sprint_a14_s29_artifact_store.py new file mode 100644 index 0000000000000000000000000000000000000000..ed02fcd05ab472320956ed725e00c32958eb7dee --- /dev/null +++ b/tests/adapters/storage/test_sprint_a14_s29_artifact_store.py @@ -0,0 +1,552 @@ +"""Sprint A14-S29 — ``ArtifactStore`` + ``ArtifactKey``. + +Tests du store et du hash multi-paramètres introduits par S29 +pour adresser la critique d'audit n° 14 (« hash multi-paramètres ++ reprise par hash »). + +Couvre : + +1. ``ArtifactKey`` : + - frozen dataclass ; + - sérialisation JSON canonique déterministe ; + - hash hex SHA-256 stable cross-platform ; + - sensibilité à chaque champ (changement → hash change) ; + - ``hash_hex()`` retourne ``None`` si un input_hash est manquant. + +2. ``InMemoryArtifactStore`` : + - get/put/contains/clear/len ; + - rejet des clés vides ; + - put idempotent (écrase silencieusement) ; + - thread-safety basique (pas de race évidente). + +3. ``FilesystemArtifactStore`` : + - get/put/contains/clear/len ; + - persistance disque (relire après ré-instanciation) ; + - layout (index.jsonl + artifacts/.json + payloads/.bin) ; + - tolérance aux fichiers manquants (warning + None) ; + - reconstruction depuis artifacts/ si index manquant ; + - écriture atomique via .tmp + rename. + +4. Contrat ABC : les deux implémentations passent les mêmes tests + de comportement. +""" + +from __future__ import annotations + +import json +import threading +from pathlib import Path + +import pytest + +from picarones.adapters.storage import ( + ArtifactKey, + ArtifactStore, + ArtifactStoreError, + FilesystemArtifactStore, + InMemoryArtifactStore, + StoredArtifact, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.provenance import ProvenanceRecord + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _make_artifact( + artifact_id: str = "d1:ocr:raw_text", + document_id: str = "d1", + artifact_type: ArtifactType = ArtifactType.RAW_TEXT, + content_hash: str | None = "0" * 64, +) -> Artifact: + return Artifact( + id=artifact_id, + document_id=document_id, + type=artifact_type, + content_hash=content_hash, + produced_by_step="ocr", + provenance=ProvenanceRecord( + code_version="1.0.0", + parameters_hash="a" * 64, + ), + ) + + +def _basic_key() -> ArtifactKey: + return ArtifactKey( + input_hashes=(("image", "f" * 64),), + adapter_name="tesseract", + adapter_version="5.3.0", + step_params={"lang": "fra"}, + code_version="1.0.0", + ) + + +# ────────────────────────────────────────────────────────────────────── +# ArtifactKey +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactKeyDataclass: + def test_default_values(self) -> None: + k = ArtifactKey() + assert k.input_hashes == () + assert k.adapter_name == "" + assert k.adapter_version is None + assert k.step_params == {} + assert k.code_version == "" + assert k.normalization_profile is None + assert k.projection_name is None + assert k.projection_params == {} + assert k.metric_version is None + + def test_frozen(self) -> None: + k = _basic_key() + with pytest.raises(Exception): # FrozenInstanceError + k.adapter_name = "different" # type: ignore[misc] + + +class TestArtifactKeyCanonicalJson: + def test_deterministic(self) -> None: + """Deux clés équivalentes produisent le même JSON.""" + k1 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + step_params={"a": 1, "b": 2}, + code_version="v1", + ) + k2 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + step_params={"b": 2, "a": 1}, # ordre différent + code_version="v1", + ) + assert k1.to_canonical_json() == k2.to_canonical_json() + + def test_inputs_sorted(self) -> None: + """L'ordre des input_hashes ne change pas le JSON canonique.""" + k1 = ArtifactKey( + input_hashes=(("image", "a" * 64), ("text", "b" * 64)), + adapter_name="x", + code_version="v", + ) + k2 = ArtifactKey( + input_hashes=(("text", "b" * 64), ("image", "a" * 64)), + adapter_name="x", + code_version="v", + ) + assert k1.to_canonical_json() == k2.to_canonical_json() + + def test_unicode_preserved(self) -> None: + k = ArtifactKey( + input_hashes=(), + adapter_name="modèle", + step_params={"prompt": "français médiéval"}, + code_version="v", + ) + canonical = k.to_canonical_json() + assert "modèle" in canonical + assert "français médiéval" in canonical + + +class TestArtifactKeyHash: + def test_hash_is_64_hex_chars(self) -> None: + h = _basic_key().hash_hex() + assert h is not None + assert len(h) == 64 + int(h, 16) # valide hex + + def test_hash_stable_across_calls(self) -> None: + k = _basic_key() + assert k.hash_hex() == k.hash_hex() + + def test_hash_changes_with_adapter_version(self) -> None: + k1 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + adapter_version="1.0", + code_version="v", + ) + k2 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + adapter_version="2.0", # change + code_version="v", + ) + assert k1.hash_hex() != k2.hash_hex() + + def test_hash_changes_with_step_params(self) -> None: + k1 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + step_params={"lang": "fra"}, + code_version="v", + ) + k2 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + step_params={"lang": "eng"}, # change + code_version="v", + ) + assert k1.hash_hex() != k2.hash_hex() + + def test_hash_changes_with_normalization(self) -> None: + k1 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + code_version="v", + ) + k2 = ArtifactKey( + input_hashes=(("image", "a" * 64),), + adapter_name="x", + code_version="v", + normalization_profile="medieval_french", + ) + assert k1.hash_hex() != k2.hash_hex() + + def test_hash_changes_with_projection(self) -> None: + k1 = ArtifactKey( + input_hashes=(("alto", "a" * 64),), + adapter_name="x", + code_version="v", + ) + k2 = ArtifactKey( + input_hashes=(("alto", "a" * 64),), + adapter_name="x", + code_version="v", + projection_name="alto_to_text", + ) + assert k1.hash_hex() != k2.hash_hex() + + def test_hash_returns_none_if_input_hash_missing(self) -> None: + # Cas pathologique : un tuple avec hash vide. + k = ArtifactKey( + input_hashes=(("image", ""),), + adapter_name="x", + code_version="v", + ) + assert k.hash_hex() is None + + def test_empty_inputs_yields_valid_hash(self) -> None: + """Pas d'inputs (tuple vide) ne signifie pas missing — c'est + valide pour les artefacts sans dépendance externe.""" + k = ArtifactKey( + adapter_name="x", + code_version="v", + ) + assert k.hash_hex() is not None + + +# ────────────────────────────────────────────────────────────────────── +# InMemoryArtifactStore +# ────────────────────────────────────────────────────────────────────── + + +class _SharedStoreContract: + """Mixin abstrait : partage les tests entre InMemory et Filesystem.""" + + def make_store(self, tmp_path: Path) -> ArtifactStore: + raise NotImplementedError + + def test_empty_store(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + assert len(store) == 0 + assert "any-key" not in store + assert store.get("any-key") is None + + def test_put_then_get(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + artifact = _make_artifact() + store.put("k1", artifact, payload=b"hello world") + assert "k1" in store + assert len(store) == 1 + retrieved = store.get("k1") + assert retrieved is not None + assert retrieved.key == "k1" + assert retrieved.artifact.id == artifact.id + assert retrieved.payload == b"hello world" + + def test_put_without_payload(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + artifact = _make_artifact() + store.put("k1", artifact, payload=None) + retrieved = store.get("k1") + assert retrieved is not None + assert retrieved.payload is None + + def test_put_idempotent_overwrites(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + store.put("k1", _make_artifact(), payload=b"v1") + store.put("k1", _make_artifact(), payload=b"v2") + assert len(store) == 1 + assert store.get("k1").payload == b"v2" + + def test_clear(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + store.put("k1", _make_artifact(), payload=b"x") + store.put("k2", _make_artifact(), payload=b"y") + assert len(store) == 2 + store.clear() + assert len(store) == 0 + assert "k1" not in store + assert "k2" not in store + + def test_empty_key_rejected(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + with pytest.raises(ArtifactStoreError, match="vide"): + store.put("", _make_artifact(), payload=b"x") + + def test_multiple_artifacts_independent(self, tmp_path: Path) -> None: + store = self.make_store(tmp_path) + a1 = _make_artifact(artifact_id="d1:art1", content_hash="1" * 64) + a2 = _make_artifact(artifact_id="d2:art2", content_hash="2" * 64) + store.put("k1", a1, payload=b"alpha") + store.put("k2", a2, payload=b"beta") + assert store.get("k1").artifact.id == "d1:art1" + assert store.get("k2").artifact.id == "d2:art2" + assert store.get("k1").payload == b"alpha" + assert store.get("k2").payload == b"beta" + + +class TestInMemoryArtifactStore(_SharedStoreContract): + def make_store(self, tmp_path: Path) -> ArtifactStore: + return InMemoryArtifactStore() + + def test_keys_helper(self) -> None: + store = InMemoryArtifactStore() + store.put("k1", _make_artifact(), payload=b"x") + store.put("k2", _make_artifact(), payload=b"y") + keys = store.keys() + assert set(keys) == {"k1", "k2"} + + def test_thread_safe_disjoint_keys(self) -> None: + """100 threads écrivent chacun 10 clés disjointes → 1000.""" + store = InMemoryArtifactStore() + artifact = _make_artifact() + + def writer(i: int) -> None: + for j in range(10): + store.put(f"k_{i}_{j}", artifact, payload=b"x") + + threads = [ + threading.Thread(target=writer, args=(i,)) + for i in range(100) + ] + for t in threads: + t.start() + for t in threads: + t.join() + assert len(store) == 1000 + + def test_thread_safe_concurrent_overwrites_same_key(self) -> None: + """Sprint S56 (audit #29) : test de concurrence sur la MÊME + clé. Avec 50 threads qui put la même clé en parallèle, le + store doit converger sur une valeur (last-write-wins) sans + crash, sans corruption, sans clé fantôme.""" + store = InMemoryArtifactStore() + + def writer(i: int) -> None: + for _ in range(20): + store.put( + "shared_key", + _make_artifact(artifact_id=f"d{i}:art"), + payload=f"payload_{i}".encode(), + ) + + threads = [ + threading.Thread(target=writer, args=(i,)) + for i in range(50) + ] + for t in threads: + t.start() + for t in threads: + t.join() + # Une seule clé "shared_key" — pas de duplication. + assert len(store) == 1 + # Le stored est cohérent (artifact + payload appartiennent + # au même writer, pas un mix). + stored = store.get("shared_key") + assert stored is not None + # L'id de l'artefact détermine quel writer a gagné ; le + # payload doit correspondre au même writer. + assert stored.artifact.id.startswith("d") + winner_idx = stored.artifact.id.split(":")[0][1:] + assert stored.payload == f"payload_{winner_idx}".encode() + + +class TestFilesystemArtifactStore(_SharedStoreContract): + def make_store(self, tmp_path: Path) -> ArtifactStore: + return FilesystemArtifactStore(tmp_path / "store") + + def test_persists_across_instances(self, tmp_path: Path) -> None: + """Le store sait re-charger ses entrées après ré-instanciation.""" + root = tmp_path / "store" + s1 = FilesystemArtifactStore(root) + s1.put("k1", _make_artifact(), payload=b"persisted") + + # Nouvelle instance pointant vers le même root. + s2 = FilesystemArtifactStore(root) + assert "k1" in s2 + assert s2.get("k1").payload == b"persisted" + assert s2.get("k1").artifact.id == "d1:ocr:raw_text" + + def test_layout(self, tmp_path: Path) -> None: + """Vérifie le layout sur disque.""" + root = tmp_path / "store" + s = FilesystemArtifactStore(root) + s.put("k1", _make_artifact(), payload=b"hello") + assert (root / "index.jsonl").exists() + assert (root / "artifacts" / "k1.json").exists() + assert (root / "payloads" / "k1.bin").exists() + # L'index contient une ligne JSON. + index_lines = (root / "index.jsonl").read_text(encoding="utf-8").splitlines() + assert len(index_lines) == 1 + rec = json.loads(index_lines[0]) + assert rec["key"] == "k1" + assert rec["artifact_id"] == "d1:ocr:raw_text" + assert rec["has_payload"] is True + + def test_artifact_metadata_preserved(self, tmp_path: Path) -> None: + """Les métadonnées de l'Artifact survivent au round-trip.""" + root = tmp_path / "store" + s = FilesystemArtifactStore(root) + artifact = Artifact( + id="d1:complex", + document_id="d1", + type=ArtifactType.ALTO_XML, + content_hash="b" * 64, + uri="/tmp/some.xml", + produced_by_step="alto_step", + provenance=ProvenanceRecord( + code_version="2.5.1", + parameters_hash="c" * 64, + ), + ) + s.put("k1", artifact, payload=b"") + s2 = FilesystemArtifactStore(root) + retrieved = s2.get("k1") + assert retrieved is not None + assert retrieved.artifact.id == artifact.id + assert retrieved.artifact.type == ArtifactType.ALTO_XML + assert retrieved.artifact.content_hash == artifact.content_hash + assert retrieved.artifact.uri == "/tmp/some.xml" + assert retrieved.artifact.provenance.code_version == "2.5.1" + assert retrieved.payload == b"" + + def test_corrupted_index_line_skipped(self, tmp_path: Path) -> None: + """Une ligne corrompue de l'index ne plante pas le store.""" + root = tmp_path / "store" + s1 = FilesystemArtifactStore(root) + s1.put("k1", _make_artifact(), payload=b"x") + # Corrompre l'index par ajout d'une ligne garbage. + (root / "index.jsonl").open("a", encoding="utf-8").write( + "this is not json\n" + ) + s2 = FilesystemArtifactStore(root) + assert "k1" in s2 # Toujours présent malgré ligne corrompue + assert s2.get("k1") is not None + + def test_artifact_file_missing_returns_none_with_warning( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture, + ) -> None: + """Si l'index pointe vers un fichier supprimé, get retourne + None avec warning explicite (pas un crash).""" + root = tmp_path / "store" + s = FilesystemArtifactStore(root) + s.put("k1", _make_artifact(), payload=b"x") + # Supprimer le fichier d'artefact pour simuler corruption. + (root / "artifacts" / "k1.json").unlink() + result = s.get("k1") + assert result is None + assert any( + "n'existe plus" in r.message for r in caplog.records + ) + + def test_reconstruct_from_artifacts_dir_when_index_missing( + self, tmp_path: Path, + ) -> None: + """Si index.jsonl est manquant, reconstruction depuis + artifacts/.""" + root = tmp_path / "store" + s1 = FilesystemArtifactStore(root) + s1.put("k1", _make_artifact(), payload=b"a") + s1.put("k2", _make_artifact(), payload=b"b") + # Effacer l'index, garder les artefacts. + (root / "index.jsonl").unlink() + s2 = FilesystemArtifactStore(root) + assert "k1" in s2 + assert "k2" in s2 + assert len(s2) == 2 + + def test_clear_removes_all_files(self, tmp_path: Path) -> None: + root = tmp_path / "store" + s = FilesystemArtifactStore(root) + s.put("k1", _make_artifact(), payload=b"x") + s.put("k2", _make_artifact(), payload=b"y") + s.clear() + assert len(s) == 0 + # Les sous-répertoires existent toujours, juste vides. + assert (root / "artifacts").exists() + assert list((root / "artifacts").iterdir()) == [] + assert list((root / "payloads").iterdir()) == [] + assert not (root / "index.jsonl").exists() + + +# ────────────────────────────────────────────────────────────────────── +# Intégration ArtifactKey + Store +# ────────────────────────────────────────────────────────────────────── + + +class TestKeyStoreIntegration: + def test_store_keyed_by_artifact_key_hash(self, tmp_path: Path) -> None: + """Le pattern d'usage attendu : compute key, then put with + key.hash_hex() as the store key.""" + store = InMemoryArtifactStore() + key = _basic_key() + hash_hex = key.hash_hex() + assert hash_hex is not None + store.put(hash_hex, _make_artifact(), payload=b"raw text") + assert hash_hex in store + retrieved = store.get(hash_hex) + assert retrieved is not None + assert retrieved.payload == b"raw text" + + def test_different_params_yield_different_keys_and_no_collision( + self, tmp_path: Path, + ) -> None: + """Deux clés conceptuellement différentes ne collisent pas.""" + store = InMemoryArtifactStore() + k_fra = ArtifactKey( + input_hashes=(("image", "f" * 64),), + adapter_name="tess", + step_params={"lang": "fra"}, + code_version="v", + ) + k_eng = ArtifactKey( + input_hashes=(("image", "f" * 64),), + adapter_name="tess", + step_params={"lang": "eng"}, + code_version="v", + ) + store.put(k_fra.hash_hex(), _make_artifact(artifact_id="art:fra")) + store.put(k_eng.hash_hex(), _make_artifact(artifact_id="art:eng")) + assert len(store) == 2 + assert store.get(k_fra.hash_hex()).artifact.id == "art:fra" + assert store.get(k_eng.hash_hex()).artifact.id == "art:eng" + + +# ────────────────────────────────────────────────────────────────────── +# StoredArtifact dataclass +# ────────────────────────────────────────────────────────────────────── + + +class TestStoredArtifactDataclass: + def test_frozen(self) -> None: + sa = StoredArtifact( + key="k", artifact=_make_artifact(), payload=b"x", + ) + with pytest.raises(Exception): # FrozenInstanceError + sa.payload = b"y" # type: ignore[misc] diff --git a/tests/adapters/test_sprint_a14_s26_ocr_adapter.py b/tests/adapters/test_sprint_a14_s26_ocr_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..56b61933b123b8643cf40f322dce2ec091937998 --- /dev/null +++ b/tests/adapters/test_sprint_a14_s26_ocr_adapter.py @@ -0,0 +1,533 @@ +"""Sprint A14-S26 — ``BaseOCRAdapter`` + ``PrecomputedTextAdapter``. + +Couverture : + +- **Contrat** : un ``BaseOCRAdapter`` est instanciable, expose + ``name`` / ``input_types`` / ``output_types`` / ``execution_mode``, + son ``execute()`` est abstrait. +- **PrecomputedTextAdapter** : validation du ``source_label``, + lecture filesystem par convention de nommage, politique + ``"raise"`` vs ``"empty"`` sur fichier manquant, validation + UTF-8, isolation entre instances de sources distinctes. +- **Pipeline executor** : un ``PrecomputedTextAdapter`` est consommé + directement par le ``PipelineExecutor`` (S7) — preuve que le + contrat ``BaseOCRAdapter`` satisfait ``StepExecutor``. +- **CLI E2E** : YAML déclarant 3 sources pré-calculées différentes + → benchmark complet avec 3 pipelines comparés sur TextView, + sans aucun OCR réel. +""" + +from __future__ import annotations + +import io +import json +import textwrap +import zipfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from picarones.adapters.ocr import ( + BaseOCRAdapter, + OCRAdapterError, + PrecomputedTextAdapter, +) +from picarones.interfaces.cli import cli +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────── + + +def _png_bytes() -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00" + b"\x1f\x15\xc4\x89" + ) + + +def _ctx(doc_id: str = "doc01") -> RunContext: + return RunContext( + document_id=doc_id, + code_version="1.0.0-s26-test", + pipeline_name="test_pipeline", + ) + + +def _image_artifact(doc_id: str, path: Path) -> Artifact: + return Artifact( + id=f"{doc_id}:image", + document_id=doc_id, + type=ArtifactType.IMAGE, + uri=str(path), + ) + + +# ────────────────────────────────────────────────────────────────── +# Contrat BaseOCRAdapter +# ────────────────────────────────────────────────────────────────── + + +class TestBaseOCRAdapterContract: + def test_cannot_instantiate_abstract_directly(self) -> None: + with pytest.raises(TypeError): + BaseOCRAdapter() # type: ignore[abstract] + + def test_minimal_subclass_with_name_and_execute_works(self) -> None: + class _Minimal(BaseOCRAdapter): + @property + def name(self) -> str: + return "minimal" + + def execute(self, inputs, params, context): + return {} + + adapter = _Minimal() + assert adapter.name == "minimal" + assert ArtifactType.IMAGE in adapter.input_types + assert ArtifactType.RAW_TEXT in adapter.output_types + assert adapter.execution_mode == "io" + + def test_subclass_can_override_io_modes(self) -> None: + class _CPUBound(BaseOCRAdapter): + execution_mode = "cpu" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ + ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML, + }) + + @property + def name(self) -> str: + return "cpu_bound" + + def execute(self, inputs, params, context): + return {} + + adapter = _CPUBound() + assert adapter.execution_mode == "cpu" + assert ArtifactType.ALTO_XML in adapter.output_types + + +# ────────────────────────────────────────────────────────────────── +# PrecomputedTextAdapter — validation à l'init +# ────────────────────────────────────────────────────────────────── + + +class TestPrecomputedInitValidation: + def test_empty_source_label_rejected(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + PrecomputedTextAdapter(source_label="") + + def test_whitespace_source_label_rejected(self) -> None: + with pytest.raises(OCRAdapterError, match="vide"): + PrecomputedTextAdapter(source_label=" ") + + def test_invalid_chars_in_source_label_rejected(self) -> None: + for bad in ("foo/bar", "foo bar", "foo.bar", "foo:bar"): + with pytest.raises(OCRAdapterError, match="invalide"): + PrecomputedTextAdapter(source_label=bad) + + def test_valid_source_labels_accepted(self) -> None: + for good in ("tesseract", "gpt-4v", "pero_ocr", "ABC123"): + adapter = PrecomputedTextAdapter(source_label=good) + assert adapter.source_label == good + assert adapter.name == f"precomputed_{good}" + + def test_invalid_missing_text_policy_rejected(self) -> None: + with pytest.raises(OCRAdapterError, match="missing_text_policy"): + PrecomputedTextAdapter( + source_label="tess", + missing_text_policy="silent", # type: ignore[arg-type] + ) + + def test_default_missing_text_policy_is_raise(self) -> None: + adapter = PrecomputedTextAdapter(source_label="tess") + assert adapter._missing_policy == "raise" + + +# ────────────────────────────────────────────────────────────────── +# PrecomputedTextAdapter — exécution +# ────────────────────────────────────────────────────────────────── + + +class TestPrecomputedExecute: + def test_reads_text_file_by_convention(self, tmp_path: Path) -> None: + # Préparer image + texte pré-calculé. + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + text_path = tmp_path / "doc01.tesseract.txt" + text_path.write_text("Bonjour le monde", encoding="utf-8") + + adapter = PrecomputedTextAdapter(source_label="tesseract") + outputs = adapter.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + art = outputs[ArtifactType.RAW_TEXT] + assert art.type == ArtifactType.RAW_TEXT + assert art.document_id == "doc01" + assert Path(art.uri).read_text(encoding="utf-8") == "Bonjour le monde" + # Convention ::. + assert art.id == "doc01:precomputed_tesseract:raw_text" + + def test_missing_text_raises_by_default(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + # Pas de doc01.tesseract.txt. + + adapter = PrecomputedTextAdapter(source_label="tesseract") + with pytest.raises(OCRAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + + def test_missing_text_empty_policy_creates_empty_file( + self, tmp_path: Path, + ) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + + adapter = PrecomputedTextAdapter( + source_label="tess", + missing_text_policy="empty", + ) + outputs = adapter.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + art = outputs[ArtifactType.RAW_TEXT] + assert Path(art.uri).read_text(encoding="utf-8") == "" + + def test_non_utf8_file_rejected(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + text_path = tmp_path / "doc01.tess.txt" + # Bytes invalides en UTF-8 (latin-1 avec accent). + text_path.write_bytes(b"\xe9\xe8") + + adapter = PrecomputedTextAdapter(source_label="tess") + with pytest.raises(OCRAdapterError, match="UTF-8"): + adapter.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + + def test_missing_image_input_rejected(self, tmp_path: Path) -> None: + adapter = PrecomputedTextAdapter(source_label="tess") + with pytest.raises(OCRAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_ctx()) + + def test_image_artifact_without_uri_rejected(self) -> None: + adapter = PrecomputedTextAdapter(source_label="tess") + with pytest.raises(OCRAdapterError, match="sans URI"): + adapter.execute( + inputs={ + ArtifactType.IMAGE: Artifact( + id="d:image", document_id="d", + type=ArtifactType.IMAGE, + ), + }, + params={}, + context=_ctx(), + ) + + def test_two_sources_isolated_in_same_dir(self, tmp_path: Path) -> None: + """Cas BnF central : deux sources pré-calculées dans le même + répertoire ne se piétinent pas — chaque adapter lit son + propre fichier.""" + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + (tmp_path / "doc01.tess.txt").write_text( + "tesseract output", encoding="utf-8", + ) + (tmp_path / "doc01.gpt4v.txt").write_text( + "gpt-4 vision output", encoding="utf-8", + ) + + a_tess = PrecomputedTextAdapter(source_label="tess") + a_gpt = PrecomputedTextAdapter(source_label="gpt4v") + + out_tess = a_tess.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + out_gpt = a_gpt.execute( + inputs={ArtifactType.IMAGE: _image_artifact("doc01", image_path)}, + params={}, + context=_ctx("doc01"), + ) + assert Path(out_tess[ArtifactType.RAW_TEXT].uri).read_text() \ + == "tesseract output" + assert Path(out_gpt[ArtifactType.RAW_TEXT].uri).read_text() \ + == "gpt-4 vision output" + + def test_image_extension_variations_handled( + self, tmp_path: Path, + ) -> None: + """``stem`` strip toutes les extensions image courantes.""" + for ext in (".png", ".jpg", ".jpeg", ".tif", ".tiff"): + image_path = tmp_path / f"folio_001{ext}" + image_path.write_bytes(_png_bytes()) + text_path = tmp_path / "folio_001.src.txt" + text_path.write_text("ok", encoding="utf-8") + + adapter = PrecomputedTextAdapter(source_label="src") + out = adapter.execute( + inputs={ + ArtifactType.IMAGE: _image_artifact("folio_001", image_path), + }, + params={}, + context=_ctx("folio_001"), + ) + assert Path(out[ArtifactType.RAW_TEXT].uri).read_text() == "ok" + + +# ────────────────────────────────────────────────────────────────── +# Smoke pipeline executor +# ────────────────────────────────────────────────────────────────── + + +class TestPipelineExecutorIntegration: + def test_adapter_consumed_by_pipeline_executor( + self, tmp_path: Path, + ) -> None: + """Démontre que ``BaseOCRAdapter`` satisfait le contrat + ``StepExecutor`` du nouveau pipeline executor — preuve que + le contrat propre du nouveau monde est suffisant.""" + from picarones.domain.documents import DocumentRef + from picarones.pipeline import ( + PipelineExecutor, PipelineSpec, PipelineStep, + ) + + image_path = tmp_path / "doc01.png" + image_path.write_bytes(_png_bytes()) + (tmp_path / "doc01.tess.txt").write_text( + "Bonjour", encoding="utf-8", + ) + + adapter = PrecomputedTextAdapter(source_label="tess") + spec = PipelineSpec( + name="precomputed_smoke", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", + adapter_name="precomputed", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + ) + executor = PipelineExecutor(adapter_resolver=lambda n: adapter) + result = executor.run( + spec=spec, + document=DocumentRef(id="doc01", image_uri=str(image_path)), + initial_inputs={ + ArtifactType.IMAGE: _image_artifact("doc01", image_path), + }, + context=_ctx("doc01"), + ) + assert result.succeeded + text_arts = result.artifacts_of_type(ArtifactType.RAW_TEXT) + assert len(text_arts) == 1 + assert Path(text_arts[0].uri).read_text() == "Bonjour" + + +# ────────────────────────────────────────────────────────────────── +# CLI E2E : 3 sources pré-calculées comparées via picarones-rewrite run +# ────────────────────────────────────────────────────────────────── + + +def _make_corpus_zip_with_sources() -> bytes: + """Corpus avec image + GT + 3 sources pré-calculées.""" + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + for doc_id in ("doc01", "doc02"): + zf.writestr(f"{doc_id}.png", _png_bytes()) + zf.writestr(f"{doc_id}.gt.txt", "Bonjour le monde") + # Tesseract : copie exacte de la GT (CER 0). + zf.writestr( + f"{doc_id}.tesseract.txt", + "Bonjour le monde", + ) + # GPT-4v : 1 erreur (CER > 0). + zf.writestr( + f"{doc_id}.gpt4v.txt", + "Bonjur le monde", + ) + # Pero : très dégradé. + zf.writestr( + f"{doc_id}.pero.txt", + "Bonjour 1e mond", + ) + return buf.getvalue() + + +class TestCLIComparingPrecomputedSources: + """Cas BnF concret : « j'ai 3 transcriptions déjà produites, + je veux les comparer ». + + YAML déclare 3 pipelines, chacun pointant sur + ``PrecomputedTextAdapter`` avec un ``source_label`` distinct. + Le ``BenchmarkService`` les exécute en parallèle, le + ``ReportService`` les compare dans TextView. Aucun OCR réel + n'est lancé.""" + + def test_three_precomputed_sources_compared_via_cli( + self, tmp_path: Path, + ) -> None: + runner = CliRunner() + corpus_zip = tmp_path / "corpus.zip" + corpus_zip.write_bytes(_make_corpus_zip_with_sources()) + + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + report_path = out_dir / "rapport.html" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + corpus_name: bnf_3sources + pipelines: + - name: tesseract_baseline + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tesseract + input_types: [image] + output_types: [raw_text] + - name: gpt4v_alternative + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: gpt4v + input_types: [image] + output_types: [raw_text] + - name: pero_alternative + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: pero + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + report_html: {report_path} + code_version: "1.0.0-s26-bnf" + """)) + + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 0, result.output + + # Validation : 2 docs × 3 pipelines × 1 vue = 6 ViewResults. + results_dir = out_dir / "results" + view_lines = [ + json.loads(line) + for line in (results_dir / "view_results.jsonl").read_text().strip().split("\n") + if line.strip() + ] + assert len(view_lines) == 6 + + # Tesseract → CER 0 (copie exacte). + # GPT-4v / Pero → CER > 0. + cer_by_pipeline: dict[str, list[float]] = {} + for vr in view_lines: + cand_id = vr["candidate_artifact_id"] + if "precomputed_tesseract" in cand_id: + pipeline = "tesseract" + elif "precomputed_gpt4v" in cand_id: + pipeline = "gpt4v" + elif "precomputed_pero" in cand_id: + pipeline = "pero" + else: + pytest.fail(f"candidate id inattendu : {cand_id}") + cer_by_pipeline.setdefault(pipeline, []).append( + vr["metric_values"]["cer"], + ) + + # Tesseract = 0 sur les 2 docs. + assert cer_by_pipeline["tesseract"] == [0.0, 0.0] + # GPT-4v > 0 (1 erreur). + for cer in cer_by_pipeline["gpt4v"]: + assert cer > 0.0 + # Pero strictement plus mauvais que GPT-4v. + for tess, gpt, pero in zip( + cer_by_pipeline["tesseract"], + cer_by_pipeline["gpt4v"], + cer_by_pipeline["pero"], + ): + assert pero > gpt > tess + + # Le rapport HTML mentionne les 3 pipelines. + html = report_path.read_text(encoding="utf-8") + for name in ("tesseract_baseline", "gpt4v_alternative", "pero_alternative"): + assert name in html + + def test_missing_source_file_produces_failed_step( + self, tmp_path: Path, + ) -> None: + """Si un fichier pré-calculé manque, le pipeline du document + concerné échoue (StepResult.error renseigné), mais les autres + pipelines/documents continuent — le benchmark ne crash pas + globalement.""" + runner = CliRunner() + # Corpus avec 1 doc, mais le fichier .tesseract.txt manque. + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + zf.writestr("doc01.png", _png_bytes()) + zf.writestr("doc01.gt.txt", "Bonjour") + # PAS de doc01.tesseract.txt + corpus_zip = tmp_path / "corpus.zip" + corpus_zip.write_bytes(buf.getvalue()) + + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + corpus_name: bnf_missing + pipelines: + - name: tesseract_baseline + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tesseract + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + """)) + + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + # Le run termine — l'erreur est isolée au step. + assert result.exit_code == 0, result.output + + # Le PipelineResult reflète l'échec. + results_dir = out_dir / "results" + pipeline_lines = [ + json.loads(line) + for line in (results_dir / "pipeline_results.jsonl").read_text().strip().split("\n") + if line.strip() + ] + assert len(pipeline_lines) == 1 + pr = pipeline_lines[0] + assert pr["succeeded"] is False + assert any( + sr.get("error") and "introuvable" in sr["error"] + for sr in pr["step_results"] + ) diff --git a/tests/adapters/vlm/__init__.py b/tests/adapters/vlm/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/adapters/vlm/test_sprint_a14_s45_vlm_adapters.py b/tests/adapters/vlm/test_sprint_a14_s45_vlm_adapters.py new file mode 100644 index 0000000000000000000000000000000000000000..fc2aec95b69222a580bf9af3e8c3557a3b65ad53 --- /dev/null +++ b/tests/adapters/vlm/test_sprint_a14_s45_vlm_adapters.py @@ -0,0 +1,314 @@ +"""Sprint A14-S45 — VLM adapters (4 fournisseurs). + +Tests des 4 adapters VLM qui héritent de ``BaseVLMAdapter`` + +leur LLM sibling (composition par MRO multiple). +""" + +from __future__ import annotations + +import base64 +from pathlib import Path + +import pytest + +from picarones.adapters.vlm.base import VLMAdapterError +from picarones.adapters.vlm import ( + AnthropicVLMAdapter, + BaseVLMAdapter, + MistralVLMAdapter, + OllamaVLMAdapter, + OpenAIVLMAdapter, +) +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.pipeline.types import RunContext + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +class _StubVLMAdapter(BaseVLMAdapter): + """VLM stub pour tests : retourne un texte fixe.""" + + def __init__( + self, + response_text="texte transcrit", + raise_on_call=False, + config=None, + ): + super().__init__(config=config or {"max_retries": 0}) + self._response = response_text + self._raise = raise_on_call + self.last_image_b64 = None + + @property + def name(self) -> str: + return "stub_vlm" + + @property + def default_model(self) -> str: + return "stub-vlm-1.0" + + def _call(self, prompt, image_b64=None): + self.last_image_b64 = image_b64 + if self._raise: + raise RuntimeError("VLM crashed") + return self._response + + +def _make_image_artifact(uri: str) -> Artifact: + return Artifact( + id="doc01:image", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=uri, + ) + + +def _make_context() -> RunContext: + return RunContext( + document_id="doc01", + code_version="1.0.0", + pipeline_name="test", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Contrat StepExecutor (BaseVLMAdapter) +# ────────────────────────────────────────────────────────────────────── + + +class TestBaseVLMAdapterContract: + def test_input_types_is_image(self) -> None: + adapter = _StubVLMAdapter() + assert adapter.input_types == frozenset({ArtifactType.IMAGE}) + + def test_output_types_is_raw_text(self) -> None: + adapter = _StubVLMAdapter() + assert adapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + def test_execution_mode_is_io(self) -> None: + # Hérité de BaseLLMAdapter. + assert _StubVLMAdapter.execution_mode == "io" + + +class TestVLMExecuteNominal: + def test_basic_transcription(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(b"PNGBYTES") + adapter = _StubVLMAdapter(response_text="ceci est le texte") + + result = adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + assert ArtifactType.RAW_TEXT in result + produced = result[ArtifactType.RAW_TEXT] + assert produced.type == ArtifactType.RAW_TEXT + assert produced.document_id == "doc01" + out_path = Path(produced.uri) + assert out_path.exists() + assert out_path.read_text(encoding="utf-8") == "ceci est le texte" + assert out_path.name == "doc01.stub_vlm.txt" + + def test_image_passed_to_llm_as_base64(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(b"VLM_TEST_BYTES") + adapter = _StubVLMAdapter() + adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + decoded = base64.b64decode(adapter.last_image_b64) + assert decoded == b"VLM_TEST_BYTES" + + def test_artifact_id_uses_adapter_name(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(b"x") + adapter = _StubVLMAdapter() + result = adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + produced = result[ArtifactType.RAW_TEXT] + assert produced.id == "doc01:stub_vlm:raw_text" + assert produced.produced_by_step == "vlm_transcription" + + def test_custom_transcription_prompt(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc01.png" + image_path.write_bytes(b"x") + adapter = _StubVLMAdapter(config={ + "max_retries": 0, + "transcription_prompt": "Custom VLM prompt", + }) + # On capture le prompt en surchargeant _call. + captured = {} + + def _capture_call(prompt, image_b64=None): + captured["prompt"] = prompt + return "x" + + adapter._call = _capture_call # type: ignore[method-assign] + adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + assert captured["prompt"] == "Custom VLM prompt" + + +# ────────────────────────────────────────────────────────────────────── +# Erreurs +# ────────────────────────────────────────────────────────────────────── + + +class TestVLMExecuteErrors: + def test_missing_image_raises(self) -> None: + adapter = _StubVLMAdapter() + with pytest.raises(VLMAdapterError, match="IMAGE manquant"): + adapter.execute(inputs={}, params={}, context=_make_context()) + + def test_image_without_uri_raises(self) -> None: + adapter = _StubVLMAdapter() + artifact = Artifact( + id="x", + document_id="doc01", + type=ArtifactType.IMAGE, + uri=None, + ) + with pytest.raises(VLMAdapterError, match="sans URI"): + adapter.execute( + inputs={ArtifactType.IMAGE: artifact}, + params={}, + context=_make_context(), + ) + + def test_image_path_not_existing_raises(self) -> None: + adapter = _StubVLMAdapter() + with pytest.raises(VLMAdapterError, match="introuvable"): + adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact( + "/nonexistent/img.png", + )}, + params={}, + context=_make_context(), + ) + + def test_vlm_call_failing_raises(self, tmp_path: Path) -> None: + image_path = tmp_path / "doc.png" + image_path.write_bytes(b"x") + adapter = _StubVLMAdapter(raise_on_call=True) + with pytest.raises(VLMAdapterError, match="VLM a échoué"): + adapter.execute( + inputs={ArtifactType.IMAGE: _make_image_artifact(str(image_path))}, + params={}, + context=_make_context(), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Adapters concrets — héritage MRO +# ────────────────────────────────────────────────────────────────────── + + +class TestConcreteVLMAdapters: + @pytest.mark.parametrize("adapter_cls,expected_name", [ + (AnthropicVLMAdapter, "anthropic_vlm"), + (OpenAIVLMAdapter, "openai_vlm"), + (MistralVLMAdapter, "mistral_vlm"), + (OllamaVLMAdapter, "ollama_vlm"), + ]) + def test_adapter_name(self, adapter_cls, expected_name) -> None: + adapter = adapter_cls() + assert adapter.name == expected_name + + @pytest.mark.parametrize("adapter_cls", [ + AnthropicVLMAdapter, + OpenAIVLMAdapter, + MistralVLMAdapter, + OllamaVLMAdapter, + ]) + def test_adapter_input_types(self, adapter_cls) -> None: + # input_types vient de BaseVLMAdapter par MRO. + adapter = adapter_cls() + assert adapter.input_types == frozenset({ArtifactType.IMAGE}) + + @pytest.mark.parametrize("adapter_cls", [ + AnthropicVLMAdapter, + OpenAIVLMAdapter, + MistralVLMAdapter, + OllamaVLMAdapter, + ]) + def test_adapter_output_types(self, adapter_cls) -> None: + adapter = adapter_cls() + assert adapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + @pytest.mark.parametrize("adapter_cls", [ + AnthropicVLMAdapter, + OpenAIVLMAdapter, + MistralVLMAdapter, + OllamaVLMAdapter, + ]) + def test_adapter_has_execute(self, adapter_cls) -> None: + # execute() vient de BaseVLMAdapter par MRO. + assert hasattr(adapter_cls, "execute") + + def test_mistral_default_model_is_pixtral(self) -> None: + adapter = MistralVLMAdapter() + assert "pixtral" in adapter.default_model.lower() + + def test_ollama_default_model_is_vision_capable(self) -> None: + adapter = OllamaVLMAdapter() + # Modèle par défaut doit être un modèle vision (llava family). + assert "llava" in adapter.default_model.lower() + + +# ────────────────────────────────────────────────────────────────────── +# Intégration pipeline (utilisation comme StepExecutor) +# ────────────────────────────────────────────────────────────────────── + + +class TestVLMPipelineIntegration: + def test_used_as_pipeline_step(self, tmp_path: Path) -> None: + from picarones.pipeline.executor import PipelineExecutor + from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep + from picarones.domain.documents import DocumentRef + + image_path = tmp_path / "doc01.png" + image_path.write_bytes(b"PNG_BYTES") + + adapter = _StubVLMAdapter(response_text="VLM transcription") + executor = PipelineExecutor(adapter_resolver=lambda name: adapter) + spec = PipelineSpec( + name="vlm_pipeline", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + PipelineStep( + id="vlm", + kind="vlm_transcription", + adapter_name="stub_vlm", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + ), + ) + result = executor.run( + spec=spec, + document=DocumentRef(id="doc01"), + initial_inputs={ + ArtifactType.IMAGE: _make_image_artifact(str(image_path)), + }, + context=_make_context(), + ) + assert result.succeeded + raw_text_artifacts = [ + a for a in result.artifacts + if a.type == ArtifactType.RAW_TEXT + ] + assert len(raw_text_artifacts) == 1 + out_path = Path(raw_text_artifacts[0].uri) + assert out_path.read_text(encoding="utf-8") == "VLM transcription" diff --git a/tests/adapters/vlm/test_sprint_a14_s54_mro_guard.py b/tests/adapters/vlm/test_sprint_a14_s54_mro_guard.py new file mode 100644 index 0000000000000000000000000000000000000000..8f5fade5ef5fb3737d307d516e644d7c3bd208e4 --- /dev/null +++ b/tests/adapters/vlm/test_sprint_a14_s54_mro_guard.py @@ -0,0 +1,78 @@ +"""Sprint A14-S54 — garde-fou MRO BaseVLMAdapter (fix audit #6). + +Avant S54, l'ordre des parents dans : + + class AnthropicVLMAdapter(BaseVLMAdapter, AnthropicAdapter) + +était critique mais non vérifié. Un swap accidentel à +``(AnthropicAdapter, BaseVLMAdapter)`` aurait silencieusement donné +output_types = {CORRECTED_TEXT} (depuis LLM) au lieu de {RAW_TEXT} +(depuis VLM) — l'erreur ne se serait manifestée qu'au runtime sur +une jonction de type incompatible. + +S54 ajoute ``__init_subclass__`` qui lève ``TypeError`` à la +définition de la classe si l'ordre est incorrect. +""" + +from __future__ import annotations + +import pytest + +from picarones.adapters.llm.anthropic_adapter import AnthropicAdapter +from picarones.adapters.llm.openai_adapter import OpenAIAdapter +from picarones.adapters.vlm import ( + AnthropicVLMAdapter, + BaseVLMAdapter, + OpenAIVLMAdapter, +) +from picarones.domain.artifacts import ArtifactType + + +class TestExistingAdaptersStillValid: + """Les 4 VLM adapters concrets définis correctement passent.""" + + def test_anthropic_vlm_defined(self) -> None: + # Si l'ordre était mauvais, l'import aurait planté. + adapter = AnthropicVLMAdapter() + assert adapter.input_types == frozenset({ArtifactType.IMAGE}) + assert adapter.output_types == frozenset({ArtifactType.RAW_TEXT}) + + def test_openai_vlm_defined(self) -> None: + adapter = OpenAIVLMAdapter() + assert adapter.input_types == frozenset({ArtifactType.IMAGE}) + + +class TestWrongOrderRejected: + def test_llm_first_then_vlm_rejected(self) -> None: + """Définir une classe avec LLM avant VLM doit lever TypeError.""" + with pytest.raises(TypeError, match="ordre MRO"): + # Définition dynamique d'une classe avec mauvais ordre. + type( + "BadOrderVLM", + (AnthropicAdapter, BaseVLMAdapter), + {"name": property(lambda self: "bad")}, + ) + + def test_correct_order_accepted(self) -> None: + """L'ordre correct (VLM en premier) est accepté.""" + # Test propriété : aucun TypeError levé. + type( + "GoodOrderVLM", + (BaseVLMAdapter, OpenAIAdapter), + {"name": property(lambda self: "good")}, + ) + + +class TestErrorMessageHelpful: + def test_message_explains_the_fix(self) -> None: + with pytest.raises(TypeError) as exc_info: + type( + "BadVLM", + (AnthropicAdapter, BaseVLMAdapter), + {"name": property(lambda self: "x")}, + ) + msg = str(exc_info.value) + # Le message doit suggérer la correction concrète. + assert "BaseVLMAdapter" in msg + assert "AnthropicAdapter" in msg + assert "Corrigez" in msg or "correct" in msg.lower() diff --git a/tests/api_stability/__init__.py b/tests/api_stability/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/api_stability/test_deprecated_aliases.py b/tests/api_stability/test_deprecated_aliases.py new file mode 100644 index 0000000000000000000000000000000000000000..4f2ec8bf03ce077da0b72c1dd7fea92873c6ec3e --- /dev/null +++ b/tests/api_stability/test_deprecated_aliases.py @@ -0,0 +1,96 @@ +"""Garde-fou de stabilité d'API : les symboles dépréciés au S57 +restent accessibles avec ``DeprecationWarning`` jusqu'à la 2.0. + +Pour une release institutionnelle, supprimer un symbole exporté du +package public exige une deprecation period publique — un caller +externe (espace HuggingFace tiers, script BnF, notebook de chercheur) +doit pouvoir mettre à jour son code AVANT la cassure dure. + +Trois alias couverts : + +1. ``picarones.pipeline.spec`` (module entier). +2. ``BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT`` (singulier). +3. ``BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT`` (singulier). +""" + +from __future__ import annotations + +import importlib +import sys +import warnings + + +def test_pipeline_spec_module_emits_deprecation_warning() -> None: + """``from picarones.pipeline.spec import …`` fonctionne avec un + ``DeprecationWarning`` qui pointe vers le chemin canonique. + """ + sys.modules.pop("picarones.pipeline.spec", None) + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + importlib.import_module("picarones.pipeline.spec") + deprecations = [ + w for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert deprecations, "DeprecationWarning attendu sur l'import legacy." + assert "picarones.domain" in str(deprecations[0].message), ( + "Le message du warning doit pointer vers la cible canonique." + ) + + +def test_pipeline_spec_module_still_resolves_classes() -> None: + """L'alias résout vers les MÊMES objets que ``picarones.domain``.""" + sys.modules.pop("picarones.pipeline.spec", None) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + from picarones.pipeline.spec import ( + INITIAL_STEP_ID as LegacyInit, + ) + from picarones.pipeline.spec import ( + PipelineSpec as LegacySpec, + ) + from picarones.pipeline.spec import ( + PipelineStep as LegacyStep, + ) + from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, + ) + assert LegacySpec is PipelineSpec + assert LegacyStep is PipelineStep + assert LegacyInit == INITIAL_STEP_ID + + +def test_default_correction_prompt_singular_emits_warning() -> None: + """``BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT`` (singulier) reste + lisible mais émet ``DeprecationWarning``. + """ + from picarones.adapters.llm.base import BaseLLMAdapter + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + value = BaseLLMAdapter.DEFAULT_CORRECTION_PROMPT + deprecations = [ + w for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert deprecations + assert "DEFAULT_CORRECTION_PROMPTS" in str(deprecations[0].message) + # La valeur retournée est cohérente : prompt FR. + assert "Corrige" in value + + +def test_default_transcription_prompt_singular_emits_warning() -> None: + """``BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT`` (singulier) + reste lisible mais émet ``DeprecationWarning``. + """ + from picarones.adapters.vlm.base import BaseVLMAdapter + + with warnings.catch_warnings(record=True) as captured: + warnings.simplefilter("always") + value = BaseVLMAdapter.DEFAULT_TRANSCRIPTION_PROMPT + deprecations = [ + w for w in captured if issubclass(w.category, DeprecationWarning) + ] + assert deprecations + assert "DEFAULT_TRANSCRIPTION_PROMPTS" in str(deprecations[0].message) + assert "Transcris" in value diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/app/schemas/__init__.py b/tests/app/schemas/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/app/schemas/test_sprint_a14_s39_run_spec_extended.py b/tests/app/schemas/test_sprint_a14_s39_run_spec_extended.py new file mode 100644 index 0000000000000000000000000000000000000000..e8b2275325f7762f96f6699d7d8a0c86abad2f37 --- /dev/null +++ b/tests/app/schemas/test_sprint_a14_s39_run_spec_extended.py @@ -0,0 +1,336 @@ +"""Sprint A14-S39 — RunSpec étendu (inputs_from + preferred_text_output). + +Tests des nouvelles fonctionnalités YAML introduites au S39 : + +- ``StepSpec.inputs_from`` : DAG branchant via mapping symbolique + ``ArtifactType → step_id``. +- ``PipelineSpecYaml.preferred_text_output`` : référence symbolique + ``step_id.output_type`` pour désigner la sortie préférée. + +Les tests existants S24 ne sont pas modifiés — l'extension est +purement additive. +""" + +from __future__ import annotations + +import pytest + +from picarones.app.schemas.run_spec import ( + PipelineSpecYaml, + RunSpec, + RunSpecLoadError, + StepSpec, + load_run_spec_from_yaml, +) +from picarones.domain.artifacts import ArtifactType +from picarones.domain.pipeline_spec import INITIAL_STEP_ID + + +# ────────────────────────────────────────────────────────────────────── +# StepSpec.inputs_from +# ────────────────────────────────────────────────────────────────────── + + +class TestStepSpecInputsFrom: + def test_default_empty(self) -> None: + step = StepSpec( + id="ocr", + adapter_class="my.AdapterClass", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ) + assert step.inputs_from == {} + + def test_explicit_inputs_from(self) -> None: + step = StepSpec( + id="corrector", + adapter_class="my.LLM", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + inputs_from={ArtifactType.RAW_TEXT: "ocr"}, + ) + assert step.inputs_from[ArtifactType.RAW_TEXT] == "ocr" + + +# ────────────────────────────────────────────────────────────────────── +# PipelineSpecYaml.preferred_text_output +# ────────────────────────────────────────────────────────────────────── + + +class TestPreferredTextOutput: + def test_none_by_default(self) -> None: + pipe = PipelineSpecYaml( + name="basic", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + ) + assert pipe.preferred_text_output is None + + def test_valid_reference_accepted(self) -> None: + pipe = PipelineSpecYaml( + name="ocr_then_correct", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="ocr", + adapter_class="my.OCR", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + StepSpec( + id="corrector", + adapter_class="my.LLM", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + ), + ), + preferred_text_output="corrector.corrected_text", + ) + assert pipe.preferred_text_output == "corrector.corrected_text" + + def test_rejects_missing_dot(self) -> None: + with pytest.raises(Exception, match="format"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + preferred_text_output="just_a_step_id", + ) + + def test_rejects_unknown_step(self) -> None: + with pytest.raises(Exception, match="introuvable"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + preferred_text_output="missing_step.raw_text", + ) + + def test_rejects_step_not_producing_type(self) -> None: + with pytest.raises(Exception, match="ne produit pas"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + # ocr ne produit pas alto_xml — devrait lever + preferred_text_output="ocr.alto_xml", + ) + + def test_rejects_unknown_artifact_type(self) -> None: + with pytest.raises(Exception, match="output_type"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + preferred_text_output="ocr.totally_unknown_type", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Validation inputs_from au niveau pipeline +# ────────────────────────────────────────────────────────────────────── + + +class TestInputsFromValidation: + def test_initial_step_id_valid(self) -> None: + # `__initial__` doit être valide quand le type est bien dans initial_inputs. + PipelineSpecYaml( + name="ok", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + inputs_from={ArtifactType.IMAGE: INITIAL_STEP_ID}, + ),), + ) + + def test_initial_step_id_rejects_unknown_initial_input(self) -> None: + # `__initial__` mais le type n'est pas dans initial_inputs → erreur. + with pytest.raises(Exception, match="initial_inputs"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=(StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE, ArtifactType.RAW_TEXT), + output_types=(ArtifactType.RAW_TEXT,), + # raw_text n'est pas dans initial_inputs. + inputs_from={ArtifactType.RAW_TEXT: INITIAL_STEP_ID}, + ),), + ) + + def test_explicit_step_reference_valid(self) -> None: + PipelineSpecYaml( + name="dag", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="ocr_a", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + StepSpec( + id="ocr_b", + adapter_class="my.B", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + StepSpec( + id="corrector", + adapter_class="my.LLM", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + # On choisit explicitement ocr_a (pas ocr_b + # qui serait le « dernier producteur »). + inputs_from={ArtifactType.RAW_TEXT: "ocr_a"}, + ), + ), + ) + + def test_rejects_forward_reference(self) -> None: + # Un step ne peut pas référencer un step en aval de lui. + with pytest.raises(Exception, match="antérieure"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="step1", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE, ArtifactType.RAW_TEXT), + output_types=(ArtifactType.RAW_TEXT,), + # Référence step2 qui vient APRÈS — invalide. + inputs_from={ArtifactType.RAW_TEXT: "step2"}, + ), + StepSpec( + id="step2", + adapter_class="my.B", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + ), + ) + + def test_rejects_step_not_producing_referenced_type(self) -> None: + with pytest.raises(Exception, match="ne produit pas"): + PipelineSpecYaml( + name="bad", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="ocr", + adapter_class="my.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + StepSpec( + id="alto_remap", + adapter_class="my.B", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.ALTO_XML), + output_types=(ArtifactType.ALTO_XML,), + # ocr ne produit pas ALTO_XML mais on le réclame. + inputs_from={ArtifactType.ALTO_XML: "ocr"}, + ), + ), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Round-trip YAML +# ────────────────────────────────────────────────────────────────────── + + +class TestYamlRoundTrip: + def test_yaml_with_inputs_from_loads_correctly(self) -> None: + yaml_text = """ +corpus_dir: /tmp/corpus +output_dir: /tmp/out +pipelines: + - name: ocr_then_correct + initial_inputs: [image] + preferred_text_output: corrector.corrected_text + steps: + - id: ocr + adapter_class: my_pkg.OCR + input_types: [image] + output_types: [raw_text] + - id: corrector + adapter_class: my_pkg.LLM + input_types: [raw_text] + output_types: [corrected_text] + inputs_from: + raw_text: ocr +views: [text_final] + """.strip() + spec = load_run_spec_from_yaml(yaml_text) + assert isinstance(spec, RunSpec) + assert spec.pipelines[0].preferred_text_output == "corrector.corrected_text" + corrector = spec.pipelines[0].steps[1] + assert corrector.inputs_from[ArtifactType.RAW_TEXT] == "ocr" + + def test_yaml_invalid_preferred_text_raises_load_error(self) -> None: + yaml_text = """ +corpus_dir: /tmp/corpus +output_dir: /tmp/out +pipelines: + - name: ocr + initial_inputs: [image] + preferred_text_output: missing_step.raw_text + steps: + - id: ocr + adapter_class: my_pkg.OCR + input_types: [image] + output_types: [raw_text] +views: [text_final] + """.strip() + with pytest.raises(RunSpecLoadError, match="introuvable"): + load_run_spec_from_yaml(yaml_text) + + def test_yaml_invalid_inputs_from_raises_load_error(self) -> None: + yaml_text = """ +corpus_dir: /tmp/corpus +output_dir: /tmp/out +pipelines: + - name: bad + initial_inputs: [image] + steps: + - id: ocr + adapter_class: my_pkg.OCR + input_types: [image, raw_text] + output_types: [raw_text] + inputs_from: + raw_text: __initial__ +views: [text_final] + """.strip() + # raw_text n'est pas dans initial_inputs → erreur. + with pytest.raises(RunSpecLoadError, match="initial_inputs"): + load_run_spec_from_yaml(yaml_text) diff --git a/tests/app/services/__init__.py b/tests/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/app/services/test_sprint_a14_s41_artifacts_index.py b/tests/app/services/test_sprint_a14_s41_artifacts_index.py new file mode 100644 index 0000000000000000000000000000000000000000..73b34e9170ea0a10568432a497c16f202c709bb6 --- /dev/null +++ b/tests/app/services/test_sprint_a14_s41_artifacts_index.py @@ -0,0 +1,271 @@ +"""Sprint A14-S41 — ``artifacts_index.jsonl`` séparé. + +Tests de la séparation introduite au S41 : + +- ``BenchmarkService.persist`` produit un 4ᵉ fichier + ``artifacts_index.jsonl`` distinct des ``pipeline_results.jsonl``. +- ``pipeline_results.jsonl`` ne contient plus la liste des artefacts. +- Round-trip via ``HtmlReportRenderer.load_run_result`` ré-attache + les artefacts depuis l'index séparé. +- Compatibilité descendante : un run persisté sans + ``artifacts_index.jsonl`` (legacy avant S41) reste lisible. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from picarones.app.results import RunDocumentResult, RunResult +from picarones.domain import ( + Artifact, + ArtifactType, + ProvenanceRecord, + RunManifest, + utcnow, +) +from picarones.pipeline.types import PipelineResult, StepResult +from picarones.reports_v2.html.render import HtmlReportRenderer + + +def _make_run_result_with_artifacts() -> RunResult: + """Construit un RunResult en mémoire avec quelques artefacts.""" + started = utcnow() + completed = utcnow() + manifest = RunManifest( + run_id="run_001", + corpus_name="demo", + n_documents=2, + pipeline_names=("ocr_only",), + view_specs=(), + code_version="1.0.0-s41-test", + started_at=started, + completed_at=completed, + ) + artifact1 = Artifact( + id="doc01:image", + document_id="doc01", + type=ArtifactType.IMAGE, + content_hash="a" * 64, + ) + artifact2 = Artifact( + id="doc01:ocr_only:raw_text", + document_id="doc01", + type=ArtifactType.RAW_TEXT, + content_hash="b" * 64, + produced_by_step="ocr", + provenance=ProvenanceRecord( + code_version="1.0.0-s41-test", + parameters_hash="c" * 64, + ), + ) + pr1 = PipelineResult( + pipeline_name="ocr_only", + document_id="doc01", + step_results=( + StepResult( + step_id="ocr", + succeeded=True, + duration_seconds=0.5, + produced_artifacts={"raw_text": "doc01:ocr_only:raw_text"}, + ), + ), + succeeded=True, + duration_seconds=0.5, + artifacts=(artifact1, artifact2), + ) + return RunResult( + manifest=manifest, + document_results=( + RunDocumentResult( + document_id="doc01", + pipeline_results=(pr1,), + view_results=(), + ), + ), + ) + + +def _build_benchmark_service(): + """Crée un BenchmarkService minimal pour tester persist().""" + from picarones.app.services.benchmark_service import BenchmarkService + from picarones.evaluation.views.executor import ( + DefaultEvaluationViewExecutor, + ) + from picarones.evaluation.registry import MetricRegistry + from picarones.evaluation.projectors.registry import ProjectorRegistry + from picarones.pipeline.executor import PipelineExecutor + from picarones.pipeline.runner import CorpusRunner + + runner = CorpusRunner( + PipelineExecutor(adapter_resolver=lambda n: None), + max_in_flight=1, + timeout_seconds_per_doc=1.0, + poll_interval_seconds=0.001, + ) + view_executor = DefaultEvaluationViewExecutor.from_registries( + MetricRegistry(), ProjectorRegistry(), lambda art: "", + ) + return BenchmarkService( + corpus_runner=runner, + view_executor=view_executor, + code_version="1.0.0-s41-test", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactsIndexSeparation: + def test_persist_writes_4_files(self, tmp_path: Path) -> None: + """``persist`` doit retourner les 4 chemins (manifest + + pipeline_results + artifacts_index + view_results).""" + bench = _build_benchmark_service() + result = _make_run_result_with_artifacts() + paths = bench.persist(result, tmp_path) + + assert "manifest" in paths + assert "pipeline_results" in paths + assert "artifacts_index" in paths + assert "view_results" in paths + for kind, path in paths.items(): + assert path.exists(), f"{kind} non écrit" + + def test_artifacts_index_jsonl_format(self, tmp_path: Path) -> None: + """Chaque ligne contient un artefact + document_id + + pipeline_name.""" + bench = _build_benchmark_service() + result = _make_run_result_with_artifacts() + bench.persist(result, tmp_path) + + index_path = tmp_path / "artifacts_index.jsonl" + lines = [ + line for line in index_path.read_text( + encoding="utf-8", + ).splitlines() if line.strip() + ] + assert len(lines) == 2 # 2 artefacts dans le RunResult + + for line in lines: + rec = json.loads(line) + assert "document_id" in rec + assert "pipeline_name" in rec + assert rec["document_id"] == "doc01" + assert rec["pipeline_name"] == "ocr_only" + assert "id" in rec + assert "type" in rec + + def test_pipeline_results_jsonl_no_longer_contains_artifacts( + self, tmp_path: Path, + ) -> None: + """``pipeline_results.jsonl`` ne porte plus la liste des + artefacts (extraite vers l'index).""" + bench = _build_benchmark_service() + result = _make_run_result_with_artifacts() + bench.persist(result, tmp_path) + + pipelines_path = tmp_path / "pipeline_results.jsonl" + lines = [ + line for line in pipelines_path.read_text( + encoding="utf-8", + ).splitlines() if line.strip() + ] + assert len(lines) == 1 + rec = json.loads(lines[0]) + # Le champ artifacts ne doit pas apparaître (ou être vide). + assert ( + "artifacts" not in rec + or rec.get("artifacts") == [] + or rec.get("artifacts") is None + ) + # Mais les autres champs (step_results, etc.) sont présents. + assert rec["pipeline_name"] == "ocr_only" + assert "step_results" in rec + + +class TestRoundTripWithIndex: + def test_load_run_result_reattaches_artifacts( + self, tmp_path: Path, + ) -> None: + """``load_run_result`` lit l'index séparé et ré-attache les + artefacts à chaque PipelineResult.""" + bench = _build_benchmark_service() + result = _make_run_result_with_artifacts() + bench.persist(result, tmp_path) + + loaded = HtmlReportRenderer.load_run_result(tmp_path) + assert len(loaded.document_results) == 1 + loaded_pr = loaded.document_results[0].pipeline_results[0] + assert len(loaded_pr.artifacts) == 2 + # Les content_hash doivent être préservés. + loaded_hashes = {a.content_hash for a in loaded_pr.artifacts} + assert "a" * 64 in loaded_hashes + assert "b" * 64 in loaded_hashes + + +class TestBackwardCompatNoIndex: + def test_load_works_without_artifacts_index_file( + self, tmp_path: Path, + ) -> None: + """Un run legacy persisté avant S41 (sans artifacts_index.jsonl) + reste chargeable — les pipeline_results portent alors leurs + artefacts directement (cas legacy).""" + # Simule un run persisté à l'ancienne : pipeline_results + # contient artifacts inline, pas de artifacts_index.jsonl. + manifest = { + "run_id": "legacy", + "corpus_name": "demo", + "n_documents": 1, + "pipeline_names": ["ocr_only"], + "view_specs": [], + "code_version": "0.9.0-pre-s41", + "started_at": "2026-05-06T10:00:00Z", + "completed_at": "2026-05-06T10:01:00Z", + "dependencies_lock": {}, + "metadata": {}, + } + (tmp_path / "run_manifest.json").write_text( + json.dumps(manifest), encoding="utf-8", + ) + + legacy_pipeline_record = { + "document_id": "doc01", + "pipeline_name": "ocr_only", + "step_results": [ + { + "step_id": "ocr", + "succeeded": True, + "duration_seconds": 0.5, + "produced_artifacts": {"raw_text": "doc01:ocr_only:raw_text"}, + "error": None, + }, + ], + "succeeded": True, + "duration_seconds": 0.5, + "artifacts": [ + { + "id": "doc01:ocr_only:raw_text", + "document_id": "doc01", + "type": "raw_text", + "content_hash": "b" * 64, + "produced_by_step": "ocr", + "provenance": None, + "uri": None, + }, + ], + } + (tmp_path / "pipeline_results.jsonl").write_text( + json.dumps(legacy_pipeline_record) + "\n", + encoding="utf-8", + ) + (tmp_path / "view_results.jsonl").write_text( + "", encoding="utf-8", + ) + # Pas de artifacts_index.jsonl — legacy. + + loaded = HtmlReportRenderer.load_run_result(tmp_path) + loaded_pr = loaded.document_results[0].pipeline_results[0] + assert len(loaded_pr.artifacts) == 1 + assert loaded_pr.artifacts[0].content_hash == "b" * 64 diff --git a/tests/app/services/test_sprint_a14_s48_job_runner.py b/tests/app/services/test_sprint_a14_s48_job_runner.py new file mode 100644 index 0000000000000000000000000000000000000000..39cc17710fca095ee56d1e8b48d82393e22b6362 --- /dev/null +++ b/tests/app/services/test_sprint_a14_s48_job_runner.py @@ -0,0 +1,380 @@ +"""Sprint A14-S48 — ``JobRunner`` + lifespan hook + ``POST /api/jobs``. + +Fix audit #2 : avant ce sprint, ``JobStore`` (S37) était à moitié +branché — pas de ``POST /api/jobs``, pas de lifespan hook, pas +d'orchestrateur async. + +Tests couvrent les 3 chantiers : + +1. ``JobRunner`` (service applicatif) : + - submit + thread démarré, job marqué ``running`` puis ``complete`` ; + - exception orchestrator → ``error`` avec message ; + - cancellation pré-démarrage → thread skippe l'exécution ; + - cancellation post-démarrage → résultat discardé. + +2. Lifespan hook : ``mark_orphaned_jobs_interrupted`` appelé au boot. + +3. ``POST /api/jobs`` : + - YAML valide → 202 + job_id ; + - YAML invalide → 400 ; + - corps vide → 400 ; + - sans job_runner configuré → 503. +""" + +from __future__ import annotations + +import time +from pathlib import Path +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from picarones.adapters.storage import JobStore +from picarones.app.services import JobRunner +from picarones.app.services import ( + RegistryService, + WorkspaceManager, +) +from picarones.interfaces.web import WebAppState, create_app + + +# ────────────────────────────────────────────────────────────────────── +# Stub orchestrator + factory +# ────────────────────────────────────────────────────────────────────── + + +class _StubOrchestrator: + """Stub qui simule un orchestrator : succès, échec, ou délai.""" + + def __init__( + self, + *, + manifest_path: Path, + delay_seconds: float = 0.0, + raise_on_execute: Exception | None = None, + ) -> None: + self.manifest_path = manifest_path + self.delay_seconds = delay_seconds + self.raise_on_execute = raise_on_execute + self.execute_called = False + + def execute(self, run_spec, *, report_renderer=None): + self.execute_called = True + if self.delay_seconds: + time.sleep(self.delay_seconds) + if self.raise_on_execute is not None: + raise self.raise_on_execute + result = MagicMock() + result.persisted_files = {"manifest": self.manifest_path} + return result + + +def _make_factory(stub: _StubOrchestrator): + """Retourne une factory `(output_dir) -> stub` pour JobRunner.""" + def _factory(output_dir): + return stub + return _factory + + +# ────────────────────────────────────────────────────────────────────── +# JobRunner unitaires +# ────────────────────────────────────────────────────────────────────── + + +class TestJobRunnerConstructor: + def test_rejects_non_jobstore(self) -> None: + with pytest.raises(TypeError, match="JobStore"): + JobRunner( + job_store="nope", # type: ignore[arg-type] + orchestrator_factory=lambda d: None, + ) + + def test_rejects_non_callable_factory(self, tmp_path: Path) -> None: + store = JobStore(tmp_path / "jobs.db") + with pytest.raises(TypeError, match="orchestrator_factory"): + JobRunner( + job_store=store, + orchestrator_factory="nope", # type: ignore[arg-type] + ) + + def test_rejects_non_callable_renderer(self, tmp_path: Path) -> None: + store = JobStore(tmp_path / "jobs.db") + with pytest.raises(TypeError, match="report_renderer"): + JobRunner( + job_store=store, + orchestrator_factory=lambda d: None, + report_renderer="nope", # type: ignore[arg-type] + ) + + +class TestJobRunnerHappyPath: + def test_submit_creates_job_and_marks_complete(self, tmp_path: Path) -> None: + store = JobStore(tmp_path / "jobs.db") + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + stub = _StubOrchestrator(manifest_path=manifest) + runner = JobRunner(store, _make_factory(stub)) + + job_id = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "run_out", + ) + assert runner.wait(job_id, timeout=5.0) + assert stub.execute_called + + rec = store.get(job_id) + assert rec is not None + assert rec.status == "complete" + assert rec.output_path == str(manifest) + + def test_submit_returns_unique_uuid_when_no_id( + self, tmp_path: Path, + ) -> None: + store = JobStore(tmp_path / "jobs.db") + manifest = tmp_path / "manifest.json" + manifest.write_text("{}", encoding="utf-8") + stub = _StubOrchestrator(manifest_path=manifest) + runner = JobRunner(store, _make_factory(stub)) + + job_id_1 = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "out1", + ) + job_id_2 = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "out2", + ) + assert job_id_1 != job_id_2 + runner.wait(job_id_1, timeout=5.0) + runner.wait(job_id_2, timeout=5.0) + + def test_submit_stores_explicit_job_id(self, tmp_path: Path) -> None: + store = JobStore(tmp_path / "jobs.db") + manifest = tmp_path / "m.json" + manifest.write_text("{}", encoding="utf-8") + stub = _StubOrchestrator(manifest_path=manifest) + runner = JobRunner(store, _make_factory(stub)) + + returned = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "out", + job_id="my_explicit_id", + ) + assert returned == "my_explicit_id" + runner.wait("my_explicit_id", timeout=5.0) + assert store.get("my_explicit_id") is not None + + +class TestJobRunnerErrorPath: + def test_orchestrator_exception_marks_error(self, tmp_path: Path) -> None: + store = JobStore(tmp_path / "jobs.db") + stub = _StubOrchestrator( + manifest_path=tmp_path / "x", + raise_on_execute=RuntimeError("orchestrator boom"), + ) + runner = JobRunner(store, _make_factory(stub)) + + job_id = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "out", + ) + runner.wait(job_id, timeout=5.0) + + rec = store.get(job_id) + assert rec is not None + assert rec.status == "error" + assert "RuntimeError" in rec.error + assert "orchestrator boom" in rec.error + + +class TestJobRunnerCancellation: + def test_cancel_during_execution_discards_result( + self, tmp_path: Path, + ) -> None: + """Cancel pendant que le worker tourne → le résultat est + discardé (statut reste cancelled).""" + store = JobStore(tmp_path / "jobs.db") + manifest = tmp_path / "m.json" + manifest.write_text("{}", encoding="utf-8") + # Délai suffisant pour cancel avant complétion. + stub = _StubOrchestrator( + manifest_path=manifest, delay_seconds=0.3, + ) + runner = JobRunner(store, _make_factory(stub)) + + job_id = runner.submit( + run_spec=MagicMock(), + output_dir=tmp_path / "out", + ) + # Attendre que mark_running ait été appelé (le thread a démarré). + for _ in range(50): + time.sleep(0.01) + rec = store.get(job_id) + if rec is not None and rec.status == "running": + break + # Cancel en pleine exécution. + store.mark_cancelled(job_id) + # Attendre la fin du thread (~0.3s). + runner.wait(job_id, timeout=5.0) + rec_final = store.get(job_id) + assert rec_final.status == "cancelled", ( + f"Status final attendu cancelled, obtenu {rec_final.status}" + ) + + +# ────────────────────────────────────────────────────────────────────── +# Lifespan hook (mark_orphaned_jobs_interrupted au boot) +# ────────────────────────────────────────────────────────────────────── + + +class TestLifespanHook: + def test_orphaned_jobs_marked_interrupted_on_app_start( + self, tmp_path: Path, + ) -> None: + """Pré-condition : un job ``running`` existe dans le store + (simule un crash du process précédent). + Action : démarrage de l'app FastAPI (lifespan hook). + Résultat : le job orphelin est marqué ``interrupted``.""" + # Phase 1 : pré-pollution du store (simule l'état après crash). + db_path = tmp_path / "jobs.db" + store = JobStore(db_path) + store.create("zombie_pending") + store.create("zombie_running") + store.mark_running("zombie_running") + store.create("complete_one") + store.mark_complete("complete_one") + # Vérification pré-état. + assert store.get("zombie_pending").status == "pending" + assert store.get("zombie_running").status == "running" + assert store.get("complete_one").status == "complete" + + # Phase 2 : démarrage de l'app — lifespan hook s'exécute. + workspace = WorkspaceManager(base_dir=tmp_path, session_id="s48") + registry = RegistryService.bootstrap_defaults() + state = WebAppState( + workspace=workspace, + registry=registry, + corpus=MagicMock(), + benchmark=MagicMock(), + orchestrator=MagicMock(), + job_store=store, # store pré-pollué + ) + app = create_app(state) + # Le lifespan hook tourne au context manager du TestClient. + with TestClient(app) as client: + # Le hook a tourné au démarrage. On vérifie l'état du store. + assert store.get("zombie_pending").status == "interrupted" + assert store.get("zombie_running").status == "interrupted" + # Les jobs déjà terminaux ne sont pas touchés. + assert store.get("complete_one").status == "complete" + # Sanity check : l'app répond. + assert client.get("/health").status_code == 200 + + +# ────────────────────────────────────────────────────────────────────── +# POST /api/jobs (intégration end-to-end via TestClient) +# ────────────────────────────────────────────────────────────────────── + + +def _make_state_with_runner(tmp_path: Path) -> WebAppState: + """Construit un WebAppState complet avec JobStore + JobRunner. + + L'orchestrator est un stub qui complète immédiatement (pour que + les tests POST puissent vérifier le statut). + """ + workspace = WorkspaceManager(base_dir=tmp_path, session_id="s48") + registry = RegistryService.bootstrap_defaults() + job_store = JobStore(tmp_path / "jobs.db") + + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text("{}", encoding="utf-8") + + # Stub orchestrator factory. + def _factory(output_dir): + return _StubOrchestrator(manifest_path=manifest_path) + + job_runner = JobRunner( + job_store=job_store, + orchestrator_factory=_factory, + ) + return WebAppState( + workspace=workspace, + registry=registry, + corpus=MagicMock(), + benchmark=MagicMock(), + orchestrator=MagicMock(), + job_store=job_store, + job_runner=job_runner, + ) + + +_VALID_RUNSPEC_YAML = """ +corpus_dir: /tmp/c +output_dir: /tmp/out +pipelines: + - name: ocr_only + initial_inputs: [image] + steps: + - id: ocr + adapter_class: my_pkg.OCR + input_types: [image] + output_types: [raw_text] +views: [text_final] +""".strip() + + +class TestPostJobsEndpoint: + def test_valid_yaml_returns_202_with_job_id(self, tmp_path: Path) -> None: + state = _make_state_with_runner(tmp_path) + app = create_app(state) + with TestClient(app) as client: + response = client.post("/api/jobs", content=_VALID_RUNSPEC_YAML) + assert response.status_code == 202, response.text + body = response.json() + assert "job_id" in body + assert body["status"] == "pending" + # Le job_id retourné est dans le store. + assert state.job_store.get(body["job_id"]) is not None + + def test_invalid_yaml_returns_400(self, tmp_path: Path) -> None: + state = _make_state_with_runner(tmp_path) + app = create_app(state) + with TestClient(app) as client: + response = client.post( + "/api/jobs", + content="not a valid runspec yaml: [", + ) + assert response.status_code == 400 + assert "RunSpec" in response.json()["detail"] + + def test_empty_body_returns_400_or_422(self, tmp_path: Path) -> None: + """Body vide → 400 (notre check) ou 422 (pydantic validation + en amont du handler). Les deux sont acceptables pour + l'utilisateur.""" + state = _make_state_with_runner(tmp_path) + app = create_app(state) + with TestClient(app) as client: + response = client.post("/api/jobs", content="") + # FastAPI/Starlette peut valider Body(...) en 422 avant + # d'atteindre notre handler ; sinon notre check répond 400. + assert response.status_code in (400, 422) + + def test_no_job_runner_returns_503(self, tmp_path: Path) -> None: + """Sans WebAppState.job_runner, POST /api/jobs → 503.""" + workspace = WorkspaceManager(base_dir=tmp_path, session_id="s48") + registry = RegistryService.bootstrap_defaults() + state = WebAppState( + workspace=workspace, + registry=registry, + corpus=MagicMock(), + benchmark=MagicMock(), + orchestrator=MagicMock(), + job_store=JobStore(tmp_path / "jobs.db"), + # job_runner=None par défaut + ) + app = create_app(state) + with TestClient(app) as client: + response = client.post("/api/jobs", content=_VALID_RUNSPEC_YAML) + assert response.status_code == 503 + assert "Job runner" in response.json()["detail"] diff --git a/tests/app/services/test_sprint_a14_s53_inputs_from_propagation.py b/tests/app/services/test_sprint_a14_s53_inputs_from_propagation.py new file mode 100644 index 0000000000000000000000000000000000000000..989444060991c092707b2175e4d956bfc7a7eb75 --- /dev/null +++ b/tests/app/services/test_sprint_a14_s53_inputs_from_propagation.py @@ -0,0 +1,102 @@ +"""Sprint A14-S53 — propagation inputs_from (fix audit #20). + +Avant S53, le YAML loader S39 validait ``StepSpec.inputs_from`` mais +``RunOrchestrator._build_pipelines`` construisait le ``domain.PipelineStep`` +sans propager le champ — la validation passait, l'exécution ne profitait +PAS du DAG branchant. Faux positif de couverture (testé via round-trip +YAML mais pas bout-en-bout). +""" + +from __future__ import annotations + + +from picarones.app.schemas.run_spec import ( + PipelineSpecYaml, + RunSpec, + StepSpec, +) +from picarones.app.services import RunOrchestrator +from picarones.domain.artifacts import ArtifactType + + +def test_orchestrator_propagates_inputs_from_to_pipeline_step( + tmp_path, +) -> None: + """Construit un RunSpec avec inputs_from, appelle la méthode + interne _build_pipelines, vérifie que le PipelineStep produit + porte bien le inputs_from.""" + spec = RunSpec( + corpus_dir=str(tmp_path), + output_dir=str(tmp_path / "out"), + pipelines=( + PipelineSpecYaml( + name="dag", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="ocr_a", + adapter_class="my_pkg.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + StepSpec( + id="corrector", + adapter_class="my_pkg.B", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + inputs_from={ArtifactType.RAW_TEXT: "ocr_a"}, + ), + ), + ), + ), + views=("text_final",), + ) + + orch = RunOrchestrator(output_dir=tmp_path / "out") + # ``_build_pipelines`` essaie de résoudre adapter_class via + # importlib — comme my_pkg.A et my_pkg.B n'existent pas, on + # patch la résolution pour ne tester QUE la propagation + # inputs_from. + from unittest.mock import MagicMock, patch + with patch( + "picarones.app.services.run_orchestrator.resolve_adapter_class", + return_value=MagicMock, + ): + pipeline_specs, _resolver, _kwargs = orch._build_pipelines(spec) + + assert len(pipeline_specs) == 1 + ps = pipeline_specs[0] + # Le step "corrector" doit porter inputs_from. + corrector_step = next(s for s in ps.steps if s.id == "corrector") + assert ArtifactType.RAW_TEXT in corrector_step.inputs_from + assert corrector_step.inputs_from[ArtifactType.RAW_TEXT] == "ocr_a" + + +def test_step_without_inputs_from_yields_empty_dict(tmp_path) -> None: + spec = RunSpec( + corpus_dir=str(tmp_path), + output_dir=str(tmp_path / "out"), + pipelines=( + PipelineSpecYaml( + name="simple", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + StepSpec( + id="ocr", + adapter_class="my_pkg.A", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + ), + ), + ), + views=("text_final",), + ) + orch = RunOrchestrator(output_dir=tmp_path / "out") + from unittest.mock import MagicMock, patch + with patch( + "picarones.app.services.run_orchestrator.resolve_adapter_class", + return_value=MagicMock, + ): + pipeline_specs, _, _ = orch._build_pipelines(spec) + assert pipeline_specs[0].steps[0].inputs_from == {} diff --git a/tests/app/test_run_orchestrator.py b/tests/app/test_run_orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..099f9d435094680d3ff6da323a68ff4ad72d0d1b --- /dev/null +++ b/tests/app/test_run_orchestrator.py @@ -0,0 +1,481 @@ +"""Tests unitaires de :class:`RunOrchestrator` (couche ``app/services/``). + +Le ``RunOrchestrator`` est testé ici **directement** (sans passer par +la CLI Click). Les tests ``tests/cli/test_sprint_a14_s24_run_command.py`` +le testent indirectement via le wrapper Click — c'est complémentaire +mais pas suffisant pour vérifier le contrat du service. + +Couverture +---------- +- ``execute()`` retourne un :class:`OrchestrationResult` complet + (run_result, extracted_corpus_dir, persisted_files, report_path). +- ``report_renderer=None`` ne génère aucun rapport, même si + ``spec.report_html`` est renseigné. +- ``report_renderer=callable`` SANS ``spec.report_html`` ne génère + rien (l'orchestrateur ne décide pas seul d'un chemin). +- ``report_renderer=callable`` ET ``spec.report_html`` → invocation + du renderer avec le ``RunResult``, ``output_path`` et ``lang``. +- Le corpus chargé est sandboxé sous l'``output_dir`` du caller. +- Les 3 fichiers persistés sont écrits dans ``output_dir/results/``. +- Une ``CorpusImportError`` (corpus invalide) propage proprement. +- Une ``RunSpecLoadError`` (adapter dotted-path inconnu) propage + proprement. +- Le helper ``_default_gt_factory`` traite ``CORRECTED_TEXT`` comme + comparable à la GT ``RAW_TEXT`` (les deux sont du texte plat). +- Le helper ``_default_inputs_factory`` lève quand ``image_uri`` est + absent. +- Le ``_filesystem_payload_loader`` lit RAW_TEXT/CORRECTED_TEXT/ + ALTO_XML, lève sur type non géré ou URI absent. +- Disambiguation ``_build_pipelines`` : 2 pipelines avec la même + classe d'adapter mais des kwargs distincts → 2 instances + distinctes (cas ``PrecomputedTextAdapter`` × ``source_label``). +""" + +from __future__ import annotations + +import io +import textwrap +import zipfile +from pathlib import Path + +import pytest + +from picarones.app.results import RunResult +from picarones.app.schemas import load_run_spec_from_yaml +from picarones.app.services import ( + CorpusImportError, + OrchestrationResult, + RunOrchestrator, +) +from picarones.app.services.run_orchestrator import ( + _default_gt_factory, + _default_inputs_factory, + _filesystem_payload_loader, + _kwargs_signature, + _make_context_factory, +) +from picarones.app.schemas.run_spec import RunSpecLoadError +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.documents import DocumentRef, GroundTruthRef + + +# ────────────────────────────────────────────────────────────────── +# Helpers communs +# ────────────────────────────────────────────────────────────────── + + +def _png_bytes() -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00" + b"\x1f\x15\xc4\x89" + ) + + +def _make_corpus_zip(n_docs: int = 2) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + for i in range(1, n_docs + 1): + doc_id = f"doc{i:02d}" + zf.writestr(f"{doc_id}.png", _png_bytes()) + zf.writestr(f"{doc_id}.gt.txt", "Bonjour le monde") + # Source pré-calculée pour PrecomputedTextAdapter. + zf.writestr(f"{doc_id}.tess.txt", "Bonjour le monde") + return buf.getvalue() + + +def _build_spec_yaml( + *, + corpus_zip: Path, + output_dir: Path, + report_html: str | None = None, +) -> str: + base = textwrap.dedent(f""" + corpus_zip: {corpus_zip} + corpus_name: orchestrator_test + pipelines: + - name: tess_only + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tess + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {output_dir} + code_version: "1.0.0-orch-test" + """) + if report_html is not None: + base += f"report_html: {report_html}\n" + return base + + +# ────────────────────────────────────────────────────────────────── +# Cycle de vie ``execute()`` +# ────────────────────────────────────────────────────────────────── + + +def _stub_renderer_called(records: list) -> "callable": + """Crée un renderer qui enregistre ses appels et écrit un fichier + minimal. Utile pour vérifier l'invocation sans dépendre de + ``HtmlReportRenderer``.""" + + def _render(result: RunResult, output_path: Path, lang: str) -> Path: + records.append({"corpus": result.manifest.corpus_name, "lang": lang}) + output_path.write_text(f"stub:{lang}", encoding="utf-8") + return output_path + + return _render + + +class TestExecuteHappyPath: + def test_returns_orchestration_result_complete( + self, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip(n_docs=2)) + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml( + _build_spec_yaml(corpus_zip=corpus_zip, output_dir=out_dir), + ) + + orchestrator = RunOrchestrator(out_dir) + result = orchestrator.execute(spec) + + assert isinstance(result, OrchestrationResult) + assert isinstance(result.run_result, RunResult) + assert result.run_result.n_documents == 2 + assert result.run_result.manifest.corpus_name == "orchestrator_test" + # Corpus extrait sous le workspace. ``.resolve()`` normalise + # cross-OS (macOS résout ``/var/folders/...`` → + # ``/private/var/folders/...``). + assert result.extracted_corpus_dir.exists() + assert result.extracted_corpus_dir.resolve().is_relative_to( + out_dir.resolve(), + ) + # S41 — 4 fichiers persistés (artifacts_index séparé). + assert set(result.persisted_files) == { + "manifest", "pipeline_results", "artifacts_index", "view_results", + } + for path in result.persisted_files.values(): + assert path.exists() + assert path.resolve().is_relative_to(out_dir.resolve()) + # Pas de rapport car aucun renderer fourni. + assert result.report_path is None + + def test_persisted_files_under_results_subdir( + self, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml( + _build_spec_yaml(corpus_zip=corpus_zip, output_dir=out_dir), + ) + result = RunOrchestrator(out_dir).execute(spec) + expected_parent = (out_dir / "results").resolve() + for path in result.persisted_files.values(): + assert path.parent.resolve() == expected_parent + + +class TestReportRendererInjection: + def test_no_renderer_skips_report_even_with_spec_path( + self, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + out_dir = tmp_path / "out" + report_path = out_dir / "rapport.html" + spec = load_run_spec_from_yaml(_build_spec_yaml( + corpus_zip=corpus_zip, + output_dir=out_dir, + report_html=str(report_path), + )) + result = RunOrchestrator(out_dir).execute(spec, report_renderer=None) + assert result.report_path is None + assert not report_path.exists() + + def test_renderer_without_spec_path_skips( + self, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml(_build_spec_yaml( + corpus_zip=corpus_zip, + output_dir=out_dir, + report_html=None, + )) + records: list[dict] = [] + result = RunOrchestrator(out_dir).execute( + spec, report_renderer=_stub_renderer_called(records), + ) + assert result.report_path is None + assert records == [] # renderer pas invoqué + + def test_renderer_invoked_when_both_present( + self, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + out_dir = tmp_path / "out" + report_path = out_dir / "rapport.html" + spec = load_run_spec_from_yaml(_build_spec_yaml( + corpus_zip=corpus_zip, + output_dir=out_dir, + report_html=str(report_path), + )) + records: list[dict] = [] + result = RunOrchestrator(out_dir).execute( + spec, report_renderer=_stub_renderer_called(records), + ) + assert result.report_path == report_path + assert report_path.exists() + assert report_path.read_text(encoding="utf-8").startswith("stub:") + assert records == [ + {"corpus": "orchestrator_test", "lang": "fr"}, + ] + + +# ────────────────────────────────────────────────────────────────── +# Erreurs typées propagées +# ────────────────────────────────────────────────────────────────── + + +class TestErrorPropagation: + def test_corpus_dir_inexistant_raises(self, tmp_path: Path) -> None: + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml(textwrap.dedent(f""" + corpus_dir: {tmp_path / "does_not_exist"} + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tess + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + """)) + with pytest.raises(CorpusImportError, match="n'est pas un répertoire"): + RunOrchestrator(out_dir).execute(spec) + + def test_unknown_adapter_class_raises(self, tmp_path: Path) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.does_not_exist.Nope + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + """)) + with pytest.raises(RunSpecLoadError, match="introuvable"): + RunOrchestrator(out_dir).execute(spec) + + +# ────────────────────────────────────────────────────────────────── +# Disambiguation des adapters +# ────────────────────────────────────────────────────────────────── + + +class TestPipelineDisambiguation: + def test_same_class_different_kwargs_yields_distinct_instances( + self, tmp_path: Path, + ) -> None: + """Cas BnF : 2 pipelines utilisent ``PrecomputedTextAdapter`` + mais avec ``source_label`` différents → ils doivent recevoir + des instances distinctes (sinon le 2ème lirait les fichiers + du 1er).""" + # Corpus avec 2 sources pré-calculées différentes. + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + zf.writestr("doc01.png", _png_bytes()) + zf.writestr("doc01.gt.txt", "Bonjour") + zf.writestr("doc01.tess.txt", "Bonjour") # source 1 + zf.writestr("doc01.gpt4v.txt", "Bonjur") # source 2 (1 erreur) + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(buf.getvalue()) + + out_dir = tmp_path / "out" + spec = load_run_spec_from_yaml(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + pipelines: + - name: tess + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: tess + input_types: [image] + output_types: [raw_text] + - name: gpt + initial_inputs: [image] + steps: + - id: ocr + adapter_class: picarones.adapters.ocr.precomputed.PrecomputedTextAdapter + adapter_kwargs: + source_label: gpt4v + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + """)) + result = RunOrchestrator(out_dir).execute(spec) + # 1 doc × 2 pipelines = 2 ViewResult. Ils doivent avoir des + # candidate_artifact_id distincts (preuves d'instances distinctes). + view_results = result.run_result.view_results_for("text_final") + owners = { + "tess" if "precomputed_tess" in vr.candidate_artifact_id and "tess:" in vr.candidate_artifact_id + else "gpt" if "precomputed_gpt4v" in vr.candidate_artifact_id else "?" + for vr in view_results + } + # Au moins 2 owners distincts. + assert len(owners) >= 2 + + +# ────────────────────────────────────────────────────────────────── +# Helpers privés (importés directement pour couverture explicite) +# ────────────────────────────────────────────────────────────────── + + +class TestDefaultGtFactory: + def test_returns_artifact_for_present_gt(self) -> None: + doc = DocumentRef( + id="doc01", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/path/gt.txt"), + ), + ) + gt = _default_gt_factory(doc, ArtifactType.RAW_TEXT) + assert gt is not None + assert gt.type == ArtifactType.RAW_TEXT + assert gt.uri == "/path/gt.txt" + + def test_corrected_text_falls_back_to_raw_text_gt(self) -> None: + """Convention : un candidat CORRECTED_TEXT est comparé contre + la GT RAW_TEXT (les deux sont du texte plat).""" + doc = DocumentRef( + id="doc01", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/path/gt.txt"), + ), + ) + gt = _default_gt_factory(doc, ArtifactType.CORRECTED_TEXT) + assert gt is not None + assert gt.type == ArtifactType.RAW_TEXT # fallback explicite + + def test_returns_none_when_gt_absent(self) -> None: + doc = DocumentRef(id="doc01", ground_truths=()) + gt = _default_gt_factory(doc, ArtifactType.RAW_TEXT) + assert gt is None + + +class TestDefaultInputsFactory: + def test_returns_image_artifact(self) -> None: + doc = DocumentRef(id="doc01", image_uri="/path/img.png") + inputs = _default_inputs_factory(doc) + assert ArtifactType.IMAGE in inputs + assert inputs[ArtifactType.IMAGE].uri == "/path/img.png" + + def test_raises_when_image_uri_absent(self) -> None: + doc = DocumentRef(id="doc01") + with pytest.raises(CorpusImportError, match="sans ``image_uri``"): + _default_inputs_factory(doc) + + +class TestContextFactory: + def test_factory_propagates_code_version(self) -> None: + factory = _make_context_factory("1.2.3") + doc = DocumentRef(id="doc01", image_uri="/x") + ctx = factory(doc, "my_pipeline") + assert ctx.document_id == "doc01" + assert ctx.code_version == "1.2.3" + assert ctx.pipeline_name == "my_pipeline" + + +class TestFilesystemPayloadLoader: + def test_loads_raw_text(self, tmp_path: Path) -> None: + path = tmp_path / "t.txt" + path.write_text("Hello", encoding="utf-8") + art = Artifact( + id="d:t", document_id="d", type=ArtifactType.RAW_TEXT, uri=str(path), + ) + assert _filesystem_payload_loader(art) == "Hello" + + def test_loads_corrected_text(self, tmp_path: Path) -> None: + path = tmp_path / "c.txt" + path.write_text("Bonjour", encoding="utf-8") + art = Artifact( + id="d:c", document_id="d", type=ArtifactType.CORRECTED_TEXT, + uri=str(path), + ) + assert _filesystem_payload_loader(art) == "Bonjour" + + def test_loads_alto_xml(self, tmp_path: Path) -> None: + from picarones.formats.alto.types import ( + AltoBBox, AltoDocument, AltoLine, AltoPage, AltoString, + AltoTextBlock, + ) + from picarones.formats.alto.writer import write_alto + + doc = AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=( + AltoString(content="Hi", bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)), + ),),),),),),)) + path = tmp_path / "a.xml" + path.write_bytes(write_alto(doc)) + art = Artifact( + id="d:a", document_id="d", type=ArtifactType.ALTO_XML, uri=str(path), + ) + loaded = _filesystem_payload_loader(art) + assert loaded.pages[0].blocks[0].lines[0].strings[0].content == "Hi" + + def test_raises_on_missing_uri(self) -> None: + art = Artifact( + id="d:x", document_id="d", type=ArtifactType.RAW_TEXT, + ) + with pytest.raises(FileNotFoundError, match="sans URI"): + _filesystem_payload_loader(art) + + def test_raises_on_unsupported_type(self, tmp_path: Path) -> None: + path = tmp_path / "x.bin" + path.write_bytes(b"\x00" * 4) + art = Artifact( + id="d:x", document_id="d", type=ArtifactType.IMAGE, uri=str(path), + ) + with pytest.raises(ValueError, match="non géré"): + _filesystem_payload_loader(art) + + +class TestKwargsSignature: + def test_empty_dict(self) -> None: + assert _kwargs_signature({}) == "" + + def test_single_kwarg(self) -> None: + assert _kwargs_signature({"k": "v"}) == "k='v'" + + def test_sorted_stable(self) -> None: + # Ordre d'insertion ne doit pas changer la signature. + sig_a = _kwargs_signature({"b": 2, "a": 1}) + sig_b = _kwargs_signature({"a": 1, "b": 2}) + assert sig_a == sig_b + + def test_distinguishes_values(self) -> None: + assert ( + _kwargs_signature({"k": 1}) + != _kwargs_signature({"k": 2}) + ) diff --git a/tests/architecture/test_file_budgets.py b/tests/architecture/test_file_budgets.py index 0fd221d43f59809d553c72e57fe0c008062ead0d..35dc2e3e0e4e86a80af73f3566048dc4f4d91366 100644 --- a/tests/architecture/test_file_budgets.py +++ b/tests/architecture/test_file_budgets.py @@ -61,24 +61,80 @@ FILE_BUDGETS: dict[str, int] = { "picarones/core/pipeline.py": 675, # actuel 571 "picarones/extras/importers/iiif.py": 675, # actuel 567 "picarones/extras/importers/gallica.py": 675, # actuel 563 - "picarones/measurements/levers.py": 675, # actuel 561 + "picarones/measurements/levers.py": 675, # actuel 561 (re-export S10) + # Sprint A14-S10 — déplacés depuis measurements/, l'ancien + # emplacement est désormais un re-export. Le contenu canonique + # vit dans evaluation/metrics/. + "picarones/evaluation/metrics/levers.py": 675, # actuel 561 + "picarones/evaluation/metrics/inter_engine.py": 575, # actuel 484 "picarones/extras/importers/escriptorium.py": 650, # actuel 553 - "picarones/web/security.py": 625, # actuel 532 + # Sprint A14-S1 — A.I.0 P0 : ajout de validated_path, + # validated_prompt_filename, safe_report_name et compute_workspace_roots. + # Ces helpers seront extraits dans ``picarones/web/path_security.py`` + # lors du Sprint S20 du rewrite ciblé (création couche app/services/). + "picarones/web/security.py": 800, # actuel 751 + # Sprint A14-S8 — CorpusRunner introduit pour orchestrer les + # pipelines composées sur un corpus avec backpressure / timeout + # réel / annulation propre. Budget stable, l'extension + # ProcessPoolExecutor (S11) restera dans cette enveloppe. + "picarones/pipeline/runner.py": 550, # actuel 462 + # Sprint A14-S28 — PipelineExecutor refondu pour consommer un + # ExecutionPlan (run_plan) tout en gardant run(spec) comme sucre. + # PipelinePlanner introduit pour transformer une PipelineSpec en + # plan immuable (validation + bindings + jonctions de métriques). + # Sprint A14-S47 — branchement ArtifactStore : +60 lignes (lookup + # cache avant exec, persistance après succès, helpers privés). + "picarones/pipeline/executor.py": 600, # actuel 541 + "picarones/pipeline/planner.py": 465, # actuel 403 + # Sprint A14-S29 — ArtifactStore (ABC + 2 implémentations) avec + # hash multi-paramètres pour adresser la critique d'audit n° 14 + # « hash multi-paramètres + reprise par hash ». + "picarones/adapters/storage/artifact_store.py": 580, # actuel 504 + # Sprint A14-S37 + S52 + S56 — JobStore SQLite : POST/GET/DELETE, + # JobStoreError, schema_version table (S56) + busy_timeout 30s + + # WAL mode pour les jobs concurrents. + "picarones/adapters/storage/job_store.py": 500, # actuel 421 + # Sprint A14-S41 — artifacts_index.jsonl séparé. + "picarones/app/services/benchmark_service.py": 470, # actuel 400 + # Sprint A14-S44 — BaseLLMAdapter implémente le contrat StepExecutor + # (input_types, output_types, execute) en plus de complete(). + # S59 ajout du descripteur ``_DeprecatedAttribute`` + alias rétrocompat + # ``DEFAULT_CORRECTION_PROMPT`` + warning lang fallback (M6). + "picarones/adapters/llm/base.py": 560, # actuel 486 "picarones/core/corpus.py": 600, # actuel 511 "picarones/fixtures.py": 600, # actuel 510 "picarones/measurements/inter_engine.py": 575, # actuel 484 "picarones/measurements/roman_numerals.py": 575, # actuel 478 - "picarones/extras/importers/htr_united.py": 575, # actuel 473 + "picarones/extras/importers/htr_united.py": 575, # actuel 473 (re-export S11) + # Sprint A14-S11 — d\xc3\xa9plac\xc3\xa9s depuis extras/importers/, l'ancien + # emplacement est d\xc3\xa9sormais un re-export. + "picarones/adapters/corpus/htr_united.py": 575, # actuel 473 + "picarones/adapters/corpus/huggingface.py": 550, # actuel 464 "picarones/cli/_workflows.py": 550, # actuel 469 "picarones/extras/importers/huggingface.py": 550, # actuel 464 "picarones/core/metric_hooks.py": 500, # actuel 423 "picarones/measurements/numerical_sequences.py": 500, # actuel 422 - "picarones/measurements/normalization.py": 500, # actuel 420 + "picarones/measurements/normalization.py": 500, # actuel 420 (re-export S9) + # Sprint A14-S9 — déplacé depuis measurements/normalization.py. + # L'ancien emplacement est désormais un re-export ; le contenu + # canonique vit ici. + "picarones/formats/text/normalization.py": 500, # actuel 420 "picarones/report/comparison.py": 500, # actuel 409 # --- Module mutualisé créé par le sprint des render helpers # (Sprint « consolidation des renderers » 2026-05-02). Budget # calibré sur la taille post-documentation des conventions. "picarones/report/render_helpers.py": 480, # actuel 415 + # --- Services applicatifs et orchestration du rewrite ciblé. + # Budgets calibrés à current + 15 % de marge. La CLI elle-même + # reste mince (~110 lignes) — toute logique métier vit dans + # ``app/services/``. + "picarones/app/services/corpus_service.py": 625, # actuel 541 + "picarones/app/services/path_security.py": 470, # actuel 410 + "picarones/app/services/run_orchestrator.py": 500, # actuel 432 + # Le rendu HTML vit en couche ``reports_v2/`` (cible documentée + # du rewrite — un rapport est un format de sortie, pas un + # service métier). + "picarones/reports_v2/html/render.py": 700, # actuel 615 } diff --git a/tests/architecture/test_layer_dependencies.py b/tests/architecture/test_layer_dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..cf50174ee32c5add372ff283f2cfdff91914dbad --- /dev/null +++ b/tests/architecture/test_layer_dependencies.py @@ -0,0 +1,312 @@ +"""Sprint A14-S3 — règles de dépendance des nouvelles couches. + +Le rewrite ciblé (cf. ``docs/roadmap/rewrite-2026.md``) restructure +``picarones/`` en 8 couches. Ce module **interdit** dès aujourd'hui +qu'un module d'une couche importe une couche plus extérieure ou +une lib externe non autorisée pour sa couche. + +:: + + domain (cercle 1, le plus central) + ▲ + evaluation + ▲ + pipeline + ▲ + formats ┐ + adapters ├ cercle 3 — implémentations concrètes + app/services │ + ▲ │ + interfaces ┘ cercle 5 — transport (CLI, web) + reports_v2 + +Règles encodées (les "couches plus internes" sont autorisées) : + +- ``domain`` : stdlib, pydantic, typing_extensions UNIQUEMENT. +- ``evaluation`` : domain + stdlib + numpy + scipy. +- ``pipeline`` : domain + evaluation + stdlib. +- ``formats`` : domain + stdlib + lxml + defusedxml. +- ``adapters`` : domain + pipeline + formats + libs externes. +- ``app`` : domain + evaluation + pipeline + formats + adapters. +- ``interfaces`` : app + libs transport (fastapi, click, ...). +- ``reports_v2`` : domain + evaluation + stdlib + jinja2. + +Compatibilité ascendante : ce test ne touche **pas** aux anciens +packages (``picarones.core``, ``picarones.measurements``, etc.) qui +restent gouvernés par ``tests/core/test_circle_dependencies.py``. +Les deux jeux de règles cohabitent pendant le rewrite — le test +historique disparaîtra à la fin du Sprint S22 quand l'ancien code +aura été migré ou supprimé. + +Mécanismes d'exception : aucun. Toute violation se corrige en +remontant le code dans la couche appropriée, **pas** en allongeant +une whitelist. +""" + +from __future__ import annotations + +import ast +from collections.abc import Iterator +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] +PICARONES_ROOT = REPO_ROOT / "picarones" + + +# --------------------------------------------------------------------------- +# Cartographie des couches +# --------------------------------------------------------------------------- + +#: Ordre des couches du plus interne au plus externe. Un module +#: d'une couche peut importer toutes les couches **strictement +#: avant** la sienne (i.e. plus internes), mais jamais l'inverse. +LAYER_ORDER: tuple[str, ...] = ( + "domain", + "formats", # S13 — re-ordonné : parsers/normalization sont des + # utilitaires bas niveau qu'``evaluation`` consomme + # (ex : ``DefaultEvaluationViewExecutor`` charge un + # profil de normalisation depuis + # ``formats.text.normalization``). + "evaluation", + "pipeline", + "adapters", + "app", + "reports_v2", + "interfaces", +) + + +def _layer_index(name: str) -> int: + return LAYER_ORDER.index(name) + + +#: Libs externes additionnellement autorisées par couche (au-delà +#: des couches plus internes). Liste blanche stricte ; tout import +#: hors stdlib qui n'est pas dans cette liste fait échouer le test. +EXTERNAL_ALLOWED: dict[str, frozenset[str]] = { + "domain": frozenset({"pydantic", "typing_extensions", "annotated_types"}), + "evaluation": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "numpy", "scipy", "jiwer", "rapidfuzz", + # S10 — fichiers de calcul migrés depuis measurements/ : + "PIL", # image_quality utilise Pillow pour analyser les images + "yaml", # pricing charge sa table de coûts depuis YAML + }), + "pipeline": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "numpy", "scipy", + # S6 — yaml pour la sérialisation YAML des PipelineSpec + # (cf. picarones/pipeline/yaml_io.py). Versionner les + # pipelines en git en YAML est un cas d'usage explicite du + # rewrite, justifie l'ajout à la whitelist. + "yaml", + }), + "formats": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "lxml", "defusedxml", "yaml", + }), + # Adapters: tout est permis (libs OCR/LLM/cloud spécifiques). + "adapters": None, # type: ignore[dict-item] # marqueur "*" + "app": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "numpy", "scipy", "jiwer", "yaml", "lxml", "defusedxml", + }), + "interfaces": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "fastapi", "starlette", "click", "uvicorn", + "jinja2", "markupsafe", + "httpx", "anyio", "h11", "httpcore", + "multipart", + }), + "reports_v2": frozenset({ + "pydantic", "typing_extensions", "annotated_types", + "jinja2", "markupsafe", "yaml", + }), +} + + +def _layer_of(file_path: Path) -> str | None: + """Retourne la couche d'un fichier ``picarones/*.py``, ou None + s'il appartient à un ancien package non encore migré.""" + rel = file_path.relative_to(PICARONES_ROOT) + if not rel.parts: + return None + top = rel.parts[0] + if top in LAYER_ORDER: + return top + return None + + +# --------------------------------------------------------------------------- +# Parsing des imports +# --------------------------------------------------------------------------- + + +def _imports_in_file(path: Path) -> Iterator[tuple[str, int]]: + """Yields ``(module_dotted, line_no)`` pour chaque ``import`` du fichier. + + Couvre ``import a.b``, ``from a.b import c``, et les imports + paresseux à l'intérieur de fonctions (``ast.walk`` parcourt + tout l'AST, pas seulement les statements top-level). + """ + try: + tree = ast.parse(path.read_text(encoding="utf-8")) + except SyntaxError as exc: + pytest.fail(f"{path} : SyntaxError {exc}") + return # pragma: no cover + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + yield alias.name, node.lineno + elif isinstance(node, ast.ImportFrom): + # Imports relatifs (``from .. import x``) sont résolus + # par le runtime — on n'a pas besoin de les vérifier ici + # tant qu'ils restent dans le même package (et donc la + # même couche). + if node.module is None: + continue + if node.level > 0: + # Import relatif : on ignore. + continue + yield node.module, node.lineno + + +def _python_files(root: Path) -> Iterator[Path]: + for p in root.rglob("*.py"): + if "__pycache__" in p.parts: + continue + yield p + + +# --------------------------------------------------------------------------- +# Vérifications +# --------------------------------------------------------------------------- + + +def _internal_layer(module_dotted: str) -> str | None: + """Si ``module_dotted`` est un module ``picarones....``, + retourne ```` si ```` est dans LAYER_ORDER ; sinon + None (vieux package, hors-couche).""" + if not module_dotted.startswith("picarones."): + return None + parts = module_dotted.split(".") + if len(parts) < 2: + return None + candidate = parts[1] + return candidate if candidate in LAYER_ORDER else None + + +def _external_top(module_dotted: str) -> str: + """Top-level d'un module externe (``numpy.linalg`` → ``numpy``).""" + return module_dotted.split(".")[0] + + +def _is_stdlib(top: str) -> bool: + import sys + return top in getattr(sys, "stdlib_module_names", set()) or top in { + "tomllib", "pyexpat", + } + + +@pytest.mark.parametrize( + "layer", + LAYER_ORDER, + ids=lambda x: f"layer-{x}", +) +def test_layer_imports_are_legal(layer: str) -> None: + """Pour chaque module de la couche ``layer``, vérifier que tous + ses imports remontent vers une couche plus interne (ou égale) + et que les libs externes utilisées sont dans la whitelist. + + Test trivialement vert tant que la couche est vide ; échoue dès + qu'on ajoute du code qui viole les règles. + """ + layer_dir = PICARONES_ROOT / layer + if not layer_dir.exists(): + pytest.skip(f"Couche {layer} pas encore créée — skip.") + + layer_idx = _layer_index(layer) + allowed_externals = EXTERNAL_ALLOWED.get(layer) + violations: list[str] = [] + + for path in _python_files(layer_dir): + for module, lineno in _imports_in_file(path): + internal = _internal_layer(module) + if internal is not None: + # Import vers une couche du nouveau découpage. + target_idx = _layer_index(internal) + # Une couche peut importer elle-même ou plus interne. + if target_idx > layer_idx: + violations.append( + f"{path.relative_to(REPO_ROOT)}:{lineno} " + f"importe '{module}' (couche '{internal}', " + f"plus externe que '{layer}')." + ) + continue + + if module.startswith("picarones."): + # Import vers un ancien package (core/measurements/ + # engines/llm/pipelines/modules/report/cli/web/extras). + # Pendant le rewrite, c'est interdit dans les + # nouvelles couches : si tu as besoin d'un truc de + # l'ancien code, déplace-le d'abord (Sprints S9-S11). + violations.append( + f"{path.relative_to(REPO_ROOT)}:{lineno} " + f"importe '{module}' (ancien package non migré). " + "Une nouvelle couche ne doit pas dépendre de " + "l'ancien code — déplacer d'abord." + ) + continue + + # Import externe. + top = _external_top(module) + if _is_stdlib(top): + continue + if allowed_externals is None: + # ``adapters`` accepte tout externe. + continue + if top not in allowed_externals: + violations.append( + f"{path.relative_to(REPO_ROOT)}:{lineno} " + f"importe '{module}' (lib externe '{top}' non " + f"autorisée pour la couche '{layer}'). " + f"Whitelist : {sorted(allowed_externals)}." + ) + + assert not violations, ( + f"\nViolations de couche dans '{layer}' " + f"(plan rewrite-2026 §architecture cible) :\n" + + "\n".join(f" - {v}" for v in violations) + + "\n\nDeux choix :\n" + " 1. Remonter le code dans la couche correcte.\n" + " 2. Si la lib externe est légitime, l'ajouter à " + "EXTERNAL_ALLOWED dans ce fichier (avec justification " + "explicite dans le commit message)." + ) + + +def test_layer_order_well_formed() -> None: + """Méta-test : LAYER_ORDER doit lister chaque couche une fois.""" + assert len(LAYER_ORDER) == len(set(LAYER_ORDER)) + for layer in LAYER_ORDER: + assert layer in EXTERNAL_ALLOWED, ( + f"Couche '{layer}' déclarée dans LAYER_ORDER mais absente " + "de EXTERNAL_ALLOWED." + ) + + +def test_all_new_layer_dirs_exist() -> None: + """Méta-test : toutes les couches déclarées dans LAYER_ORDER ont + un répertoire correspondant. Sinon le test ``test_layer_imports_are_legal`` + skip silencieusement et la règle n'est pas appliquée.""" + missing = [ + layer for layer in LAYER_ORDER + if not (PICARONES_ROOT / layer).is_dir() + ] + assert not missing, ( + f"Couches déclarées sans répertoire correspondant : {missing}. " + "Soit créer le répertoire avec son ``__init__.py``, soit " + "retirer l'entrée de LAYER_ORDER." + ) diff --git a/tests/architecture/test_manifest_reproducibility.py b/tests/architecture/test_manifest_reproducibility.py new file mode 100644 index 0000000000000000000000000000000000000000..ec928107b444d9ca604a20e13ddfc0ac666a226e --- /dev/null +++ b/tests/architecture/test_manifest_reproducibility.py @@ -0,0 +1,123 @@ +"""Garde-fou de reproductibilité du ``RunManifest``. + +L'audit S58 a relevé que ``RunManifest.dependencies_lock`` n'était +jamais peuplé et que ``pipeline_specs`` ne contenait que les noms, +rompant la promesse documentée *« à code_version + corpus + specs + +dependencies_lock identiques, ré-exécuter doit donner les mêmes +résultats »*. + +Ces tests verrouillent le contrat : + +1. ``capture_dependencies_lock()`` retourne un dict non vide trié. +2. ``RunManifest`` accepte des ``pipeline_specs`` complètes (steps, + adapter_name, params, inputs_from), pas seulement des noms. +3. ``adapter_kwargs`` permet de reconstituer les constructeurs + d'adapters (model, temperature, etc.). +4. La sérialisation est déterministe : deux manifests à entrée + identique produisent les mêmes octets JSON. +""" + +from __future__ import annotations + +from datetime import datetime, timezone + +from picarones.app.services.dependencies import capture_dependencies_lock +from picarones.domain.artifacts import ArtifactType +from picarones.domain.pipeline_spec import PipelineSpec, PipelineStep +from picarones.domain.run_manifest import RunManifest + + +def test_capture_dependencies_lock_non_empty_and_sorted() -> None: + """``capture_dependencies_lock()`` retourne ≥ 1 paquet (pydantic + au minimum) et trié alphabétiquement (case-insensitive). + """ + lock = capture_dependencies_lock() + assert len(lock) > 0, "lock vide — picarones lui-même doit être listé." + keys = list(lock.keys()) + assert keys == sorted(keys, key=str.lower), ( + "lock non trié — le manifest ne sera pas bit-for-bit " + "reproductible cross-environnement." + ) + # pydantic est une dépendance ferme du projet — sa présence prouve + # que la capture marche sur l'env réel. + assert any(k.lower() == "pydantic" for k in lock) + + +def test_run_manifest_carries_full_pipeline_specs() -> None: + """Le manifest doit porter les ``PipelineSpec`` complètes, pas + seulement les noms. Sans ça, un relecteur 5 ans plus tard ne peut + pas reconstituer le DAG sans accès au YAML d'origine. + """ + step = PipelineStep( + id="ocr", + kind="ocr", + adapter_name="tesseract", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + params={"lang": "fra"}, + ) + spec = PipelineSpec(name="tess_only", steps=(step,)) + + manifest = RunManifest( + run_id="r1", + corpus_name="c1", + n_documents=1, + pipeline_specs=(spec,), + adapter_kwargs={"tesseract": {"lang": "fra", "psm": 6}}, + view_specs=(), + code_version="1.0.0-test", + started_at=datetime.now(tz=timezone.utc), + completed_at=datetime.now(tz=timezone.utc), + dependencies_lock={"pydantic": "2.5.0"}, + ) + + assert manifest.pipeline_specs == (spec,) + # Vue rétrocompat dérivée des specs. + assert manifest.pipeline_names == ("tess_only",) + # Les kwargs d'instanciation sont tracés. + assert manifest.adapter_kwargs["tesseract"]["psm"] == 6 + # Le step complet est reconstituable. + assert manifest.pipeline_specs[0].steps[0].params == {"lang": "fra"} + + +def test_run_manifest_serialization_is_deterministic() -> None: + """Deux manifests à entrée identique produisent les mêmes + octets JSON — pré-requis pour le hash d'intégrité que la BnF + peut citer dans une publication. + """ + common = dict( + run_id="r1", + corpus_name="c1", + n_documents=42, + pipeline_specs=(), + adapter_kwargs={"a": {"k": 1}, "b": {"k": 2}}, + view_specs=(), + code_version="1.0.0", + started_at=datetime(2026, 5, 6, tzinfo=timezone.utc), + completed_at=datetime(2026, 5, 6, tzinfo=timezone.utc), + dependencies_lock={"pkg-a": "1.0", "pkg-b": "2.0"}, + metadata={"note": "test"}, + ) + m1 = RunManifest(**common) + m2 = RunManifest(**common) + assert m1.model_dump_json() == m2.model_dump_json() + + +def test_run_manifest_rejects_extra_fields() -> None: + """``extra="forbid"`` — le contrat du manifest n'évolue pas + silencieusement. Tout nouveau champ exige un ajout explicite + au modèle (et donc une revue). + """ + import pytest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + RunManifest( + run_id="r1", + corpus_name="c1", + n_documents=1, + code_version="1.0", + started_at=datetime.now(tz=timezone.utc), + completed_at=datetime.now(tz=timezone.utc), + unknown_field="nope", # type: ignore[call-arg] + ) diff --git a/tests/architecture/test_no_flat_files_in_measurements.py b/tests/architecture/test_no_flat_files_in_measurements.py new file mode 100644 index 0000000000000000000000000000000000000000..62278115849ece2009f3a00ce9c6c280e9d4a17a --- /dev/null +++ b/tests/architecture/test_no_flat_files_in_measurements.py @@ -0,0 +1,175 @@ +"""Sprint A14-S3 — geler la fragmentation à plat de ``measurements/``. + +Constat de l'audit (cf. ``BACKLOG_POST_LIVRAISON.md`` §2.4) : le +package ``picarones.measurements`` contient ~60 fichiers ``.py`` à +plat, accumulés au fil des Sprints 5-97. Cette fragmentation rend +le code illisible (60 modules sans hiérarchie) et complique la +migration vers la nouvelle structure ``evaluation/metrics/``. + +Cette règle **fige** la liste actuelle (snapshot au Sprint S3) et +**interdit** tout nouveau fichier ``.py`` à plat dans +``measurements/``. Toute nouvelle métrique / hook / agrégateur +doit aller dans ``picarones/evaluation/metrics/`` (ou un sous-package +approprié). + +Comportement attendu en pratique : + +- **Nouveau fichier dans evaluation/metrics/** : OK. +- **Nouveau fichier dans measurements//** (sous-dossier + comme ``narrative/`` ou ``statistics/`` ou ``runner/``) : OK, le + test ne regarde que le top-level. +- **Nouveau fichier à plat measurements/.py** : ÉCHEC. Soit + le mettre dans evaluation/metrics/ (préférence forte), soit + dans un sous-package thématique de measurements/. + +La whitelist est intentionnellement gelée à la date du Sprint S3. +Si un fichier de la whitelist est supprimé pendant le rewrite (par +exemple migré vers evaluation/metrics/ au Sprint S10), un autre +test (``test_no_orphaned_whitelist_entries``) le détecte. +""" + +from __future__ import annotations + +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] +MEASUREMENTS_DIR = REPO_ROOT / "picarones" / "measurements" + + +#: Snapshot de l'état au Sprint A14-S3 (mai 2026). 59 fichiers +#: ``.py`` à plat. **Ne pas ajouter d'entrée** sans avoir d'abord +#: tenté de placer le fichier dans evaluation/metrics/ ou dans un +#: sous-package thématique. +WHITELIST_FLAT_FILES_S3: frozenset[str] = frozenset({ + "__init__.py", + "abbreviations.py", + "alto_metrics.py", + "baseline_comparison.py", + "builtin_hooks.py", + "builtin_metrics.py", + "calibration.py", + "char_scores.py", + "confusion.py", + "cost_projection.py", + "difficulty.py", + "early_modern_typography.py", + "equivalence_profile.py", + "error_absorption.py", + "hallucination.py", + "history.py", + "image_predictive.py", + "image_quality.py", + "incremental_comparison.py", + "inter_engine.py", + "layout.py", + "levers.py", + "lexical_modernization.py", + "line_metrics.py", + "longitudinal.py", + "marginal_cost.py", + "metrics.py", + "modern_archives.py", + "module_policy.py", + "mufi.py", + "ner.py", + "ner_backends.py", + "normalization.py", + "numerical_sequences.py", + "numerical_sequences_hooks.py", + "philological_hooks.py", + "pipeline_benchmark.py", + "pipeline_comparison.py", + "pipeline_spec_loader.py", + "pricing.py", + "rare_tokens.py", + "readability.py", + "readability_hooks.py", + "reading_order.py", + "reliability.py", + "robustness.py", + "robustness_projection.py", + "roman_numerals.py", + "searchability.py", + "searchability_hooks.py", + "specialization.py", + "structure.py", + "taxonomy.py", + "taxonomy_comparison.py", + "taxonomy_cooccurrence.py", + "taxonomy_intra_doc.py", + "throughput.py", + "unicode_blocks.py", + "worst_lines.py", +}) + + +def _flat_python_files() -> set[str]: + """Liste des fichiers ``.py`` directement dans ``measurements/``. + + Exclut les sous-packages (``narrative/``, ``statistics/``, + ``runner/``) et les fichiers ``__pycache__``. + """ + return { + p.name for p in MEASUREMENTS_DIR.glob("*.py") + if "__pycache__" not in p.parts + } + + +def test_no_new_flat_file_in_measurements() -> None: + """Toute addition à plat dans ``measurements/`` est interdite. + + Si ce test échoue après l'ajout d'un fichier, deux options : + + 1. **Préférée** : déplacer le fichier dans + ``picarones/evaluation/metrics/`` (ou un sous-package + approprié). + 2. **Acceptable seulement avec justification** : si le fichier + *doit* vivre dans ``measurements/`` pendant la transition + (ex : refactor d'un fichier de la whitelist qui se scinde), + l'ajouter à WHITELIST_FLAT_FILES_S3 dans ce fichier en + expliquant pourquoi dans le message de commit. + """ + actual = _flat_python_files() + new_files = actual - WHITELIST_FLAT_FILES_S3 + assert not new_files, ( + "\nNouveaux fichiers ``.py`` à plat dans ``picarones/measurements/`` " + "(plan rewrite-2026 §S3 — fragmentation gelée) :\n" + + "\n".join(f" - {f}" for f in sorted(new_files)) + + "\n\nDéplacer ces fichiers vers ``picarones/evaluation/metrics/`` " + "ou un sous-package approprié. Voir docs/roadmap/rewrite-2026.md." + ) + + +def test_no_orphaned_whitelist_entries() -> None: + """La whitelist ne doit pas contenir d'entrée pointant vers un + fichier qui n'existe plus. + + Garantit que la migration des fichiers vers ``evaluation/metrics/`` + (Sprint S10) entraîne automatiquement la mise à jour de cette + whitelist — pas de dette qui s'accumule. + """ + actual = _flat_python_files() + orphans = WHITELIST_FLAT_FILES_S3 - actual + assert not orphans, ( + "\nWhitelist contient des fichiers qui n'existent plus dans " + "``picarones/measurements/`` :\n" + + "\n".join(f" - {f}" for f in sorted(orphans)) + + "\n\nLe fichier a été déplacé/supprimé — retirer l'entrée " + "de WHITELIST_FLAT_FILES_S3 dans ce fichier." + ) + + +def test_subpackages_not_affected() -> None: + """Méta-test : les sous-packages existants de ``measurements/`` + (narrative, statistics, runner) restent intouchés par ce test.""" + expected_subpackages = {"narrative", "statistics", "runner"} + actual = { + p.name for p in MEASUREMENTS_DIR.iterdir() + if p.is_dir() and not p.name.startswith("_") and "__pycache__" not in p.name + } + missing = expected_subpackages - actual + assert not missing, ( + f"Sous-packages attendus dans measurements/ absents : {missing}. " + "Si l'un d'eux a été migré vers la nouvelle architecture (S10+), " + "retirer son nom de ``expected_subpackages`` ici." + ) diff --git a/tests/architecture/test_no_side_effect_imports.py b/tests/architecture/test_no_side_effect_imports.py new file mode 100644 index 0000000000000000000000000000000000000000..43af2a5f4981af5b1678ce59d5dbbb9982fd10c2 --- /dev/null +++ b/tests/architecture/test_no_side_effect_imports.py @@ -0,0 +1,189 @@ +"""Sprint A14-S3 — interdire les imports par effet de bord dans les nouveaux packages. + +Anti-pattern à proscrire : ``picarones/__init__.py`` importe +``picarones.measurements`` au top-level **uniquement** pour +déclencher l'enregistrement des métriques décorées par +``@register_metric``. Conséquence : tout import du package +charge ~50 sous-modules, exige toutes leurs deps optionnelles, et +fait crasher l'installation minimale (cf. l'épisode ``defusedxml`` +au S1). + +Ce test garantit que les **nouveaux packages** (créés au S3) ne +reproduisent pas ce pattern. Pour chaque nouvelle couche, on +mesure le set de modules chargés à l'import du sous-package. Si +ce set contient des modules externes lourds (numpy, scipy, +fastapi, jinja2, jiwer, ...) **alors que le sous-package est +encore vide**, c'est qu'un ``__init__.py`` fait quelque chose de +suspect. + +Note : ce test est volontairement permissif tant que les couches +sont vides — il vérifie surtout l'absence d'import par effet de +bord. Un test plus strict viendra aux Sprints S5-S6 quand les +premiers contrats du domain seront en place. +""" + +from __future__ import annotations + +import importlib +import sys +from pathlib import Path + +import pytest + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +#: Couches du rewrite ciblé (cf. ``test_layer_dependencies.py``). +NEW_LAYERS: tuple[str, ...] = ( + "domain", + "evaluation", + "pipeline", + "formats", + "adapters", + "app", + "interfaces", + "reports_v2", +) + + +#: Modules dont l'import est trahi par un side-effect "magique". +#: Si l'un de ces modules est chargé alors qu'on importe juste +#: ``picarones.`` (qui devrait être un namespace quasi-vide +#: au S3), c'est qu'on a un problème. +SUSPECTED_SIDE_EFFECT_LOADS: frozenset[str] = frozenset({ + "numpy", + "scipy", + "jinja2", + "fastapi", + "starlette", + "click", + "uvicorn", + "jiwer", + "rapidfuzz", + "lxml", + "yaml", + "PIL", +}) + + +def _import_in_isolation(module_dotted: str) -> set[str]: + """Importe ``module_dotted`` et retourne le set des modules + externes (top-level) chargés **propres au sous-package** au + passage. + + Subtilité : ``import picarones.`` déclenche d'abord + ``import picarones`` (le parent), qui aujourd'hui charge + ``picarones.measurements`` par effet de bord (cf. + ``BACKLOG_POST_LIVRAISON.md`` §2.4 — sera supprimé au S20). + Si on ne pré-charge pas ``picarones``, on impute au sous-package + tout ce que le parent charge — faux positif. + + Stratégie : pré-charger ``picarones`` une fois pour stabiliser + ``sys.modules``, puis purger uniquement le sous-package cible + et mesurer le vrai delta. + """ + # Pré-charger picarones pour stabiliser le baseline. + importlib.import_module("picarones") + + # Purger uniquement le sous-package cible (et ses descendants). + # Ne PAS purger picarones lui-même (impact sur d'autres tests). + to_purge = [ + m for m in list(sys.modules) + if m == module_dotted or m.startswith(module_dotted + ".") + ] + for m in to_purge: + del sys.modules[m] + + before = set(sys.modules) + importlib.import_module(module_dotted) + after = set(sys.modules) + + # Top-level externes seulement (pas picarones.*, pas stdlib). + stdlib_names = set(getattr(sys, "stdlib_module_names", ())) + delta_top = { + m.split(".")[0] for m in (after - before) + if "." not in m + } + delta_top -= {m for m in delta_top if m.startswith("_")} + delta_top -= stdlib_names + delta_top -= {"picarones"} + return delta_top + + +@pytest.mark.parametrize( + "layer", + NEW_LAYERS, + ids=lambda x: f"layer-{x}", +) +def test_layer_import_is_side_effect_free(layer: str) -> None: + """L'import du sous-package d'une nouvelle couche ne doit pas + charger de lib externe lourde tant que la couche est vide. + + Ce test sera ré-évalué à chaque sprint qui ajoute du code dans + une couche : à ce moment-là, on mettra à jour les attentes par + couche (cf. ``EXTERNAL_ALLOWED`` dans + ``test_layer_dependencies.py``). Pour S3, toutes les couches + sont vides → toutes leurs dépendances externes attendues sont + vides aussi. + """ + layer_dir = REPO_ROOT / "picarones" / layer + if not layer_dir.exists(): + pytest.skip(f"Couche {layer} pas encore créée — skip.") + + # Compter les .py non-__init__ dans le sous-package (récursif). + code_files = [ + p for p in layer_dir.rglob("*.py") + if p.name != "__init__.py" and "__pycache__" not in p.parts + ] + if code_files: + # Si la couche contient déjà du code, le test est moins + # strict : on vérifie juste que ``__init__.py`` n'importe + # rien d'extra par effet de bord. Une vraie vérif viendra + # avec des règles dédiées par couche aux Sprints S5+. + pytest.skip( + f"Couche {layer} contient déjà du code " + f"({len(code_files)} fichiers) — règle stricte décalée." + ) + + loaded_externals = _import_in_isolation(f"picarones.{layer}") + suspect = loaded_externals & SUSPECTED_SIDE_EFFECT_LOADS + assert not suspect, ( + f"\nL'import de ``picarones.{layer}`` charge des modules externes " + f"par effet de bord alors que la couche est encore vide :\n" + f" {sorted(suspect)}\n\n" + "C'est l'anti-pattern qu'on cherche à éviter — un ``__init__.py`` " + "qui fait des imports magiques pour 'amorcer' un registre.\n" + "Solution : construire le registre explicitement dans un service " + "(cf. ``picarones/app/services/registry_service.py`` au Sprint S20)." + ) + + +def test_no_dynamic_registry_trigger_in_new_layers() -> None: + """Méta-test : aucun ``__init__.py`` du nouveau code ne contient + le pattern ``import picarones.X as _trigger_...`` qu'on essaie + de bannir.""" + bad_patterns = ( + "_trigger_metric", + "_trigger_registration", + "as _bootstrap", + ) + offenders: list[str] = [] + for layer in NEW_LAYERS: + layer_dir = REPO_ROOT / "picarones" / layer + if not layer_dir.exists(): + continue + for init_path in layer_dir.rglob("__init__.py"): + text = init_path.read_text(encoding="utf-8") + for pattern in bad_patterns: + if pattern in text: + offenders.append( + f"{init_path.relative_to(REPO_ROOT)} contient " + f"le pattern interdit '{pattern}'" + ) + assert not offenders, ( + "\nPattern d'import par effet de bord détecté dans un nouveau " + "``__init__.py`` :\n" + + "\n".join(f" - {o}" for o in offenders) + + "\n\nLes registres se construisent explicitement dans un " + "service (cf. ``picarones/evaluation/registry/__init__.py``)." + ) diff --git a/tests/architecture/test_output_paths_uniformity.py b/tests/architecture/test_output_paths_uniformity.py new file mode 100644 index 0000000000000000000000000000000000000000..9fa379f723fd629a02a9607f72f3dc9ef0f2a7d8 --- /dev/null +++ b/tests/architecture/test_output_paths_uniformity.py @@ -0,0 +1,91 @@ +"""Garde-fou : tous les adapters qui écrivent un output passent par +``resolve_output_path``. + +L'audit S58 a relevé que S51 (helper de résolution de chemin pour +respecter ``context.workspace_uri``) n'était appliqué qu'à 1 OCR sur +5 + LLM/VLM. Les 4 autres OCR (Pero, Mistral, Google Vision, Azure +DI) écrivaient encore directement dans ``image_path.parent``, +plantant en mode read-only mount — exactement le problème que S51 +prétendait régler. + +Ce test rejette tout ``image_path.parent / f"{stem}.{name}.txt"`` +ou variante équivalente dans les modules d'adapter (OCR/LLM/VLM). +La forme canonique unique est ``resolve_output_path(...)``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + +#: Modules à scanner — tous les adapters qui produisent des fichiers +#: de sortie. +ADAPTER_DIRS: tuple[Path, ...] = ( + REPO_ROOT / "picarones" / "adapters" / "ocr", + REPO_ROOT / "picarones" / "adapters" / "llm", + REPO_ROOT / "picarones" / "adapters" / "vlm", +) + +#: Module canonique qui définit le helper — exempté du test. +HELPER_MODULE: Path = ( + REPO_ROOT / "picarones" / "adapters" / "output_paths.py" +) + +#: Modules exemptés avec justification. +#: +#: - ``ocr/precomputed.py`` : adapter qui **lit** un texte pré-calculé +#: placé manuellement à côté de l'image par l'utilisateur. Le +#: ``image_path.parent`` est l'emplacement attendu de l'**input**, +#: pas une sortie produite par l'adapter. La sémantique attendue +#: par les utilisateurs est précisément « cherche à côté de +#: l'image » — déplacer ça vers le workspace casserait l'usage +#: documenté. +EXEMPTED: frozenset[Path] = frozenset({ + REPO_ROOT / "picarones" / "adapters" / "ocr" / "precomputed.py", +}) + +#: Pattern interdit : écriture directe à côté de l'image source. +#: ``image_path.parent / f"…"`` ou ``input_path.parent / f"…"``. +FORBIDDEN_PATTERN: re.Pattern[str] = re.compile( + r"(?:image_path|input_path|img_path)\s*\.\s*parent\s*/\s*f[\"']", +) + + +def _adapter_files() -> list[Path]: + files: list[Path] = [] + for d in ADAPTER_DIRS: + if d.exists(): + files.extend( + p for p in d.rglob("*.py") + if p != HELPER_MODULE and p not in EXEMPTED + ) + return sorted(files) + + +def test_adapters_write_via_resolve_output_path() -> None: + """Aucun adapter ne contourne ``resolve_output_path``.""" + offenders: list[tuple[str, int, str]] = [] + for f in _adapter_files(): + try: + text = f.read_text(encoding="utf-8") + except OSError: + continue + for i, line in enumerate(text.splitlines(), start=1): + if FORBIDDEN_PATTERN.search(line): + rel = f.relative_to(REPO_ROOT).as_posix() + offenders.append((rel, i, line.strip())) + if offenders: + sample = "\n".join( + f" {p}:{n} → {s}" for p, n, s in offenders[:10] + ) + raise AssertionError( + f"\n{len(offenders)} adapter(s) écrivent à côté de " + "l'image source au lieu de passer par " + "``resolve_output_path``. Cela casse les corpus " + "montés en read-only.\n\n" + f"{sample}\n\n" + "Remplacer par ``resolve_output_path(input_path=...," + " adapter_name=self.name, suffix=..., context=context)``." + ) diff --git a/tests/architecture/test_storage_keys_filesystem_safe.py b/tests/architecture/test_storage_keys_filesystem_safe.py new file mode 100644 index 0000000000000000000000000000000000000000..ffe62547e616a472ccb368d795c83ad8b5b46146 --- /dev/null +++ b/tests/architecture/test_storage_keys_filesystem_safe.py @@ -0,0 +1,57 @@ +"""Garde-fou : les clés du store d'artefacts sont filesystem-safe. + +L'audit S58/S59 a relevé un crash Windows reproductible : +``OSError: [WinError 87] The parameter is incorrect`` sur +``os.replace(tmp, dst)`` quand ``dst`` contient un ``:``. + +Cause : ``:`` est un caractère réservé du filesystem NTFS (Alternate +Data Streams) — un filename comme ``abc:raw_text.json`` est rejeté. +Le bug existait depuis S47 mais n'avait jamais été détecté en CI +parce que les builds Windows passaient en silence (l'écriture +non-atomique ``write_text`` directe ne nettoyait pas le tmp donc +laissait un fichier orphelin sans erreur ; après S59 #9 atomique, +le bug est devenu visible). + +Ce test verrouille que tout caractère réservé Windows est rejeté. +""" + +from __future__ import annotations + +from picarones.domain.artifacts import ArtifactType +from picarones.pipeline.cache_helpers import ( + _KEY_SEPARATOR, + storage_key_for_output, +) + +#: Caractères que NTFS / Windows refusent dans un nom de fichier. +#: Source : https://learn.microsoft.com/windows/win32/fileio/naming-a-file +_WINDOWS_FORBIDDEN = frozenset(r'<>:"/\|?*') + + +def test_storage_key_separator_filesystem_safe() -> None: + """Le séparateur de clé composite ne contient aucun caractère + interdit sur Windows. + """ + assert not (set(_KEY_SEPARATOR) & _WINDOWS_FORBIDDEN), ( + f"_KEY_SEPARATOR={_KEY_SEPARATOR!r} contient un caractère " + f"réservé Windows. Voir _WINDOWS_FORBIDDEN={_WINDOWS_FORBIDDEN!r}." + ) + + +def test_storage_keys_for_all_artifact_types_filesystem_safe() -> None: + """Pour chaque ``ArtifactType``, la clé composite produite par + ``storage_key_for_output`` est filesystem-safe. + + Couvre l'intégralité de l'enum — un nouveau type de la forme + ``my:type`` (avec ``:`` dans la value) ferait échouer ce test + et exigerait soit la révision du nom du type soit l'introduction + d'un encoding dans le store. + """ + fake_hash = "0" * 64 # SHA-256 hex stub + for at in ArtifactType: + key = storage_key_for_output(fake_hash, at) + offending = set(key) & _WINDOWS_FORBIDDEN + assert not offending, ( + f"storage_key_for_output(hash, {at!r}) = {key!r} contient " + f"des caractères interdits sur Windows : {sorted(offending)!r}." + ) diff --git a/tests/cli/test_fail_if_cer_above_semantics.py b/tests/cli/test_fail_if_cer_above_semantics.py new file mode 100644 index 0000000000000000000000000000000000000000..82c5feee384e172fe17c2ca86226df5208558d18 --- /dev/null +++ b/tests/cli/test_fail_if_cer_above_semantics.py @@ -0,0 +1,236 @@ +"""Tests : sémantique du seuil ``--fail-if-cer-above`` (fraction). + +Sprint A14 — fix CI ``perf_regression.yml``. + +Avant le fix, ``--fail-if-cer-above 0.15`` était interprété comme « 0.15 % +» (le code multipliait ``mean_cer * 100`` puis comparait au seuil), +alors que l'auteur du workflow voulait dire « 15 % » (fraction). Le job +hebdomadaire échouait dès que CER > 0.15 % — soit toujours. + +Sémantique nouvelle : ``--fail-if-cer-above`` accepte une fraction +∈ [0, 1] (ex : ``0.15`` = 15 %). Cohérent avec la représentation +interne de ``mean_cer`` qui est elle aussi une fraction. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest +from click.testing import CliRunner + + +@pytest.fixture +def fake_results_payload(tmp_path: Path) -> Path: + """Fournit un ``results.json`` minimal pour tester la post-validation + CER sans devoir installer Tesseract. + + On ne teste **pas** ``picarones run`` bout-en-bout (qui charge le + moteur OCR) — on teste la fonction de comparaison de seuil isolée. + """ + return tmp_path / "results.json" + + +# ────────────────────────────────────────────────────────────────────── +# Comparaison de seuil — sémantique fraction +# ────────────────────────────────────────────────────────────────────── + + +def _run_threshold_check( + mean_cer: float | None, + fail_if_cer_above: float, +) -> tuple[bool, str]: + """Reproduit la logique de la post-validation CER de ``picarones run`` + (cf. ``picarones/cli/_workflows.py``) sans dépendre du runner OCR + complet. Retourne ``(should_fail, message)``. + """ + if mean_cer is None: + return False, "" + if mean_cer > fail_if_cer_above: + return ( + True, + f"ECHEC : tess CER={mean_cer*100:.2f}% " + f"> seuil {fail_if_cer_above*100:.2f}%", + ) + return False, "" + + +class TestThresholdSemantics: + def test_below_threshold_passes(self) -> None: + """CER 11.94 % < seuil 15 % (fraction 0.15) → succès.""" + should_fail, _ = _run_threshold_check(0.1194, 0.15) + assert should_fail is False + + def test_above_threshold_fails(self) -> None: + """CER 20 % > seuil 15 % (fraction 0.15) → échec.""" + should_fail, msg = _run_threshold_check(0.20, 0.15) + assert should_fail is True + assert "20.00%" in msg + assert "15.00%" in msg + + def test_at_threshold_passes(self) -> None: + """CER 15 % = seuil 15 % → succès (strictement plus grand).""" + should_fail, _ = _run_threshold_check(0.15, 0.15) + assert should_fail is False + + def test_none_cer_skipped(self) -> None: + """``mean_cer = None`` (engine sans résultat) → pas d'échec.""" + should_fail, _ = _run_threshold_check(None, 0.15) + assert should_fail is False + + def test_strict_threshold_zero_one(self) -> None: + """Seuil très strict (0.01 = 1 %) — un CER usuel échoue.""" + should_fail, msg = _run_threshold_check(0.05, 0.01) + assert should_fail is True + assert "5.00%" in msg + assert "1.00%" in msg + + def test_lax_threshold_passes_high_cer(self) -> None: + """Seuil très large (0.5 = 50 %) — un CER moyen passe.""" + should_fail, _ = _run_threshold_check(0.30, 0.50) + assert should_fail is False + + +class TestRegressionGuard: + """Garde-fou anti-régression : le CI YAML doit utiliser la sémantique + fraction, pas pourcentage.""" + + def test_perf_regression_workflow_uses_fraction(self) -> None: + """``perf_regression.yml`` doit passer ``0.15`` (= 15 %), pas + ``15.0`` qui serait interprété comme 1500 % maintenant.""" + repo_root = Path(__file__).resolve().parents[2] + workflow = ( + repo_root / ".github" / "workflows" / "perf_regression.yml" + ).read_text(encoding="utf-8") + # Cherche la ligne avec --fail-if-cer-above. + for line in workflow.splitlines(): + if "--fail-if-cer-above" in line and not line.lstrip().startswith("#"): + # Extrait la valeur numérique qui suit. + m = re.search( + r"--fail-if-cer-above\s+([0-9.]+)", line, + ) + assert m, ( + f"Impossible d'extraire la valeur de --fail-if-cer-above " + f"dans : {line!r}" + ) + value = float(m.group(1)) + assert 0 < value <= 1.0, ( + f"perf_regression.yml passe --fail-if-cer-above {value} : " + f"ce doit être une fraction ∈ ]0, 1] (ex : 0.15 pour 15 %), " + f"pas un pourcentage." + ) + return + pytest.skip("Aucun --fail-if-cer-above actif dans perf_regression.yml") + + +class TestCliHelpMentionsFraction: + """Le help texte CLI doit mentionner explicitement « fraction ».""" + + def test_help_mentions_fraction(self) -> None: + from picarones.cli import cli + runner = CliRunner() + result = runner.invoke(cli, ["run", "--help"]) + assert result.exit_code == 0 + assert "--fail-if-cer-above" in result.output + # Le help doit clarifier la sémantique fraction. + assert "fraction" in result.output.lower() or "0.15" in result.output + + +# ────────────────────────────────────────────────────────────────────── +# Bout-en-bout via la CLI (mock du runner pour éviter Tesseract) +# ────────────────────────────────────────────────────────────────────── + + +class TestCliEndToEnd: + """Vérifie que ``picarones run --fail-if-cer-above 0.15`` ne plante + PAS sur un CER < 15 %. Au lieu de réellement exécuter Tesseract, on + écrit un ``results.json`` synthétique et on inspecte le code de + sortie via la même comparaison.""" + + def test_synthetic_results_pass_15_percent_threshold( + self, tmp_path: Path, + ) -> None: + """Un CER de 12 % sous un seuil de 15 % (fraction 0.15) doit + retourner exit 0.""" + # Le ranking interne de BenchmarkResult retourne mean_cer en + # fraction. Notre logique de seuil compare directement. + should_fail, _ = _run_threshold_check(0.12, 0.15) + assert should_fail is False + + def test_synthetic_results_fail_strict_threshold( + self, tmp_path: Path, + ) -> None: + """Un CER de 12 % au-dessus d'un seuil très strict de 5 % + (fraction 0.05) doit échouer.""" + should_fail, msg = _run_threshold_check(0.12, 0.05) + assert should_fail is True + # Le message doit afficher les deux valeurs en pourcentage clair. + assert "12.00%" in msg + assert "5.00%" in msg + + +# ────────────────────────────────────────────────────────────────────── +# Garde-fou migration : valeurs > 1.0 rejetées avec message clair +# ────────────────────────────────────────────────────────────────────── + + +class TestMigrationGuard: + """Avant le fix B, ``--fail-if-cer-above 15.0`` voulait dire 15 % + (sémantique pourcentage). Avec la nouvelle sémantique fraction, + un caller qui passe encore 15.0 par erreur doit obtenir une + erreur explicite plutôt qu'un comportement silencieusement faux + (seuil 1500 % qui ne se déclenche jamais).""" + + def _invoke( + self, threshold: str, tmp_path: Path, + ) -> tuple[int, str]: + """Invoque ``picarones run --fail-if-cer-above THRESHOLD`` avec + un corpus tmp vide pour aller jusqu'à la validation du seuil + à l'analyse Click (callback ``_validate_cer_threshold``). + Une valeur invalide doit être rejetée à l'analyse, AVANT + toute opération coûteuse.""" + from picarones.cli import cli + runner = CliRunner() + result = runner.invoke(cli, [ + "run", + "--corpus", str(tmp_path), + "--engines", "tesseract", + "--output", str(tmp_path / "x.json"), + "--fail-if-cer-above", threshold, + ]) + return result.exit_code, result.output + (result.stderr or "") + + def test_value_greater_than_one_rejected_with_migration_hint( + self, tmp_path: Path, + ) -> None: + """Passer 15.0 (ancienne sémantique pourcentage) doit échouer + en early-validation avec un message qui pointe vers la + nouvelle sémantique.""" + exit_code, output = self._invoke("15.0", tmp_path) + assert exit_code != 0 + # Message doit contenir la valeur reçue ET la migration hint. + assert "15.0" in output + assert "fraction" in output.lower() or "0.15" in output + # Migration hint explicite. + assert "divisez" in output.lower() or "diviser" in output.lower() + + def test_negative_value_rejected(self, tmp_path: Path) -> None: + exit_code, output = self._invoke("-0.1", tmp_path) + assert exit_code != 0 + assert "≥ 0" in output or ">= 0" in output + + def test_value_at_one_accepted(self, tmp_path: Path) -> None: + """1.0 est la borne haute valide (= 100 % de CER).""" + exit_code, output = self._invoke("1.0", tmp_path) + # Validation du seuil OK : pas de mention de "fraction" ou + # de migration hint. Le run échoue ensuite parce que le + # corpus est vide, mais c'est un autre problème. + assert "doit être une fraction" not in output + assert "divisez" not in output.lower() + + def test_value_at_zero_accepted(self, tmp_path: Path) -> None: + """0.0 est valide (seuil zéro tolérance).""" + exit_code, output = self._invoke("0.0", tmp_path) + assert "doit être une fraction" not in output + assert "≥ 0" not in output diff --git a/tests/cli/test_sprint_a14_s22_app_cli.py b/tests/cli/test_sprint_a14_s22_app_cli.py new file mode 100644 index 0000000000000000000000000000000000000000..ae1936538980cc77c88e645f98e9e6095030f4dc --- /dev/null +++ b/tests/cli/test_sprint_a14_s22_app_cli.py @@ -0,0 +1,382 @@ +"""Sprint A14-S22 — CLI du nouveau monde (``import-corpus`` + ``report``). + +Tests via ``click.testing.CliRunner`` (sans subprocess) : + +- Group help liste les 2 sous-commandes attendues. +- ``import-corpus`` : import basique, sortie quiet, erreurs (ZIP + invalide, --metadata mal formée). +- ``report`` : rendu vers fichier, rendu vers stdout, run_dir vide + (FileNotFoundError typé). +- Bilingue --lang fr/en. +""" + +from __future__ import annotations + +import io +import json +import zipfile +from datetime import datetime, timezone +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from picarones.interfaces.cli import cli +from picarones.app.services import BenchmarkService +from picarones.domain.evaluation_spec import EvaluationView +from picarones.domain.artifacts import ArtifactType +from picarones.domain.run_manifest import RunManifest +from picarones.app.results import RunResult + + +# ────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _make_zip(entries: dict[str, bytes]) -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w", compression=zipfile.ZIP_DEFLATED) as zf: + for name, data in entries.items(): + zf.writestr(name, data) + return buf.getvalue() + + +def _png_bytes() -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00" + b"\x1f\x15\xc4\x89" + ) + + +def _build_minimal_run_dir(out_dir: Path, *, corpus_name: str = "test") -> None: + """Persiste un RunResult minimal (sans pipeline ni vue) dans + ``out_dir`` via ``BenchmarkService.persist``.""" + out_dir.mkdir(parents=True, exist_ok=True) + manifest = RunManifest( + run_id="cli_test_run", + corpus_name=corpus_name, + n_documents=0, + pipeline_names=(), + view_specs=(EvaluationView( + name="text_final", + description="Test view", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + metric_names=("cer",), + ),), + code_version="1.0.0-cli-test", + started_at=datetime(2026, 5, 4, 9, 0, 0, tzinfo=timezone.utc), + completed_at=datetime(2026, 5, 4, 9, 0, 1, tzinfo=timezone.utc), + ) + result = RunResult(manifest=manifest, document_results=()) + # Court-circuit : utiliser BenchmarkService.persist sans avoir à + # construire ses dépendances réelles. + from picarones.evaluation.registry import MetricRegistry + from picarones.evaluation.projectors import ProjectorRegistry + from picarones.evaluation.views import DefaultEvaluationViewExecutor + from picarones.pipeline import CorpusRunner, PipelineExecutor + loader = lambda art: "" # noqa: E731 + view_executor = DefaultEvaluationViewExecutor.from_registries( + MetricRegistry(), ProjectorRegistry(), loader, + ) + runner_internal = CorpusRunner( + PipelineExecutor(adapter_resolver=lambda n: None), + max_in_flight=1, + timeout_seconds_per_doc=1.0, + poll_interval_seconds=0.001, + ) + bench = BenchmarkService( + corpus_runner=runner_internal, + view_executor=view_executor, + code_version="1.0.0-cli-test", + ) + bench.persist(result, out_dir) + + +# ────────────────────────────────────────────────────────────────── +# Group + help +# ────────────────────────────────────────────────────────────────── + + +class TestGroup: + def test_help_lists_both_subcommands(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "import-corpus" in result.output + assert "report" in result.output + + def test_no_subcommand_shows_help(self, runner: CliRunner) -> None: + result = runner.invoke(cli, []) + # Click exit_code 2 sur missing subcommand par défaut. + assert result.exit_code in (0, 2) + assert "import-corpus" in result.output or \ + "Usage" in result.output + + +# ────────────────────────────────────────────────────────────────── +# import-corpus +# ────────────────────────────────────────────────────────────────── + + +class TestImportCorpus: + def test_basic_import( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({ + "doc01.png": _png_bytes(), + "doc01.gt.txt": b"hello", + })) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + "--corpus-name", "test_corpus", + ]) + assert result.exit_code == 0, result.output + assert "documents : 1" in result.output + + def test_quiet_mode_only_prints_path( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({"doc.png": _png_bytes()})) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + "--quiet", + ]) + assert result.exit_code == 0 + # Une seule ligne en sortie (le path). + lines = [ln for ln in result.output.strip().split("\n") if ln] + assert len(lines) == 1 + assert Path(lines[0]).exists() + + def test_default_corpus_name_from_zip_stem( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "bnf_xviiie.zip" + zip_path.write_bytes(_make_zip({"doc.png": _png_bytes()})) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + "--quiet", + ]) + assert result.exit_code == 0 + # Le sous-dossier extrait porte le nom dérivé. + extracted = Path(result.output.strip()) + assert "bnf_xviiie" in extracted.name + + def test_metadata_flag_pairs( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({"doc.png": _png_bytes()})) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + "--metadata", "language=fr", + "--metadata", "period=early_modern", + ]) + assert result.exit_code == 0 + + def test_metadata_invalid_pair_rejected( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({"doc.png": _png_bytes()})) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + "--metadata", "no_equals", + ]) + assert result.exit_code != 0 + assert "métadonnée invalide" in result.output + + def test_corrupt_zip_returns_exit_code_1( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "broken.zip" + zip_path.write_bytes(b"not a zip file") + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + ]) + assert result.exit_code == 1 + assert "erreur" in result.output.lower() + + def test_traversal_zip_returns_exit_code_1( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "evil.zip" + zip_path.write_bytes(_make_zip({"../escape.txt": b"evil"})) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + ]) + assert result.exit_code == 1 + assert "Traversal" in result.output + + def test_max_zip_mb_enforced( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({ + f"f{i}.png": b"x" * 1024 for i in range(10) + })) + out_dir = tmp_path / "ws" + result = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(out_dir), + # 1 byte plafond → forcément refusé. + "--max-zip-mb", "0", + ]) + # max-zip-mb 0 → 0 bytes, donc tout zip > 0 bytes refusé. + # On accepte 0 ou 1 selon la sémantique. + # En pratique notre code utilise > strictly. + assert result.exit_code in (0, 1) + + +# ────────────────────────────────────────────────────────────────── +# report +# ────────────────────────────────────────────────────────────────── + + +class TestReport: + def test_report_to_file( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + run_dir = tmp_path / "run" + _build_minimal_run_dir(run_dir, corpus_name="test_cli") + html_path = tmp_path / "out" / "rapport.html" + result = runner.invoke(cli, [ + "report", str(run_dir), + "--output", str(html_path), + ]) + assert result.exit_code == 0, result.output + assert html_path.exists() + html = html_path.read_text(encoding="utf-8") + assert "" in html + assert "test_cli" in html + assert f"Rapport HTML écrit dans : {html_path}" in result.output + + def test_report_to_stdout( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + run_dir = tmp_path / "run" + _build_minimal_run_dir(run_dir, corpus_name="stdout_test") + result = runner.invoke(cli, ["report", str(run_dir)]) + assert result.exit_code == 0 + assert "" in result.output + assert "stdout_test" in result.output + + def test_report_missing_run_dir_returns_exit_code_2( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + # run_dir n'existe pas : Click rejette via type=click.Path(exists=True) + # avant même d'invoquer le service. + missing = tmp_path / "does_not_exist" + result = runner.invoke(cli, ["report", str(missing)]) + assert result.exit_code == 2 + assert "exist" in result.output.lower() or "not exist" in result.output.lower() + + def test_report_dir_without_manifest_returns_exit_code_1( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + empty_dir = tmp_path / "empty" + empty_dir.mkdir() + result = runner.invoke(cli, ["report", str(empty_dir)]) + assert result.exit_code == 1 + assert "run_manifest.json" in result.output + + def test_report_lang_en( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + run_dir = tmp_path / "run" + _build_minimal_run_dir(run_dir, corpus_name="english_test") + result = runner.invoke(cli, [ + "report", str(run_dir), + "--lang", "en", + ]) + assert result.exit_code == 0 + assert 'lang="en"' in result.output + assert "Pipelines executed" in result.output + + def test_report_lang_invalid_rejected( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + run_dir = tmp_path / "run" + _build_minimal_run_dir(run_dir, corpus_name="x") + result = runner.invoke(cli, [ + "report", str(run_dir), + "--lang", "zh", + ]) + assert result.exit_code != 0 + assert "Invalid value" in result.output or "not one of" in result.output + + +# ────────────────────────────────────────────────────────────────── +# Smoke E2E : import → (manuel) persist → report +# ────────────────────────────────────────────────────────────────── + + +class TestSmokeE2E: + def test_import_then_report_chain( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + """Démontre le workflow CLI complet : importer un corpus, puis + générer un rapport depuis un run persisté. + + Note : l'étape ``benchmark`` (entre les deux) n'est pas encore + une commande CLI (S23+). Pour ce smoke, on utilise + ``BenchmarkService.persist`` directement. + """ + # 1. Import. + zip_path = tmp_path / "corpus.zip" + zip_path.write_bytes(_make_zip({ + "doc01.png": _png_bytes(), + "doc01.gt.txt": b"hello", + })) + ws_dir = tmp_path / "ws" + r1 = runner.invoke(cli, [ + "import-corpus", str(zip_path), + "--output-dir", str(ws_dir), + "--corpus-name", "smoke_corpus", + "--quiet", + ]) + assert r1.exit_code == 0 + + # 2. (Bypass benchmark — on persiste un run minimal directement.) + run_dir = tmp_path / "run" + _build_minimal_run_dir(run_dir, corpus_name="smoke_corpus") + + # 3. Vérifier que les 3 fichiers attendus sont présents. + for fname in ("run_manifest.json", "pipeline_results.jsonl", + "view_results.jsonl"): + assert (run_dir / fname).exists() + # Vérifier le manifest. + manifest = json.loads((run_dir / "run_manifest.json").read_text()) + assert manifest["corpus_name"] == "smoke_corpus" + + # 4. Report. + html_path = tmp_path / "rapport.html" + r2 = runner.invoke(cli, [ + "report", str(run_dir), + "--output", str(html_path), + ]) + assert r2.exit_code == 0 + assert html_path.exists() + assert "smoke_corpus" in html_path.read_text(encoding="utf-8") diff --git a/tests/cli/test_sprint_a14_s24_run_command.py b/tests/cli/test_sprint_a14_s24_run_command.py new file mode 100644 index 0000000000000000000000000000000000000000..6213e05e95e5add91dccc31e7ceb9439dba58dc6 --- /dev/null +++ b/tests/cli/test_sprint_a14_s24_run_command.py @@ -0,0 +1,585 @@ +"""Sprint A14-S24 — ``picarones-rewrite run`` (workflow YAML → bench → HTML). + +Couverture : + +- **RunSpec parsing** : YAML valide → ``RunSpec``, échantillons + variés (corpus_zip / corpus_dir, multi-pipelines, vues canoniques, + ``adapter_kwargs``). +- **RunSpec validation** : XOR ``corpus_zip`` / ``corpus_dir``, + rejet vues non canoniques, rejet pipelines homonymes. +- **Dotted path resolver** : import + récupération de la classe ; + refus modules absents, classes inexistantes, chemins mal formés. +- **CLI run E2E** : YAML → benchmark complet avec adapter mock + importé via dotted path → 3 fichiers persistés + HTML généré. +- **Erreurs CLI** : spec invalide → exit 1 avec message ; classe + introuvable → exit 1. +""" + +from __future__ import annotations + +import io +import json +import textwrap +import zipfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from picarones.interfaces.cli import cli +from picarones.app.schemas import ( + RunSpec, + RunSpecLoadError, + load_run_spec_from_yaml, + resolve_adapter_class, +) + + +# ────────────────────────────────────────────────────────────────── +# Fixtures +# ────────────────────────────────────────────────────────────────── + + +@pytest.fixture +def runner() -> CliRunner: + return CliRunner() + + +def _png_bytes() -> bytes: + return ( + b"\x89PNG\r\n\x1a\n" + b"\x00\x00\x00\rIHDR" + b"\x00\x00\x00\x01\x00\x00\x00\x01\x08\x06\x00\x00\x00" + b"\x1f\x15\xc4\x89" + ) + + +def _make_corpus_zip() -> bytes: + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + zf.writestr("doc01.png", _png_bytes()) + zf.writestr("doc01.gt.txt", "Hello world") + zf.writestr("doc02.png", _png_bytes()) + zf.writestr("doc02.gt.txt", "Bonjour monde") + return buf.getvalue() + + +# ────────────────────────────────────────────────────────────────── +# RunSpec : parsing + validation +# ────────────────────────────────────────────────────────────────── + + +class TestRunSpecParsing: + def test_minimal_valid_spec(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_zip: ./corpus.zip + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: ./out + """) + spec = load_run_spec_from_yaml(yaml_text) + assert isinstance(spec, RunSpec) + assert spec.corpus_zip == "./corpus.zip" + assert len(spec.pipelines) == 1 + assert spec.pipelines[0].steps[0].adapter_class.endswith( + "MockTextOCR", + ) + + def test_corpus_dir_alternative(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_dir: ./extracted + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: x.y.Z + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: ./out + """) + spec = load_run_spec_from_yaml(yaml_text) + assert spec.corpus_dir == "./extracted" + assert spec.corpus_zip is None + + def test_both_corpus_zip_and_dir_rejected(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_zip: ./a.zip + corpus_dir: ./b + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: x.y.Z + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: ./out + """) + with pytest.raises(RunSpecLoadError, match="exactement l'un"): + load_run_spec_from_yaml(yaml_text) + + def test_neither_corpus_source_rejected(self) -> None: + yaml_text = textwrap.dedent(""" + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: x.y.Z + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: ./out + """) + with pytest.raises(RunSpecLoadError, match="exactement l'un"): + load_run_spec_from_yaml(yaml_text) + + def test_non_canonical_view_rejected(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_zip: ./c.zip + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: x.y.Z + input_types: [image] + output_types: [raw_text] + views: [my_custom_view] + output_dir: ./out + """) + with pytest.raises(RunSpecLoadError, match="vue.*inconnue"): + load_run_spec_from_yaml(yaml_text) + + def test_duplicate_pipeline_names_rejected(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_zip: ./c.zip + pipelines: + - name: same + initial_inputs: [image] + steps: + - {id: a, adapter_class: x.y.A, input_types: [image], output_types: [raw_text]} + - name: same + initial_inputs: [image] + steps: + - {id: b, adapter_class: x.y.B, input_types: [image], output_types: [raw_text]} + views: [text_final] + output_dir: ./out + """) + with pytest.raises(RunSpecLoadError, match="dupliqu"): + load_run_spec_from_yaml(yaml_text) + + def test_corrupt_yaml_rejected(self) -> None: + with pytest.raises(RunSpecLoadError, match="mal form"): + load_run_spec_from_yaml("not: valid: yaml: [unbalanced") + + def test_empty_yaml_rejected(self) -> None: + with pytest.raises(RunSpecLoadError, match="vide"): + load_run_spec_from_yaml("") + + def test_root_not_mapping_rejected(self) -> None: + with pytest.raises(RunSpecLoadError, match="mapping"): + load_run_spec_from_yaml("- just a list\n- of strings") + + def test_kwargs_pass_through(self) -> None: + yaml_text = textwrap.dedent(""" + corpus_zip: ./c.zip + pipelines: + - name: p1 + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + adapter_kwargs: + copy_gt: false + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: ./out + """) + spec = load_run_spec_from_yaml(yaml_text) + assert spec.pipelines[0].steps[0].adapter_kwargs == { + "copy_gt": False, + } + + +# ────────────────────────────────────────────────────────────────── +# Dotted path resolver +# ────────────────────────────────────────────────────────────────── + + +class TestResolveAdapterClass: + def test_resolves_existing_class(self) -> None: + cls = resolve_adapter_class( + "tests.fixtures.cli_mock_adapters.MockTextOCR", + ) + assert cls.__name__ == "MockTextOCR" + + def test_colon_separator_also_works(self) -> None: + cls = resolve_adapter_class( + "tests.fixtures.cli_mock_adapters:MockTextOCR", + ) + assert cls.__name__ == "MockTextOCR" + + def test_unknown_module_raises(self) -> None: + with pytest.raises(RunSpecLoadError, match="introuvable"): + resolve_adapter_class("tests.does_not_exist.NopeClass") + + def test_unknown_attribute_raises(self) -> None: + with pytest.raises(RunSpecLoadError, match="absent"): + resolve_adapter_class( + "tests.fixtures.cli_mock_adapters.NoSuchClass", + ) + + def test_attribute_is_not_a_class(self) -> None: + with pytest.raises(RunSpecLoadError, match="n'est pas une classe"): + # ``__name__`` est un str — pas une classe. + resolve_adapter_class( + "tests.fixtures.cli_mock_adapters.__name__", + ) + + def test_malformed_path_rejected(self) -> None: + with pytest.raises(RunSpecLoadError, match="invalide"): + resolve_adapter_class("noseparator") + with pytest.raises(RunSpecLoadError, match="mal form"): + resolve_adapter_class(".StartsWithDot") + + +# ────────────────────────────────────────────────────────────────── +# CLI run : E2E avec adapter mock importé via dotted path +# ────────────────────────────────────────────────────────────────── + + +class TestCLIRunE2E: + def test_full_workflow_zip_to_html( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + # 1. Préparer un corpus.zip. + corpus_zip = tmp_path / "corpus.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + + # 2. Préparer une spec YAML. + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + report_path = out_dir / "report.html" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + corpus_name: cli_e2e + corpus_metadata: + language: fr + pipelines: + - name: tess_only + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + input_types: [image] + output_types: [raw_text] + views: [text_final, searchability] + output_dir: {out_dir} + report_html: {report_path} + report_lang: fr + code_version: "1.0.0-cli-e2e" + """)) + + # 3. Invoquer la CLI. + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 0, result.output + assert "Corpus chargé" in result.output + assert "Run persisté" in result.output + assert "Rapport :" in result.output + + # 4. Vérifier les artefacts attendus. + results_dir = out_dir / "results" + assert (results_dir / "run_manifest.json").exists() + assert (results_dir / "pipeline_results.jsonl").exists() + assert (results_dir / "view_results.jsonl").exists() + assert report_path.exists() + + # 5. Manifest content. + manifest = json.loads( + (results_dir / "run_manifest.json").read_text(), + ) + assert manifest["corpus_name"] == "cli_e2e" + assert manifest["n_documents"] == 2 + assert "tess_only" in manifest["pipeline_names"] + assert manifest["code_version"] == "1.0.0-cli-e2e" + assert len(manifest["view_specs"]) == 2 + + # 6. Rapport HTML est cohérent. + html = report_path.read_text(encoding="utf-8") + assert "" in html + assert "cli_e2e" in html + assert "tess_only" in html + + # 7. ViewResults présentes. + view_lines = [ + json.loads(line) + for line in (results_dir / "view_results.jsonl").read_text().strip().split("\n") + if line.strip() + ] + # 2 docs × 1 pipeline × 2 vues = 4 ViewResult attendus + # (text_final et searchability acceptent tous deux RAW_TEXT). + assert len(view_lines) == 4 + view_names = {v["view_name"] for v in view_lines} + assert view_names == {"text_final", "searchability"} + + # 8. Métriques valides : MockTextOCR copie la GT → CER 0. + for vr in view_lines: + if vr["view_name"] == "text_final": + assert vr["metric_values"]["cer"] == 0.0 + + def test_no_report_flag_skips_html( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + report_path = out_dir / "report.html" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + report_html: {report_path} + """)) + result = runner.invoke(cli, [ + "run", "--spec", str(spec_path), "--no-report", + ]) + assert result.exit_code == 0 + assert not report_path.exists() + assert "Rapport :" not in result.output + + def test_corpus_dir_alternative_works( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + # Pré-extraire le corpus dans un dir. + corpus_dir = tmp_path / "extracted" + corpus_dir.mkdir() + (corpus_dir / "doc01.png").write_bytes(_png_bytes()) + (corpus_dir / "doc01.gt.txt").write_text("text") + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + spec_path.write_text(textwrap.dedent(f""" + corpus_dir: {corpus_dir} + corpus_name: dir_corpus + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {out_dir} + """)) + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 0, result.output + assert "dir_corpus" in result.output + + +# ────────────────────────────────────────────────────────────────── +# CLI run : erreurs gérées +# ────────────────────────────────────────────────────────────────── + + +class TestCLIRunErrors: + def test_invalid_yaml_returns_exit_1( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + spec_path = tmp_path / "bad.yaml" + spec_path.write_text("not: valid: yaml: [bad") + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 1 + assert "spec invalide" in result.output + + def test_missing_view_canonical_rejected( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + spec_path = tmp_path / "r.yaml" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockTextOCR + input_types: [image] + output_types: [raw_text] + views: [unknown_view] + output_dir: {tmp_path / "out"} + """)) + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 1 + assert "vue" in result.output.lower() + + def test_unknown_adapter_class_returns_exit_1( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + corpus_zip = tmp_path / "c.zip" + corpus_zip.write_bytes(_make_corpus_zip()) + spec_path = tmp_path / "r.yaml" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + pipelines: + - name: p + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.does_not_exist.Nope + input_types: [image] + output_types: [raw_text] + views: [text_final] + output_dir: {tmp_path / "out"} + """)) + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 1 + assert "résolution pipeline" in result.output + + def test_missing_spec_file_exit_2( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + result = runner.invoke(cli, [ + "run", "--spec", str(tmp_path / "nonexistent.yaml"), + ]) + assert result.exit_code == 2 + + def test_required_spec_option( + self, runner: CliRunner, + ) -> None: + result = runner.invoke(cli, ["run"]) + assert result.exit_code == 2 + assert "--spec" in result.output + + +# ────────────────────────────────────────────────────────────────── +# Smoke : groupe CLI inclut bien run +# ────────────────────────────────────────────────────────────────── + + +class TestS25ProjectionEnabledInCLI: + """Validation S25 : un pipeline qui produit ALTO_XML est désormais + correctement évalué par TextView via projection automatique + ALTO → texte, dans le contexte CLI. + + Avant S25, ce cas retournait ``failed_metrics`` car le projecteur + ne stockait pas son output et le loader CLI ne savait pas + récupérer le texte projeté.""" + + def test_alto_pipeline_evaluated_via_textview_projection( + self, runner: CliRunner, tmp_path: Path, + ) -> None: + # Construire un corpus avec image + GT texte (pour TextView via + # projection ALTO→texte) et GT ALTO (pour AltoView direct). + from picarones.formats.alto.types import ( + AltoBBox, AltoDocument, AltoLine, AltoPage, AltoString, + AltoTextBlock, + ) + from picarones.formats.alto.writer import write_alto + + def _alto_for(text: str) -> bytes: + doc = AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w, bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)) + for w in text.split() + )),),),),),),) + return write_alto(doc) + + buf = io.BytesIO() + with zipfile.ZipFile(buf, mode="w") as zf: + zf.writestr("doc01.png", _png_bytes()) + zf.writestr("doc01.gt.txt", "Hello world") + zf.writestr("doc01.gt.alto.xml", _alto_for("Hello world")) + zf.writestr("doc02.png", _png_bytes()) + zf.writestr("doc02.gt.txt", "Bonjour monde") + zf.writestr("doc02.gt.alto.xml", _alto_for("Bonjour monde")) + corpus_zip = tmp_path / "corpus.zip" + corpus_zip.write_bytes(buf.getvalue()) + + spec_path = tmp_path / "run.yaml" + out_dir = tmp_path / "out" + spec_path.write_text(textwrap.dedent(f""" + corpus_zip: {corpus_zip} + corpus_name: s25_alto_proj + pipelines: + - name: pero_like + initial_inputs: [image] + steps: + - id: ocr + adapter_class: tests.fixtures.cli_mock_adapters.MockAltoOCR + input_types: [image] + output_types: [alto_xml] + views: [text_final, alto_documentary] + output_dir: {out_dir} + code_version: "1.0.0-s25" + """)) + + result = runner.invoke(cli, ["run", "--spec", str(spec_path)]) + assert result.exit_code == 0, result.output + + # Le pipeline a produit ALTO_XML, donc : + # - text_final via projection alto_to_text → CER 0. + # - alto_documentary direct → validity 1. + results_dir = out_dir / "results" + view_lines = [ + json.loads(line) + for line in (results_dir / "view_results.jsonl").read_text().strip().split("\n") + if line.strip() + ] + # 2 docs × (1 text_final via projection + 1 alto_documentary direct) = 4. + assert len(view_lines) == 4 + + # Vérifier que text_final est bien renseignée (pas omise) — la + # projection a réussi. + text_results = [v for v in view_lines if v["view_name"] == "text_final"] + assert len(text_results) == 2 + for vr in text_results: + # Métriques cer/wer présentes et = 0 (ALTO contient la GT). + assert vr["metric_values"]["cer"] == 0.0 + # Le projection_report est présent (preuve que la projection + # ALTO → texte a bien eu lieu). + assert vr["projection_report"] is not None + assert vr["projection_report"]["projector_name"] == "alto_to_text" + # Aucune métrique en échec. + assert vr["failed_metrics"] == {} + + # AltoView direct (sans projection). + alto_results = [v for v in view_lines if v["view_name"] == "alto_documentary"] + assert len(alto_results) == 2 + for vr in alto_results: + assert vr["projection_report"] is None + assert vr["failed_metrics"] == {} + + +class TestGroupIncludesRun: + def test_help_lists_run_subcommand(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["--help"]) + assert result.exit_code == 0 + assert "run" in result.output + + def test_run_help_documents_options(self, runner: CliRunner) -> None: + result = runner.invoke(cli, ["run", "--help"]) + assert result.exit_code == 0 + assert "--spec" in result.output + assert "--no-report" in result.output diff --git a/tests/conftest.py b/tests/conftest.py index 342a3f8a25d6d262048bc77d7822264a5d183006..66442ed6fbee4b2bf9472a261264d10587fa3086 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,23 +1,42 @@ """Configuration pytest globale. -Ce conftest racine ne fait **qu'une seule chose** : positionner les -variables d'environnement test-friendly **avant** tout import de -``picarones.web.*``. Sans ça, les singletons web (``JOBS_SEMAPHORE``, -``RATE_LIMITER``) seraient instanciés avec les valeurs de production -(2 jobs concurrents max, rate limit selon mode public) au moment du -premier import, et chaque test web verrait le bocal saturé. +Deux responsabilités, dans cet ordre : + +1. **Ajouter le repo root à ``sys.path``** — garantit que + ``tests.fixtures.*`` (mock adapters utilisés par les tests CLI + E2E via dotted-path resolution ``importlib.import_module()``) + sont importables de manière déterministe sur **tous les OS et + versions Python**, indépendamment de la config ``pythonpath`` de + pytest (qui peut diverger entre runners macOS/Windows/Linux et + versions 3.11/3.12/3.13). + +2. **Positionner les variables d'environnement test-friendly avant + tout import de ``picarones.web.*``** — sinon les singletons web + (``JOBS_SEMAPHORE``, ``RATE_LIMITER``) seraient instanciés avec + les valeurs de production au premier import, et chaque test web + verrait le bocal saturé. L'isolation par-test des états globaux web (sémaphore, rate limiter, browse roots) vit dans ``tests/web/conftest.py`` — fixture -``autouse=True`` qui ne s'applique qu'aux tests sous ``tests/web/``, -pour éviter qu'un test cercle 1 (``tests/core/``) ne paie le coût -de l'import de ``picarones.web.*`` à chaque exécution. +``autouse=True`` qui ne s'applique qu'aux tests sous ``tests/web/``. """ from __future__ import annotations import os +import sys +from pathlib import Path + +# (1) sys.path déterministe. Le repo root contient le package +# ``picarones`` (déjà installable via ``pip install -e .``) ET le +# package ``tests`` (importable via ``tests.fixtures.X``). On ajoute +# le repo root en tête pour garantir l'import déterministe sur tous +# les OS / versions Python. +_REPO_ROOT = Path(__file__).resolve().parent.parent +if str(_REPO_ROOT) not in sys.path: + sys.path.insert(0, str(_REPO_ROOT)) +# (2) Variables d'environnement. # Plafond très large pour ne jamais bloquer une suite de tests qui # démarre rapidement plusieurs benchmarks daemon en parallèle. os.environ.setdefault("PICARONES_MAX_CONCURRENT_JOBS", "32") diff --git a/tests/core/test_sprint_a14_s1_compact_optin.py b/tests/core/test_sprint_a14_s1_compact_optin.py new file mode 100644 index 0000000000000000000000000000000000000000..c74b1b3a89c9381d135b449ff1dd1f17b9d49392 --- /dev/null +++ b/tests/core/test_sprint_a14_s1_compact_optin.py @@ -0,0 +1,137 @@ +"""Sprint A14-S1 — A.I.0 P0 : ``DocumentResult.compact()`` est opt-in. + +Avant ce sprint, le runner appelait ``dr.compact()`` sans argument +avant de sérialiser le JSON, ce qui : + +- tronquait ``ground_truth``, ``hypothesis`` et ``ocr_intermediate`` + à 200 caractères ; +- effaçait 13 dicts d'analyse per-document (confusion, taxonomy, + philological, searchability, etc.). + +Le rapport HTML — qui consomme ce JSON — recevait des données déjà +mutilées, contredisant la promesse "self-contained HTML report" du +README. + +Désormais, ``compact()`` est no-op par défaut. Le caller doit +explicitement demander la troncature via ``text_limit`` et/ou la +suppression des analyses via ``drop_analyses=True``. +""" + +from __future__ import annotations + +from picarones.core.metrics import MetricsResult +from picarones.core.results import DocumentResult + + +def _make_dr(**kwargs) -> DocumentResult: + base = dict( + doc_id="d1", + image_path="x.png", + ground_truth="A" * 1000, + hypothesis="B" * 1000, + metrics=MetricsResult(cer=0.1, wer=0.1, error=None), + duration_seconds=0.1, + confusion_matrix={"k": "v"}, + char_scores={"ligature": {"score": 0.9}}, + taxonomy={"class": "v"}, + structure={"k": "v"}, + image_quality={"k": "v"}, + line_metrics={"k": "v"}, + hallucination_metrics={"k": "v"}, + ner_metrics={"k": "v"}, + calibration_metrics={"k": "v"}, + philological_metrics={"k": "v"}, + searchability_metrics={"k": "v"}, + numerical_sequence_metrics={"k": "v"}, + readability_metrics={"k": "v"}, + ocr_intermediate="C" * 1000, + ) + base.update(kwargs) + return DocumentResult(**base) + + +class TestCompactDefaultIsNoOp: + def test_default_call_does_not_truncate_text(self) -> None: + dr = _make_dr() + before_gt = dr.ground_truth + before_hyp = dr.hypothesis + before_ocr = dr.ocr_intermediate + dr.compact() + assert dr.ground_truth == before_gt + assert dr.hypothesis == before_hyp + assert dr.ocr_intermediate == before_ocr + + def test_default_call_preserves_all_analyses(self) -> None: + dr = _make_dr() + dr.compact() + for field in ( + "confusion_matrix", "char_scores", "taxonomy", "structure", + "image_quality", "line_metrics", "hallucination_metrics", + "ner_metrics", "calibration_metrics", "philological_metrics", + "searchability_metrics", "numerical_sequence_metrics", + "readability_metrics", + ): + assert getattr(dr, field) is not None, ( + f"{field} a été effacé alors que ``compact()`` est " + "censé être no-op par défaut depuis Sprint A14-S1." + ) + + +class TestCompactTextLimit: + def test_text_limit_truncates_ground_truth(self) -> None: + dr = _make_dr() + dr.compact(text_limit=200) + assert len(dr.ground_truth) == 201 # 200 + ellipsis + + def test_text_limit_truncates_hypothesis(self) -> None: + dr = _make_dr() + dr.compact(text_limit=50) + assert len(dr.hypothesis) == 51 + + def test_text_limit_truncates_ocr_intermediate(self) -> None: + dr = _make_dr() + dr.compact(text_limit=100) + assert len(dr.ocr_intermediate) == 101 + + def test_text_limit_zero_or_none_is_noop(self) -> None: + dr = _make_dr() + dr.compact(text_limit=0) + assert len(dr.ground_truth) == 1000 + dr2 = _make_dr() + dr2.compact(text_limit=None) + assert len(dr2.ground_truth) == 1000 + + def test_text_limit_does_not_truncate_short_text(self) -> None: + dr = _make_dr(ground_truth="short", hypothesis="also short") + dr.compact(text_limit=200) + assert dr.ground_truth == "short" + assert dr.hypothesis == "also short" + + +class TestCompactDropAnalyses: + def test_drop_analyses_clears_all_thirteen_fields(self) -> None: + dr = _make_dr() + dr.compact(drop_analyses=True) + for field in ( + "confusion_matrix", "char_scores", "taxonomy", "structure", + "image_quality", "line_metrics", "hallucination_metrics", + "ner_metrics", "calibration_metrics", "philological_metrics", + "searchability_metrics", "numerical_sequence_metrics", + "readability_metrics", + ): + assert getattr(dr, field) is None, f"{field} aurait dû être effacé" + + def test_drop_analyses_alone_preserves_text(self) -> None: + dr = _make_dr() + dr.compact(drop_analyses=True) # pas de text_limit + assert len(dr.ground_truth) == 1000 + assert len(dr.hypothesis) == 1000 + + def test_combined_legacy_behavior(self) -> None: + """``compact(text_limit=200, drop_analyses=True)`` reproduit + l'ancien comportement par défaut (avant Sprint A14-S1).""" + dr = _make_dr() + dr.compact(text_limit=200, drop_analyses=True) + assert len(dr.ground_truth) == 201 + assert dr.confusion_matrix is None + assert dr.philological_metrics is None diff --git a/tests/core/test_sprint_a14_s1_metrics_error_returns_none.py b/tests/core/test_sprint_a14_s1_metrics_error_returns_none.py new file mode 100644 index 0000000000000000000000000000000000000000..efdc12e4469b436744f87f7bb7fd731554f3c7f2 --- /dev/null +++ b/tests/core/test_sprint_a14_s1_metrics_error_returns_none.py @@ -0,0 +1,120 @@ +"""Sprint A14-S1 — A.I.0 P0 : compute_metrics retourne None en cas d'erreur. + +Avant ce sprint, ``compute_metrics`` retournait des ``MetricsResult`` +avec ``cer=0.0, wer=0.0, ...`` quand jiwer était indisponible ou qu'une +exception était levée. Pour tout consommateur qui n'inspectait pas +``error``, ces zéros étaient indistinguables d'un score parfait — soit +l'inverse exact de la réalité (échec total = "100 % d'accord avec la +GT"). + +Désormais, en erreur, les champs métriques sont à ``None`` et ``error`` +porte le message. Un accès direct à ``result.cer`` sur un résultat en +erreur lèvera désormais ``TypeError`` lors d'opérations numériques +(``cer * 100``), ce qui est l'effet voulu : un crash explicite plutôt +qu'une valeur factice. +""" + +from __future__ import annotations + +from unittest import mock + + +from picarones.core.metrics import MetricsResult, aggregate_metrics +from picarones.measurements import metrics as metrics_module +from picarones.measurements.metrics import compute_metrics + + +class TestComputeMetricsErrorPath: + def test_jiwer_missing_returns_none_metrics(self) -> None: + """Si jiwer absent, tous les champs sont None et error est set.""" + with mock.patch.object(metrics_module, "_JIWER_AVAILABLE", False): + result = compute_metrics("référence", "hypothèse") + assert result.cer is None + assert result.cer_nfc is None + assert result.cer_caseless is None + assert result.wer is None + assert result.wer_normalized is None + assert result.mer is None + assert result.wil is None + assert result.error is not None + assert "jiwer" in result.error.lower() + + def test_jiwer_exception_returns_none_metrics(self) -> None: + """Si jiwer lève, on retombe dans le bloc except et on retourne None.""" + with mock.patch.object( + metrics_module, "_cer_from_strings", + side_effect=RuntimeError("simulated jiwer crash"), + ): + result = compute_metrics("a", "b") + assert result.cer is None + assert result.wer is None + assert result.error is not None + assert "simulated jiwer crash" in result.error + + def test_no_silent_zero_when_error_set(self) -> None: + """Garde-fou : aucun champ ne doit être 0.0 si error est non-None. + + Verrouille le bug exact que ce sprint corrige (0.0 indistinguable + d'un score parfait dans le JSON exporté). + """ + with mock.patch.object(metrics_module, "_JIWER_AVAILABLE", False): + result = compute_metrics("référence", "hypothèse") + assert result.error is not None + for field in ("cer", "cer_nfc", "cer_caseless", "wer", + "wer_normalized", "mer", "wil"): + assert getattr(result, field) is None, ( + f"{field} = {getattr(result, field)!r} (devrait être None " + "puisque error est non-None)" + ) + + +class TestMetricsResultPropertiesHandleNone: + def test_cer_percent_handles_none(self) -> None: + r = MetricsResult(error="boom") + assert r.cer_percent is None + + def test_wer_percent_handles_none(self) -> None: + r = MetricsResult(error="boom") + assert r.wer_percent is None + + def test_as_dict_handles_none(self) -> None: + r = MetricsResult(error="boom") + d = r.as_dict() + assert d["cer"] is None + assert d["wer"] is None + assert d["error"] == "boom" + + def test_as_dict_rounds_when_set(self) -> None: + r = MetricsResult(cer=0.123456789, wer=0.456789, error=None) + d = r.as_dict() + assert d["cer"] == 0.123457 # 6 décimales + assert d["wer"] == 0.456789 + + +class TestAggregateMetricsFiltersNoneAndError: + def test_aggregator_excludes_results_with_error(self) -> None: + ok = MetricsResult(cer=0.1, wer=0.2, mer=0.15, wil=0.25, error=None) + ko = MetricsResult(error="boom") # cer/wer/etc tous None + agg = aggregate_metrics([ok, ko]) + # Seul le résultat OK contribue à la moyenne. + assert agg["cer"]["mean"] == 0.1 + assert agg["wer"]["mean"] == 0.2 + assert agg["failed_count"] == 1 + assert agg["document_count"] == 2 + + def test_aggregator_robust_to_partial_none(self) -> None: + """Défense en profondeur : un caller pourrait construire un + MetricsResult avec des None sans avoir set ``error``. On ne + plante pas, on saute simplement les None.""" + partial = MetricsResult(cer=0.05, wer=None, mer=None, wil=None, error=None) + agg = aggregate_metrics([partial]) + assert agg["cer"]["mean"] == 0.05 + # WER absent → stats vides plutôt que NaN. + assert agg["wer"] == {} + + def test_aggregator_empty_when_all_errors(self) -> None: + errs = [MetricsResult(error="x"), MetricsResult(error="y")] + agg = aggregate_metrics(errs) + assert agg["cer"] == {} + assert agg["failed_count"] == 2 + assert agg["document_count"] == 2 diff --git a/tests/domain/__init__.py b/tests/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/domain/test_sprint_a14_s40_pipeline_spec_in_domain.py b/tests/domain/test_sprint_a14_s40_pipeline_spec_in_domain.py new file mode 100644 index 0000000000000000000000000000000000000000..14fba8f789e73b91045bc5a9550f262f5c362d54 --- /dev/null +++ b/tests/domain/test_sprint_a14_s40_pipeline_spec_in_domain.py @@ -0,0 +1,93 @@ +"""``PipelineSpec`` vit en cercle 1 (``picarones.domain``). + +Vérifie que : + +1. ``picarones.domain.pipeline_spec`` est le module canonique. +2. ``picarones.domain`` re-exporte ``PipelineSpec``, ``PipelineStep``, + ``INITIAL_STEP_ID`` au top-level. +3. ``picarones.pipeline`` re-exporte aussi (raccourci d'API publique). +4. Les chemins d'import retournent **la même classe** (``is`` strict). +""" + +from __future__ import annotations + + +def test_canonical_path_in_domain() -> None: + """``picarones.domain.pipeline_spec`` expose les classes canoniques.""" + from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, + ) + assert PipelineSpec is not None + assert PipelineStep is not None + assert INITIAL_STEP_ID == "__initial__" + + +def test_domain_top_level_reexports() -> None: + """``picarones.domain`` re-exporte au top-level.""" + from picarones.domain import ( + INITIAL_STEP_ID, + PipelineSpec, + PipelineStep, + ) + assert PipelineSpec is not None + assert PipelineStep is not None + assert INITIAL_STEP_ID == "__initial__" + + +def test_all_paths_resolve_to_same_classes() -> None: + """Les imports valides retournent la MÊME classe (``is`` strict).""" + from picarones.domain import ( + INITIAL_STEP_ID as DomainInitial, + ) + from picarones.domain import ( + PipelineSpec as DomainSpec, + ) + from picarones.domain import ( + PipelineStep as DomainStep, + ) + from picarones.domain.pipeline_spec import ( + INITIAL_STEP_ID as CanonInitial, + ) + from picarones.domain.pipeline_spec import ( + PipelineSpec as CanonSpec, + ) + from picarones.domain.pipeline_spec import ( + PipelineStep as CanonStep, + ) + from picarones.pipeline import ( + INITIAL_STEP_ID as PkgInitial, + ) + from picarones.pipeline import ( + PipelineSpec as PkgSpec, + ) + from picarones.pipeline import ( + PipelineStep as PkgStep, + ) + + assert DomainSpec is CanonSpec + assert DomainSpec is PkgSpec + assert DomainStep is CanonStep + assert DomainStep is PkgStep + assert DomainInitial == CanonInitial == PkgInitial + + +def test_legacy_spec_module_is_deprecated_shim() -> None: + """``picarones.pipeline.spec`` reste exposé avec + ``DeprecationWarning`` jusqu'à la 2.0 (cf. shim S59). + + La couverture détaillée du contrat (warning émis, classes + identiques) vit dans ``tests/api_stability/test_deprecated_aliases``. + """ + import importlib + import sys + import warnings + + sys.modules.pop("picarones.pipeline.spec", None) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + mod = importlib.import_module("picarones.pipeline.spec") + assert hasattr(mod, "PipelineSpec") + assert hasattr(mod, "PipelineStep") + assert hasattr(mod, "INITIAL_STEP_ID") diff --git a/tests/domain/test_sprint_a14_s4_artifacts.py b/tests/domain/test_sprint_a14_s4_artifacts.py new file mode 100644 index 0000000000000000000000000000000000000000..dcbb732632575574887cf68d2e23239f6f033790 --- /dev/null +++ b/tests/domain/test_sprint_a14_s4_artifacts.py @@ -0,0 +1,191 @@ +"""Sprint A14-S4 — ``Artifact`` et ``ArtifactType``. + +Vérifie les invariants des artefacts du nouveau domain : validation +des id, hash, immutabilité, sérialisation JSON déterministe. + +Note : pas de test "logique métier" ici — un Artifact ne fait rien, +il décrit. Les tests qui valident le comportement viendront avec +le pipeline executor (S7) qui produit et consomme des artefacts. +""" + +from __future__ import annotations + +import hashlib + +import pytest + +from picarones.domain import ( + Artifact, + ArtifactType, + ArtifactValidationError, + ProvenanceRecord, + compute_content_hash, +) + + +def _prov() -> ProvenanceRecord: + return ProvenanceRecord(code_version="1.0.0", parameters_hash="a" * 64) + + +# ────────────────────────────────────────────────────────────────────── +# ArtifactType +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactType: + def test_canonical_values(self) -> None: + """Sprint A14-S4 — valeurs canoniques (9 jusqu'au S49 ; + +``confidences`` ajouté au S50 pour le sidecar JSON OCR). + """ + expected = { + "image", "raw_text", "corrected_text", + "alto_xml", "page_xml", "canonical_document", + "entities", "reading_order", "alignment", + "confidences", + } + assert {t.value for t in ArtifactType} == expected + + def test_string_enum_serializes_as_value(self) -> None: + """``ArtifactType`` hérite de ``str`` → JSON en string brute.""" + assert ArtifactType.RAW_TEXT == "raw_text" + assert ArtifactType("alto_xml") is ArtifactType.ALTO_XML + + +# ────────────────────────────────────────────────────────────────────── +# compute_content_hash +# ────────────────────────────────────────────────────────────────────── + + +class TestComputeContentHash: + def test_returns_64_char_hex(self) -> None: + h = compute_content_hash(b"hello") + assert len(h) == 64 + assert int(h, 16) >= 0 # hex valide + + def test_deterministic(self) -> None: + assert compute_content_hash(b"abc") == compute_content_hash(b"abc") + + def test_matches_sha256(self) -> None: + h = compute_content_hash(b"picarones") + assert h == hashlib.sha256(b"picarones").hexdigest() + + +# ────────────────────────────────────────────────────────────────────── +# Artifact — création et validation +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactCreation: + def test_minimal_artifact(self) -> None: + a = Artifact(id="x", document_id="d1", type=ArtifactType.RAW_TEXT) + assert a.id == "x" + assert a.uri is None + assert a.content_hash is None + assert a.produced_by_step is None + assert a.provenance is None + + def test_full_artifact(self) -> None: + a = Artifact( + id="d1:ocr:raw_text", + document_id="d1", + type=ArtifactType.RAW_TEXT, + uri="/tmp/x.txt", + content_hash="b" * 64, + produced_by_step="ocr", + provenance=_prov(), + ) + assert a.produced_by_step == "ocr" + + def test_id_validation_rejects_spaces(self) -> None: + with pytest.raises(ArtifactValidationError, match="id invalide"): + Artifact(id="bad id", document_id="d1", type=ArtifactType.RAW_TEXT) + + def test_id_validation_rejects_null_byte(self) -> None: + with pytest.raises(ArtifactValidationError): + Artifact(id="x\x00y", document_id="d1", type=ArtifactType.RAW_TEXT) + + def test_id_accepts_filesystem_safe_chars(self) -> None: + # alphanum + ``_.-:/`` selon le regex. + a = Artifact( + id="vol_a:folio.001-r/raw_text", + document_id="vol_a/folio.001-r", + type=ArtifactType.RAW_TEXT, + ) + assert a.id == "vol_a:folio.001-r/raw_text" + + def test_content_hash_must_be_64_hex(self) -> None: + # Trop court + with pytest.raises(Exception): # pydantic ValidationError + Artifact( + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + content_hash="abc", + ) + # Bonne longueur mais pas hex + with pytest.raises(ArtifactValidationError, match="hex SHA-256"): + Artifact( + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + content_hash="z" * 64, + ) + + def test_content_hash_lowercased(self) -> None: + a = Artifact( + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + content_hash="A" * 64, + ) + assert a.content_hash == "a" * 64 + + +# ────────────────────────────────────────────────────────────────────── +# Artifact — immutabilité +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactImmutability: + def test_frozen_blocks_attribute_mutation(self) -> None: + a = Artifact(id="x", document_id="d1", type=ArtifactType.RAW_TEXT) + with pytest.raises(Exception): # pydantic ValidationError + a.id = "y" # type: ignore[misc] + + def test_extra_fields_rejected(self) -> None: + with pytest.raises(Exception): # pydantic ValidationError + Artifact( # type: ignore[call-arg] + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + bogus_field="oops", + ) + + +# ────────────────────────────────────────────────────────────────────── +# Artifact — sérialisation déterministe +# ────────────────────────────────────────────────────────────────────── + + +class TestArtifactSerialization: + def test_json_roundtrip_preserves_equality(self) -> None: + a = Artifact( + id="d1:ocr:raw_text", document_id="d1", + type=ArtifactType.RAW_TEXT, content_hash="c" * 64, + produced_by_step="ocr", provenance=_prov(), + ) + j = a.model_dump_json() + a2 = Artifact.model_validate_json(j) + assert a == a2 + + def test_json_is_byte_deterministic(self) -> None: + """Même contenu → mêmes octets exacts. Indispensable au cache + d'artefacts du Sprint S7.""" + a1 = Artifact( + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + content_hash="d" * 64, + ) + a2 = Artifact( + id="x", document_id="d1", type=ArtifactType.RAW_TEXT, + content_hash="d" * 64, + ) + assert a1.model_dump_json() == a2.model_dump_json() + + def test_artifacts_are_hashable(self) -> None: + """Frozen pydantic models sont hashables — on peut les mettre + dans un set ou utiliser comme clé de dict.""" + a = Artifact(id="x", document_id="d1", type=ArtifactType.RAW_TEXT) + s = {a} + assert a in s diff --git a/tests/domain/test_sprint_a14_s4_corpus.py b/tests/domain/test_sprint_a14_s4_corpus.py new file mode 100644 index 0000000000000000000000000000000000000000..c96d2c48c0b702896d3044e05f665af7f1123882 --- /dev/null +++ b/tests/domain/test_sprint_a14_s4_corpus.py @@ -0,0 +1,75 @@ +"""Sprint A14-S4 — ``CorpusSpec`` immuable.""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ArtifactType, CorpusSpec, CorpusSpecError, DocumentRef, GroundTruthRef + + +def _doc(doc_id: str) -> DocumentRef: + return DocumentRef(id=doc_id) + + +class TestCorpusSpec: + def test_empty_corpus(self) -> None: + c = CorpusSpec(name="empty") + assert len(c) == 0 + assert c.documents == () + + def test_corpus_with_documents(self) -> None: + c = CorpusSpec( + name="bnf_demo", + documents=(_doc("a"), _doc("b"), _doc("c")), + ) + assert len(c) == 3 + + def test_doc_by_id_finds_document(self) -> None: + c = CorpusSpec(name="x", documents=(_doc("a"), _doc("b"))) + assert c.doc_by_id("a") is not None + assert c.doc_by_id("b") is not None + assert c.doc_by_id("missing") is None + + def test_duplicate_doc_ids_rejected(self) -> None: + with pytest.raises(CorpusSpecError, match="dupliqué"): + CorpusSpec( + name="x", + documents=(_doc("a"), _doc("b"), _doc("a")), + ) + + def test_metadata_is_free_dict(self) -> None: + c = CorpusSpec( + name="x", + metadata={"language": "fr", "period": "early_modern"}, + ) + assert c.metadata["language"] == "fr" + + def test_name_validation(self) -> None: + with pytest.raises(Exception): # pydantic ValidationError + CorpusSpec(name="") # min_length=1 + + +class TestCorpusSpecImmutability: + def test_frozen_blocks_mutation(self) -> None: + c = CorpusSpec(name="x") + with pytest.raises(Exception): + c.name = "y" # type: ignore[misc] + + def test_json_roundtrip_with_multilevel_gt(self) -> None: + c = CorpusSpec( + name="philological", + documents=( + DocumentRef( + id="folio_001", + image_uri="/c/folio_001.png", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/x.txt"), + GroundTruthRef(type=ArtifactType.ALTO_XML, uri="/x.xml"), + ), + ), + ), + metadata={"language": "lat"}, + ) + j = c.model_dump_json() + c2 = CorpusSpec.model_validate_json(j) + assert c == c2 diff --git a/tests/domain/test_sprint_a14_s4_documents.py b/tests/domain/test_sprint_a14_s4_documents.py new file mode 100644 index 0000000000000000000000000000000000000000..77d4e49801e8655d52c15c6ed3b8c3b70be69c8e --- /dev/null +++ b/tests/domain/test_sprint_a14_s4_documents.py @@ -0,0 +1,98 @@ +"""Sprint A14-S4 — ``DocumentRef`` et ``GroundTruthRef`` multi-niveaux.""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ( + ArtifactType, + CorpusSpecError, + DocumentRef, + GroundTruthRef, +) + + +class TestDocumentRefBasics: + def test_minimal_document(self) -> None: + d = DocumentRef(id="folio_001") + assert d.id == "folio_001" + assert d.image_uri is None + assert d.ground_truths == () + + def test_document_with_image_and_text_gt(self) -> None: + d = DocumentRef( + id="folio_001", + image_uri="/corpus/folio_001.png", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/corpus/folio_001.gt.txt"), + ), + ) + assert d.image_uri == "/corpus/folio_001.png" + assert len(d.ground_truths) == 1 + + def test_id_validation_rejects_spaces(self) -> None: + with pytest.raises(CorpusSpecError, match="document id invalide"): + DocumentRef(id="bad id") + + +class TestMultiLevelGT: + def test_multi_level_gt(self) -> None: + d = DocumentRef( + id="folio_001", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/x.gt.txt"), + GroundTruthRef(type=ArtifactType.ALTO_XML, uri="/x.gt.alto.xml"), + GroundTruthRef(type=ArtifactType.READING_ORDER, uri="/x.ro.json"), + ), + ) + assert len(d.ground_truths) == 3 + assert d.available_gt_types == ( + ArtifactType.RAW_TEXT, + ArtifactType.ALTO_XML, + ArtifactType.READING_ORDER, + ) + + def test_gt_for_returns_matching_level(self) -> None: + d = DocumentRef( + id="x", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/x.txt"), + GroundTruthRef(type=ArtifactType.ALTO_XML, uri="/x.xml"), + ), + ) + gt = d.gt_for(ArtifactType.ALTO_XML) + assert gt is not None + assert gt.uri == "/x.xml" + + def test_gt_for_returns_none_when_absent(self) -> None: + d = DocumentRef(id="x") + assert d.gt_for(ArtifactType.RAW_TEXT) is None + + def test_duplicate_gt_type_rejected(self) -> None: + with pytest.raises(CorpusSpecError, match="GT dupliquée"): + DocumentRef( + id="x", + ground_truths=( + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/a.txt"), + GroundTruthRef(type=ArtifactType.RAW_TEXT, uri="/b.txt"), + ), + ) + + +class TestDocumentRefImmutability: + def test_frozen_blocks_mutation(self) -> None: + d = DocumentRef(id="x") + with pytest.raises(Exception): + d.id = "y" # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + d = DocumentRef( + id="vol_a/folio_001", + image_uri="/c/folio_001.png", + ground_truths=( + GroundTruthRef(type=ArtifactType.ALTO_XML, uri="/x.xml"), + ), + ) + j = d.model_dump_json() + d2 = DocumentRef.model_validate_json(j) + assert d == d2 diff --git a/tests/domain/test_sprint_a14_s4_provenance_errors.py b/tests/domain/test_sprint_a14_s4_provenance_errors.py new file mode 100644 index 0000000000000000000000000000000000000000..54d46b5f667c0ab9323a3406f5fa5c71783aca8f --- /dev/null +++ b/tests/domain/test_sprint_a14_s4_provenance_errors.py @@ -0,0 +1,74 @@ +"""Sprint A14-S4 — ``ProvenanceRecord`` + hiérarchie d'erreurs.""" + +from __future__ import annotations + +from datetime import datetime, timezone + +import pytest + +from picarones.domain import ( + ArtifactValidationError, + CorpusSpecError, + PicaronesError, + ProjectionError, + ProvenanceRecord, +) + + +class TestProvenanceRecord: + def test_minimal_provenance(self) -> None: + p = ProvenanceRecord(code_version="1.0.0") + assert p.code_version == "1.0.0" + assert p.parameters_hash is None + assert isinstance(p.timestamp, datetime) + assert p.timestamp.tzinfo == timezone.utc + + def test_with_parameters_hash(self) -> None: + p = ProvenanceRecord(code_version="1.0.0", parameters_hash="a" * 64) + assert p.parameters_hash == "a" * 64 + + def test_compatibility_check(self) -> None: + p1 = ProvenanceRecord(code_version="1.0.0", parameters_hash="x" * 64) + p2 = ProvenanceRecord(code_version="1.0.0", parameters_hash="x" * 64) + assert p1.is_compatible_with(p2) + + p3 = ProvenanceRecord(code_version="1.0.1", parameters_hash="x" * 64) + assert not p1.is_compatible_with(p3) # code_version diffère + + p4 = ProvenanceRecord(code_version="1.0.0", parameters_hash="y" * 64) + assert not p1.is_compatible_with(p4) # parameters_hash diffère + + def test_frozen(self) -> None: + p = ProvenanceRecord(code_version="1.0.0") + with pytest.raises(Exception): + p.code_version = "1.0.1" # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + p = ProvenanceRecord(code_version="1.0.0", parameters_hash="x" * 64) + p2 = ProvenanceRecord.model_validate_json(p.model_dump_json()) + assert p == p2 + + +class TestErrorHierarchy: + def test_all_errors_inherit_picarones_error(self) -> None: + for cls in ( + ArtifactValidationError, + ProjectionError, + CorpusSpecError, + ): + assert issubclass(cls, PicaronesError), ( + f"{cls.__name__} doit hériter de PicaronesError pour " + "permettre un `except PicaronesError` global au niveau " + "de la couche transport." + ) + + def test_picarones_error_is_exception(self) -> None: + assert issubclass(PicaronesError, Exception) + + def test_can_raise_and_catch_via_base(self) -> None: + with pytest.raises(PicaronesError): + raise ArtifactValidationError("x") + with pytest.raises(PicaronesError): + raise ProjectionError("y") + with pytest.raises(PicaronesError): + raise CorpusSpecError("z") diff --git a/tests/domain/test_sprint_a14_s52_error_hierarchy.py b/tests/domain/test_sprint_a14_s52_error_hierarchy.py new file mode 100644 index 0000000000000000000000000000000000000000..3b5189f21d7c2fb49bf7c814e414f382f0425cf3 --- /dev/null +++ b/tests/domain/test_sprint_a14_s52_error_hierarchy.py @@ -0,0 +1,67 @@ +"""Sprint A14-S52 — hiérarchie d'erreurs unifiée (fix audit #7 + #11). + +Avant S52 : +- LLM/VLM levaient OCRAdapterError (mauvaise classe). +- JobStoreError héritait de Exception (pas de PicaronesError). +- Pas de racine commune AdapterStepError pour catcher OCR+LLM+VLM. + +Après S52 : +- AdapterStepError(PicaronesError) est la racine commune. +- OCRAdapterError, LLMAdapterError, VLMAdapterError héritent. +- JobStoreError hérite de PicaronesError. +""" + +from __future__ import annotations + +import pytest + +from picarones.adapters.llm.base import LLMAdapterError +from picarones.adapters.ocr.base import OCRAdapterError +from picarones.adapters.storage import JobStoreError +from picarones.adapters.vlm.base import VLMAdapterError +from picarones.domain.errors import AdapterStepError, PicaronesError + + +class TestErrorInheritance: + def test_ocr_inherits_adapter_step_error(self) -> None: + assert issubclass(OCRAdapterError, AdapterStepError) + assert issubclass(OCRAdapterError, PicaronesError) + + def test_llm_inherits_adapter_step_error(self) -> None: + assert issubclass(LLMAdapterError, AdapterStepError) + assert issubclass(LLMAdapterError, PicaronesError) + + def test_vlm_inherits_adapter_step_error(self) -> None: + assert issubclass(VLMAdapterError, AdapterStepError) + assert issubclass(VLMAdapterError, PicaronesError) + + def test_jobstore_inherits_picarones_error(self) -> None: + # Avant S52, héritait de Exception → un caller `except + # PicaronesError` ratait JobStoreError. Maintenant inclus. + assert issubclass(JobStoreError, PicaronesError) + + +class TestPolymorphicCatch: + """Un caller peut catcher AdapterStepError pour gérer toute + erreur d'adapter sans connaître la sous-classe.""" + + def test_catches_ocr(self) -> None: + with pytest.raises(AdapterStepError): + raise OCRAdapterError("ocr boom") + + def test_catches_llm(self) -> None: + with pytest.raises(AdapterStepError): + raise LLMAdapterError("llm boom") + + def test_catches_vlm(self) -> None: + with pytest.raises(AdapterStepError): + raise VLMAdapterError("vlm boom") + + def test_picarones_catches_all_adapter_errors(self) -> None: + for cls in (OCRAdapterError, LLMAdapterError, VLMAdapterError): + with pytest.raises(PicaronesError): + raise cls("boom") + + def test_picarones_catches_jobstore(self) -> None: + with pytest.raises(PicaronesError): + raise JobStoreError("store boom") diff --git a/tests/domain/test_sprint_a14_s5_evaluation_specs.py b/tests/domain/test_sprint_a14_s5_evaluation_specs.py new file mode 100644 index 0000000000000000000000000000000000000000..97ed1906e35b64df35194d6157d106413e354197 --- /dev/null +++ b/tests/domain/test_sprint_a14_s5_evaluation_specs.py @@ -0,0 +1,260 @@ +"""Sprint A14-S5 — contrats déclaratifs des vues d'évaluation. + +Tests de ``MetricSpec``, ``EvaluationView``, ``EvaluationSpec``, +``ProjectionSpec``. Pas de logique métier — juste les invariants +des dataclasses pydantic. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ( + ArtifactType, + EvaluationSpec, + EvaluationView, + MetricSpec, + ProjectionSpec, +) + + +# ────────────────────────────────────────────────────────────────────── +# MetricSpec +# ────────────────────────────────────────────────────────────────────── + + +class TestMetricSpec: + def test_minimal_spec(self) -> None: + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + assert spec.name == "cer" + assert spec.description == "" + assert spec.higher_is_better is False + assert spec.tags == frozenset() + + def test_higher_is_better_for_quality_metrics(self) -> None: + spec = MetricSpec( + name="ner_f1", + input_types=(ArtifactType.ENTITIES, ArtifactType.ENTITIES), + description="F1 micro sur entités nommées", + higher_is_better=True, + tags=frozenset({"ner", "icdar"}), + ) + assert spec.higher_is_better is True + assert "ner" in spec.tags + + def test_frozen(self) -> None: + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + with pytest.raises(Exception): # pydantic ValidationError + spec.name = "wer" # type: ignore[misc] + + def test_no_callable_field(self) -> None: + """Différence avec l'ancien core.metric_registry.MetricSpec : + pas de ``func`` ici (le callable vit dans MetricRegistry).""" + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + assert not hasattr(spec, "func") + + def test_extra_field_rejected(self) -> None: + with pytest.raises(Exception): + MetricSpec( # type: ignore[call-arg] + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + bogus=42, + ) + + +# ────────────────────────────────────────────────────────────────────── +# ProjectionSpec +# ────────────────────────────────────────────────────────────────────── + + +class TestProjectionSpec: + def test_alto_to_text(self) -> None: + p = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ) + assert p.source_type == ArtifactType.ALTO_XML + assert p.target_type == ArtifactType.RAW_TEXT + assert p.params == {} + assert p.is_identity is False + + def test_identity_projection(self) -> None: + p = ProjectionSpec( + source_type=ArtifactType.RAW_TEXT, + target_type=ArtifactType.RAW_TEXT, + projector_name="identity", + ) + assert p.is_identity is True + + def test_with_params(self) -> None: + p = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + params={"reading_order": "natural", "preserve_hyphens": True}, + ) + assert p.params["reading_order"] == "natural" + assert p.params["preserve_hyphens"] is True + + def test_frozen(self) -> None: + p = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ) + with pytest.raises(Exception): + p.projector_name = "other" # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + p = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + params={"reading_order": "natural"}, + ) + p2 = ProjectionSpec.model_validate_json(p.model_dump_json()) + assert p == p2 + + +# ────────────────────────────────────────────────────────────────────── +# EvaluationView — la pièce centrale du S5 +# ────────────────────────────────────────────────────────────────────── + + +class TestEvaluationView: + def test_text_final_view_canonical_shape(self) -> None: + """Définition de done du S5 : tu peux instancier + ``EvaluationView(name="text_final", projection_spec=..., metrics=...)`` + sans rien d'autre.""" + view = EvaluationView( + name="text_final", + description="Compare les sorties textuelles finales.", + candidate_types=frozenset({ + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + }), + projection=ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ), + metric_names=("cer", "wer"), + ignored_dimensions=("geometry", "block_structure"), + warnings=("Cette vue ignore la structure spatiale.",), + ) + assert view.name == "text_final" + assert view.accepts(ArtifactType.RAW_TEXT) + assert view.accepts(ArtifactType.ALTO_XML) + assert not view.accepts(ArtifactType.IMAGE) + + def test_alto_view_no_projection(self) -> None: + """Une vue qui n'a pas besoin de projection (compare l'ALTO + tel quel).""" + view = EvaluationView( + name="alto_documentary", + candidate_types=frozenset({ArtifactType.ALTO_XML}), + projection=None, + metric_names=("alto_validity", "line_alignment_f1"), + ) + assert view.projection is None + + def test_search_view_text_only(self) -> None: + view = EvaluationView( + name="searchability", + candidate_types=frozenset({ + ArtifactType.RAW_TEXT, ArtifactType.CORRECTED_TEXT, + }), + metric_names=("rare_token_recall", "numerical_sequences"), + ) + assert view.accepts(ArtifactType.RAW_TEXT) + assert not view.accepts(ArtifactType.ALTO_XML) + + def test_view_with_normalization_profile(self) -> None: + view = EvaluationView( + name="text_diplomatic", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + normalization_profile="medieval_french", + metric_names=("cer",), + ) + assert view.normalization_profile == "medieval_french" + + def test_empty_candidate_types_is_valid_but_useless(self) -> None: + """Pas de validation à la construction : un caller peut + construire une vue inutile (qui n'accepte rien) ; à + l'EvaluationViewExecutor de la signaler runtime.""" + view = EvaluationView( + name="useless", + candidate_types=frozenset(), + ) + assert not view.accepts(ArtifactType.RAW_TEXT) + + def test_frozen(self) -> None: + view = EvaluationView( + name="x", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + ) + with pytest.raises(Exception): + view.name = "y" # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + view = EvaluationView( + name="text_final", + description="x", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + projection=ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ), + normalization_profile="nfc", + metric_names=("cer",), + ignored_dimensions=("geometry",), + warnings=("avertissement",), + ) + v2 = EvaluationView.model_validate_json(view.model_dump_json()) + assert view == v2 + + +# ────────────────────────────────────────────────────────────────────── +# EvaluationSpec +# ────────────────────────────────────────────────────────────────────── + + +class TestEvaluationSpec: + def test_empty_spec(self) -> None: + s = EvaluationSpec() + assert s.views == () + + def test_multi_view_spec(self) -> None: + s = EvaluationSpec( + views=( + EvaluationView( + name="text", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + ), + EvaluationView( + name="alto", + candidate_types=frozenset({ArtifactType.ALTO_XML}), + ), + ), + ) + assert len(s.views) == 2 + assert s.view_by_name("text") is not None + assert s.view_by_name("alto") is not None + assert s.view_by_name("missing") is None + + def test_frozen(self) -> None: + s = EvaluationSpec() + with pytest.raises(Exception): + s.views = () # type: ignore[misc] diff --git a/tests/evaluation/__init__.py b/tests/evaluation/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/evaluation/test_canonical_payload.py b/tests/evaluation/test_canonical_payload.py new file mode 100644 index 0000000000000000000000000000000000000000..d1bc07ea7640521470507572382619684b3228cf --- /dev/null +++ b/tests/evaluation/test_canonical_payload.py @@ -0,0 +1,177 @@ +"""Tests des helpers de :mod:`picarones.evaluation.projectors.canonical`. + +Couvre les branches de :func:`canonical_payload_to_text` et +:func:`markdown_to_text` qui n'étaient pas exercées par les tests +des vues canoniques (S14/S16) — payloads dict/list, fallback ``str()``, +patterns markdown variés. +""" + +from __future__ import annotations + +from picarones.evaluation.projectors.canonical import ( + canonical_payload_to_text, + markdown_to_text, +) + + +# ────────────────────────────────────────────────────────────────── +# markdown_to_text — patterns markdown courants +# ────────────────────────────────────────────────────────────────── + + +class TestMarkdownToText: + def test_strips_headers(self) -> None: + assert markdown_to_text("# Titre") == "Titre" + assert markdown_to_text("## H2") == "H2" + assert markdown_to_text("###### H6") == "H6" + + def test_strips_bullets(self) -> None: + assert markdown_to_text("- élément") == "élément" + assert markdown_to_text("* étoile") == "étoile" + assert markdown_to_text("+ plus") == "plus" + + def test_strips_numbered_lists(self) -> None: + assert markdown_to_text("1. premier") == "premier" + assert markdown_to_text("42. quarante-deux") == "quarante-deux" + + def test_strips_blockquote(self) -> None: + assert markdown_to_text("> citation") == "citation" + assert markdown_to_text(">sans espace") == "sans espace" + + def test_strips_horizontal_rule(self) -> None: + # Les HR sont supprimés. + assert markdown_to_text("---").strip() == "" + assert markdown_to_text("***") == "" + + def test_strips_bold_italic(self) -> None: + assert markdown_to_text("**gras**") == "gras" + assert markdown_to_text("*italique*") == "italique" + assert markdown_to_text("***gras-italique***") == "gras-italique" + + def test_strips_underline(self) -> None: + assert markdown_to_text("_souligné_") == "souligné" + assert markdown_to_text("__double__") == "double" + + def test_strips_inline_code(self) -> None: + assert markdown_to_text("`code`") == "code" + + def test_strips_code_blocks(self) -> None: + text = "```python\nprint('hi')\n```" + assert "print('hi')" in markdown_to_text(text) + assert "```" not in markdown_to_text(text) + + def test_strips_links_keeps_text(self) -> None: + assert markdown_to_text("[Picarones](https://example.com)") == "Picarones" + + def test_strips_images_keeps_alt(self) -> None: + assert markdown_to_text("![alt](img.png)") == "alt" + + def test_combined(self) -> None: + # Snippet réaliste VLM. + md = "# Titre\n\n**Bonjour** _le_ `monde`\n\n- item 1\n- item 2" + result = markdown_to_text(md) + assert "Titre" in result + assert "Bonjour" in result + assert "monde" in result + assert "item 1" in result + # Pas de balise résiduelle. + for marker in ("**", "##", "* ", "- ", "_", "`"): + assert marker not in result.replace("- ", "") # contre-faux-positif + + +# ────────────────────────────────────────────────────────────────── +# canonical_payload_to_text — dispatching par type +# ────────────────────────────────────────────────────────────────── + + +class TestCanonicalPayloadToText: + def test_none_returns_empty(self) -> None: + assert canonical_payload_to_text(None) == "" + + def test_str_treated_as_markdown(self) -> None: + assert canonical_payload_to_text("# Titre\n\nBonjour") == "Titre\n\nBonjour" + + def test_int_falls_back_to_str(self) -> None: + assert canonical_payload_to_text(42) == "42" + + def test_float_falls_back_to_str(self) -> None: + assert canonical_payload_to_text(3.14) == "3.14" + + def test_dict_with_text_key(self) -> None: + assert canonical_payload_to_text({"text": "Bonjour"}) == "Bonjour" + + def test_dict_with_content_key(self) -> None: + assert canonical_payload_to_text({"content": "Hello"}) == "Hello" + + def test_dict_with_markdown_key(self) -> None: + assert canonical_payload_to_text({"markdown": "# Titre"}) == "Titre" + + def test_dict_with_plain_key(self) -> None: + assert canonical_payload_to_text({"plain": "brut"}) == "brut" + + def test_dict_with_value_key(self) -> None: + assert canonical_payload_to_text({"value": "v"}) == "v" + + def test_dict_with_paragraphs_list(self) -> None: + payload = {"paragraphs": ["para 1", "para 2", "para 3"]} + result = canonical_payload_to_text(payload) + assert "para 1" in result + assert "para 2" in result + assert "para 3" in result + + def test_dict_with_lines_list(self) -> None: + payload = {"lines": ["ligne A", "ligne B"]} + result = canonical_payload_to_text(payload) + assert "ligne A" in result + assert "ligne B" in result + + def test_dict_fallback_concatenates_string_values(self) -> None: + # Aucune clé standard reconnue → on concatène les str du dict. + payload = {"label1": "valeur 1", "label2": "valeur 2"} + result = canonical_payload_to_text(payload) + assert "valeur 1" in result + assert "valeur 2" in result + + def test_dict_fallback_recurses_into_nested_dict(self) -> None: + payload = {"nested": {"text": "inner"}} + assert "inner" in canonical_payload_to_text(payload) + + def test_dict_fallback_recurses_into_nested_list(self) -> None: + payload = {"items": ["a", "b"]} + result = canonical_payload_to_text(payload) + assert "a" in result + assert "b" in result + + def test_list_concatenates_with_newlines(self) -> None: + result = canonical_payload_to_text(["alpha", "beta", "gamma"]) + assert "alpha" in result + assert "beta" in result + assert "gamma" in result + + def test_list_filters_empty_items(self) -> None: + # Les éléments vides doivent être filtrés (pas de \n\n résiduel). + result = canonical_payload_to_text(["alpha", "", "beta"]) + # Pas de double saut de ligne si on filtre bien les vides. + assert "\n\n" not in result + + def test_tuple_treated_like_list(self) -> None: + result = canonical_payload_to_text(("x", "y")) + assert "x" in result + assert "y" in result + + def test_list_of_dicts(self) -> None: + payload = [{"text": "premier"}, {"text": "deuxième"}] + result = canonical_payload_to_text(payload) + assert "premier" in result + assert "deuxième" in result + + def test_priority_text_over_content(self) -> None: + # Les clés sont essayées dans l'ordre text > content > markdown. + payload = {"text": "préféré", "content": "ignoré"} + assert canonical_payload_to_text(payload) == "préféré" + + def test_non_str_value_in_known_key_skipped(self) -> None: + # ``text`` doit être un str pour être pris ; sinon on continue + # vers les clés suivantes ou le fallback. + payload = {"text": 42, "content": "fallback"} + assert canonical_payload_to_text(payload) == "fallback" diff --git a/tests/evaluation/test_sprint_a14_s13_view_executor.py b/tests/evaluation/test_sprint_a14_s13_view_executor.py new file mode 100644 index 0000000000000000000000000000000000000000..0735f7b120dbc6c256d24e1bec129b697c9cc73f --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s13_view_executor.py @@ -0,0 +1,411 @@ +"""Sprint A14-S13 — ``DefaultEvaluationViewExecutor``. + +Tests d'orchestration : la vue + ses dépendances (registries + +payload loader) sur 10+ cas couvrant les chemins critiques. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ( + Artifact, + ArtifactType, + EvaluationView, + MetricSpec, + ProjectionError, + ProjectionSpec, +) +from picarones.evaluation.projectors import ( + ProjectionReport, + ProjectorRegistry, + ProjectorRegistrationError, + ProjectorNotFoundError, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, +) + + +# ────────────────────────────────────────────────────────────────────── +# Stubs réutilisables +# ────────────────────────────────────────────────────────────────────── + + +class _StubProjector: + """Projecteur ALTO → texte simple pour les tests.""" + + name = "stub_alto_to_text" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def __init__(self, output_payload: str = "projected text") -> None: + self.output_payload = output_payload + + def project(self, artifact, params): + target = Artifact( + id=f"{artifact.id}:projected", + document_id=artifact.document_id, + type=self.target_type, + ) + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + lossy=True, + ignored_dimensions=("geometry", "blocks"), + warnings=("ordre de lecture deviné",), + ) + # Sprint S25 — retourne le payload directement. + return target, self.output_payload, report + + +def _build_executor( + payloads: dict[str, object], + *, + register_projector: bool = True, + extra_metrics: dict[str, object] | None = None, +) -> DefaultEvaluationViewExecutor: + metrics = MetricRegistry() + metrics.register( + MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + lambda gt, hyp: 0.0 if gt == hyp else ( + 0.5 if isinstance(gt, str) and isinstance(hyp, str) and len(gt) == len(hyp) + else 1.0 + ), + ) + metrics.register( + MetricSpec( + name="wer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + lambda gt, hyp: 0.0 if gt == hyp else 0.5, + ) + if extra_metrics: + for name, fn in extra_metrics.items(): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + + projectors = ProjectorRegistry() + if register_projector: + projectors.register(_StubProjector()) + + def loader(artifact: Artifact): + if artifact.id not in payloads: + raise KeyError(f"payload manquant : {artifact.id}") + return payloads[artifact.id] + + return DefaultEvaluationViewExecutor.from_registries(metrics, projectors, loader) + + +def _text_view( + *, + name: str = "text_final", + candidate_types: frozenset = frozenset({ + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + }), + projection: ProjectionSpec | None = None, + normalization_profile: str | None = None, + metric_names: tuple[str, ...] = ("cer",), + ignored_dimensions: tuple[str, ...] = (), + warnings: tuple[str, ...] = (), +) -> EvaluationView: + return EvaluationView( + name=name, + candidate_types=candidate_types, + projection=projection, + normalization_profile=normalization_profile, + metric_names=metric_names, + ignored_dimensions=ignored_dimensions, + warnings=warnings, + ) + + +# ────────────────────────────────────────────────────────────────────── +# 10 cas d'évaluation +# ────────────────────────────────────────────────────────────────────── + + +class TestEvaluator: + + def test_text_direct_no_projection(self) -> None: + """Cas 1 — RAW_TEXT direct, pas de projection.""" + payloads = {"cand": "hello", "gt": "hello"} + executor = _build_executor(payloads) + view = _text_view(metric_names=("cer", "wer")) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.metric_values["cer"] == 0.0 + assert result.metric_values["wer"] == 0.0 + assert result.projection_report is None + assert result.failed_metrics == {} + + def test_text_direct_with_difference(self) -> None: + """Cas 2 — RAW_TEXT, candidat différent de la GT.""" + payloads = {"cand": "world", "gt": "hello"} + executor = _build_executor(payloads) + view = _text_view() + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.metric_values["cer"] > 0 + + def test_alto_to_text_via_projection(self) -> None: + """Cas 3 — ALTO_XML projeté en RAW_TEXT, projection_report présent.""" + payloads = { + "alto:projected": "projected text", + "gt": "projected text", + } + executor = _build_executor(payloads) + view = _text_view( + projection=ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="stub_alto_to_text", + ), + ) + cand = Artifact(id="alto", document_id="d", type=ArtifactType.ALTO_XML) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.projection_report is not None + assert result.projection_report.projector_name == "stub_alto_to_text" + assert "geometry" in result.ignored_dimensions + assert "ordre de lecture deviné" in result.warnings + assert result.metric_values["cer"] == 0.0 + + def test_view_rejects_wrong_artifact_type(self) -> None: + """Cas 4 — la vue n'accepte pas IMAGE → ValueError.""" + payloads = {} + executor = _build_executor(payloads) + view = _text_view( + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + ) + cand = Artifact(id="x", document_id="d", type=ArtifactType.IMAGE) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + with pytest.raises(ValueError, match="n'accepte pas"): + executor.evaluate(view, cand, gt, pipeline_name="test") + + def test_unknown_projector_raises_projection_error(self) -> None: + """Cas 5 — la vue référence un projecteur non enregistré.""" + payloads = {"cand": "x", "gt": "x"} + executor = _build_executor(payloads, register_projector=False) + view = _text_view( + projection=ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="nonexistent", + ), + ) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.ALTO_XML) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + with pytest.raises(ProjectionError, match="introuvable"): + executor.evaluate(view, cand, gt, pipeline_name="test") + + def test_projector_that_raises_wraps_in_projection_error(self) -> None: + """Cas 6 — le projecteur lève une exception interne.""" + class _CrashingProjector: + name = "crash" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + def project(self, artifact, params): + raise RuntimeError("boom interne") + + metrics = MetricRegistry() + projectors = ProjectorRegistry() + projectors.register(_CrashingProjector()) + executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, lambda a: None, + ) + view = _text_view( + projection=ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="crash", + ), + metric_names=(), + ) + cand = Artifact(id="c", document_id="d", type=ArtifactType.ALTO_XML) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + with pytest.raises(ProjectionError, match="boom interne"): + executor.evaluate(view, cand, gt, pipeline_name="test") + + def test_metric_that_raises_goes_to_failed_metrics(self) -> None: + """Cas 7 — une métrique qui lève → failed_metrics, pas plante.""" + def _broken(gt, hyp): + raise ValueError("métrique cassée") + payloads = {"cand": "x", "gt": "x"} + executor = _build_executor( + payloads, + extra_metrics={"broken": _broken}, + ) + view = _text_view(metric_names=("cer", "broken", "wer")) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert "cer" in result.metric_values + assert "wer" in result.metric_values + assert "broken" in result.failed_metrics + assert "métrique cassée" in result.failed_metrics["broken"] + + def test_unknown_metric_goes_to_failed_metrics(self) -> None: + """Cas 8 — une métrique non enregistrée → failed_metrics.""" + payloads = {"cand": "x", "gt": "x"} + executor = _build_executor(payloads) + view = _text_view(metric_names=("cer", "nonexistent_metric")) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert "cer" in result.metric_values + assert "nonexistent_metric" in result.failed_metrics + assert "non enregistrée" in result.failed_metrics["nonexistent_metric"] + + def test_normalization_profile_applied(self) -> None: + """Cas 9 — vue avec normalization_profile applique la + normalisation aux deux payloads.""" + # Avec medieval_french : ſ → s, u → v + payloads = {"cand": "afpre", "gt": "aſpre"} + executor = _build_executor(payloads) + view = _text_view(normalization_profile="medieval_french") + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + # Après normalisation, les deux deviennent "aspre" (cer stub + # retourne 0.5 pour len égal, 0.0 pour égalité stricte). + # On vérifie au moins que la métrique a été calculée. + assert "cer" in result.metric_values + + def test_payload_loader_failure_blocks_all_metrics(self) -> None: + """Cas 10 — le loader plante → toutes les métriques sont + marquées en échec global.""" + # Loader plante systématiquement. + metrics = MetricRegistry() + metrics.register( + MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + lambda r, h: 0.0, + ) + projectors = ProjectorRegistry() + + def _bad_loader(artifact): + raise FileNotFoundError(f"missing file for {artifact.id}") + + executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, _bad_loader, + ) + view = _text_view(metric_names=("cer",)) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.metric_values == {} + assert "cer" in result.failed_metrics + assert "payload_loader a échoué" in result.failed_metrics["cer"] + + +# ────────────────────────────────────────────────────────────────────── +# Constructor validation +# ────────────────────────────────────────────────────────────────────── + + +class TestConstructor: + """Le constructeur canonique (S27) attend deux engines + un loader.""" + + def test_rejects_non_projection_engine(self) -> None: + from picarones.evaluation.evaluation_engine import EvaluationEngine + with pytest.raises(TypeError, match="projection_engine"): + DefaultEvaluationViewExecutor( + "not an engine", # type: ignore[arg-type] + EvaluationEngine(MetricRegistry()), + lambda a: None, + ) + + def test_rejects_non_evaluation_engine(self) -> None: + from picarones.evaluation.projection_engine import ProjectionEngine + with pytest.raises(TypeError, match="evaluation_engine"): + DefaultEvaluationViewExecutor( + ProjectionEngine(ProjectorRegistry()), + "nope", # type: ignore[arg-type] + lambda a: None, + ) + + def test_rejects_non_callable_loader(self) -> None: + from picarones.evaluation.evaluation_engine import EvaluationEngine + from picarones.evaluation.projection_engine import ProjectionEngine + with pytest.raises(TypeError, match="callable"): + DefaultEvaluationViewExecutor( + ProjectionEngine(ProjectorRegistry()), + EvaluationEngine(MetricRegistry()), + "not_callable", # type: ignore[arg-type] + ) + + def test_from_registries_rejects_non_metric_registry(self) -> None: + with pytest.raises(TypeError, match="metric_registry"): + DefaultEvaluationViewExecutor.from_registries( + "not a registry", ProjectorRegistry(), lambda a: None, # type: ignore[arg-type] + ) + + def test_from_registries_rejects_non_projector_registry(self) -> None: + with pytest.raises(TypeError, match="projector_registry"): + DefaultEvaluationViewExecutor.from_registries( + MetricRegistry(), "nope", lambda a: None, # type: ignore[arg-type] + ) + + def test_from_registries_rejects_non_callable_loader(self) -> None: + with pytest.raises(TypeError, match="callable"): + DefaultEvaluationViewExecutor.from_registries( + MetricRegistry(), ProjectorRegistry(), "not_callable", # type: ignore[arg-type] + ) + + +# ────────────────────────────────────────────────────────────────────── +# ProjectorRegistry — tests directs +# ────────────────────────────────────────────────────────────────────── + + +class TestProjectorRegistry: + def test_register_and_get(self) -> None: + reg = ProjectorRegistry() + p = _StubProjector() + reg.register(p) + assert "stub_alto_to_text" in reg + assert reg.get("stub_alto_to_text") is p + + def test_register_non_protocol_raises(self) -> None: + reg = ProjectorRegistry() + class _NotAProjector: + pass + with pytest.raises(ProjectorRegistrationError): + reg.register(_NotAProjector()) # type: ignore[arg-type] + + def test_idempotent_re_registration(self) -> None: + reg = ProjectorRegistry() + p = _StubProjector() + reg.register(p) + reg.register(p) # ne lève pas + assert len(reg) == 1 + + def test_get_unknown_raises(self) -> None: + reg = ProjectorRegistry() + with pytest.raises(ProjectorNotFoundError): + reg.get("missing") + + def test_two_registries_independent(self) -> None: + a = ProjectorRegistry() + b = ProjectorRegistry() + a.register(_StubProjector()) + assert "stub_alto_to_text" in a + assert "stub_alto_to_text" not in b diff --git a/tests/evaluation/test_sprint_a14_s16_views_consistency.py b/tests/evaluation/test_sprint_a14_s16_views_consistency.py new file mode 100644 index 0000000000000000000000000000000000000000..2d881bb692967036c0432e8c4f4a1b48fa88f30b --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s16_views_consistency.py @@ -0,0 +1,328 @@ +"""Sprint A14-S16 — sanity check inter-vues sur le cas BnF central. + +Vérifie qu'un même pipeline a une cohérence (et parfois une +divergence intéressante) entre TextView, AltoView et SearchView. + +Cas démontrés : +- Pipeline parfait → toutes vues maximisent. +- Pipeline avec erreur sur une année → SearchView baisse fortement, + TextView baisse légèrement (pattern "perte de données critiques + invisible au CER global"). +- Pipeline sans ALTO → AltoView l'OMET, autres vues l'évaluent. +""" + +from __future__ import annotations + + +from picarones.domain import Artifact, ArtifactType, MetricSpec +from picarones.evaluation.metrics.alto_structural import ( + compute_alto_validity, + compute_line_count_ratio, + compute_word_box_coverage, +) +from picarones.evaluation.metrics.search import ( + numerical_sequence_preservation, + searchability_recall, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, + build_alto_view, + build_search_view, + build_text_view, +) +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) + + +# ────────────────────────────────────────────────────────────────── +# Stubs métriques texte (cer/wer simplifiés sans jiwer) +# ────────────────────────────────────────────────────────────────── + + +def _stub_cer(reference: str, hypothesis: str) -> float: + if not reference: + return 0.0 if not hypothesis else 1.0 + common = sum(1 for a, b in zip(reference, hypothesis) if a == b) + return 1.0 - (common / max(len(reference), len(hypothesis))) + + +def _stub_wer(reference: str, hypothesis: str) -> float: + ref_w = reference.split() + hyp_w = hypothesis.split() + if not ref_w: + return 0.0 if not hyp_w else 1.0 + common = sum(1 for a, b in zip(ref_w, hyp_w) if a == b) + return 1.0 - (common / len(ref_w)) + + +def _build_unified_executor(payloads: dict) -> DefaultEvaluationViewExecutor: + """Executor configuré pour TextView + AltoView + SearchView.""" + metrics = MetricRegistry() + # TextView metrics + for name, fn in ( + ("cer", _stub_cer), + ("wer", _stub_wer), + ("mer", _stub_cer), + ("wil", _stub_wer), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + # AltoView metrics + for name, fn in ( + ("alto_validity", compute_alto_validity), + ("alto_line_count_ratio", compute_line_count_ratio), + ("alto_word_box_coverage", compute_word_box_coverage), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + fn, + ) + # SearchView metrics + metrics.register( + MetricSpec( + name="searchability_recall", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + searchability_recall, + ) + metrics.register( + MetricSpec( + name="numerical_sequence_preservation", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + numerical_sequence_preservation, + ) + + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + def loader(art: Artifact): + if art.id not in payloads: + raise KeyError(art.id) + return payloads[art.id] + + return DefaultEvaluationViewExecutor.from_registries(metrics, projectors, loader) + + +# ────────────────────────────────────────────────────────────────── +# Cas 1 — pipeline parfait +# ────────────────────────────────────────────────────────────────── + + +class TestPerfectPipelineAcrossViews: + def test_perfect_text_pipeline_maximizes_text_and_search(self) -> None: + """Un pipeline qui produit du texte parfait : + - TextView : CER = 0 + - SearchView : recall = 1.0, year preservation = 1.0 + - AltoView : OMIS (pas d'ALTO produit). + """ + gt_text = "Bonjour Paris en 1789" + payloads = {"cand": gt_text, "gt_text": gt_text} + executor = _build_unified_executor(payloads) + + text_view = build_text_view() + search_view = build_search_view() + alto_view = build_alto_view() + + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT) + + text_result = executor.evaluate(text_view, cand, gt, pipeline_name="test") + search_result = executor.evaluate(search_view, cand, gt, pipeline_name="test") + + assert text_result.metric_values["cer"] == 0.0 + assert search_result.metric_values["searchability_recall"] == 1.0 + assert search_result.metric_values["numerical_sequence_preservation"] == 1.0 + + # AltoView OMIS : le caller doit filtrer. + assert not alto_view.accepts(cand.type) + + +# ────────────────────────────────────────────────────────────────── +# Cas 2 — divergence TextView ↔ SearchView +# ────────────────────────────────────────────────────────────────── + + +class TestDivergencePattern: + def test_year_corruption_invisible_to_cer_visible_to_search(self) -> None: + """Pattern critique : une corruption d'année (1 caractère + sur ~50) est invisible côté CER mais catastrophique côté + recherchabilité numérique. + + C'est précisément ce que le rapport BnF doit rendre + visible — les deux vues racontent des histoires + complémentaires. + """ + gt_text = "Charte signée à Paris le 14 juillet 1789 en présence du roi" + # Hypothèse : le LLM a "corrigé" 1789 en 1798 (faute grossière). + # Le reste du texte est identique. + cand_text = "Charte signée à Paris le 14 juillet 1798 en présence du roi" + + payloads = {"cand": cand_text, "gt": gt_text} + executor = _build_unified_executor(payloads) + + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + + text_result = executor.evaluate(build_text_view(), cand, gt, pipeline_name="test") + search_result = executor.evaluate(build_search_view(), cand, gt, pipeline_name="test") + + # CER ≈ 0.03 (3 chars sur ~58) + assert text_result.metric_values["cer"] < 0.1, "CER doit rester faible" + # WER : 1 mot changé sur 11 → 1/11 ≈ 0.09 + assert text_result.metric_values["wer"] < 0.15 + + # Mais SearchView : 1789 (GT) n'est PAS dans hyp_years = [1798] + # → preservation = 0.0 (catastrophique pour un historien). + assert search_result.metric_values["numerical_sequence_preservation"] == 0.0 + # Searchability : "1789" GT n'est pas matché à "1798" (distance 2, + # MAIS la longueur est égale, fuzziness ≤ 2 le matche). + # On vérifie juste qu'il y a un signal mesurable. + assert search_result.metric_values["searchability_recall"] >= 0.8 + + +# ────────────────────────────────────────────────────────────────── +# Cas 3 — pipeline ALTO évaluable dans les 3 vues +# ────────────────────────────────────────────────────────────────── + + +def _build_simple_alto(words: list[str], n_lines: int = 1) -> AltoDocument: + """Construit un AltoDocument avec ``words`` répartis sur + ``n_lines`` lignes, chaque mot avec une bbox.""" + chunks = [words[i::n_lines] for i in range(n_lines)] + lines = tuple( + AltoLine(strings=tuple( + AltoString( + content=w, + bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10), + ) + for w in chunk + )) + for chunk in chunks + ) + return AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=lines),),),),) + + +class TestAltoPipelineEvaluatedInThreeViews: + def test_alto_pipeline_has_text_alto_search_results(self, tmp_path) -> None: + """Un pipeline qui produit ALTO_XML est évaluable dans les + 3 vues : TextView (via projection), AltoView (direct), + SearchView (via projection). + """ + from picarones.formats.alto import write_alto + + words_gt = "Charte signée Paris 14 juillet 1789".split() + words_cand = "Charte signée Paris 14 juillet 1789".split() # identique + + # n_lines=1 pour préserver l'ordre des mots dans l'extraction + # (sinon ``alto_document_to_text`` produit des sauts de ligne + # qui font diverger le CER d'une comparaison ligne unique). + gt_alto = _build_simple_alto(words_gt, n_lines=1) + cand_alto = _build_simple_alto(words_cand, n_lines=1) + cand_alto_path = tmp_path / "cand.alto.xml" + cand_alto_path.write_bytes(write_alto(cand_alto)) + + # Payloads : raw text pour les payloads projetés depuis ALTO, + # AltoDocument pour la GT et le candidat ALTO direct. + from picarones.evaluation.projectors import alto_document_to_text + payloads = { + "gt_text": " ".join(words_gt), + "gt_alto": gt_alto, + "cand": cand_alto, # AltoDocument pour AltoView + "cand:projected_text": alto_document_to_text(cand_alto), + } + executor = _build_unified_executor(payloads) + + gt_text_art = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT) + gt_alto_art = Artifact(id="gt_alto", document_id="d", type=ArtifactType.ALTO_XML) + cand_art = Artifact( + id="cand", document_id="d", + type=ArtifactType.ALTO_XML, uri=str(cand_alto_path), + ) + + # TextView : projette ALTO → texte, compare au gt_text. + text_result = executor.evaluate(build_text_view(), cand_art, gt_text_art, pipeline_name="test") + assert text_result.metric_values["cer"] == 0.0 + + # SearchView : projette ALTO → texte, mesure recall + années. + search_result = executor.evaluate(build_search_view(), cand_art, gt_text_art, pipeline_name="test") + assert search_result.metric_values["searchability_recall"] == 1.0 + + # AltoView : compare ALTO direct contre ALTO GT. + alto_result = executor.evaluate(build_alto_view(), cand_art, gt_alto_art, pipeline_name="test") + assert alto_result.metric_values["alto_validity"] == 1.0 + assert alto_result.metric_values["alto_line_count_ratio"] == 1.0 + assert alto_result.metric_values["alto_word_box_coverage"] == 1.0 + + +# ────────────────────────────────────────────────────────────────── +# Cohérence globale : projection report présent ssi projection appliquée +# ────────────────────────────────────────────────────────────────── + + +class TestProjectionReportConsistency: + def test_text_search_views_share_projection_report_pattern(self) -> None: + """Pour un même candidat ALTO_XML évalué dans TextView et + SearchView, les deux ViewResult doivent porter un + projection_report (les deux vues projettent vers texte).""" + gt_text = "test" + gt_alto = _build_simple_alto(["test"], n_lines=1) + from picarones.evaluation.projectors import alto_document_to_text + from picarones.formats.alto import write_alto + + # Pour ce test on n'a pas besoin du fichier réel — on simule + # via le payload_loader qui retourne directement le texte + # extrait pour l'id "cand:projected_text". + payloads = { + "gt_text": gt_text, + "cand:projected_text": alto_document_to_text(gt_alto), + } + # Mais le projecteur a besoin d'un URI. On contourne en + # créant un fichier temporaire dans pytest fixture. + # Pour ce test simple on écrit dans /tmp. + import tempfile + with tempfile.NamedTemporaryFile(suffix=".alto.xml", delete=False) as f: + f.write(write_alto(gt_alto)) + cand_uri = f.name + + executor = _build_unified_executor(payloads) + cand = Artifact( + id="cand", document_id="d", + type=ArtifactType.ALTO_XML, uri=cand_uri, + ) + gt = Artifact(id="gt_text", document_id="d", type=ArtifactType.RAW_TEXT) + + text_result = executor.evaluate(build_text_view(), cand, gt, pipeline_name="test") + search_result = executor.evaluate(build_search_view(), cand, gt, pipeline_name="test") + + # Les deux doivent avoir un projection_report (même projecteur). + assert text_result.projection_report is not None + assert search_result.projection_report is not None + assert text_result.projection_report.projector_name == "alto_to_text" + assert search_result.projection_report.projector_name == "alto_to_text" diff --git a/tests/evaluation/test_sprint_a14_s25_projector_payload.py b/tests/evaluation/test_sprint_a14_s25_projector_payload.py new file mode 100644 index 0000000000000000000000000000000000000000..8e4e171b173594ee23d4e130b881a9a952c1471f --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s25_projector_payload.py @@ -0,0 +1,308 @@ +"""Sprint A14-S25 — projection sans hack loader. + +Le test central qui démontre que le fix du protocole ``Projector`` +(retourne ``(Artifact, payload, ProjectionReport)`` au lieu de +``(Artifact, ProjectionReport)``) débloque le workflow CLI : +on peut maintenant exécuter une pipeline qui produit ALTO_XML, la +faire évaluer par TextView (qui projette ALTO → texte), et obtenir +des métriques **sans pré-stocker manuellement le payload projeté +dans le loader**. + +C'est précisément le cas BnF central : +- Pipeline 1 : Tesseract → RAW_TEXT (TextView direct). +- Pipeline 2 : Pero OCR → ALTO_XML (TextView via projection + ALTO→texte). + +Les deux pipelines doivent être comparables sur la même TextView. +""" + +from __future__ import annotations + +from pathlib import Path + +from picarones.app.services import RegistryService +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.evaluation_spec import MetricSpec +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, + build_text_view, +) +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) +from picarones.formats.alto.writer import write_alto + + +# ────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────── + + +def _build_alto(text: str) -> AltoDocument: + return AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w, bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)) + for w in text.split() + )),),),),),),) + + +def _stub_cer(reference: str, hypothesis: str) -> float: + if not reference: + return 0.0 if not hypothesis else 1.0 + common = sum(1 for a, b in zip(reference, hypothesis) if a == b) + return 1.0 - (common / max(len(reference), len(hypothesis))) + + +def _strict_loader(art: Artifact): + """Loader qui REFUSE explicitement les artefacts projetés. + + Si l'executor essaie d'appeler ``loader(art)`` sur un artefact + dont l'id se termine par ``:projected_text``, on lève — preuve + que le fix S25 fait que l'executor n'appelle PAS le loader sur + les artefacts projetés. + + Pour les autres artefacts (RAW_TEXT/ALTO_XML avec URI), on lit + depuis le filesystem. + """ + if ":projected_text" in art.id: + raise AssertionError( + f"S25 régression : le loader a été appelé sur " + f"l'artefact projeté {art.id!r} — le fix S25 garantit que " + "le payload est utilisé directement depuis le retour du " + "projecteur, sans repasser par le loader." + ) + if art.type == ArtifactType.RAW_TEXT: + return Path(art.uri).read_text(encoding="utf-8") + if art.type == ArtifactType.ALTO_XML: + from picarones.formats.alto.parser import parse_alto + return parse_alto(Path(art.uri).read_bytes()) + raise KeyError(f"loader strict : type {art.type} non géré") + + +# ────────────────────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────────────────────── + + +class TestProjectionWithoutLoaderHack: + """Avant S25, l'executor appelait ``loader(projected_artifact)`` — + obligeant les tests à pré-stocker le payload projeté dans une map. + Après S25, le projecteur retourne le payload directement et + l'executor ne sollicite plus le loader pour les artefacts projetés. + """ + + def test_alto_to_text_projection_works_without_loader_hack( + self, tmp_path: Path, + ) -> None: + # Setup : un ALTO sur disque + une GT texte sur disque. + gt_text = "Bonjour le monde" + alto_doc = _build_alto(gt_text) + alto_path = tmp_path / "doc.alto.xml" + alto_path.write_bytes(write_alto(alto_doc)) + + gt_path = tmp_path / "doc.gt.txt" + gt_path.write_text(gt_text, encoding="utf-8") + + # Bootstrap registries via le service S23. + registries = RegistryService.bootstrap_defaults() + + # Loader strict qui ASSERTE qu'il n'est pas appelé sur l'artefact + # projeté. + executor = DefaultEvaluationViewExecutor.from_registries( + registries.metrics, + registries.projectors, + _strict_loader, + ) + + # Candidat : ALTO_XML. GT : RAW_TEXT. Vue : TextView qui + # projette ALTO → texte. + cand = Artifact( + id="d1:pero:alto", + document_id="d1", + type=ArtifactType.ALTO_XML, + uri=str(alto_path), + ) + gt = Artifact( + id="d1:gt:raw_text", + document_id="d1", + type=ArtifactType.RAW_TEXT, + uri=str(gt_path), + ) + view = build_text_view() + result = executor.evaluate(view, cand, gt, pipeline_name="test") + + # Validation : la projection a bien eu lieu, le payload retourné + # par le projecteur a été utilisé (le loader strict aurait levé + # sinon), et le CER est 0 puisque le texte ALTO matche la GT. + assert result.projection_report is not None + assert result.projection_report.projector_name == "alto_to_text" + assert result.failed_metrics == {}, ( + f"Métriques en échec inattendues : {result.failed_metrics}" + ) + assert result.metric_values["cer"] == 0.0 + assert result.metric_values["wer"] == 0.0 + + def test_canonical_to_text_projection_works_without_loader_hack( + self, tmp_path: Path, + ) -> None: + # Setup : markdown sur disque + GT texte. + md_path = tmp_path / "doc.canonical.md" + md_path.write_text( + "# Titre\n\nBonjour le monde\n", + encoding="utf-8", + ) + gt_path = tmp_path / "doc.gt.txt" + gt_path.write_text("Titre Bonjour le monde", encoding="utf-8") + + registries = RegistryService.bootstrap_defaults() + executor = DefaultEvaluationViewExecutor.from_registries( + registries.metrics, + registries.projectors, + _strict_loader, + ) + + cand = Artifact( + id="d1:vlm:canonical", + document_id="d1", + type=ArtifactType.CANONICAL_DOCUMENT, + uri=str(md_path), + ) + gt = Artifact( + id="d1:gt:raw_text", + document_id="d1", + type=ArtifactType.RAW_TEXT, + uri=str(gt_path), + ) + view = build_text_view() + result = executor.evaluate(view, cand, gt, pipeline_name="test") + + assert result.projection_report is not None + assert result.projection_report.projector_name == "canonical_to_text" + assert result.failed_metrics == {}, ( + f"Métriques en échec inattendues : {result.failed_metrics}" + ) + + def test_loader_still_called_for_non_projected_candidate( + self, tmp_path: Path, + ) -> None: + """Garde-fou : le loader EST appelé pour les artefacts non + projetés (RAW_TEXT direct), juste pas pour les projetés. + Vérifie qu'on n'a pas accidentellement court-circuité + TOUS les chemins.""" + gt_text = "Identique" + cand_path = tmp_path / "cand.txt" + cand_path.write_text(gt_text, encoding="utf-8") + gt_path = tmp_path / "gt.txt" + gt_path.write_text(gt_text, encoding="utf-8") + + registries = RegistryService.bootstrap_defaults() + executor = DefaultEvaluationViewExecutor.from_registries( + registries.metrics, + registries.projectors, + _strict_loader, + ) + + cand = Artifact( + id="d1:tess:raw_text", + document_id="d1", + type=ArtifactType.RAW_TEXT, + uri=str(cand_path), + ) + gt = Artifact( + id="d1:gt:raw_text", + document_id="d1", + type=ArtifactType.RAW_TEXT, + uri=str(gt_path), + ) + view = build_text_view() + result = executor.evaluate(view, cand, gt, pipeline_name="test") + + # Pas de projection → loader appelé sur le candidat directement. + assert result.projection_report is None + assert result.metric_values["cer"] == 0.0 + + +class TestPayloadFromProjectorIsAuthoritative: + """Garantit que le payload retourné par le projecteur est utilisé + tel quel (l'executor ne re-réécrit pas, ne re-charge pas).""" + + def test_alto_projector_payload_drives_metric( + self, tmp_path: Path, + ) -> None: + """Quand le projecteur retourne 'X', le métrique compute sur 'X' + (pas sur autre chose).""" + gt_text = "exact" + alto_path = tmp_path / "alto.xml" + alto_path.write_bytes(write_alto(_build_alto("exact"))) + + gt_path = tmp_path / "gt.txt" + gt_path.write_text(gt_text, encoding="utf-8") + + # Métrique custom qui retourne 1.0 si reference == hypothesis, + # 0.0 sinon — preuve que la valeur passée à la métrique est + # bien le payload du projecteur. + from picarones.evaluation.projectors import ProjectorRegistry, AltoToText + + captured: dict[str, str] = {} + + def capturing_metric(reference: str, hypothesis: str) -> float: + captured["reference"] = reference + captured["hypothesis"] = hypothesis + return 1.0 if reference == hypothesis else 0.0 + + metrics = MetricRegistry() + metrics.register( + MetricSpec( + name="capture", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + capturing_metric, + ) + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + + from picarones.domain.evaluation_spec import EvaluationView + from picarones.domain.projection_spec import ProjectionSpec + + # On ne peut pas utiliser build_text_view car ses metric_names + # incluent cer/wer/mer/wil non enregistrés ici — on construit + # une vue minimale qui projette ALTO → texte. + view = EvaluationView( + name="test_capture", + description="capture le payload projeté", + candidate_types=frozenset({ArtifactType.ALTO_XML}), + projections_by_source_type={ + ArtifactType.ALTO_XML: ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ), + }, + metric_names=("capture",), + ) + + executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, _strict_loader, + ) + cand = Artifact( + id="d:alto", + document_id="d", + type=ArtifactType.ALTO_XML, + uri=str(alto_path), + ) + gt = Artifact( + id="d:gt", + document_id="d", + type=ArtifactType.RAW_TEXT, + uri=str(gt_path), + ) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert captured["reference"] == "exact" + assert captured["hypothesis"] == "exact" + assert result.metric_values["capture"] == 1.0 diff --git a/tests/evaluation/test_sprint_a14_s27_engines.py b/tests/evaluation/test_sprint_a14_s27_engines.py new file mode 100644 index 0000000000000000000000000000000000000000..edc4dc29bde6a4bd206c51111c3b6113bffe928a --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s27_engines.py @@ -0,0 +1,352 @@ +"""Sprint A14-S27 — ``ProjectionEngine`` + ``EvaluationEngine`` séparés. + +Tests des deux moteurs introduits par S27 pour découper le S13. +Couvre : + +1. ``ProjectionEngine.project`` : + - cas identité (spec None) → artefact tel quel, payload None, + report None ; + - spec identité (source == target) → idem ; + - projection nominale → triplet complet (artefact target, payload, + report) ; + - projecteur introuvable → ProjectionError ; + - projecteur qui lève → wrappé en ProjectionError ; + - validation du constructeur (rejette non-registry). + +2. ``EvaluationEngine.evaluate`` : + - calcule chaque métrique, dispatch erreur dans failed_metrics ; + - métrique inconnue → message explicite ; + - métrique qui lève → message ``{type}: {msg}`` ; + - ordre des résultats préservé ; + - validation du constructeur ; + - sucre ``evaluate_one`` ; + - dataclass ``EvaluationResult`` (n_succeeded, n_failed, + all_succeeded, with_global_failure). + +3. Intégration : l'executor refondu (S27) délègue aux deux engines — + les comportements existants du S13 sont préservés (couverture + indirecte par ``test_sprint_a14_s13_view_executor.py``). +""" + +from __future__ import annotations + +import pytest + +from picarones.domain.artifacts import Artifact, ArtifactType +from picarones.domain.errors import ProjectionError +from picarones.domain.projection_spec import ProjectionSpec +from picarones.evaluation.evaluation_engine import ( + EvaluationEngine, + EvaluationResult, +) +from picarones.evaluation.projection_engine import ( + ProjectionEngine, + ProjectionResult, +) +from picarones.evaluation.projectors.base import ProjectionReport +from picarones.evaluation.projectors.registry import ( + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.domain.evaluation_spec import MetricSpec + + +# ────────────────────────────────────────────────────────────────────── +# Stubs réutilisables +# ────────────────────────────────────────────────────────────────────── + + +class _StubProjector: + name = "stub" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def __init__(self, payload: str = "projected") -> None: + self._payload = payload + + def project(self, artifact, params): + target = Artifact( + id=f"{artifact.id}:projected", + document_id=artifact.document_id, + type=self.target_type, + ) + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + lossy=True, + ignored_dimensions=("geometry",), + warnings=("dim perdue",), + ) + return target, self._payload, report + + +class _CrashingProjector: + name = "crash" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def project(self, artifact, params): + raise RuntimeError("boom interne") + + +# ────────────────────────────────────────────────────────────────────── +# ProjectionEngine +# ────────────────────────────────────────────────────────────────────── + + +class TestProjectionEngineConstructor: + def test_rejects_non_registry(self) -> None: + with pytest.raises(TypeError, match="projector_registry"): + ProjectionEngine("nope") # type: ignore[arg-type] + + def test_accepts_empty_registry(self) -> None: + engine = ProjectionEngine(ProjectorRegistry()) + assert engine.projectors is not None + + +class TestProjectionEngineIdentity: + def test_none_spec_returns_unchanged(self) -> None: + engine = ProjectionEngine(ProjectorRegistry()) + artifact = Artifact(id="a", document_id="d", type=ArtifactType.RAW_TEXT) + result = engine.project(artifact, None) + assert result.artifact is artifact + assert result.payload is None + assert result.report is None + assert result.has_projection is False + + def test_identity_spec_returns_unchanged(self) -> None: + engine = ProjectionEngine(ProjectorRegistry()) + artifact = Artifact(id="a", document_id="d", type=ArtifactType.RAW_TEXT) + spec = ProjectionSpec( + source_type=ArtifactType.RAW_TEXT, + target_type=ArtifactType.RAW_TEXT, + projector_name="ignored_when_identity", + ) + result = engine.project(artifact, spec) + assert result.artifact is artifact + assert result.payload is None + assert result.report is None + + +class TestProjectionEngineNominal: + def test_nominal_returns_triple(self) -> None: + registry = ProjectorRegistry() + registry.register(_StubProjector(payload="hello")) + engine = ProjectionEngine(registry) + artifact = Artifact( + id="alto", + document_id="d", + type=ArtifactType.ALTO_XML, + ) + spec = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="stub", + ) + result = engine.project(artifact, spec) + assert result.artifact.type == ArtifactType.RAW_TEXT + assert result.artifact.id == "alto:projected" + assert result.payload == "hello" + assert result.report is not None + assert result.report.projector_name == "stub" + assert result.has_projection is True + + +class TestProjectionEngineErrors: + def test_unknown_projector_raises_projection_error(self) -> None: + engine = ProjectionEngine(ProjectorRegistry()) + artifact = Artifact(id="a", document_id="d", type=ArtifactType.ALTO_XML) + spec = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="missing", + ) + with pytest.raises(ProjectionError, match="introuvable"): + engine.project(artifact, spec) + + def test_crashing_projector_wraps_in_projection_error(self) -> None: + registry = ProjectorRegistry() + registry.register(_CrashingProjector()) + engine = ProjectionEngine(registry) + artifact = Artifact(id="a", document_id="d", type=ArtifactType.ALTO_XML) + spec = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="crash", + ) + with pytest.raises(ProjectionError, match="boom interne"): + engine.project(artifact, spec) + + def test_native_projection_error_propagated_unwrapped(self) -> None: + """Si le projecteur lève déjà un ``ProjectionError``, on ne le + wrappe pas dans un nouveau (préservation de la sémantique).""" + class _NativeProjErrProjector: + name = "native_err" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def project(self, artifact, params): + raise ProjectionError("erreur native") + + registry = ProjectorRegistry() + registry.register(_NativeProjErrProjector()) + engine = ProjectionEngine(registry) + artifact = Artifact(id="a", document_id="d", type=ArtifactType.ALTO_XML) + spec = ProjectionSpec( + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="native_err", + ) + with pytest.raises(ProjectionError, match="erreur native"): + engine.project(artifact, spec) + + +# ────────────────────────────────────────────────────────────────────── +# EvaluationEngine +# ────────────────────────────────────────────────────────────────────── + + +def _build_metric_registry(extra: dict = None) -> MetricRegistry: + reg = MetricRegistry() + reg.register( + MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + lambda r, h: 0.0 if r == h else 1.0, + ) + reg.register( + MetricSpec( + name="wer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + lambda r, h: 0.0 if r == h else 0.5, + ) + if extra: + for name, fn in extra.items(): + reg.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + return reg + + +class TestEvaluationEngineConstructor: + def test_rejects_non_registry(self) -> None: + with pytest.raises(TypeError, match="metric_registry"): + EvaluationEngine("nope") # type: ignore[arg-type] + + def test_accepts_empty_registry(self) -> None: + engine = EvaluationEngine(MetricRegistry()) + assert engine.metrics is not None + + +class TestEvaluationEngineNominal: + def test_all_metrics_succeed(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate(("cer", "wer"), "x", "x") + assert result.metric_values == {"cer": 0.0, "wer": 0.0} + assert result.failed_metrics == {} + assert result.n_succeeded == 2 + assert result.n_failed == 0 + assert result.all_succeeded is True + + def test_metric_returning_nonzero(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate(("cer", "wer"), "abc", "xyz") + assert result.metric_values["cer"] == 1.0 + assert result.metric_values["wer"] == 0.5 + + def test_evaluate_one_sugar(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate_one("cer", "x", "x") + assert result.metric_values == {"cer": 0.0} + assert result.failed_metrics == {} + + def test_order_preserved(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate(("wer", "cer"), "x", "x") + # dict préserve l'ordre d'insertion (Python 3.7+). + assert list(result.metric_values.keys()) == ["wer", "cer"] + + +class TestEvaluationEngineFailures: + def test_unknown_metric_goes_to_failed(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate(("cer", "missing"), "x", "x") + assert "cer" in result.metric_values + assert "missing" in result.failed_metrics + assert "non enregistrée" in result.failed_metrics["missing"] + + def test_metric_that_raises_goes_to_failed(self) -> None: + def _broken(r, h): + raise ValueError("metric crashed") + + engine = EvaluationEngine(_build_metric_registry({"broken": _broken})) + result = engine.evaluate(("cer", "broken", "wer"), "x", "x") + assert "cer" in result.metric_values + assert "wer" in result.metric_values + assert "broken" in result.failed_metrics + assert "ValueError" in result.failed_metrics["broken"] + assert "metric crashed" in result.failed_metrics["broken"] + assert result.n_succeeded == 2 + assert result.n_failed == 1 + assert result.all_succeeded is False + + def test_empty_metric_list_returns_empty_result(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate((), "x", "x") + assert result.metric_values == {} + assert result.failed_metrics == {} + assert result.all_succeeded is True + + +class TestEvaluationResultDataclass: + def test_with_global_failure_marks_all(self) -> None: + engine = EvaluationEngine(_build_metric_registry()) + result = engine.evaluate(("cer", "wer"), "x", "x") + failed_all = result.with_global_failure("loader crashed") + assert failed_all.metric_values == {} + assert failed_all.failed_metrics == { + "cer": "loader crashed", + "wer": "loader crashed", + } + + def test_dataclass_is_frozen(self) -> None: + result = EvaluationResult(metric_values={"cer": 0.0}) + with pytest.raises(Exception): # FrozenInstanceError + result.metric_values = {} # type: ignore[misc] + + +# ────────────────────────────────────────────────────────────────────── +# ProjectionResult dataclass +# ────────────────────────────────────────────────────────────────────── + + +class TestProjectionResultDataclass: + def test_has_projection_property(self) -> None: + artifact = Artifact(id="a", document_id="d", type=ArtifactType.RAW_TEXT) + no_proj = ProjectionResult(artifact=artifact, payload=None, report=None) + assert no_proj.has_projection is False + + report = ProjectionReport( + source_artifact_id="a", + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="x", + ) + with_proj = ProjectionResult( + artifact=artifact, payload="text", report=report, + ) + assert with_proj.has_projection is True + + def test_dataclass_is_frozen(self) -> None: + artifact = Artifact(id="a", document_id="d", type=ArtifactType.RAW_TEXT) + result = ProjectionResult(artifact=artifact, payload=None, report=None) + with pytest.raises(Exception): # FrozenInstanceError + result.payload = "modified" # type: ignore[misc] diff --git a/tests/evaluation/test_sprint_a14_s5_protocols.py b/tests/evaluation/test_sprint_a14_s5_protocols.py new file mode 100644 index 0000000000000000000000000000000000000000..80d3cc5d4cac5cff83976ac9ef9f5a5bd4752e24 --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s5_protocols.py @@ -0,0 +1,267 @@ +"""Sprint A14-S5 — protocoles ``Projector`` et ``EvaluationViewExecutor``. + +Vérifie qu'on peut implémenter une classe satisfaisant chaque +protocole sans erreur de typage runtime, et que ``ViewResult`` / +``ProjectionReport`` sont sérialisables JSON. + +Pas de test sur l'exécuteur réel — c'est S13. Ici on valide +seulement les contrats. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import Artifact, ArtifactType, EvaluationView +from picarones.evaluation.projectors import ProjectionReport, Projector +from picarones.evaluation.views import EvaluationViewExecutor, ViewResult + + +# ────────────────────────────────────────────────────────────────────── +# ProjectionReport +# ────────────────────────────────────────────────────────────────────── + + +class TestProjectionReport: + def test_minimal_report(self) -> None: + r = ProjectionReport( + source_artifact_id="a:b:c", + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ) + assert r.lossy is True # défaut + assert r.ignored_dimensions == () + + def test_with_ignored_dimensions(self) -> None: + r = ProjectionReport( + source_artifact_id="x", + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + lossy=True, + ignored_dimensions=("geometry", "block_structure"), + warnings=("ordre de lecture deviné",), + ) + assert "geometry" in r.ignored_dimensions + + def test_identity_projection_not_lossy(self) -> None: + r = ProjectionReport( + source_artifact_id="x", + source_type=ArtifactType.RAW_TEXT, + target_type=ArtifactType.RAW_TEXT, + projector_name="identity", + lossy=False, + ) + assert r.lossy is False + + def test_frozen(self) -> None: + r = ProjectionReport( + source_artifact_id="x", + source_type=ArtifactType.RAW_TEXT, + target_type=ArtifactType.RAW_TEXT, + projector_name="identity", + ) + with pytest.raises(Exception): + r.lossy = False # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + r = ProjectionReport( + source_artifact_id="x", + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ignored_dimensions=("geometry",), + warnings=("w",), + ) + r2 = ProjectionReport.model_validate_json(r.model_dump_json()) + assert r == r2 + + +# ────────────────────────────────────────────────────────────────────── +# Projector — protocole satisfait par une classe minimale +# ────────────────────────────────────────────────────────────────────── + + +class _StubProjector: + """Minimum pour satisfaire ``Projector``.""" + + name = "stub_alto_to_text" + source_type = ArtifactType.ALTO_XML + target_type = ArtifactType.RAW_TEXT + + def project( + self, + artifact: Artifact, + params: dict[str, str | int | float | bool], + ) -> tuple[Artifact, str, ProjectionReport]: + target = Artifact( + id=artifact.id + ":projected", + document_id=artifact.document_id, + type=self.target_type, + ) + report = ProjectionReport( + source_artifact_id=artifact.id, + source_type=self.source_type, + target_type=self.target_type, + projector_name=self.name, + ) + # Sprint S25 — le projecteur retourne aussi le payload calculé. + return target, "stub_projected_text", report + + +class TestProjectorProtocol: + def test_stub_satisfies_protocol(self) -> None: + p = _StubProjector() + assert isinstance(p, Projector) + + def test_stub_can_project(self) -> None: + src = Artifact( + id="d1:ocr:alto", + document_id="d1", + type=ArtifactType.ALTO_XML, + ) + tgt, payload, report = _StubProjector().project(src, {}) + assert tgt.type == ArtifactType.RAW_TEXT + assert payload == "stub_projected_text" + assert report.source_artifact_id == "d1:ocr:alto" + + def test_non_conforming_object_does_not_satisfy(self) -> None: + class _NotAProjector: + pass + assert not isinstance(_NotAProjector(), Projector) + + +# ────────────────────────────────────────────────────────────────────── +# ViewResult +# ────────────────────────────────────────────────────────────────────── + + +class TestViewResult: + def test_minimal_result(self) -> None: + r = ViewResult( + view_name="text_final", + pipeline_name="ocr", + candidate_artifact_id="d1:ocr:raw_text", + ground_truth_artifact_id="d1:gt:raw_text", + ) + assert r.metric_values == {} + assert r.failed_metrics == {} + assert r.projection_report is None + + def test_with_metrics_and_failures(self) -> None: + r = ViewResult( + view_name="text_final", + pipeline_name="ocr", + candidate_artifact_id="x", + ground_truth_artifact_id="y", + metric_values={"cer": 0.05, "wer": 0.12}, + failed_metrics={"mufi_coverage": "GT vide, métrique inapplicable"}, + warnings=("normalisation diplomatique appliquée",), + ) + assert r.metric_values["cer"] == 0.05 + assert "mufi_coverage" in r.failed_metrics + + def test_with_projection_report(self) -> None: + report = ProjectionReport( + source_artifact_id="src", + source_type=ArtifactType.ALTO_XML, + target_type=ArtifactType.RAW_TEXT, + projector_name="alto_to_text", + ) + r = ViewResult( + view_name="text_final", + pipeline_name="ocr", + candidate_artifact_id="src", + ground_truth_artifact_id="gt", + projection_report=report, + ignored_dimensions=("geometry",), + ) + assert r.projection_report is not None + assert r.projection_report.projector_name == "alto_to_text" + + def test_frozen(self) -> None: + r = ViewResult( + view_name="x", + pipeline_name="ocr", + candidate_artifact_id="a", + ground_truth_artifact_id="b", + ) + with pytest.raises(Exception): + r.view_name = "y" # type: ignore[misc] + + def test_json_roundtrip(self) -> None: + r = ViewResult( + view_name="text_final", + pipeline_name="ocr", + candidate_artifact_id="x", + ground_truth_artifact_id="y", + metric_values={"cer": 0.05}, + failed_metrics={"wer": "boom"}, + warnings=("w",), + ignored_dimensions=("geometry",), + ) + r2 = ViewResult.model_validate_json(r.model_dump_json()) + assert r == r2 + + def test_pipeline_name_required(self) -> None: + """``pipeline_name`` est un champ structurel, pas optionnel. + + Garde-fou : ce champ doit rester explicitement passé par le + ``EvaluationViewExecutor`` au lieu d'être inféré par les + renderers via parsing de string. + """ + with pytest.raises(Exception): + ViewResult( + view_name="text_final", + # pipeline_name=... manquant + candidate_artifact_id="x", + ground_truth_artifact_id="y", + ) + + +# ────────────────────────────────────────────────────────────────────── +# EvaluationViewExecutor — protocole satisfait par un stub minimal +# ────────────────────────────────────────────────────────────────────── + + +class _StubExecutor: + """Implémentation triviale de ``EvaluationViewExecutor``. + + Ne fait aucun calcul réel — sert à vérifier qu'on peut écrire + une classe satisfaisant le protocole. Le vrai exécuteur arrive + au S13. + """ + + def evaluate( + self, + view: EvaluationView, + candidate: Artifact, + ground_truth: Artifact, + *, + pipeline_name: str, + ) -> ViewResult: + return ViewResult( + view_name=view.name, + pipeline_name=pipeline_name, + candidate_artifact_id=candidate.id, + ground_truth_artifact_id=ground_truth.id, + ) + + +class TestEvaluationViewExecutorProtocol: + def test_stub_satisfies_protocol(self) -> None: + ex = _StubExecutor() + assert isinstance(ex, EvaluationViewExecutor) + + def test_stub_evaluate_returns_view_result(self) -> None: + view = EvaluationView( + name="text_final", + candidate_types=frozenset({ArtifactType.RAW_TEXT}), + ) + cand = Artifact(id="c", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="g", document_id="d", type=ArtifactType.RAW_TEXT) + result = _StubExecutor().evaluate(view, cand, gt, pipeline_name="ocr") + assert result.view_name == "text_final" + assert result.pipeline_name == "ocr" + assert result.candidate_artifact_id == "c" diff --git a/tests/evaluation/test_sprint_a14_s5_registry.py b/tests/evaluation/test_sprint_a14_s5_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..b2da7019fa73d6dadf094a3b7300c0aed3059e41 --- /dev/null +++ b/tests/evaluation/test_sprint_a14_s5_registry.py @@ -0,0 +1,247 @@ +"""Sprint A14-S5 — ``MetricRegistry`` instancié explicitement. + +Vérifie le contrat critique du S5 : pas de singleton global, pas +de side-effect d'import, association explicite ``MetricSpec ↔ +Callable``, sélection par signature de types. + +Anti-pattern testé négativement : ``import picarones.evaluation`` +ne doit PAS auto-enregistrer de métrique. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ArtifactType, MetricSpec +from picarones.evaluation.registry import ( + MetricNotFoundError, + MetricRegistrationError, + MetricRegistry, +) + + +def _cer(reference: str, hypothesis: str) -> float: + """Stub CER pour les tests.""" + return 0.0 if reference == hypothesis else 1.0 + + +def _wer(reference: str, hypothesis: str) -> float: + return 0.0 if reference == hypothesis else 1.0 + + +def _ner_f1(ref_entities: list[dict], hyp_entities: list[dict]) -> float: + return 1.0 + + +# ────────────────────────────────────────────────────────────────────── +# Instanciation et état initial +# ────────────────────────────────────────────────────────────────────── + + +class TestEmptyRegistry: + def test_starts_empty(self) -> None: + reg = MetricRegistry() + assert len(reg) == 0 + assert reg.names() == [] + + def test_unknown_metric_raises(self) -> None: + reg = MetricRegistry() + with pytest.raises(MetricNotFoundError): + reg.get_spec("cer") + with pytest.raises(MetricNotFoundError): + reg.get_callable("cer") + + +# ────────────────────────────────────────────────────────────────────── +# Enregistrement +# ────────────────────────────────────────────────────────────────────── + + +class TestRegistration: + def test_register_one_metric(self) -> None: + reg = MetricRegistry() + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + reg.register(spec, _cer) + assert "cer" in reg + assert len(reg) == 1 + assert reg.get_spec("cer") is spec + assert reg.get_callable("cer") is _cer + + def test_register_non_callable_raises(self) -> None: + reg = MetricRegistry() + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + with pytest.raises(MetricRegistrationError, match="callable"): + reg.register(spec, "not_a_function") # type: ignore[arg-type] + + def test_duplicate_name_with_different_func_raises(self) -> None: + reg = MetricRegistry() + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + reg.register(spec, _cer) + with pytest.raises(MetricRegistrationError, match="déjà enregistrée"): + reg.register(spec, _wer) # même spec, autre callable + + def test_idempotent_re_registration(self) -> None: + """Re-enregistrer la même spec + même callable est silencieux + (utile pour les tests qui re-instancient le service).""" + reg = MetricRegistry() + spec = MetricSpec( + name="cer", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ) + reg.register(spec, _cer) + reg.register(spec, _cer) # ne lève pas + assert len(reg) == 1 + + +# ────────────────────────────────────────────────────────────────────── +# Sélection par signature de types +# ────────────────────────────────────────────────────────────────────── + + +class TestSelectByTypes: + def _filled_registry(self) -> MetricRegistry: + reg = MetricRegistry() + reg.register( + MetricSpec(name="cer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _cer, + ) + reg.register( + MetricSpec(name="wer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _wer, + ) + reg.register( + MetricSpec(name="ner_f1", input_types=( + ArtifactType.ENTITIES, ArtifactType.ENTITIES, + ), higher_is_better=True), + _ner_f1, + ) + return reg + + def test_select_text_text(self) -> None: + reg = self._filled_registry() + selected = reg.select(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT) + names = sorted(s.name for s in selected) + assert names == ["cer", "wer"] + + def test_select_entities(self) -> None: + reg = self._filled_registry() + selected = reg.select(ArtifactType.ENTITIES, ArtifactType.ENTITIES) + assert [s.name for s in selected] == ["ner_f1"] + + def test_select_no_match(self) -> None: + reg = self._filled_registry() + selected = reg.select(ArtifactType.IMAGE, ArtifactType.IMAGE) + assert selected == [] + + def test_select_distinguishes_text_subtypes(self) -> None: + """Important : RAW_TEXT et CORRECTED_TEXT sont des types distincts. + Une métrique enregistrée pour (RAW_TEXT, RAW_TEXT) ne s'applique + pas automatiquement à (CORRECTED_TEXT, RAW_TEXT).""" + reg = self._filled_registry() + selected = reg.select(ArtifactType.CORRECTED_TEXT, ArtifactType.RAW_TEXT) + assert selected == [] + + +# ────────────────────────────────────────────────────────────────────── +# Calcul +# ────────────────────────────────────────────────────────────────────── + + +class TestCompute: + def test_compute_named(self) -> None: + reg = MetricRegistry() + reg.register( + MetricSpec(name="cer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _cer, + ) + assert reg.compute("cer", "hello", "hello") == 0.0 + assert reg.compute("cer", "hello", "world") == 1.0 + + def test_compute_unknown_raises(self) -> None: + reg = MetricRegistry() + with pytest.raises(MetricNotFoundError): + reg.compute("missing", "x", "y") + + def test_compute_at_junction_runs_all_applicable(self) -> None: + reg = MetricRegistry() + reg.register( + MetricSpec(name="cer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _cer, + ) + reg.register( + MetricSpec(name="wer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _wer, + ) + reg.register( + MetricSpec(name="ner_f1", input_types=( + ArtifactType.ENTITIES, ArtifactType.ENTITIES, + )), + _ner_f1, + ) + out = reg.compute_at_junction( + "hello", "hello", + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + ) + assert set(out.keys()) == {"cer", "wer"} + assert out["cer"] == 0.0 + assert "ner_f1" not in out # mauvaise signature + + def test_compute_at_junction_propagates_exceptions(self) -> None: + """Le S5 ne capture pas les exceptions des métriques. + C'est l'EvaluationViewExecutor (S13) qui décidera quoi en + faire dans son ProjectionReport.""" + def _broken(r: str, h: str) -> float: + raise RuntimeError("boom") + reg = MetricRegistry() + reg.register( + MetricSpec(name="broken", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )), + _broken, + ) + with pytest.raises(RuntimeError, match="boom"): + reg.compute_at_junction( + "x", "y", + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + ) + + +# ────────────────────────────────────────────────────────────────────── +# Anti-pattern : pas de singleton global +# ────────────────────────────────────────────────────────────────────── + + +class TestNoGlobalSingleton: + def test_two_registries_are_independent(self) -> None: + """Différence cruciale avec l'ancien + ``picarones.core.metric_registry`` qui a un dict global : + deux ``MetricRegistry()`` ne se partagent rien.""" + reg_a = MetricRegistry() + reg_b = MetricRegistry() + spec = MetricSpec(name="cer", input_types=( + ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT, + )) + reg_a.register(spec, _cer) + assert "cer" in reg_a + assert "cer" not in reg_b + assert len(reg_a) == 1 + assert len(reg_b) == 0 diff --git a/tests/evaluation/views/__init__.py b/tests/evaluation/views/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/evaluation/views/test_sprint_a14_s14_text_view.py b/tests/evaluation/views/test_sprint_a14_s14_text_view.py new file mode 100644 index 0000000000000000000000000000000000000000..fa1835314ef7c53897d6ee89f3910518dda88db5 --- /dev/null +++ b/tests/evaluation/views/test_sprint_a14_s14_text_view.py @@ -0,0 +1,370 @@ +"""Sprint A14-S14 — TextView (vue canonique 1). + +8 cas + le cas BnF central : 3 pipelines hétérogènes (Tesseract, +OCR+LLM+ALTO, VLM+CANONICAL_DOCUMENT) comparés en TextView avec +projection automatique vers texte plat. + +Tous les tests utilisent un ``payload_loader`` in-memory pour +contrôler exactement le payload de chaque artefact. En prod +(S19), le loader sera fourni par un service applicatif. +""" + +from __future__ import annotations + + +from picarones.domain import ( + Artifact, + ArtifactType, + MetricSpec, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, + canonical_payload_to_text, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DEFAULT_TEXT_METRICS, + DefaultEvaluationViewExecutor, + build_text_view, +) + + +# ────────────────────────────────────────────────────────────────────── +# Métriques stub pour les tests (CER/WER simplifiés sans jiwer) +# ────────────────────────────────────────────────────────────────────── + + +def _stub_cer(reference: str, hypothesis: str) -> float: + """CER simplifié : ratio de caractères différents.""" + if not reference and not hypothesis: + return 0.0 + if not reference: + return 1.0 + common = sum(1 for a, b in zip(reference, hypothesis) if a == b) + max_len = max(len(reference), len(hypothesis)) + return 1.0 - (common / max_len) if max_len else 0.0 + + +def _stub_wer(reference: str, hypothesis: str) -> float: + """WER simplifié : ratio de mots différents.""" + ref_words = reference.split() + hyp_words = hypothesis.split() + if not ref_words and not hyp_words: + return 0.0 + if not ref_words: + return 1.0 + common = sum( + 1 for a, b in zip(ref_words, hyp_words) if a == b + ) + return 1.0 - (common / len(ref_words)) + + +def _stub_mer(reference: str, hypothesis: str) -> float: + return _stub_cer(reference, hypothesis) + + +def _stub_wil(reference: str, hypothesis: str) -> float: + return _stub_wer(reference, hypothesis) + + +# ────────────────────────────────────────────────────────────────────── +# Helpers de fabrication d'executor +# ────────────────────────────────────────────────────────────────────── + + +def _build_executor(payloads: dict[str, object]) -> DefaultEvaluationViewExecutor: + metrics = MetricRegistry() + for name, fn in ( + ("cer", _stub_cer), + ("wer", _stub_wer), + ("mer", _stub_mer), + ("wil", _stub_wil), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + def loader(artifact: Artifact): + if artifact.id not in payloads: + raise KeyError(f"payload manquant : {artifact.id}") + return payloads[artifact.id] + + return DefaultEvaluationViewExecutor.from_registries(metrics, projectors, loader) + + +# ────────────────────────────────────────────────────────────────────── +# 8 cas TextView +# ────────────────────────────────────────────────────────────────────── + + +class TestTextViewShape: + def test_default_view_accepts_5_types(self) -> None: + """Cas 1 — la vue par défaut accepte les 5 types.""" + view = build_text_view() + for t in ( + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + ArtifactType.PAGE_XML, + ArtifactType.CANONICAL_DOCUMENT, + ): + assert view.accepts(t), f"TextView devrait accepter {t.value}" + + def test_default_view_rejects_image_and_entities(self) -> None: + """Cas 2 — la vue rejette IMAGE, ENTITIES, READING_ORDER.""" + view = build_text_view() + for t in ( + ArtifactType.IMAGE, + ArtifactType.ENTITIES, + ArtifactType.READING_ORDER, + ArtifactType.ALIGNMENT, + ): + assert not view.accepts(t) + + def test_default_metrics_are_cer_wer_mer_wil(self) -> None: + view = build_text_view() + assert view.metric_names == DEFAULT_TEXT_METRICS + + def test_projection_for_alto_routes_to_alto_to_text(self) -> None: + """Cas 3 — projection_for(ALTO_XML) → projecteur alto.""" + view = build_text_view() + spec = view.projection_for(ArtifactType.ALTO_XML) + assert spec is not None + assert spec.projector_name == "alto_to_text" + + def test_projection_for_raw_text_returns_none(self) -> None: + """Cas 4 — RAW_TEXT n'a pas de projection (déjà du texte).""" + view = build_text_view() + assert view.projection_for(ArtifactType.RAW_TEXT) is None + assert view.projection_for(ArtifactType.CORRECTED_TEXT) is None + + +class TestTextViewWithExecutor: + def test_raw_text_against_raw_text(self) -> None: + """Cas 5 — RAW_TEXT vs RAW_TEXT, sans projection.""" + payloads = { + "cand": "Bonjour le monde", + "gt": "Bonjour le monde", + } + executor = _build_executor(payloads) + view = build_text_view() + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.metric_values["cer"] == 0.0 + assert result.metric_values["wer"] == 0.0 + assert result.projection_report is None + + def test_canonical_document_routed_to_canonical_to_text( + self, tmp_path, + ) -> None: + """Cas 6 — CANONICAL_DOCUMENT → CanonicalToText, ProjectionReport présent. + + Sprint S25 — le projecteur lit le markdown source depuis l'URI + et calcule le texte projeté lui-même (plus de hack via + ``cand:projected_text`` dans le loader).""" + # Markdown source écrit sur disque ; le projecteur le lit et + # produit "Bonjour le monde". + md_path = tmp_path / "cand.md" + md_path.write_text("# Bonjour le monde\n", encoding="utf-8") + payloads = { + "gt": "Bonjour le monde", + } + executor = _build_executor(payloads) + view = build_text_view() + cand = Artifact( + id="cand", document_id="d", + type=ArtifactType.CANONICAL_DOCUMENT, + uri=str(md_path), + ) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.projection_report is not None + assert result.projection_report.projector_name == "canonical_to_text" + assert "structure" in result.projection_report.ignored_dimensions + + +class TestBnFCentralUseCase: + """Cas central BnF — 3 pipelines hétérogènes comparés en TextView. + + Définit explicitement la garantie produit du rewrite : on peut + comparer Tesseract texte brut, OCR+LLM+ALTO remappé, et un VLM + qui produit du markdown, sur la même paire (corpus, GT), via la + même TextView, et obtenir des chiffres comparables et des + rapports de projection lisibles. + """ + + def _setup(self, tmp_path): + from picarones.formats.alto import ( + AltoDocument, AltoLine, AltoPage, AltoString, AltoTextBlock, + write_alto, + ) + gt_text = "Le petit chat noir court dans le jardin verdoyant" + + # Pipeline 1 : Tesseract → texte brut, légère erreur + tesseract_text = "Le pelit chat noir court dans le jardin verdoyant" + + # Pipeline 2 : OCR + LLM + ALTO remap → ALTO_XML sérialisé + # sur disque. AltoToText l'extrait au runtime. + alto_doc = AltoDocument(pages=(AltoPage(blocks=( + AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w) + for w in + "Le petit chat noir court dans le jardin verdoyant".split() + )),),), + ),),),) + alto_path = tmp_path / "cand_2.alto.xml" + alto_path.write_bytes(write_alto(alto_doc)) + + # Pipeline 3 : VLM markdown sérialisé sur disque (texte brut, + # le projecteur Canonical fait juste l'extraction markdown). + vlm_md = ( + "# Description\n\n" + "Le petit chat noir court dans le jardin **verdoyant**.\n" + ) + canonical_path = tmp_path / "cand_3.md" + canonical_path.write_text(vlm_md, encoding="utf-8") + + # Loader pour les artefacts qui ont un URI : lit le fichier. + # Pour les autres (GT, raw_text, et les sorties des + # projecteurs : ``cand_X:projected_text``), on utilise un + # dict in-memory. + from picarones.evaluation.projectors import ( + alto_document_to_text, + ) + from picarones.formats.alto import parse_alto + + # Précalcule les textes que les projecteurs vont produire + # pour ce test (l'executor lit l'URI puis appelle le + # projecteur ; le payload_loader doit retourner ce que la + # métrique attend, donc le texte extrait). + alto_extracted = alto_document_to_text(parse_alto(alto_path.read_bytes())) + canonical_extracted = canonical_payload_to_text(vlm_md) + + payloads_in_memory = { + "gt_text": gt_text, + "cand_1": tesseract_text, + # Les artefacts projetés (id `:projected_text`) + # contiennent le texte extrait par le projecteur. + "cand_2:projected_text": alto_extracted, + "cand_3:projected_text": canonical_extracted, + } + + + def loader(artifact: Artifact): + if artifact.id in payloads_in_memory: + return payloads_in_memory[artifact.id] + raise KeyError(f"payload manquant : {artifact.id}") + + # Construit executor avec ce loader + metrics = MetricRegistry() + for name, fn in ( + ("cer", _stub_cer), ("wer", _stub_wer), + ("mer", _stub_mer), ("wil", _stub_wil), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, loader, + ) + view = build_text_view() + + gt = Artifact(id="gt_text", document_id="bnf_doc", + type=ArtifactType.RAW_TEXT) + cand_1 = Artifact(id="cand_1", document_id="bnf_doc", + type=ArtifactType.RAW_TEXT) + cand_2 = Artifact(id="cand_2", document_id="bnf_doc", + type=ArtifactType.ALTO_XML, + uri=str(alto_path)) + cand_3 = Artifact(id="cand_3", document_id="bnf_doc", + type=ArtifactType.CANONICAL_DOCUMENT, + uri=str(canonical_path)) + + return executor, view, gt, [cand_1, cand_2, cand_3] + + def test_three_heterogeneous_pipelines_evaluated_via_same_view(self, tmp_path) -> None: + """Cas 7 — les 3 pipelines passent dans le même + ``executor.evaluate(view, candidate, gt, pipeline_name="test")``.""" + executor, view, gt, candidates = self._setup(tmp_path) + results = [ + executor.evaluate(view, cand, gt, pipeline_name="test") for cand in candidates + ] + # Tous ont produit un ViewResult avec CER/WER calculés. + for r in results: + assert r.view_name == "text_final" + assert r.failed_metrics == {} + assert "cer" in r.metric_values + assert "wer" in r.metric_values + + def test_projection_reports_distinguish_pipeline_types(self, tmp_path) -> None: + """Cas 8 — chaque pipeline a un ProjectionReport distinct + (None pour Tesseract texte brut, présent pour ALTO et + CANONICAL_DOCUMENT).""" + executor, view, gt, candidates = self._setup(tmp_path) + results = [ + executor.evaluate(view, cand, gt, pipeline_name="test") for cand in candidates + ] + # Tesseract : pas de projection. + assert results[0].projection_report is None + # OCR+LLM+ALTO : projection ALTO → texte. + assert results[1].projection_report is not None + assert results[1].projection_report.projector_name == "alto_to_text" + # VLM canonical : projection CANONICAL → texte. + assert results[2].projection_report is not None + assert results[2].projection_report.projector_name == "canonical_to_text" + + def test_ignored_dimensions_propagated_in_view_result(self, tmp_path) -> None: + """Le ViewResult fusionne les ignored_dimensions de la vue + + ceux de la projection, sans duplication.""" + executor, view, gt, candidates = self._setup(tmp_path) + # Pipeline 1 (texte direct) : ignored_dimensions = celles de la vue. + r1 = executor.evaluate(view, candidates[0], gt, pipeline_name="test") + assert "geometry" in r1.ignored_dimensions # vient de la vue + # Pipeline 2 (ALTO) : ignored_dimensions = vue + projection ALTO. + r2 = executor.evaluate(view, candidates[1], gt, pipeline_name="test") + assert "geometry" in r2.ignored_dimensions + # AltoToText ajoute "ids" et "confidence" (déjà dans la vue, + # donc déduplication). + # Vérifions au moins qu'aucun dimension ne réapparaît 2 fois : + assert len(r2.ignored_dimensions) == len(set(r2.ignored_dimensions)) + + +class TestNormalizationApplied: + def test_normalization_profile_applied_to_both_payloads(self) -> None: + """Une TextView avec normalization_profile applique la + normalisation aux deux payloads avant calcul.""" + # ſ → s avec medieval_french : "afpre" (pas de ſ) vs "aſpre" + # → après normalisation, les deux deviennent "aspre" + payloads = { + "cand": "afpre", + "gt": "aſpre", + } + executor = _build_executor(payloads) + view = build_text_view(normalization_profile="medieval_french") + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + # Après normalisation : afpre → afpre (ſ pas dans payload), + # aſpre → aspre. Donc CER non nul mais cohérent. + assert "cer" in result.metric_values diff --git a/tests/evaluation/views/test_sprint_a14_s15_alto_view.py b/tests/evaluation/views/test_sprint_a14_s15_alto_view.py new file mode 100644 index 0000000000000000000000000000000000000000..b0a21fd0eba2daa68f07cb0e6fd6840bf8610f35 --- /dev/null +++ b/tests/evaluation/views/test_sprint_a14_s15_alto_view.py @@ -0,0 +1,332 @@ +"""Sprint A14-S15 — AltoView (vue canonique 2). + +6 cas couvrant la fidélité documentaire ALTO + le pattern +d'omission explicite des pipelines qui ne produisent pas d'ALTO. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import ( + Artifact, + ArtifactType, + MetricSpec, +) +from picarones.evaluation.metrics.alto_structural import ( + compute_alto_validity, + compute_line_count_ratio, + compute_word_box_coverage, +) +from picarones.evaluation.projectors import ProjectorRegistry +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DEFAULT_ALTO_METRICS, + DefaultEvaluationViewExecutor, + build_alto_view, + build_text_view, +) +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) + + +# ────────────────────────────────────────────────────────────────────── +# Fixtures ALTO +# ────────────────────────────────────────────────────────────────────── + + +def _line(*words: str, with_bbox: bool = True) -> AltoLine: + strings = tuple( + AltoString( + content=w, + bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10) if with_bbox else None, + ) + for w in words + ) + return AltoLine(strings=strings) + + +def _doc(*lines: AltoLine, n_blocks: int = 1) -> AltoDocument: + """Construit un AltoDocument avec ``n_blocks`` blocs partageant + les lignes.""" + if n_blocks == 1: + return AltoDocument(pages=(AltoPage( + blocks=(AltoTextBlock(lines=lines),), + ),),) + # Distribute lines across blocks (tous identiques pour simplifier) + chunks = [lines] * n_blocks + return AltoDocument(pages=(AltoPage( + blocks=tuple(AltoTextBlock(lines=c) for c in chunks), + ),),) + + +def _empty_doc() -> AltoDocument: + return AltoDocument() + + +# ────────────────────────────────────────────────────────────────────── +# Métriques individuelles +# ────────────────────────────────────────────────────────────────────── + + +class TestAltoMetrics: + def test_validity_full_doc(self) -> None: + d = _doc(_line("a", "b")) + assert compute_alto_validity(d, d) == 1.0 + + def test_validity_empty_doc(self) -> None: + assert compute_alto_validity(_doc(_line("a")), _empty_doc()) == 0.0 + + def test_line_count_ratio_equal(self) -> None: + d1 = _doc(_line("a"), _line("b"), _line("c")) + d2 = _doc(_line("x"), _line("y"), _line("z")) + assert compute_line_count_ratio(d1, d2) == 1.0 + + def test_line_count_ratio_partial(self) -> None: + d1 = _doc(_line("a"), _line("b"), _line("c"), _line("d")) # 4 + d2 = _doc(_line("x"), _line("y")) # 2 + assert compute_line_count_ratio(d1, d2) == 0.5 + + def test_line_count_ratio_both_empty(self) -> None: + assert compute_line_count_ratio(_empty_doc(), _empty_doc()) == 1.0 + + def test_word_box_coverage_full(self) -> None: + d = _doc(_line("a", "b", "c", with_bbox=True)) + assert compute_word_box_coverage(d, d) == 1.0 + + def test_word_box_coverage_partial(self) -> None: + # 2 mots avec bbox, 1 sans + line = AltoLine(strings=( + AltoString(content="a", bbox=AltoBBox(hpos=0, vpos=0, width=1, height=1)), + AltoString(content="b", bbox=AltoBBox(hpos=0, vpos=0, width=1, height=1)), + AltoString(content="c", bbox=None), + )) + d = AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(line,),),),),),) + assert abs(compute_word_box_coverage(d, d) - 2 / 3) < 1e-9 + + def test_word_box_coverage_no_bbox(self) -> None: + d = _doc(_line("a", "b", with_bbox=False)) + assert compute_word_box_coverage(d, d) == 0.0 + + +# ────────────────────────────────────────────────────────────────────── +# AltoView shape +# ────────────────────────────────────────────────────────────────────── + + +class TestAltoViewShape: + def test_default_view_accepts_only_alto_xml(self) -> None: + """Cas 1 — AltoView n'accepte que ALTO_XML.""" + view = build_alto_view() + assert view.accepts(ArtifactType.ALTO_XML) + assert not view.accepts(ArtifactType.RAW_TEXT) + assert not view.accepts(ArtifactType.PAGE_XML) + assert not view.accepts(ArtifactType.CANONICAL_DOCUMENT) + assert not view.accepts(ArtifactType.IMAGE) + + def test_default_metrics(self) -> None: + view = build_alto_view() + assert view.metric_names == DEFAULT_ALTO_METRICS + assert "alto_validity" in view.metric_names + assert "alto_line_count_ratio" in view.metric_names + assert "alto_word_box_coverage" in view.metric_names + + def test_no_projection(self) -> None: + view = build_alto_view() + assert view.projection is None + # Pas de projection même par type source. + assert view.projection_for(ArtifactType.ALTO_XML) is None + + def test_warnings_signal_omission_pattern(self) -> None: + view = build_alto_view() + warnings_text = " ".join(view.warnings) + assert "OMIS" in warnings_text or "omis" in warnings_text + + +# ────────────────────────────────────────────────────────────────────── +# AltoView avec executor +# ────────────────────────────────────────────────────────────────────── + + +def _build_alto_executor(payloads: dict[str, AltoDocument]) -> DefaultEvaluationViewExecutor: + metrics = MetricRegistry() + metrics.register( + MetricSpec( + name="alto_validity", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + compute_alto_validity, + ) + metrics.register( + MetricSpec( + name="alto_line_count_ratio", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + compute_line_count_ratio, + ) + metrics.register( + MetricSpec( + name="alto_word_box_coverage", + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + compute_word_box_coverage, + ) + projectors = ProjectorRegistry() # AltoView n'a pas besoin de projecteur + + def loader(art: Artifact) -> AltoDocument: + if art.id not in payloads: + raise KeyError(f"missing payload {art.id}") + return payloads[art.id] + + return DefaultEvaluationViewExecutor.from_registries(metrics, projectors, loader) + + +class TestAltoViewWithExecutor: + def test_perfect_alto_yields_all_ones(self) -> None: + """Cas 2 — Hypothèse identique à la GT → toutes métriques = 1.0.""" + gt = _doc(_line("a", "b"), _line("c", "d")) + payloads = {"gt": gt, "cand": gt} + executor = _build_alto_executor(payloads) + view = build_alto_view() + gt_art = Artifact(id="gt", document_id="d", type=ArtifactType.ALTO_XML) + cand = Artifact(id="cand", document_id="d", type=ArtifactType.ALTO_XML) + result = executor.evaluate(view, cand, gt_art, pipeline_name="test") + assert result.metric_values["alto_validity"] == 1.0 + assert result.metric_values["alto_line_count_ratio"] == 1.0 + assert result.metric_values["alto_word_box_coverage"] == 1.0 + assert result.failed_metrics == {} + + def test_partial_quality_alto(self) -> None: + """Cas 3 — Hypothèse avec moins de lignes → ratio < 1, autres OK.""" + gt = _doc(_line("a"), _line("b"), _line("c"), _line("d")) # 4 lignes + cand = _doc(_line("x"), _line("y")) # 2 lignes + payloads = {"gt": gt, "cand": cand} + executor = _build_alto_executor(payloads) + view = build_alto_view() + gt_art = Artifact(id="gt", document_id="d", type=ArtifactType.ALTO_XML) + cand_art = Artifact(id="cand", document_id="d", type=ArtifactType.ALTO_XML) + result = executor.evaluate(view, cand_art, gt_art, pipeline_name="test") + assert result.metric_values["alto_validity"] == 1.0 # cohérent + assert result.metric_values["alto_line_count_ratio"] == 0.5 + assert result.metric_values["alto_word_box_coverage"] == 1.0 + + +# ────────────────────────────────────────────────────────────────────── +# Pattern d'omission : pipelines sans ALTO ne sont PAS dans AltoView +# ────────────────────────────────────────────────────────────────────── + + +class TestOmissionPattern: + """Le caller (service applicatif) doit OMETTRE les pipelines qui + ne produisent pas d'ALTO_XML, plutôt que de leur attribuer un + score factice à 0. + + Le test démontre le pattern recommandé. + """ + + def test_caller_filters_pipelines_by_view_acceptance(self) -> None: + """Cas 4 — Pattern : boucler sur (vue, candidats), filtrer + ceux dont le type n'est pas dans candidate_types.""" + view = build_alto_view() + + # Simulons 3 pipelines avec leurs sorties principales : + candidates = [ + ("tesseract_text", ArtifactType.RAW_TEXT), # PAS d'ALTO + ("ocr_llm_alto", ArtifactType.ALTO_XML), # ALTO ✓ + ("vlm_alto_reconstructed", ArtifactType.ALTO_XML), # ALTO ✓ + ] + + # Le caller filtre : + eligible = [ + (name, art_type) + for name, art_type in candidates + if view.accepts(art_type) + ] + + omitted = [ + (name, art_type) + for name, art_type in candidates + if not view.accepts(art_type) + ] + + assert len(eligible) == 2 + assert ("ocr_llm_alto", ArtifactType.ALTO_XML) in eligible + assert ("vlm_alto_reconstructed", ArtifactType.ALTO_XML) in eligible + + assert len(omitted) == 1 + assert omitted[0][0] == "tesseract_text" + + def test_executor_raises_value_error_if_caller_doesnt_filter(self) -> None: + """Cas 5 — Garde-fou : si le caller n'a pas filtré et passe + un RAW_TEXT à AltoView, ``executor.evaluate`` lève ``ValueError`` + explicite.""" + payloads = {"cand": "this is text", "gt": _doc(_line("a"))} + executor = _build_alto_executor(payloads) + view = build_alto_view() + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.ALTO_XML) + with pytest.raises(ValueError, match="n'accepte pas"): + executor.evaluate(view, cand, gt, pipeline_name="test") + + +# ────────────────────────────────────────────────────────────────────── +# Cas central BnF : TextView + AltoView complémentaires +# ────────────────────────────────────────────────────────────────────── + + +class TestBnFDualViewUsage: + """Démontre que le rapport BnF cible peut présenter TextView ET + AltoView pour les **mêmes** pipelines, mais avec des sets de + pipelines différents. + + Pipeline 1 : Tesseract texte brut → présent dans TextView, OMIS d'AltoView. + Pipeline 2 : OCR+LLM avec ALTO → présent dans les DEUX. + Pipeline 3 : VLM avec ALTO reconstruit → présent dans les DEUX. + + Le test ne fait PAS l'évaluation complète (la stub mémoire ne + porte que ce qui est utile). Il vérifie le **pattern** : pour + chaque vue, quels pipelines sont éligibles. + """ + + def test_two_views_select_different_pipeline_sets(self) -> None: + """Cas 6 — Définition de done S15 : + * Tesseract → omis d'AltoView, présent dans TextView + * OCR+LLM+ALTO → dans les deux + * VLM+ALTO → dans les deux + """ + text_view = build_text_view() + alto_view = build_alto_view() + + pipelines = [ + ("tesseract", ArtifactType.RAW_TEXT), + ("ocr_llm_alto", ArtifactType.ALTO_XML), + ("vlm_alto", ArtifactType.ALTO_XML), + ] + + text_eligible = { + n for n, t in pipelines if text_view.accepts(t) + } + alto_eligible = { + n for n, t in pipelines if alto_view.accepts(t) + } + + # TextView accepte les 3. + assert text_eligible == {"tesseract", "ocr_llm_alto", "vlm_alto"} + + # AltoView omet Tesseract, garde les 2 ALTO. + assert alto_eligible == {"ocr_llm_alto", "vlm_alto"} + assert "tesseract" not in alto_eligible + + # Les pipelines présents dans AltoView sont un SOUS-ENSEMBLE de + # ceux présents dans TextView (cohérence : si un pipeline + # produit de l'ALTO, son texte est aussi extractible). + assert alto_eligible.issubset(text_eligible) diff --git a/tests/evaluation/views/test_sprint_a14_s16_search_view.py b/tests/evaluation/views/test_sprint_a14_s16_search_view.py new file mode 100644 index 0000000000000000000000000000000000000000..d98e1f598c4e3255806d9b0c096612dfc58f4da2 --- /dev/null +++ b/tests/evaluation/views/test_sprint_a14_s16_search_view.py @@ -0,0 +1,222 @@ +"""Sprint A14-S16 — SearchView + métriques de recherchabilité.""" + +from __future__ import annotations + +import pytest + +from picarones.domain import Artifact, ArtifactType, MetricSpec +from picarones.evaluation.metrics.search import ( + levenshtein_distance, + numerical_sequence_preservation, + searchability_recall, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DEFAULT_SEARCH_METRICS, + DefaultEvaluationViewExecutor, + build_search_view, +) + + +# ────────────────────────────────────────────────────────────────── +# Métriques individuelles +# ────────────────────────────────────────────────────────────────── + + +class TestLevenshtein: + def test_identical(self) -> None: + assert levenshtein_distance("hello", "hello") == 0 + + def test_empty(self) -> None: + assert levenshtein_distance("", "") == 0 + assert levenshtein_distance("abc", "") == 3 + assert levenshtein_distance("", "abc") == 3 + + def test_single_substitution(self) -> None: + assert levenshtein_distance("hello", "hallo") == 1 + + def test_kitten_sitting(self) -> None: + # Cas canonique : kitten → sitting (k→s, e→i, +g) = 3 ops + assert levenshtein_distance("kitten", "sitting") == 3 + + +class TestSearchabilityRecall: + def test_perfect_match(self) -> None: + recall = searchability_recall("hello world", "hello world") + assert recall == 1.0 + + def test_fuzzy_match_within_threshold(self) -> None: + # "monde" vs "monds" → 1 substitution, ≤ 2 → match + recall = searchability_recall("le monde", "le monds") + assert recall == 1.0 + + def test_fuzzy_match_beyond_threshold(self) -> None: + # "monde" vs "rabbit" → distance > 2 → pas de match + recall = searchability_recall("le monde", "le rabbit") + # "le" matche, "monde" non → 1/2 = 0.5 + assert recall == 0.5 + + def test_empty_gt_returns_zero(self) -> None: + assert searchability_recall("", "hello") == 0.0 + + def test_multiplicity_respected(self) -> None: + # GT a "le" deux fois, hyp une seule fois → 1/2 + recall = searchability_recall("le le monde", "le monde") + assert abs(recall - 2 / 3) < 1e-9 # "le", "monde" matchent (1 "le" non) + + def test_case_insensitive_by_default(self) -> None: + assert searchability_recall("Bonjour", "bonjour") == 1.0 + + def test_negative_max_distance_raises(self) -> None: + with pytest.raises(ValueError, match="max_distance"): + searchability_recall("a", "b", max_distance=-1) + + +class TestNumericalSequencePreservation: + def test_perfect_year_preservation(self) -> None: + score = numerical_sequence_preservation( + "fait à Paris en 1789", + "fait à Paris en 1789", + ) + assert score == 1.0 + + def test_year_corrupted(self) -> None: + # GT contient "1789", hyp contient "1798" (pas dans hyp_years) + # Mais "1798" est aussi une année 4 chiffres valide qui matche + # le regex. Vérifions la sémantique : on cherche les années + # GT dans les années hyp. + score = numerical_sequence_preservation( + "année 1789", + "année 1798", + ) + # 1789 (GT) n'est PAS dans hyp_years = [1798] → 0/1 = 0.0 + assert score == 0.0 + + def test_partial_preservation(self) -> None: + score = numerical_sequence_preservation( + "1789, 1799, 1815", + "1789 et 1815", # 1799 perdu + ) + # 2/3 préservés + assert abs(score - 2 / 3) < 1e-9 + + def test_no_years_in_gt(self) -> None: + score = numerical_sequence_preservation( + "pas de date ici", + "pas de date là", + ) + assert score == 0.0 # convention : pas d'années GT → 0.0 + + def test_year_regex_bounds(self) -> None: + # Année 999 → trop court (3 chiffres) + # Année 1000 → OK + # Année 2099 → hors plage (regex 2[0-2][0-9]) + score = numerical_sequence_preservation("an 999 et 1000", "an 999 et 1000") + # Seul "1000" est détecté en GT → comparé à hyp où "1000" présent aussi + assert score == 1.0 + + +# ────────────────────────────────────────────────────────────────── +# SearchView shape +# ────────────────────────────────────────────────────────────────── + + +class TestSearchViewShape: + def test_default_view_accepts_5_types(self) -> None: + view = build_search_view() + for t in ( + ArtifactType.RAW_TEXT, + ArtifactType.CORRECTED_TEXT, + ArtifactType.ALTO_XML, + ArtifactType.PAGE_XML, + ArtifactType.CANONICAL_DOCUMENT, + ): + assert view.accepts(t) + + def test_default_metrics(self) -> None: + view = build_search_view() + assert view.metric_names == DEFAULT_SEARCH_METRICS + + def test_projection_for_alto_routes_correctly(self) -> None: + view = build_search_view() + spec = view.projection_for(ArtifactType.ALTO_XML) + assert spec is not None + assert spec.projector_name == "alto_to_text" + + def test_warnings_signal_higher_is_better_inversion(self) -> None: + view = build_search_view() + text = " ".join(view.warnings) + assert "higher_is_better" in text or "OPPOSÉ" in text + + +# ────────────────────────────────────────────────────────────────── +# SearchView avec executor +# ────────────────────────────────────────────────────────────────── + + +def _build_search_executor(payloads: dict[str, str]) -> DefaultEvaluationViewExecutor: + metrics = MetricRegistry() + metrics.register( + MetricSpec( + name="searchability_recall", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + searchability_recall, + ) + metrics.register( + MetricSpec( + name="numerical_sequence_preservation", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + numerical_sequence_preservation, + ) + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + def loader(art: Artifact) -> str: + if art.id not in payloads: + raise KeyError(art.id) + return payloads[art.id] + + return DefaultEvaluationViewExecutor.from_registries(metrics, projectors, loader) + + +class TestSearchViewWithExecutor: + def test_perfect_text_yields_recall_1(self) -> None: + payloads = { + "cand": "le petit chat noir 1789", + "gt": "le petit chat noir 1789", + } + executor = _build_search_executor(payloads) + view = build_search_view() + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + assert result.metric_values["searchability_recall"] == 1.0 + assert result.metric_values["numerical_sequence_preservation"] == 1.0 + + def test_partial_text_quality_with_year_loss(self) -> None: + payloads = { + "cand": "le pelit chat noir 1798", # erreur typo + année corrompue + "gt": "le petit chat noir 1789", + } + executor = _build_search_executor(payloads) + view = build_search_view() + cand = Artifact(id="cand", document_id="d", type=ArtifactType.RAW_TEXT) + gt = Artifact(id="gt", document_id="d", type=ArtifactType.RAW_TEXT) + result = executor.evaluate(view, cand, gt, pipeline_name="test") + # "petit"→"pelit" = 1 sub, OK ; "1789"→"1798" = 2 subs, OK pour + # searchability fuzzy. Donc searchability_recall ≈ 1.0. + assert result.metric_values["searchability_recall"] >= 0.8 + # Mais l'année 1789 N'EST PAS dans hyp → preservation = 0. + assert result.metric_values["numerical_sequence_preservation"] == 0.0 diff --git a/tests/fixtures/__init__.py b/tests/fixtures/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4a45e9db122d918de0e5629245344732208ffcb8 --- /dev/null +++ b/tests/fixtures/__init__.py @@ -0,0 +1 @@ +"""Fixtures partagées pour les tests d'intégration.""" diff --git a/tests/fixtures/cli_mock_adapters.py b/tests/fixtures/cli_mock_adapters.py new file mode 100644 index 0000000000000000000000000000000000000000..89abbe9808d19bf5196ef61b16a15e2760fc8918 --- /dev/null +++ b/tests/fixtures/cli_mock_adapters.py @@ -0,0 +1,139 @@ +"""Mock adapters utilisés par les tests CLI S24. + +Ces classes implémentent l'interface ``StepExecutor`` minimale +attendue par ``PipelineExecutor`` (S7) et ``BenchmarkService`` (S17). +Importables via dotted path : + +:: + + tests.fixtures.cli_mock_adapters.MockTextOCR + +— exactement le format ``adapter_class`` du ``RunSpec`` (S24). +""" + +from __future__ import annotations + +from pathlib import Path + +from picarones.domain.artifacts import Artifact, ArtifactType + + +class MockTextOCR: + """OCR mock : copie le texte GT dans un fichier temp et produit un + Artifact RAW_TEXT pointant dessus. + + Construit son output en lisant le ``image_uri`` du document, qu'on + suppose pointer vers une image dont le stem permet de retrouver la + GT (``foo.png`` → ``foo.gt.txt`` dans le même dossier). C'est une + convention du fixture de test, pas du domain. + """ + + name = "mock_text_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__(self, copy_gt: bool = True) -> None: + # ``copy_gt=True`` : copie la GT dans la sortie (CER nul). + # ``copy_gt=False`` : produit du texte vide (cas dégénéré). + self.copy_gt = copy_gt + + def execute(self, inputs, params, context): + image_artifact = inputs[ArtifactType.IMAGE] + image_path = Path(image_artifact.uri) + # Convention test : la GT vit à .gt.txt dans le même + # répertoire que l'image. + # On retire l'extension image (.png/.jpg/.tif…) pour trouver + # le stem. + stem = image_path.stem # "foo" pour "foo.png" + gt_path = image_path.parent / f"{stem}.gt.txt" + + out_dir = image_path.parent / "_mock_ocr_out" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{context.document_id}_text.txt" + if self.copy_gt and gt_path.exists(): + out_path.write_text( + gt_path.read_text(encoding="utf-8"), + encoding="utf-8", + ) + else: + out_path.write_text("", encoding="utf-8") + + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:mock_text_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + uri=str(out_path), + ), + } + + +class MockBrokenOCR: + """OCR mock qui lève systématiquement. + + Permet de tester la propagation d'erreurs dans le runner sans + dépendance externe. + """ + + name = "mock_broken_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def execute(self, inputs, params, context): + raise RuntimeError("MockBrokenOCR : échec simulé.") + + +class MockAltoOCR: + """OCR structuré mock : produit ALTO_XML déterministe sur disque. + + Lit la GT texte (``.gt.txt`` à côté de l'image) et écrit un + ALTO contenant exactement ce texte (1 page / 1 bloc / 1 ligne). + Sert à tester la projection ALTO→texte bout-en-bout dans le CLI + après le fix du protocole Projector au S25. + """ + + name = "mock_alto_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.ALTO_XML}) + execution_mode = "io" + + def execute(self, inputs, params, context): + from picarones.formats.alto.types import ( + AltoBBox, AltoDocument, AltoLine, AltoPage, AltoString, + AltoTextBlock, + ) + from picarones.formats.alto.writer import write_alto + + image_artifact = inputs[ArtifactType.IMAGE] + image_path = Path(image_artifact.uri) + gt_path = image_path.parent / f"{image_path.stem}.gt.txt" + text = ( + gt_path.read_text(encoding="utf-8") if gt_path.exists() + else "fallback" + ) + + alto_doc = AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w, bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)) + for w in text.split() + )),),),),),),) + + out_dir = image_path.parent / "_mock_alto_out" + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{context.document_id}.alto.xml" + out_path.write_bytes(write_alto(alto_doc)) + + return { + ArtifactType.ALTO_XML: Artifact( + id=f"{context.document_id}:mock_alto_ocr:alto", + document_id=context.document_id, + type=ArtifactType.ALTO_XML, + produced_by_step="ocr", + uri=str(out_path), + ), + } + + +__all__ = ["MockAltoOCR", "MockBrokenOCR", "MockTextOCR"] diff --git a/tests/formats/__init__.py b/tests/formats/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/formats/alto/__init__.py b/tests/formats/alto/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/formats/alto/test_sprint_a14_s9_alto.py b/tests/formats/alto/test_sprint_a14_s9_alto.py new file mode 100644 index 0000000000000000000000000000000000000000..543b8b79121ebd7c343586921e96066fa7293bb4 --- /dev/null +++ b/tests/formats/alto/test_sprint_a14_s9_alto.py @@ -0,0 +1,318 @@ +"""Sprint A14-S9 — ALTO parser, writer, projector. + +Tests minimaux mais couvrant les invariants critiques : + +- Round-trip ``parse → write → parse`` préserve la structure. +- Détection auto v2 / v3 / v4 / sans namespace. +- Extraction texte respecte ``Page → Block → Line → String``. +- Césure ``HypPart1`` / ``HypPart2`` (même ligne ET cross-ligne). +- ``defusedxml`` bloque les attaques XXE. +""" + +from __future__ import annotations + +import pytest + +from picarones.domain import Artifact, ArtifactType +from picarones.domain.errors import ProjectionError +from picarones.evaluation.projectors import AltoToText, alto_document_to_text +from picarones.formats.alto import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoParseError, + AltoString, + AltoTextBlock, + parse_alto, + write_alto, +) + + +# ────────────────────────────────────────────────────────────────────── +# Fixtures synthétiques +# ────────────────────────────────────────────────────────────────────── + + +def _simple_doc() -> AltoDocument: + return AltoDocument( + pages=(AltoPage( + id="p1", width=1000, height=1500, + blocks=(AltoTextBlock( + id="b1", + lines=( + AltoLine(id="l1", strings=( + AltoString(content="Hello", id="s1"), + AltoString(content="world", id="s2"), + )), + AltoLine(id="l2", strings=( + AltoString(content="second", id="s3"), + AltoString(content="line", id="s4"), + )), + ), + ),), + ),), + ) + + +# ────────────────────────────────────────────────────────────────────── +# Parser — détection de namespaces +# ────────────────────────────────────────────────────────────────────── + + +class TestParserVersions: + def test_v4_namespace_detected(self) -> None: + xml = b''' + + + + + + + + + + +''' + doc = parse_alto(xml) + assert doc.source_version == "v4" + assert len(doc.pages) == 1 + + def test_v3_namespace_detected(self) -> None: + xml = b''' + + + + +''' + doc = parse_alto(xml) + assert doc.source_version == "v3" + + def test_v2_namespace_detected(self) -> None: + xml = b''' + + + + +''' + doc = parse_alto(xml) + assert doc.source_version == "v2" + + def test_no_namespace_accepted(self) -> None: + xml = b''' + + + + +''' + doc = parse_alto(xml) + assert doc.source_version == "none" + + def test_invalid_xml_raises(self) -> None: + with pytest.raises(AltoParseError, match="invalide"): + parse_alto(b" None: + with pytest.raises(AltoParseError, match="vide"): + parse_alto(b"") + + def test_xxe_blocked(self) -> None: + """defusedxml doit bloquer les attaques XXE.""" + xml = b''' +]> +&xxe;''' + with pytest.raises(AltoParseError): + parse_alto(xml) + + +# ────────────────────────────────────────────────────────────────────── +# Round-trip writer/parser +# ────────────────────────────────────────────────────────────────────── + + +class TestRoundTrip: + def test_simple_doc_roundtrip(self) -> None: + doc = _simple_doc() + xml = write_alto(doc) + doc2 = parse_alto(xml) + # Les structures internes sont équivalentes (sans + # tenir compte de source_version qui peut différer). + assert len(doc2.pages) == len(doc.pages) + assert len(doc2.pages[0].blocks) == len(doc.pages[0].blocks) + assert doc2.pages[0].width == doc.pages[0].width + assert doc2.pages[0].height == doc.pages[0].height + + def test_string_content_preserved(self) -> None: + doc = _simple_doc() + xml = write_alto(doc) + doc2 = parse_alto(xml) + block = doc2.pages[0].blocks[0] + assert block.lines[0].strings[0].content == "Hello" + assert block.lines[1].strings[1].content == "line" + + def test_bbox_preserved(self) -> None: + doc = AltoDocument( + pages=(AltoPage( + blocks=(AltoTextBlock( + lines=(AltoLine(strings=( + AltoString( + content="x", + bbox=AltoBBox(hpos=10, vpos=20, width=30, height=40), + ), + ),),), + ),), + ),), + ) + doc2 = parse_alto(write_alto(doc)) + bbox = doc2.pages[0].blocks[0].lines[0].strings[0].bbox + assert bbox is not None + assert bbox.hpos == 10 and bbox.vpos == 20 + assert bbox.width == 30 and bbox.height == 40 + + def test_byte_deterministic(self) -> None: + """Même structure → mêmes octets.""" + doc1 = _simple_doc() + doc2 = _simple_doc() + assert write_alto(doc1) == write_alto(doc2) + + def test_write_in_v3(self) -> None: + xml = write_alto(_simple_doc(), version="v3") + doc = parse_alto(xml) + assert doc.source_version == "v3" + + def test_write_no_namespace(self) -> None: + xml = write_alto(_simple_doc(), version="none") + doc = parse_alto(xml) + assert doc.source_version == "none" + + def test_invalid_version_rejected(self) -> None: + from picarones.domain.errors import PicaronesError + with pytest.raises(PicaronesError, match="version ALTO invalide"): + write_alto(_simple_doc(), version="v9") + + +# ────────────────────────────────────────────────────────────────────── +# Projector — extraction texte + césure +# ────────────────────────────────────────────────────────────────────── + + +class TestExtractText: + def test_simple_text(self) -> None: + text = alto_document_to_text(_simple_doc()) + assert text == "Hello world\nsecond line" + + def test_multi_block_separated_by_blank_line(self) -> None: + doc = AltoDocument(pages=(AltoPage( + blocks=( + AltoTextBlock(lines=( + AltoLine(strings=(AltoString(content="A"),)), + ),), + AltoTextBlock(lines=( + AltoLine(strings=(AltoString(content="B"),)), + ),), + ), + ),),) + assert alto_document_to_text(doc) == "A\n\nB" + + def test_hyphenation_same_line_with_subs_content(self) -> None: + """HypPart1 + HypPart2 sur la même ligne, SUBS_CONTENT fourni.""" + doc = AltoDocument(pages=(AltoPage( + blocks=(AltoTextBlock(lines=( + AltoLine(strings=( + AltoString(content="Bonjour"), + AltoString( + content="est-", + subs_type="HypPart1", + subs_content="est-il", + ), + AltoString(content="il", subs_type="HypPart2"), + AltoString(content="clair"), + )), + ),),), + ),),) + # "est-il" reconstruit, "il" suivant skippé. + assert alto_document_to_text(doc) == "Bonjour est-il clair" + + def test_hyphenation_cross_line(self) -> None: + """HypPart1 fin d'une ligne, HypPart2 début ligne suivante. + + C'est l'usage standard ALTO (la césure visuelle correspond à + un saut de ligne réel). + """ + doc = AltoDocument(pages=(AltoPage( + blocks=(AltoTextBlock(lines=( + AltoLine(strings=( + AltoString(content="ceci"), + AltoString( + content="est-", + subs_type="HypPart1", + subs_content="est-il", + ), + )), + AltoLine(strings=( + AltoString(content="il", subs_type="HypPart2"), + AltoString(content="clair"), + )), + ),),), + ),),) + # Ligne 1 : "ceci est-il" (mot complet placé en fin de ligne 1). + # Ligne 2 : "clair" (le HypPart2 "il" est skippé). + assert alto_document_to_text(doc) == "ceci est-il\nclair" + + def test_hyphenation_no_subs_content_concatenates(self) -> None: + doc = AltoDocument(pages=(AltoPage( + blocks=(AltoTextBlock(lines=( + AltoLine(strings=( + AltoString(content="lec-", subs_type="HypPart1"), + AltoString(content="ture", subs_type="HypPart2"), + )), + ),),), + ),),) + assert alto_document_to_text(doc) == "lec-ture" + + +# ────────────────────────────────────────────────────────────────────── +# AltoToText projector (protocole) +# ────────────────────────────────────────────────────────────────────── + + +class TestAltoToTextProjector: + def test_protocol_satisfied(self) -> None: + from picarones.evaluation.projectors import Projector + assert isinstance(AltoToText(), Projector) + + def test_project_from_filesystem(self, tmp_path) -> None: + xml = write_alto(_simple_doc()) + path = tmp_path / "doc.alto.xml" + path.write_bytes(xml) + + artifact = Artifact( + id="d1:ocr:alto", + document_id="d1", + type=ArtifactType.ALTO_XML, + uri=str(path), + ) + projector = AltoToText() + target, payload, report = projector.project(artifact, {}) + assert target.type == ArtifactType.RAW_TEXT + # Sprint S25 — le projecteur retourne le texte calculé. + assert isinstance(payload, str) + assert len(payload) > 0 + assert report.lossy is True + assert "geometry" in report.ignored_dimensions + + def test_project_wrong_type_raises(self) -> None: + artifact = Artifact( + id="d1:image", document_id="d1", + type=ArtifactType.IMAGE, + ) + with pytest.raises(ProjectionError, match="ALTO_XML"): + AltoToText().project(artifact, {}) + + def test_project_missing_uri_raises(self) -> None: + artifact = Artifact( + id="d1:alto", document_id="d1", + type=ArtifactType.ALTO_XML, + ) + with pytest.raises(ProjectionError, match="URI"): + AltoToText().project(artifact, {}) diff --git a/tests/formats/pagexml/__init__.py b/tests/formats/pagexml/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/formats/pagexml/test_sprint_a14_s9_pagexml.py b/tests/formats/pagexml/test_sprint_a14_s9_pagexml.py new file mode 100644 index 0000000000000000000000000000000000000000..18ec867593211a8779b6ae961f96dcb198b3653b --- /dev/null +++ b/tests/formats/pagexml/test_sprint_a14_s9_pagexml.py @@ -0,0 +1,138 @@ +"""Sprint A14-S9 — PAGE XML parser, projector.""" + +from __future__ import annotations + +import pytest + +from picarones.domain import Artifact, ArtifactType +from picarones.domain.errors import ProjectionError +from picarones.evaluation.projectors import PageToText, page_document_to_text +from picarones.formats.pagexml import ( + PageDocument, + PageParseError, + PagePage, + PageTextLine, + PageTextRegion, + parse_pagexml, +) + + +_SAMPLE_PAGE_XML = ''' + + + + + + + + Premier ligne + + + deuxième ligne + + + + + Titre + + + + +'''.encode("utf-8") + + +class TestParser: + def test_parse_simple_page(self) -> None: + doc = parse_pagexml(_SAMPLE_PAGE_XML) + assert len(doc.pages) == 1 + page = doc.pages[0] + assert page.image_filename == "folio_001.png" + assert page.image_width == 1200 + assert page.image_height == 1800 + assert len(page.text_regions) == 2 + + def test_text_lines_extracted(self) -> None: + doc = parse_pagexml(_SAMPLE_PAGE_XML) + r1 = doc.pages[0].text_regions[0] + assert len(r1.text_lines) == 2 + assert r1.text_lines[0].text == "Premier ligne" + assert r1.text_lines[0].coords is not None + assert r1.text_lines[0].baseline is not None + + def test_region_type_preserved(self) -> None: + doc = parse_pagexml(_SAMPLE_PAGE_XML) + assert doc.pages[0].text_regions[0].region_type == "paragraph" + assert doc.pages[0].text_regions[1].region_type == "heading" + + def test_namespace_detected(self) -> None: + doc = parse_pagexml(_SAMPLE_PAGE_XML) + assert doc.source_namespace is not None + assert "primaresearch" in doc.source_namespace + + def test_empty_raises(self) -> None: + with pytest.raises(PageParseError, match="vide"): + parse_pagexml(b"") + + def test_invalid_xml_raises(self) -> None: + with pytest.raises(PageParseError, match="invalide"): + parse_pagexml(b" None: + xml = b''' +]> +&xxe;''' + with pytest.raises(PageParseError): + parse_pagexml(xml) + + +class TestExtractText: + def test_full_extraction(self) -> None: + doc = parse_pagexml(_SAMPLE_PAGE_XML) + text = page_document_to_text(doc) + # 2 régions séparées par ligne vide, lignes par \n. + assert text == "Premier ligne\ndeuxième ligne\n\nTitre" + + def test_empty_document(self) -> None: + doc = PageDocument() + assert page_document_to_text(doc) == "" + + def test_region_without_lines_skipped(self) -> None: + doc = PageDocument(pages=(PagePage( + text_regions=( + PageTextRegion(id="empty"), + PageTextRegion( + id="full", + text_lines=(PageTextLine(text="hello"),), + ), + ), + ),),) + assert page_document_to_text(doc) == "hello" + + +class TestProjector: + def test_protocol_satisfied(self) -> None: + from picarones.evaluation.projectors import Projector + assert isinstance(PageToText(), Projector) + + def test_project_from_filesystem(self, tmp_path) -> None: + path = tmp_path / "doc.page.xml" + path.write_bytes(_SAMPLE_PAGE_XML) + artifact = Artifact( + id="d:page", + document_id="d", + type=ArtifactType.PAGE_XML, + uri=str(path), + ) + target, payload, report = PageToText().project(artifact, {}) + assert target.type == ArtifactType.RAW_TEXT + # Sprint S25 — le projecteur retourne le texte calculé. + assert isinstance(payload, str) + assert len(payload) > 0 + assert "geometry" in report.ignored_dimensions + + def test_wrong_type_rejected(self) -> None: + artifact = Artifact( + id="d:alto", document_id="d", type=ArtifactType.ALTO_XML, + ) + with pytest.raises(ProjectionError, match="PAGE_XML"): + PageToText().project(artifact, {}) diff --git a/tests/formats/text/__init__.py b/tests/formats/text/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/formats/text/test_sprint_a14_s9_normalization_migration.py b/tests/formats/text/test_sprint_a14_s9_normalization_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..f3e5fd76190324a7b4246dc35b652127cb076406 --- /dev/null +++ b/tests/formats/text/test_sprint_a14_s9_normalization_migration.py @@ -0,0 +1,80 @@ +"""Sprint A14-S9 — migration de ``normalization`` vers ``formats/text/``. + +Vérifie que : + +1. Le nouveau module ``picarones.formats.text.normalization`` expose + les 11 profils canoniques. +2. L'ancien re-export ``picarones.measurements.normalization`` continue + à fonctionner sans erreur (compat ascendante stricte). +3. Les symboles privés utilisés downstream (``_parse_exclude_chars``, + ``_apply_diplomatic_table``) sont ré-exposés via le re-export. +4. Les deux chemins d'import retournent **le même objet** (pas une + copie) — preuve que c'est un vrai re-export, pas une duplication. +""" + +from __future__ import annotations + + +def test_new_path_exposes_all_eleven_profiles() -> None: + from picarones.formats.text.normalization import NORMALIZATION_PROFILES + expected = { + "nfc", "caseless", "minimal", + "medieval_french", "early_modern_french", + "medieval_latin", "early_modern_english", "medieval_english", + "secretary_hand", "sans_ponctuation", "sans_apostrophes", + } + assert set(NORMALIZATION_PROFILES.keys()) == expected + + +def test_old_reexport_works() -> None: + """Compat ascendante : ~50 consommateurs importent depuis l'ancien + chemin.""" + from picarones.measurements.normalization import ( + DEFAULT_DIPLOMATIC_PROFILE, + NORMALIZATION_PROFILES, + NormalizationProfile, + get_builtin_profile, + ) + assert NormalizationProfile is not None + assert "medieval_french" in NORMALIZATION_PROFILES + assert get_builtin_profile("nfc") is not None + assert DEFAULT_DIPLOMATIC_PROFILE.name == "medieval_french" + + +def test_private_symbols_reexported() -> None: + """Les symboles préfixés ``_`` utilisés en aval doivent rester + importables depuis l'ancien chemin.""" + from picarones.measurements.normalization import ( + _apply_diplomatic_table, + _parse_exclude_chars, + ) + assert callable(_parse_exclude_chars) + assert callable(_apply_diplomatic_table) + + +def test_old_and_new_paths_share_same_objects() -> None: + """Preuve que c'est un vrai re-export, pas une duplication.""" + from picarones.formats.text.normalization import ( + NORMALIZATION_PROFILES as new_profiles, + NormalizationProfile as NewProfile, + get_builtin_profile as new_get, + ) + from picarones.measurements.normalization import ( + NORMALIZATION_PROFILES as old_profiles, + NormalizationProfile as OldProfile, + get_builtin_profile as old_get, + ) + assert new_profiles is old_profiles # même dict + assert NewProfile is OldProfile # même classe + assert new_get is old_get # même fonction + + +def test_apply_profile_works_via_new_path() -> None: + """Test fonctionnel : un profil chargé depuis le nouveau chemin + applique bien la normalisation.""" + from picarones.formats.text.normalization import get_builtin_profile + profile = get_builtin_profile("medieval_french") + # ſ → s, u → v dans le profil médiéval français. + normalized = profile.normalize("aſpre") + assert "ſ" not in normalized + assert "s" in normalized diff --git a/tests/integration/live/__init__.py b/tests/integration/live/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/tests/integration/live/conftest.py b/tests/integration/live/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..ac800ef0a579e55eea3930985f5f77aae5c21ddd --- /dev/null +++ b/tests/integration/live/conftest.py @@ -0,0 +1,45 @@ +"""Configuration pytest pour les tests d'intégration *live*. + +Sprint A14-S55 (fix audit #9) : les 13 adapters (5 OCR + 4 LLM + +4 VLM) n'avaient aucun test contre une vraie API ni un vrai binaire +système. Tous les tests étaient mockés. Un upgrade silencieux de +l'API tierce (changement de schéma JSON, breaking dans un SDK) ne +pouvait être détecté qu'à la livraison BnF. + +Ce sous-package contient les tests **live** : + +- skippés gracieusement si l'API ou le binaire est absent ; +- vérifient le contrat bout-en-bout (input → API → output) sans + assertion de qualité ; +- non exécutés en CI par défaut — opt-in via la marker ``live``. + +Usage +----- + +:: + + # En local avec les bonnes variables d'env : + pytest tests/integration/live/ -v + pytest tests/integration/live/ -m live -v + + # Pour exécuter UN adapter spécifique : + pytest tests/integration/live/test_tesseract_live.py -v + +Marker +------ +Les tests live portent la marker ``@pytest.mark.live`` pour qu'un +``pytest -m 'not live'`` les skipe automatiquement (utile en CI +standard). +""" + +from __future__ import annotations + + + +def pytest_configure(config) -> None: + """Enregistre le marker ``live`` (évite UnknownMarkerWarning).""" + config.addinivalue_line( + "markers", + "live: tests d'intégration contre vraie API/binaire (skip si " + "credentials absents). Opt-out via -m 'not live'.", + ) diff --git a/tests/integration/live/test_llm_live.py b/tests/integration/live/test_llm_live.py new file mode 100644 index 0000000000000000000000000000000000000000..dc13b72c59a7f17bb98d9c2ffb445dca7b0b47b0 --- /dev/null +++ b/tests/integration/live/test_llm_live.py @@ -0,0 +1,77 @@ +"""Tests live des 4 LLM adapters (skip si SDK + clé API absent). + +Chaque test valide qu'un appel minimal ``complete(prompt, None)`` +retourne du texte non-vide. Pas d'assertion de qualité — on +détecte uniquement les régressions de schéma API / SDK. +""" + +from __future__ import annotations + +import os + +import pytest + + +@pytest.mark.live +def test_anthropic_live() -> None: + pytest.importorskip("anthropic") + if not os.environ.get("ANTHROPIC_API_KEY"): + pytest.skip("ANTHROPIC_API_KEY absent — skip live test") + from picarones.adapters.llm import AnthropicAdapter + adapter = AnthropicAdapter() + result = adapter.complete( + "Say 'OK' and nothing else.", image_b64=None, + ) + assert result.success, f"Anthropic call failed: {result.error}" + assert result.text + + +@pytest.mark.live +def test_openai_live() -> None: + pytest.importorskip("openai") + if not os.environ.get("OPENAI_API_KEY"): + pytest.skip("OPENAI_API_KEY absent — skip live test") + from picarones.adapters.llm import OpenAIAdapter + adapter = OpenAIAdapter() + result = adapter.complete( + "Say 'OK' and nothing else.", image_b64=None, + ) + assert result.success, f"OpenAI call failed: {result.error}" + assert result.text + + +@pytest.mark.live +def test_mistral_live() -> None: + pytest.importorskip("mistralai") + if not os.environ.get("MISTRAL_API_KEY"): + pytest.skip("MISTRAL_API_KEY absent — skip live test") + from picarones.adapters.llm import MistralAdapter + adapter = MistralAdapter() + result = adapter.complete( + "Say 'OK' and nothing else.", image_b64=None, + ) + assert result.success, f"Mistral call failed: {result.error}" + assert result.text + + +@pytest.mark.live +def test_ollama_live() -> None: + """Ollama est local — skip si serveur indisponible.""" + pytest.importorskip("requests") + import requests + base = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434") + try: + r = requests.get(f"{base}/api/tags", timeout=2) + if r.status_code != 200: + pytest.skip(f"Ollama indisponible à {base}") + except Exception: + pytest.skip(f"Ollama indisponible à {base}") + from picarones.adapters.llm import OllamaAdapter + adapter = OllamaAdapter() + result = adapter.complete( + "Say 'OK' and nothing else.", image_b64=None, + ) + # On ne réclame pas success — Ollama peut ne pas avoir le modèle + # par défaut installé ; on vérifie juste que l'adapter ne plante + # pas sur une cassure d'API. + assert isinstance(result.text, str) diff --git a/tests/integration/live/test_tesseract_live.py b/tests/integration/live/test_tesseract_live.py new file mode 100644 index 0000000000000000000000000000000000000000..f23618903b78b63f1dbd50b1c5a7806727025fb2 --- /dev/null +++ b/tests/integration/live/test_tesseract_live.py @@ -0,0 +1,59 @@ +"""Test live TesseractAdapter (skip si binaire absent).""" + +from __future__ import annotations + +import shutil +from pathlib import Path + +import pytest + +pytest.importorskip("pytesseract") +pytest.importorskip("PIL") +if shutil.which("tesseract") is None: + pytest.skip( + "binaire tesseract absent du PATH — skip live test", + allow_module_level=True, + ) + +from PIL import Image, ImageDraw, ImageFont # noqa: E402 + +from picarones.adapters.ocr import TesseractAdapter # noqa: E402 +from picarones.domain.artifacts import Artifact, ArtifactType # noqa: E402 +from picarones.pipeline.types import RunContext # noqa: E402 + + +@pytest.mark.live +def test_tesseract_reads_synthetic_text(tmp_path: Path) -> None: + """Génère une image avec du texte clair et vérifie que + Tesseract le retrouve.""" + # Image 400x100 avec "HELLO" en gros (police par défaut). + img = Image.new("RGB", (400, 100), color="white") + draw = ImageDraw.Draw(img) + try: + font = ImageFont.truetype("DejaVuSans-Bold.ttf", size=48) + except OSError: + font = ImageFont.load_default() + draw.text((20, 20), "HELLO", fill="black", font=font) + img_path = tmp_path / "synthetic.png" + img.save(img_path) + + adapter = TesseractAdapter(lang="eng", expose_confidences=False) + ctx = RunContext( + document_id="d1", code_version="1.0", pipeline_name="live", + ) + result = adapter.execute( + inputs={ + ArtifactType.IMAGE: Artifact( + id="d1:img", document_id="d1", + type=ArtifactType.IMAGE, uri=str(img_path), + ), + }, + params={}, + context=ctx, + ) + out_path = Path(result[ArtifactType.RAW_TEXT].uri) + text = out_path.read_text(encoding="utf-8") + # Tesseract a au moins capté un caractère raisonnable — + # on n'assertera pas l'exactitude (police par défaut peut + # produire des résultats variables) mais on veut du non-vide. + assert len(text) > 0, "Tesseract a retourné un texte vide" diff --git a/tests/integration/test_sprint_a14_s12_executor_equivalence.py b/tests/integration/test_sprint_a14_s12_executor_equivalence.py new file mode 100644 index 0000000000000000000000000000000000000000..fcae26812d7eaf0433b4743a6de1154552e8eb87 --- /dev/null +++ b/tests/integration/test_sprint_a14_s12_executor_equivalence.py @@ -0,0 +1,374 @@ +"""Sprint A14-S12 — équivalence numérique nouveau runner ↔ ancien runner. + +Critère go/no-go fin de Phase 2 : sur 5 fixtures patrimoniales +synthétiques, le ``CorpusRunner`` (S8) doit produire **exactement +les mêmes** CER/WER que l'ancien ``measurements.runner.run_benchmark`` +quand on lui injecte des textes hypothèses identiques. + +Méthode +------- +On construit deux orchestrations qui consomment exactement la même +``Corpus`` et produisent exactement les mêmes textes hypothèses : + +- **Ancien runner** : ``FakeOCREngine`` héritant de ``BaseOCREngine`` + retourne le texte mappé pour chaque document. + ``measurements.runner.run_benchmark`` calcule CER/WER via + ``compute_metrics`` (jiwer). +- **Nouveau runner** : ``FakeStepExecutor`` satisfait le protocole + ``StepExecutor`` du S6 et retourne un ``Artifact`` RAW_TEXT avec le + même texte (stocké dans un dict partagé pour pouvoir le récupérer + côté test). ``CorpusRunner.run`` orchestre en threads avec + backpressure, on récupère le texte produit par chaque doc et on + calcule CER/WER avec **le même** ``compute_metrics``. + +Si les deux produisent le même texte sur les mêmes documents, +``compute_metrics`` doit produire exactement les mêmes valeurs CER +et WER (jiwer est déterministe). Le test vérifie cette équivalence +à 1e-9 près sur 5 fixtures de difficulté croissante. + +Bénéfice scientifique +--------------------- +Tant que ce test passe, on peut affirmer que basculer de l'ancien +au nouveau runner ne change PAS les chiffres rapportés. C'est la +condition nécessaire pour bascular les utilisateurs (BnF) vers le +nouveau runner sans surprise. +""" + +from __future__ import annotations + +import threading +from typing import Any + +import pytest + +from picarones.core.corpus import Corpus, Document +from picarones.domain import Artifact, ArtifactType, DocumentRef +from picarones.engines.base import BaseOCREngine +from picarones.measurements.metrics import compute_metrics +from picarones.measurements.runner import run_benchmark +from picarones.pipeline import ( + CorpusRunner, + PipelineExecutor, + PipelineSpec, + PipelineStep, + RunContext, +) + + +# ────────────────────────────────────────────────────────────────────── +# Stubs partagés entre les deux orchestrations +# ────────────────────────────────────────────────────────────────────── + + +class _FakeOCREngine(BaseOCREngine): + """OCR fake pour le runner legacy. Retourne un texte fixe par + document, indexé par ``doc_id``.""" + + @property + def name(self) -> str: + return "fake_ocr" + + def version(self) -> str: + return "fake-1.0" + + def __init__(self, text_per_doc: dict[str, str]) -> None: + super().__init__(config={}) + self._text_per_doc = text_per_doc + self._lookup_lock = threading.Lock() + + def _run_ocr(self, image_path: Any) -> str: + # Pour le test, on encode le ``doc_id`` dans le nom du fichier + # ``.png`` que le caller du test crée dans tmp_path. + from pathlib import Path + doc_id = Path(image_path).stem + with self._lookup_lock: + return self._text_per_doc.get(doc_id, "") + + +class _FakeStepExecutor: + """Adapter fake pour le nouveau runner. Retourne un ``Artifact`` + RAW_TEXT avec un texte fixe par document, partagé via dict + externe pour récupération côté test.""" + + name = "fake_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__( + self, + text_per_doc: dict[str, str], + produced_text_log: dict[str, str], + ) -> None: + self._text_per_doc = text_per_doc + self._produced = produced_text_log + + def execute( + self, + inputs: dict[ArtifactType, Artifact], + params: dict, + context: RunContext, + ) -> dict[ArtifactType, Artifact]: + text = self._text_per_doc.get(context.document_id, "") + artifact_id = f"{context.document_id}:fake_ocr:raw_text" + # Stocke le texte côté test pour le calcul CER/WER hors orchestrateur. + self._produced[context.document_id] = text + return { + ArtifactType.RAW_TEXT: Artifact( + id=artifact_id, + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="fake_ocr", + ), + } + + +# ────────────────────────────────────────────────────────────────────── +# Fixtures patrimoniales (5 cas de difficulté croissante) +# ────────────────────────────────────────────────────────────────────── + + +_FIXTURES: list[tuple[str, dict[str, str], dict[str, str]]] = [ + # (nom, GT_par_doc, hypothèse_par_doc) + ( + "fixture_1_court", + { + "doc01": "Bonjour", + "doc02": "Monde", + }, + { + "doc01": "Bonjour", + "doc02": "Monde", # parfait + }, + ), + ( + "fixture_2_paragraphe", + { + "doc01": "Le petit chat noir court dans le jardin verdoyant.", + "doc02": "Une vieille horloge sonne au lointain de la rue.", + }, + { + "doc01": "Le pelit chat noir court dans le jardin verdoyant.", + "doc02": "Une vieille horloge sonne au lointain de la rue.", + }, + ), + ( + "fixture_3_multi_lignes", + { + "doc01": "Première ligne\nDeuxième ligne\nTroisième ligne", + "doc02": "Texte sur\ndeux lignes", + }, + { + "doc01": "Premiere ligne\nDeuxieme ligne\nTroisieme ligne", + "doc02": "Texte sur\ndeux lignes", + }, + ), + ( + "fixture_4_abreviations", + { + "doc01": "M. Dupont, p. 12, vol. III, art. cit.", + "doc02": "fait à Paris le 1er janvier 1789.", + }, + { + "doc01": "M. Dupont, p. 12, vol. III, art. cit.", + "doc02": "fait à Paris le 1er janvier 1798.", # erreur date + }, + ), + ( + "fixture_5_mix_langues", + { + "doc01": "In nomine patris et filii et spiritus sancti", + "doc02": "L'amour vainc tout, et nous cédons à l'amour", + }, + { + "doc01": "In nomne patris et filii et spritus sancti", + "doc02": "L'amour vainc tout, et nous cedons à l'amour", + }, + ), +] + + +# ────────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────────── + + +def _build_corpus( + tmp_path: Any, + gt_per_doc: dict[str, str], +) -> tuple[Corpus, list[DocumentRef]]: + """Construit un Corpus legacy + une liste de DocumentRef nouvelle. + + Crée des fichiers PNG vides pour satisfaire les contrats fs. + """ + from pathlib import Path + docs_legacy = [] + docs_new = [] + for doc_id, gt in gt_per_doc.items(): + img_path = Path(tmp_path) / f"{doc_id}.png" + img_path.write_bytes(b"\x89PNG\r\n\x1a\n") # entête PNG minimal + docs_legacy.append(Document( + image_path=img_path, + ground_truth=gt, + )) + docs_new.append(DocumentRef( + id=doc_id, + image_uri=str(img_path), + )) + corpus = Corpus( + name="equivalence_test", + documents=docs_legacy, + source_path=str(tmp_path), + ) + return corpus, docs_new + + +def _run_old_runner( + corpus: Corpus, + hypothesis_per_doc: dict[str, str], +) -> tuple[float | None, float | None]: + """Exécute l'ancien runner et retourne (mean_cer, mean_wer).""" + engine = _FakeOCREngine(text_per_doc=hypothesis_per_doc) + result = run_benchmark( + corpus=corpus, + engines=[engine], + show_progress=False, + max_workers=2, + ) + report = result.engine_reports[0] + return report.mean_cer, report.mean_wer + + +def _run_new_runner( + docs: list[DocumentRef], + hypothesis_per_doc: dict[str, str], + gt_per_doc: dict[str, str], +) -> tuple[float | None, float | None]: + """Exécute le nouveau runner et retourne (mean_cer, mean_wer) + calculé avec le **même** ``compute_metrics`` que l'ancien.""" + produced: dict[str, str] = {} + fake = _FakeStepExecutor( + text_per_doc=hypothesis_per_doc, + produced_text_log=produced, + ) + registry = {"fake_ocr": fake} + executor = PipelineExecutor(adapter_resolver=lambda n: registry[n]) + runner = CorpusRunner( + executor, + max_in_flight=2, + timeout_seconds_per_doc=60.0, + poll_interval_seconds=0.005, + ) + spec = PipelineSpec( + name="equivalence", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", adapter_name="fake_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + ) + + def _factory_inputs(doc: DocumentRef) -> dict[ArtifactType, Artifact]: + return {ArtifactType.IMAGE: Artifact( + id=f"{doc.id}:image", document_id=doc.id, + type=ArtifactType.IMAGE, uri=doc.image_uri, + )} + + def _factory_ctx(doc: DocumentRef) -> RunContext: + return RunContext( + document_id=doc.id, + code_version="1.0.0", + pipeline_name="equivalence", + ) + + result = runner.run( + spec, docs, _factory_inputs, _factory_ctx, + corpus_name="equivalence_test", + ) + assert result.n_succeeded == len(docs), result + + # Calcule CER/WER avec le même compute_metrics que l'ancien runner. + cers, wers = [], [] + for doc in docs: + gt = gt_per_doc[doc.id] + hyp = produced[doc.id] + m = compute_metrics(gt, hyp) + if m.error is None and m.cer is not None: + cers.append(m.cer) + if m.error is None and m.wer is not None: + wers.append(m.wer) + mean_cer = sum(cers) / len(cers) if cers else None + mean_wer = sum(wers) / len(wers) if wers else None + return mean_cer, mean_wer + + +# ────────────────────────────────────────────────────────────────────── +# Tests d'équivalence +# ────────────────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("name", "gt_per_doc", "hyp_per_doc"), + _FIXTURES, + ids=[f[0] for f in _FIXTURES], +) +def test_old_and_new_runner_produce_same_cer_wer( + tmp_path, + name: str, + gt_per_doc: dict[str, str], + hyp_per_doc: dict[str, str], +) -> None: + """Sur la fixture ``name``, l'ancien et le nouveau runner doivent + produire des CER/WER identiques à 1e-9 près.""" + corpus, docs = _build_corpus(tmp_path, gt_per_doc) + + old_cer, old_wer = _run_old_runner(corpus, hyp_per_doc) + new_cer, new_wer = _run_new_runner(docs, hyp_per_doc, gt_per_doc) + + assert old_cer is not None and new_cer is not None + assert old_wer is not None and new_wer is not None + + # Tolérance 1e-6 (et non 1e-9 du plan original) parce que + # ``aggregate_metrics`` de l'ancien runner arrondit ``mean`` à + # 6 décimales (cf. ``picarones/core/metrics.py:_stats``). Les + # valeurs brutes sont identiques bit-à-bit avant arrondi ; la + # divergence observée (~1e-7) provient strictement de cet arrondi. + # Le critère "équivalence numérique" est donc satisfait sur le + # pipeline de bout en bout — la précision réelle du calcul jiwer + # est préservée, l'arrondi est un détail de rendu côté ancien + # runner qui disparaîtra quand l'agrégation passera par les types + # non-arrondis du nouveau code (S22). + assert abs(old_cer - new_cer) < 1e-6, ( + f"[{name}] CER divergent : ancien={old_cer!r}, " + f"nouveau={new_cer!r}, écart={abs(old_cer - new_cer):.3e}" + ) + assert abs(old_wer - new_wer) < 1e-6, ( + f"[{name}] WER divergent : ancien={old_wer!r}, " + f"nouveau={new_wer!r}, écart={abs(old_wer - new_wer):.3e}" + ) + + +def test_equivalence_with_perfect_hypothesis(tmp_path) -> None: + """Garde-fou : si l'OCR retourne exactement la GT, CER = WER = 0 + pour les deux runners.""" + gt = {"d1": "Texte parfait", "d2": "Identique aux deux"} + corpus, docs = _build_corpus(tmp_path, gt) + old_cer, old_wer = _run_old_runner(corpus, gt) + new_cer, new_wer = _run_new_runner(docs, gt, gt) + assert old_cer == 0.0 + assert new_cer == 0.0 + assert old_wer == 0.0 + assert new_wer == 0.0 + + +def test_equivalence_with_empty_hypothesis(tmp_path) -> None: + """Cas limite : OCR retourne du vide → les deux runners doivent + le gérer de façon identique (CER élevé mais cohérent).""" + gt = {"d1": "Quelque chose"} + hyp = {"d1": ""} + corpus, docs = _build_corpus(tmp_path, gt) + old_cer, old_wer = _run_old_runner(corpus, hyp) + new_cer, new_wer = _run_new_runner(docs, hyp, gt) + assert old_cer is not None and new_cer is not None + assert abs(old_cer - new_cer) < 1e-9 diff --git a/tests/integration/test_sprint_a14_s17_full_run.py b/tests/integration/test_sprint_a14_s17_full_run.py new file mode 100644 index 0000000000000000000000000000000000000000..fadb34f71c79a248760035f857bb571ec02fead7 --- /dev/null +++ b/tests/integration/test_sprint_a14_s17_full_run.py @@ -0,0 +1,565 @@ +"""Sprint A14-S17 — run complet avec persistance JSONL. + +Définition de done : un benchmark produit un dossier ``result/`` +lisible humainement où on voit : + +- ``run_manifest.json`` — métadonnées (run_id, corpus, pipelines, + vues, code_version, timestamps). +- ``pipeline_results.jsonl`` — un PipelineResult par ligne avec + document_id. +- ``view_results.jsonl`` — un ViewResult par ligne avec + document_id. + +Le test exécute : +- 2 pipelines mock (un OCR pur RAW_TEXT, un OCR+ALTO). +- 3 documents synthétiques. +- 2 vues canoniques (TextView + AltoView — SearchView est testée + séparément en S16). +- Persistance dans tmp_path. +- Vérification des fichiers produits + structure du RunResult. + +Setup disque +------------ +Le ``AltoToText`` projecteur (S9) lit son XML depuis l'``Artifact.uri`` +filesystem. La fixture écrit donc des fichiers ALTO XML réels sur +disque sous ``tmp_path/alto_files/`` et les stubs OCR pointent leurs +artefacts ALTO vers ces fichiers via leur URI. Cela reproduit +l'usage production où un moteur écrit son XML dans un workspace +sandboxé (S19). +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from picarones.app.services import BenchmarkService +from picarones.domain import ( + Artifact, + ArtifactType, + CorpusSpec, + DocumentRef, + GroundTruthRef, + MetricSpec, +) +from picarones.evaluation.metrics.alto_structural import ( + compute_alto_validity, + compute_line_count_ratio, + compute_word_box_coverage, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, + build_alto_view, + build_text_view, +) +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) +from picarones.formats.alto.writer import write_alto +from picarones.pipeline import ( + CorpusRunner, + PipelineExecutor, + PipelineSpec, + PipelineStep, + RunContext, +) + + +# ────────────────────────────────────────────────────────────────── +# Fixtures de données +# ────────────────────────────────────────────────────────────────── + + +_GT_TEXTS = { + "doc01": "Bonjour le monde", + "doc02": "Test multi documents", + "doc03": "Troisième fixture", +} + + +def _build_alto(text: str) -> AltoDocument: + """Produit un AltoDocument 1 page / 1 bloc / 1 ligne avec bbox + sur chaque mot.""" + return AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w, bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)) + for w in text.split() + )),),),),),),) + + +# ────────────────────────────────────────────────────────────────── +# Adapters / pipelines mock +# ────────────────────────────────────────────────────────────────── + + +class _TextOCRStub: + """OCR mock qui produit RAW_TEXT déterministe.""" + + name = "text_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def execute(self, inputs, params, context): + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:text_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + ), + } + + +class _AltoOCRStub: + """OCR mock qui produit ALTO_XML + RAW_TEXT déterministes. + + Les fichiers ALTO sont supposés déjà présents sur disque dans + ``alto_files_dir`` (écrits par la fixture). L'artefact ALTO + pointe sa ``uri`` vers ce fichier — pour reproduire la chaîne + de production où un moteur ALTO écrit son XML dans un workspace + et l'expose via URI. + """ + + name = "alto_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.ALTO_XML, ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__(self, alto_files_dir: Path) -> None: + self._alto_files_dir = Path(alto_files_dir) + + def execute(self, inputs, params, context): + alto_path = self._alto_files_dir / f"{context.document_id}.cand.alto.xml" + return { + ArtifactType.ALTO_XML: Artifact( + id=f"{context.document_id}:alto_ocr:alto", + document_id=context.document_id, + type=ArtifactType.ALTO_XML, + produced_by_step="ocr", + uri=str(alto_path), + ), + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:alto_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + ), + } + + +# ────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────── + + +def _stub_cer(reference: str, hypothesis: str) -> float: + if not reference: + return 0.0 if not hypothesis else 1.0 + common = sum(1 for a, b in zip(reference, hypothesis) if a == b) + return 1.0 - (common / max(len(reference), len(hypothesis))) + + +def _stub_wer(reference: str, hypothesis: str) -> float: + rw = reference.split() + hw = hypothesis.split() + if not rw: + return 0.0 if not hw else 1.0 + common = sum(1 for a, b in zip(rw, hw) if a == b) + return 1.0 - (common / len(rw)) + + +def _write_alto_files(tmp_path: Path) -> tuple[Path, dict[str, Path], dict[str, Path]]: + """Écrit GT et candidate ALTO XML sur disque pour chaque doc. + + Returns + ------- + (alto_dir, gt_paths_by_doc, cand_paths_by_doc) + """ + alto_dir = tmp_path / "alto_files" + alto_dir.mkdir(parents=True, exist_ok=True) + + gt_paths: dict[str, Path] = {} + cand_paths: dict[str, Path] = {} + for doc_id, text in _GT_TEXTS.items(): + gt_doc = _build_alto(text) + cand_doc = _build_alto(text) # Texte parfait → ALTO identique. + + gt_path = alto_dir / f"{doc_id}.gt.alto.xml" + cand_path = alto_dir / f"{doc_id}.cand.alto.xml" + gt_path.write_bytes(write_alto(gt_doc)) + cand_path.write_bytes(write_alto(cand_doc)) + + gt_paths[doc_id] = gt_path + cand_paths[doc_id] = cand_path + + return alto_dir, gt_paths, cand_paths + + +# ────────────────────────────────────────────────────────────────── +# Setup complet (param tmp_path) +# ────────────────────────────────────────────────────────────────── + + +def _build_service(tmp_path: Path) -> tuple[BenchmarkService, dict[str, Path]]: + """Construit le BenchmarkService avec fixtures sur disque. + + Returns + ------- + (service, gt_paths_by_doc) + """ + alto_dir, gt_paths, _cand_paths = _write_alto_files(tmp_path) + + # Métriques (TextView + AltoView) + metrics = MetricRegistry() + for name, fn in ( + ("cer", _stub_cer), + ("wer", _stub_wer), + ("mer", _stub_cer), + ("wil", _stub_wer), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + for name, fn in ( + ("alto_validity", compute_alto_validity), + ("alto_line_count_ratio", compute_line_count_ratio), + ("alto_word_box_coverage", compute_word_box_coverage), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + fn, + ) + + # Projecteurs + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + # Loader hybride : + # - pour les RAW_TEXT directs (id se termine par ":raw_text") on + # retourne le texte parfait depuis _GT_TEXTS. + # - pour les artefacts projetés (id se termine par ":projected_text") + # on retourne aussi le texte parfait (la projection a déjà fait + # son travail en lisant le XML disque). + # - pour les ALTO_XML (GT ou candidat), on parse le fichier disque. + from picarones.formats.alto.parser import parse_alto + + def loader(art: Artifact): + if art.type == ArtifactType.RAW_TEXT: + # GT ou candidat texte direct, ou résultat de projection. + return _GT_TEXTS[art.document_id] + if art.type == ArtifactType.ALTO_XML: + if art.uri is None: + raise KeyError(f"ALTO artefact {art.id} sans URI") + return parse_alto(Path(art.uri).read_bytes()) + raise KeyError(f"loader ne sait pas charger {art.id} (type {art.type})") + + view_executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, loader, + ) + + # Pipeline executor + corpus runner. + registry_adapters = { + "text_ocr": _TextOCRStub(), + "alto_ocr": _AltoOCRStub(alto_dir), + } + pipeline_executor = PipelineExecutor( + adapter_resolver=lambda n: registry_adapters[n], + ) + corpus_runner = CorpusRunner( + pipeline_executor, + max_in_flight=2, + timeout_seconds_per_doc=10.0, + poll_interval_seconds=0.005, + ) + + service = BenchmarkService( + corpus_runner=corpus_runner, + view_executor=view_executor, + code_version="1.0.0-s17-test", + ) + return service, gt_paths + + +# ────────────────────────────────────────────────────────────────── +# Tests +# ────────────────────────────────────────────────────────────────── + + +def _build_corpus_and_specs(gt_paths: dict[str, Path]): + # Note : ``image_uri`` et le ``uri`` de la GT RAW_TEXT ne sont + # jamais lus dans S17 (les payloads sont fournis in-memory par le + # loader des stubs). On les construit comme des chemins **sous le + # tmp_path partagé** pour rester portable cross-OS — sur Windows + # ``/tmp/...`` n'est pas un chemin absolu valide. + base_dir = next(iter(gt_paths.values())).parent + docs = tuple( + DocumentRef( + id=doc_id, + image_uri=str(base_dir / f"{doc_id}.png"), + ground_truths=( + GroundTruthRef( + type=ArtifactType.RAW_TEXT, + uri=str(base_dir / f"{doc_id}.gt.txt"), + ), + GroundTruthRef( + type=ArtifactType.ALTO_XML, + uri=str(gt_paths[doc_id]), + ), + ), + ) + for doc_id in _GT_TEXTS + ) + corpus = CorpusSpec(name="s17_fixture", documents=docs) + + text_pipeline = PipelineSpec( + name="text_only_pipeline", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", adapter_name="text_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + ) + alto_pipeline = PipelineSpec( + name="alto_pipeline", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", adapter_name="alto_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.ALTO_XML, ArtifactType.RAW_TEXT), + ),), + ) + views = (build_text_view(), build_alto_view()) + + return corpus, [text_pipeline, alto_pipeline], list(views) + + +def _build_factories(gt_paths: dict[str, Path]): + def gt_factory(doc, art_type): + gt_ref = doc.gt_for(art_type) + if gt_ref is None: + return None + return Artifact( + id=f"{doc.id}:gt:{'raw_text' if art_type == ArtifactType.RAW_TEXT else 'alto'}", + document_id=doc.id, + type=art_type, + uri=gt_ref.uri, + ) + + def inputs_factory(doc): + return {ArtifactType.IMAGE: Artifact( + id=f"{doc.id}:image", document_id=doc.id, + type=ArtifactType.IMAGE, uri=doc.image_uri, + )} + + def ctx_factory(doc, pipeline_name): + return RunContext( + document_id=doc.id, + code_version="1.0.0-s17-test", + pipeline_name=pipeline_name, + ) + + return gt_factory, inputs_factory, ctx_factory + + +class TestFullRun: + def test_run_produces_pipeline_results_for_each_doc(self, tmp_path: Path) -> None: + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + assert result.n_documents == 3 + for doc_result in result.document_results: + assert len(doc_result.pipeline_results) == 2 + pipeline_names = {pr.pipeline_name for pr in doc_result.pipeline_results} + assert pipeline_names == {"text_only_pipeline", "alto_pipeline"} + + def test_omission_pattern_textview_includes_both_pipelines(self, tmp_path: Path) -> None: + """TextView accepte RAW_TEXT et ALTO_XML → les 2 pipelines + sont éligibles.""" + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + + text_view_results = result.view_results_for("text_final") + # text_only_pipeline produit RAW_TEXT (1 éligible). + # alto_pipeline produit RAW_TEXT + ALTO_XML (2 éligibles). + # Total : 3 docs × (1 + 2) = 9 ViewResult. + assert len(text_view_results) == 9 + for vr in text_view_results: + assert vr.view_name == "text_final" + + def test_omission_pattern_altoview_omits_text_only_pipeline(self, tmp_path: Path) -> None: + """AltoView n'accepte qu'ALTO_XML → text_only_pipeline OMIS.""" + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + + alto_view_results = result.view_results_for("alto_documentary") + # 3 docs × 1 pipeline (alto_pipeline) × 1 artefact ALTO = 3 results. + assert len(alto_view_results) == 3 + for vr in alto_view_results: + assert "alto_ocr" in vr.candidate_artifact_id + + def test_view_results_have_metric_values(self, tmp_path: Path) -> None: + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + for vr in result.view_results_for("text_final"): + # CER stub doit être 0 (texte parfait dans la fixture). + assert vr.metric_values.get("cer") == 0.0 + assert vr.failed_metrics == {} + + +class TestPersistence: + def test_persist_writes_three_files(self, tmp_path: Path) -> None: + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + out_dir = tmp_path / "run_output" + files = service.persist(result, out_dir) + assert files["manifest"].exists() + assert files["pipeline_results"].exists() + assert files["view_results"].exists() + + def test_persisted_manifest_is_valid_json(self, tmp_path: Path) -> None: + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + out_dir = tmp_path / "run_output" + files = service.persist(result, out_dir) + manifest_data = json.loads(files["manifest"].read_text()) + assert manifest_data["corpus_name"] == "s17_fixture" + assert manifest_data["n_documents"] == 3 + assert manifest_data["code_version"] == "1.0.0-s17-test" + assert "text_only_pipeline" in manifest_data["pipeline_names"] + assert "alto_pipeline" in manifest_data["pipeline_names"] + + def test_persisted_jsonl_is_streamable(self, tmp_path: Path) -> None: + """Chaque ligne de pipeline_results.jsonl et view_results.jsonl + est un JSON valide indépendamment (streaming).""" + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + files = service.persist(result, tmp_path / "out") + + # pipeline_results.jsonl : 3 docs × 2 pipelines = 6 lignes. + pipeline_lines = files["pipeline_results"].read_text().strip().split("\n") + assert len(pipeline_lines) == 6 + for line in pipeline_lines: + payload = json.loads(line) + assert "document_id" in payload + assert "pipeline_name" in payload + + # view_results.jsonl : 9 (TextView) + 3 (AltoView) = 12 lignes. + view_lines = files["view_results"].read_text().strip().split("\n") + assert len(view_lines) == 12 + for line in view_lines: + payload = json.loads(line) + assert "document_id" in payload + assert "view_name" in payload + assert "metric_values" in payload + + +class TestRunResultHelpers: + def test_pipeline_results_for_returns_correct_subset(self, tmp_path: Path) -> None: + service, gt_paths = _build_service(tmp_path) + corpus, pipelines, views = _build_corpus_and_specs(gt_paths) + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + result = service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + # 3 docs × 1 pipeline (filtré sur "text_only_pipeline"). + text_results = result.pipeline_results_for("text_only_pipeline") + assert len(text_results) == 3 + for pr in text_results: + assert pr.pipeline_name == "text_only_pipeline" diff --git a/tests/integration/test_sprint_a14_s18_bnf_e2e.py b/tests/integration/test_sprint_a14_s18_bnf_e2e.py new file mode 100644 index 0000000000000000000000000000000000000000..1b5086cb194642b626baf54c8c7c559990bea3d9 --- /dev/null +++ b/tests/integration/test_sprint_a14_s18_bnf_e2e.py @@ -0,0 +1,795 @@ +"""Sprint A14-S18 — test E2E sur le cas BnF central. + +Définition de done : un benchmark BnF-réaliste produit un RunResult +où on peut démontrer **qu'il n'y a pas de gagnant global** entre +les 3 pipelines hétérogènes — c'est précisément ce que le rewrite +ciblé est conçu pour rendre visible. + +Scénario +-------- +3 pipelines hétérogènes (proxies des moteurs réels) : + +1. ``pipeline_simple_ocr`` — Tesseract-like, produit RAW_TEXT seul. + Texte légèrement dégradé (faute typique de reconnaissance). +2. ``pipeline_structured_ocr`` — Pero-like, produit ALTO_XML + + RAW_TEXT. Texte de bonne qualité + structure exploitable. +3. ``pipeline_ocr_plus_correction`` — OCR+LLM, produit RAW_TEXT + (intermédiaire dégradé) puis CORRECTED_TEXT (correction LLM + excellente). + +3 vues canoniques : + +- TextView (CER/WER/MER/WIL) — meilleur **texte final**. +- AltoView (validity/line_count/word_box) — meilleur **ALTO + exploitable**. +- SearchView (searchability_recall/numerical_sequence) — + meilleur **pour la recherche plein-texte**. + +5 documents synthétiques (XVIIIᵉ–XIXᵉ siècle, contenu BMS et +biographique) avec des dates → SearchView non triviale. + +Pattern d'omission attendu +-------------------------- +- AltoView omet ``pipeline_simple_ocr`` et ``pipeline_ocr_plus_correction`` + (aucun n'a d'ALTO_XML). +- TextView et SearchView incluent les 3 pipelines (RAW_TEXT toujours + produit ; ALTO_XML projeté vers RAW_TEXT pour la pipeline 2 ; + CORRECTED_TEXT direct pour la pipeline 3). + +Comptage attendu +---------------- +Par document : + +- TextView : 1 (simple) + 2 (structured: RAW_TEXT + ALTO) + 2 + (correction: RAW_TEXT + CORRECTED_TEXT) = **5 ViewResult**. +- AltoView : 1 (structured seul) = **1 ViewResult**. +- SearchView : pareil que TextView = **5 ViewResult**. + +Total sur 5 docs : 25 + 5 + 25 = **55 ViewResult**. + +Pas de gagnant global +--------------------- +- Pipeline 1 (simple) : RAW_TEXT légèrement dégradé → mediocre + TextView, mediocre SearchView, OMIS d'AltoView. +- Pipeline 2 (structured) : RAW_TEXT excellent + ALTO disponible → + excellent TextView (sur RAW_TEXT direct), seul gagnant possible + d'AltoView, excellent SearchView. +- Pipeline 3 (correction) : CORRECTED_TEXT excellent → excellent + TextView (sur CORRECTED_TEXT), excellent SearchView, OMIS + d'AltoView. + +Conclusion : aucune pipeline ne gagne sur les 3 vues — le rewrite +est conçu pour exposer cette divergence sans masquer. +""" + +from __future__ import annotations + +import json +import re +from pathlib import Path + +from picarones.app.services import BenchmarkService +from picarones.domain import ( + Artifact, + ArtifactType, + CorpusSpec, + DocumentRef, + GroundTruthRef, + MetricSpec, +) +from picarones.evaluation.metrics.alto_structural import ( + compute_alto_validity, + compute_line_count_ratio, + compute_word_box_coverage, +) +from picarones.evaluation.metrics.search import ( + numerical_sequence_preservation, + searchability_recall, +) +from picarones.evaluation.projectors import ( + AltoToText, + CanonicalToText, + PageToText, + ProjectorRegistry, +) +from picarones.evaluation.registry import MetricRegistry +from picarones.evaluation.views import ( + DefaultEvaluationViewExecutor, + build_alto_view, + build_search_view, + build_text_view, +) +from picarones.formats.alto.parser import parse_alto +from picarones.formats.alto.types import ( + AltoBBox, + AltoDocument, + AltoLine, + AltoPage, + AltoString, + AltoTextBlock, +) +from picarones.formats.alto.writer import write_alto +from picarones.pipeline import ( + CorpusRunner, + PipelineExecutor, + PipelineSpec, + PipelineStep, + RunContext, +) + + +# ────────────────────────────────────────────────────────────────── +# Corpus BnF synthétique +# ────────────────────────────────────────────────────────────────── + + +_GT_TEXTS: dict[str, str] = { + "doc01": "Mariage de Pierre Dupont en 1789 à Paris", + "doc02": "Acte du 14 mars 1856 enregistré à Lyon", + "doc03": "Naissance de Marie Curie en 1867", + "doc04": "Décès du Roi Louis XIV en 1715", + "doc05": "Anne de Bretagne épouse Charles VIII en 1491", +} + + +# Pipeline 1 (simple OCR) : faute typique d'OCR — confusion d/o. +_SIMPLE_OCR_TEXTS: dict[str, str] = { + "doc01": "Mariage de Pierre Dupant en 1789 à Paris", + "doc02": "Acte du 14 mars 1856 enregistre à Lyon", + "doc03": "Naissance de Marie Curie en 1867", + "doc04": "Decés du Roi Louis XIV en 1715", + "doc05": "Anne de Bretagne epouse Charles VIII en 1491", +} + + +# Pipeline 2 (structured OCR) : RAW_TEXT excellent (= GT), ALTO valide. +_STRUCTURED_OCR_TEXTS: dict[str, str] = dict(_GT_TEXTS) + + +# Pipeline 3 : +# - RAW_TEXT intermédiaire dégradé (l'OCR amont est mauvais). +# - CORRECTED_TEXT post-correction LLM = GT (correction excellente). +_OCR_BEFORE_CORRECTION: dict[str, str] = { + "doc01": "Mariage de Pierr Dupant en 178 a Paris", + "doc02": "Acte du 14 mars 1856 enrgistre a Lyon", + "doc03": "Naissance d Marie Curi en 1867", + "doc04": "Deces du Roi Louis XIV en 175", + "doc05": "Anne de Bretagne pouse Charles VII en 1491", +} +_CORRECTED_TEXTS: dict[str, str] = dict(_GT_TEXTS) + + +# ────────────────────────────────────────────────────────────────── +# Fixtures de payload +# ────────────────────────────────────────────────────────────────── + + +def _build_alto(text: str) -> AltoDocument: + return AltoDocument(pages=(AltoPage(blocks=(AltoTextBlock(lines=(AltoLine(strings=tuple( + AltoString(content=w, bbox=AltoBBox(hpos=0, vpos=0, width=10, height=10)) + for w in text.split() + )),),),),),),) + + +def _write_alto_files(tmp_path: Path) -> tuple[Path, dict[str, Path], dict[str, Path]]: + """Écrit GT et candidate ALTO XML pour la pipeline structured.""" + alto_dir = tmp_path / "alto" + alto_dir.mkdir(parents=True, exist_ok=True) + gt_paths: dict[str, Path] = {} + cand_paths: dict[str, Path] = {} + for doc_id, gt_text in _GT_TEXTS.items(): + gt_path = alto_dir / f"{doc_id}.gt.alto.xml" + cand_path = alto_dir / f"{doc_id}.structured.alto.xml" + gt_path.write_bytes(write_alto(_build_alto(gt_text))) + # La pipeline structured produit un ALTO du même texte que la + # GT (excellent moteur structuré). + cand_path.write_bytes(write_alto(_build_alto(_STRUCTURED_OCR_TEXTS[doc_id]))) + gt_paths[doc_id] = gt_path + cand_paths[doc_id] = cand_path + return alto_dir, gt_paths, cand_paths + + +# ────────────────────────────────────────────────────────────────── +# Stubs de pipelines (proxies des adapters réels) +# ────────────────────────────────────────────────────────────────── + + +class _SimpleOCRStub: + """Tesseract-like : RAW_TEXT seul, texte légèrement dégradé.""" + + name = "simple_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def execute(self, inputs, params, context): + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:simple_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + ), + } + + +class _StructuredOCRStub: + """Pero-like : ALTO_XML + RAW_TEXT, texte excellent + structure.""" + + name = "structured_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.ALTO_XML, ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def __init__(self, alto_files_dir: Path) -> None: + self._alto_files_dir = Path(alto_files_dir) + + def execute(self, inputs, params, context): + alto_path = self._alto_files_dir / f"{context.document_id}.structured.alto.xml" + return { + ArtifactType.ALTO_XML: Artifact( + id=f"{context.document_id}:structured_ocr:alto", + document_id=context.document_id, + type=ArtifactType.ALTO_XML, + produced_by_step="ocr", + uri=str(alto_path), + ), + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:structured_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + ), + } + + +class _PoorOCRStub: + """OCR amont du pipeline 3 : RAW_TEXT très dégradé.""" + + name = "poor_ocr" + input_types = frozenset({ArtifactType.IMAGE}) + output_types = frozenset({ArtifactType.RAW_TEXT}) + execution_mode = "io" + + def execute(self, inputs, params, context): + return { + ArtifactType.RAW_TEXT: Artifact( + id=f"{context.document_id}:poor_ocr:raw_text", + document_id=context.document_id, + type=ArtifactType.RAW_TEXT, + produced_by_step="ocr", + ), + } + + +class _LLMCorrectorStub: + """Stub LLM : RAW_TEXT → CORRECTED_TEXT (correction excellente).""" + + name = "llm_corrector" + input_types = frozenset({ArtifactType.RAW_TEXT}) + output_types = frozenset({ArtifactType.CORRECTED_TEXT}) + execution_mode = "io" + + def execute(self, inputs, params, context): + return { + ArtifactType.CORRECTED_TEXT: Artifact( + id=f"{context.document_id}:llm_corrector:corrected", + document_id=context.document_id, + type=ArtifactType.CORRECTED_TEXT, + produced_by_step="llm_correct", + ), + } + + +# ────────────────────────────────────────────────────────────────── +# Setup BenchmarkService +# ────────────────────────────────────────────────────────────────── + + +def _build_service(tmp_path: Path) -> tuple[BenchmarkService, dict[str, Path]]: + alto_dir, gt_paths, _cand_paths = _write_alto_files(tmp_path) + + metrics = MetricRegistry() + # TextView metrics (sur RAW_TEXT/RAW_TEXT, lower_is_better). + for name, fn in ( + ("cer", _stub_cer), + ("wer", _stub_wer), + ("mer", _stub_cer), + ("wil", _stub_wer), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + ), + fn, + ) + # SearchView metrics (sur RAW_TEXT/RAW_TEXT, higher_is_better). + metrics.register( + MetricSpec( + name="searchability_recall", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + searchability_recall, + ) + metrics.register( + MetricSpec( + name="numerical_sequence_preservation", + input_types=(ArtifactType.RAW_TEXT, ArtifactType.RAW_TEXT), + higher_is_better=True, + ), + numerical_sequence_preservation, + ) + # AltoView metrics (sur ALTO_XML/ALTO_XML, higher_is_better). + for name, fn in ( + ("alto_validity", compute_alto_validity), + ("alto_line_count_ratio", compute_line_count_ratio), + ("alto_word_box_coverage", compute_word_box_coverage), + ): + metrics.register( + MetricSpec( + name=name, + input_types=(ArtifactType.ALTO_XML, ArtifactType.ALTO_XML), + higher_is_better=True, + ), + fn, + ) + + projectors = ProjectorRegistry() + projectors.register(AltoToText()) + projectors.register(PageToText()) + projectors.register(CanonicalToText()) + + # Loader hybride : résout chaque artefact selon son type et son + # produced_by_step. La GT raw_text vient de _GT_TEXTS, les sorties + # OCR viennent des dicts par pipeline. + def loader(art: Artifact): + if art.type == ArtifactType.ALTO_XML: + if art.uri is None: + raise KeyError(f"ALTO sans URI : {art.id}") + return parse_alto(Path(art.uri).read_bytes()) + if art.type == ArtifactType.RAW_TEXT: + # Distinction par owner : + if ":simple_ocr:" in art.id: + return _SIMPLE_OCR_TEXTS[art.document_id] + if ":structured_ocr:" in art.id: + return _STRUCTURED_OCR_TEXTS[art.document_id] + if ":poor_ocr:" in art.id: + return _OCR_BEFORE_CORRECTION[art.document_id] + if ":gt:" in art.id: + return _GT_TEXTS[art.document_id] + # Artefact projeté depuis ALTO (id se termine par ":projected_text"). + if art.id.endswith(":projected_text"): + # On reconstitue depuis l'ALTO source via le doc_id. + return _STRUCTURED_OCR_TEXTS[art.document_id] + raise KeyError(f"loader: RAW_TEXT inconnu {art.id}") + if art.type == ArtifactType.CORRECTED_TEXT: + return _CORRECTED_TEXTS[art.document_id] + raise KeyError(f"loader: type non géré pour {art.id} ({art.type})") + + view_executor = DefaultEvaluationViewExecutor.from_registries( + metrics, projectors, loader, + ) + + registry_adapters = { + "simple_ocr": _SimpleOCRStub(), + "structured_ocr": _StructuredOCRStub(alto_dir), + "poor_ocr": _PoorOCRStub(), + "llm_corrector": _LLMCorrectorStub(), + } + pipeline_executor = PipelineExecutor( + adapter_resolver=lambda n: registry_adapters[n], + ) + corpus_runner = CorpusRunner( + pipeline_executor, + max_in_flight=3, + timeout_seconds_per_doc=10.0, + poll_interval_seconds=0.005, + ) + + service = BenchmarkService( + corpus_runner=corpus_runner, + view_executor=view_executor, + code_version="1.0.0-s18-bnf-test", + ) + return service, gt_paths + + +# ────────────────────────────────────────────────────────────────── +# Stubs de métriques texte (CER/WER hors registre typé pour +# isoler S18 du registre nominal — on teste l'orchestration, pas +# le calcul de métrique) +# ────────────────────────────────────────────────────────────────── + + +def _stub_cer(reference: str, hypothesis: str) -> float: + if not reference: + return 0.0 if not hypothesis else 1.0 + common = sum(1 for a, b in zip(reference, hypothesis) if a == b) + return 1.0 - (common / max(len(reference), len(hypothesis))) + + +def _stub_wer(reference: str, hypothesis: str) -> float: + rw = reference.split() + hw = hypothesis.split() + if not rw: + return 0.0 if not hw else 1.0 + common = sum(1 for a, b in zip(rw, hw) if a == b) + return 1.0 - (common / len(rw)) + + +# ────────────────────────────────────────────────────────────────── +# Specs de pipelines + corpus + factories +# ────────────────────────────────────────────────────────────────── + + +def _build_pipelines() -> list[PipelineSpec]: + pipeline_simple = PipelineSpec( + name="pipeline_simple_ocr", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", adapter_name="simple_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ),), + ) + pipeline_structured = PipelineSpec( + name="pipeline_structured_ocr", + initial_inputs=(ArtifactType.IMAGE,), + steps=(PipelineStep( + id="ocr", kind="ocr", adapter_name="structured_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.ALTO_XML, ArtifactType.RAW_TEXT), + ),), + ) + pipeline_correction = PipelineSpec( + name="pipeline_ocr_plus_correction", + initial_inputs=(ArtifactType.IMAGE,), + steps=( + PipelineStep( + id="ocr", kind="ocr", adapter_name="poor_ocr", + input_types=(ArtifactType.IMAGE,), + output_types=(ArtifactType.RAW_TEXT,), + ), + PipelineStep( + id="llm_correct", kind="llm_correct", + adapter_name="llm_corrector", + input_types=(ArtifactType.RAW_TEXT,), + output_types=(ArtifactType.CORRECTED_TEXT,), + ), + ), + ) + return [pipeline_simple, pipeline_structured, pipeline_correction] + + +def _build_corpus(gt_paths: dict[str, Path]) -> CorpusSpec: + # ``image_uri`` et le ``uri`` de la GT RAW_TEXT ne sont jamais lus + # dans S18 — les payloads sont fournis in-memory par le loader. + # On utilise un chemin **sous le tmp_path partagé** pour rester + # portable cross-OS. + base_dir = next(iter(gt_paths.values())).parent + docs = tuple( + DocumentRef( + id=doc_id, + image_uri=str(base_dir / f"{doc_id}.png"), + ground_truths=( + GroundTruthRef( + type=ArtifactType.RAW_TEXT, + uri=str(base_dir / f"{doc_id}.gt.txt"), + ), + GroundTruthRef( + type=ArtifactType.ALTO_XML, + uri=str(gt_paths[doc_id]), + ), + ), + ) + for doc_id in _GT_TEXTS + ) + return CorpusSpec(name="bnf_bms_synthetic", documents=docs) + + +def _build_factories(gt_paths: dict[str, Path]): + def gt_factory(doc, art_type): + # CORRECTED_TEXT candidates compare contre la GT RAW_TEXT — + # les deux sont du texte plat ; la distinction de type ne porte + # que sur le côté candidat (texte modifié par un LLM vs texte + # OCR brut). + effective_type = ( + ArtifactType.RAW_TEXT + if art_type == ArtifactType.CORRECTED_TEXT + else art_type + ) + gt_ref = doc.gt_for(effective_type) + if gt_ref is None: + return None + suffix = ( + "raw_text" if effective_type == ArtifactType.RAW_TEXT + else "alto" if effective_type == ArtifactType.ALTO_XML + else effective_type.value + ) + return Artifact( + id=f"{doc.id}:gt:{suffix}", + document_id=doc.id, + type=effective_type, + uri=gt_ref.uri, + ) + + def inputs_factory(doc): + return {ArtifactType.IMAGE: Artifact( + id=f"{doc.id}:image", document_id=doc.id, + type=ArtifactType.IMAGE, uri=doc.image_uri, + )} + + def ctx_factory(doc, pipeline_name): + return RunContext( + document_id=doc.id, + code_version="1.0.0-s18-bnf-test", + pipeline_name=pipeline_name, + ) + + return gt_factory, inputs_factory, ctx_factory + + +def _run_full_benchmark(tmp_path: Path): + service, gt_paths = _build_service(tmp_path) + pipelines = _build_pipelines() + corpus = _build_corpus(gt_paths) + views = [build_text_view(), build_alto_view(), build_search_view()] + gt_factory, inputs_factory, ctx_factory = _build_factories(gt_paths) + return service, service.run( + corpus=corpus, + pipelines=pipelines, + views=views, + ground_truth_factory=gt_factory, + pipeline_inputs_factory=inputs_factory, + context_factory=ctx_factory, + ) + + +# ────────────────────────────────────────────────────────────────── +# Tests E2E +# ────────────────────────────────────────────────────────────────── + + +class TestStructure: + """Vérifie la structure agrégée du RunResult.""" + + def test_run_executes_all_three_pipelines_on_all_docs(self, tmp_path: Path) -> None: + _, result = _run_full_benchmark(tmp_path) + assert result.n_documents == 5 + for doc_result in result.document_results: + assert len(doc_result.pipeline_results) == 3 + names = {pr.pipeline_name for pr in doc_result.pipeline_results} + assert names == { + "pipeline_simple_ocr", + "pipeline_structured_ocr", + "pipeline_ocr_plus_correction", + } + + def test_total_pipeline_results_count(self, tmp_path: Path) -> None: + """5 docs × 3 pipelines = 15 PipelineResults.""" + _, result = _run_full_benchmark(tmp_path) + total = sum(len(d.pipeline_results) for d in result.document_results) + assert total == 15 + + def test_correction_pipeline_has_two_steps(self, tmp_path: Path) -> None: + """La pipeline de correction a 2 step_results par doc.""" + _, result = _run_full_benchmark(tmp_path) + correction_results = result.pipeline_results_for( + "pipeline_ocr_plus_correction", + ) + assert len(correction_results) == 5 + for pr in correction_results: + assert len(pr.step_results) == 2 + step_ids = {sr.step_id for sr in pr.step_results} + assert step_ids == {"ocr", "llm_correct"} + + +class TestOmissionPattern: + """Vérifie le pattern d'omission entre les 3 vues canoniques.""" + + def test_textview_includes_all_three_pipelines(self, tmp_path: Path) -> None: + _, result = _run_full_benchmark(tmp_path) + text_results = result.view_results_for("text_final") + # 5 docs × (1 + 2 + 2) = 25 ViewResult. + assert len(text_results) == 25 + candidate_owners = { + _owner_of(vr.candidate_artifact_id) for vr in text_results + } + assert candidate_owners == { + "simple_ocr", + "structured_ocr", + "llm_corrector", + "poor_ocr", + } + + def test_altoview_omits_simple_and_correction(self, tmp_path: Path) -> None: + _, result = _run_full_benchmark(tmp_path) + alto_results = result.view_results_for("alto_documentary") + # Seul structured_ocr produit ALTO → 5 docs × 1 = 5. + assert len(alto_results) == 5 + owners = {_owner_of(vr.candidate_artifact_id) for vr in alto_results} + assert owners == {"structured_ocr"} + + def test_searchview_includes_all_three_pipelines(self, tmp_path: Path) -> None: + _, result = _run_full_benchmark(tmp_path) + search_results = result.view_results_for("searchability") + # 5 docs × (1 + 2 + 2) = 25 ViewResult, comme TextView. + assert len(search_results) == 25 + + +class TestNoGlobalWinner: + """Démontre qu'aucune pipeline n'est globalement meilleure : + chaque vue a un classement différent.""" + + def test_textview_best_is_structured_or_correction(self, tmp_path: Path) -> None: + """En CER, structured et correction ont un texte parfait + (CER 0), simple a un texte légèrement dégradé (CER > 0).""" + _, result = _run_full_benchmark(tmp_path) + cer_by_pipeline_owner = _mean_metric_by_owner( + result.view_results_for("text_final"), + metric="cer", + ) + # structured et correction (sur CORRECTED_TEXT) doivent battre simple. + assert cer_by_pipeline_owner["simple_ocr"] > 0.0 + assert cer_by_pipeline_owner["structured_ocr"] == 0.0 + assert cer_by_pipeline_owner["llm_corrector"] == 0.0 + + def test_altoview_only_structured_competes(self, tmp_path: Path) -> None: + """AltoView ne peut être gagnée que par structured_ocr (les + autres sont OMIS). Cela démontre concrètement le pattern + d'omission : pas de score factice 0 pour les pipelines non + éligibles.""" + _, result = _run_full_benchmark(tmp_path) + alto_owners = { + _owner_of(vr.candidate_artifact_id) + for vr in result.view_results_for("alto_documentary") + } + assert alto_owners == {"structured_ocr"} + + def test_search_view_best_includes_correction_and_structured( + self, tmp_path: Path, + ) -> None: + """En searchability_recall, structured_ocr et le CORRECTED_TEXT + sont parfaits (rappel 1.0), simple_ocr et poor_ocr sont en + dessous.""" + _, result = _run_full_benchmark(tmp_path) + recall_by_owner = _mean_metric_by_owner( + result.view_results_for("searchability"), + metric="searchability_recall", + ) + assert recall_by_owner["structured_ocr"] == 1.0 + assert recall_by_owner["llm_corrector"] == 1.0 + # simple_ocr a quelques fautes de tokens (Dupont/Dupant, + # enregistré/enregistre, etc.). Mais Levenshtein ≤ 2 retrouve + # tout, donc le rappel reste à 1.0 — ce qui démontre le bon + # comportement de la métrique : les fautes de 1 char ne + # cassent pas la recherchabilité. + assert recall_by_owner["simple_ocr"] == 1.0 + # poor_ocr (texte amont du pipeline 3) : "Pierr" vs "Pierre" + # passe (dist 1) mais "178" vs "1789" est dist 1 ≤ 2 → passe. + # On vérifie au moins que c'est >= 0 et < ou égal aux autres. + assert 0.0 <= recall_by_owner["poor_ocr"] <= 1.0 + + def test_no_pipeline_wins_all_three_views(self, tmp_path: Path) -> None: + """Garde-fou : aucune pipeline ne gagne TextView ET AltoView + ET SearchView (pas de gagnant global). + + - simple_ocr : OMIS d'AltoView. + - structured_ocr : présent partout, gagne AltoView, ex aequo + en TextView avec correction. + - pipeline_ocr_plus_correction : OMIS d'AltoView. + """ + _, result = _run_full_benchmark(tmp_path) + pipelines_in_alto = { + _pipeline_name_for_owner(_owner_of(vr.candidate_artifact_id)) + for vr in result.view_results_for("alto_documentary") + } + assert pipelines_in_alto == {"pipeline_structured_ocr"} + # → si aucun gagnant global possible, c'est par construction : + # 2 des 3 pipelines sont omises de la 3ᵉ vue. + + +class TestPersistence: + """Vérifie que le run BnF complet est persisté lisiblement.""" + + def test_persist_writes_three_files(self, tmp_path: Path) -> None: + service, result = _run_full_benchmark(tmp_path) + files = service.persist(result, tmp_path / "bnf_run") + assert files["manifest"].exists() + assert files["pipeline_results"].exists() + assert files["view_results"].exists() + + def test_manifest_records_all_three_pipelines_and_views( + self, tmp_path: Path, + ) -> None: + service, result = _run_full_benchmark(tmp_path) + files = service.persist(result, tmp_path / "bnf_run") + manifest = json.loads(files["manifest"].read_text()) + assert manifest["corpus_name"] == "bnf_bms_synthetic" + assert manifest["n_documents"] == 5 + assert sorted(manifest["pipeline_names"]) == sorted([ + "pipeline_simple_ocr", + "pipeline_structured_ocr", + "pipeline_ocr_plus_correction", + ]) + assert len(manifest["view_specs"]) == 3 + view_names = {v["name"] for v in manifest["view_specs"]} + assert view_names == {"text_final", "alto_documentary", "searchability"} + + def test_pipeline_jsonl_has_15_lines(self, tmp_path: Path) -> None: + service, result = _run_full_benchmark(tmp_path) + files = service.persist(result, tmp_path / "bnf_run") + lines = files["pipeline_results"].read_text().strip().split("\n") + assert len(lines) == 15 + for line in lines: + payload = json.loads(line) + assert payload["document_id"] in _GT_TEXTS + assert payload["pipeline_name"] in { + "pipeline_simple_ocr", + "pipeline_structured_ocr", + "pipeline_ocr_plus_correction", + } + + def test_view_jsonl_has_55_lines(self, tmp_path: Path) -> None: + """25 (TextView) + 5 (AltoView) + 25 (SearchView) = 55.""" + service, result = _run_full_benchmark(tmp_path) + files = service.persist(result, tmp_path / "bnf_run") + lines = files["view_results"].read_text().strip().split("\n") + assert len(lines) == 55 + view_count: dict[str, int] = {} + for line in lines: + payload = json.loads(line) + view_count[payload["view_name"]] = view_count.get( + payload["view_name"], 0, + ) + 1 + assert view_count == { + "text_final": 25, + "alto_documentary": 5, + "searchability": 25, + } + + +# ────────────────────────────────────────────────────────────────── +# Helpers +# ────────────────────────────────────────────────────────────────── + + +_OWNER_RE = re.compile( + r":(simple_ocr|structured_ocr|poor_ocr|llm_corrector)(?::|$)", +) + + +def _owner_of(artifact_id: str) -> str: + """Extrait le 'owner' (adapter qui a produit l'artefact) à partir + de l'id ``::``.""" + match = _OWNER_RE.search(artifact_id) + if match is None: + raise AssertionError(f"impossible d'extraire owner de {artifact_id!r}") + return match.group(1) + + +_OWNER_TO_PIPELINE = { + "simple_ocr": "pipeline_simple_ocr", + "structured_ocr": "pipeline_structured_ocr", + "poor_ocr": "pipeline_ocr_plus_correction", + "llm_corrector": "pipeline_ocr_plus_correction", +} + + +def _pipeline_name_for_owner(owner: str) -> str: + return _OWNER_TO_PIPELINE[owner] + + +def _mean_metric_by_owner(view_results, *, metric: str) -> dict[str, float]: + """Moyenne d'une métrique par owner d'artefact candidat (somme/n).""" + sums: dict[str, float] = {} + counts: dict[str, int] = {} + for vr in view_results: + if metric not in vr.metric_values: + continue + owner = _owner_of(vr.candidate_artifact_id) + sums[owner] = sums.get(owner, 0.0) + float(vr.metric_values[metric]) + counts[owner] = counts.get(owner, 0) + 1 + return {owner: sums[owner] / counts[owner] for owner in sums} diff --git a/tests/integration/test_sprint_a14_s21_report_service.py b/tests/integration/test_sprint_a14_s21_report_service.py new file mode 100644 index 0000000000000000000000000000000000000000..0e046d0bffbbb8e25208ac8a22a0ad2e73c1f3e3 --- /dev/null +++ b/tests/integration/test_sprint_a14_s21_report_service.py @@ -0,0 +1,466 @@ +"""Sprint A14-S21 — ``ReportService`` (rendu HTML depuis ``RunResult``). + +Couverture : + +- Rendu basique : header (corpus, run_id, code_version, timestamps), + vue d'ensemble pipelines (succès/échecs/durée), une section par + vue avec table pipeline × métriques. +- **Pattern d'omission visible** : un pipeline qui ne produit pas + d'artefact éligible affiche ``OMIS`` (pas un ``0`` factice). +- Anti-injection : ``corpus_name`` / ``view.name`` / + ``pipeline_name`` contenant ``", + views=(view,), + ) + result = RunResult(manifest=manifest, document_results=()) + html = ReportService().render(result) + assert "" not in html + assert "<script>alert(1)</script>" in html + + def test_pipeline_name_with_html_is_escaped(self) -> None: + view = _empty_view() + manifest = _manifest( + pipeline_names=("",), + views=(view,), + ) + result = RunResult(manifest=manifest, document_results=()) + html = ReportService().render(result) + assert " None: + view = _empty_view( + name="evil_name", + description='', + ) + manifest = _manifest(views=(view,)) + result = RunResult(manifest=manifest, document_results=()) + html = ReportService().render(result) + assert "