Spaces:
Running
sprint49: Mistral OCR — exposition des token_confidences quand disponibles
Browse filesSuite des Sprints 47 (Tesseract) et 48 (Pero OCR). Mistral OCR a deux
chemins : endpoint dédié /v1/ocr (modèle mistral-ocr-latest) qui peut
exposer des champs confidence à différents niveaux, et API chat/vision
(pixtral-*) qui ne fournit pas de confidences.
Refactor signatures
- _run_ocr_with_response(image_path) → (text, raw_response). Centralise
les deux chemins.
- _run_ocr_native_api retourne maintenant (text, raw_response_dict)
au lieu de juste text.
- _run_ocr reste rétrocompat : appelle _run_ocr_with_response et
retourne uniquement le texte.
- Le chemin chat/vision (pixtral) retourne (text, None) car aucune
confidence n'est disponible.
Extraction des confidences
- _extract_token_confidences_from_response parse la réponse en
cascade :
1. pages[i].words[j] avec {"text", "confidence"} → extraction
directe
2. pages[i].lines[j] avec {"text", "confidence"} → propagation à
chaque mot (pattern Pero Sprint 48)
3. pages[i].blocks[j] → idem
- Filtrage cohérent avec Tesseract/Pero : texte vide, conf None,
conf négative ignorés.
- Si aucune confidence exploitable n'est trouvée (markdown brut),
retourne None.
- Flag config expose_confidences: false cohérent avec les autres
adapters.
run() surcharge BaseOCREngine.run() pour intégrer l'extraction des
confidences. L'API est appelée une seule fois — aucun overhead
supplémentaire.
Régression corrigée
- tests/test_sprint6_web_interface : test
test_mistral_ocr_latest_routes_to_native_api utilisait un mock qui
retournait juste un string ; mis à jour pour la nouvelle signature
(text, dict).
Tests : +17 dans test_sprint49_mistral_confidences.py couvrant :
- extraction des trois niveaux (words explicites, lines/blocks
propagés)
- combinaison words + lines
- filtrage texte vide / conf None / négative
- cas dégénérés (None, dict vide, pas de pages, markdown sans
confidences, types invalides)
- flag expose_confidences=False
- surcharge run() avec mock du chemin réseau (chat/vision sans
confidences, échec API)
- intégration runner avec calibration_metrics correctement calculée
Suite complète : 1887 → 1904 passed, 2 skipped, 0 failed.
Reste à adapter Google Vision et Azure DI sur le même pattern.
- CHANGELOG.md +44 -8
- CLAUDE.md +2 -1
- picarones/engines/mistral_ocr.py +169 -6
- tests/test_sprint49_mistral_confidences.py +301 -0
- tests/test_sprint6_web_interface.py +4 -1
|
@@ -16,6 +16,41 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
- **Sprint 48 — Adapter Pero OCR : exposition des `token_confidences`
|
| 20 |
natifs.** Suite directe du Sprint 47 (Tesseract). Pero OCR fournit
|
| 21 |
une confidence par ligne (``transcription_confidence``, probabilité
|
|
@@ -541,17 +576,18 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 541 |
|
| 542 |
### Tests
|
| 543 |
|
| 544 |
-
- 1478 →
|
| 545 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 546 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 547 |
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
|
| 548 |
-
+9 Sprint 47, +14 Sprint 48). Aucune régression.
|
| 549 |
-
close ; Étape 2 du plan d'évolution : inter-moteurs
|
| 550 |
-
NER (A.II.1.a), calibration (A.II.1.b) et
|
| 551 |
-
livrés bout-en-bout calcul → runner →
|
| 552 |
-
défaut livré (Sprint 44) ; Tesseract
|
| 553 |
-
(Sprint 48)
|
| 554 |
-
|
|
|
|
| 555 |
|
| 556 |
---
|
| 557 |
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 49 — Adapter Mistral OCR : exposition des
|
| 20 |
+
`token_confidences` quand l'API les fournit.** Suite des Sprints
|
| 21 |
+
47 (Tesseract) et 48 (Pero OCR). Mistral OCR a deux chemins :
|
| 22 |
+
l'endpoint dédié `/v1/ocr` (modèle `mistral-ocr-latest`) qui peut
|
| 23 |
+
exposer des champs `confidence` à différents niveaux, et l'API
|
| 24 |
+
chat/vision (`pixtral-*`) qui ne fournit pas de confidences.
|
| 25 |
+
- Refactor : nouvelle méthode `_run_ocr_with_response(image_path)`
|
| 26 |
+
retourne `(text, raw_response)`. `_run_ocr_native_api` retourne
|
| 27 |
+
désormais aussi le JSON brut. Le chemin chat/vision retourne
|
| 28 |
+
`(text, None)` car aucune confidence n'est disponible.
|
| 29 |
+
- `_extract_token_confidences_from_response` parse la réponse
|
| 30 |
+
`/v1/ocr` en cascade :
|
| 31 |
+
1. `pages[i].words[j]` avec `{"text", "confidence"}` →
|
| 32 |
+
extraction directe
|
| 33 |
+
2. `pages[i].lines[j]` avec `{"text", "confidence"}` →
|
| 34 |
+
propagation de la confidence à chaque mot (pattern Pero
|
| 35 |
+
Sprint 48)
|
| 36 |
+
3. `pages[i].blocks[j]` → idem
|
| 37 |
+
- Filtrage cohérent avec Tesseract/Pero : texte vide, confidence
|
| 38 |
+
None, confidence négative → ignorés.
|
| 39 |
+
- Si l'API ne retourne aucun champ `confidence` exploitable
|
| 40 |
+
(cas courant si Mistral retourne uniquement du markdown), ou si
|
| 41 |
+
on est sur le chemin chat/vision, `token_confidences = None`.
|
| 42 |
+
- Nouveau paramètre config `expose_confidences: false` cohérent
|
| 43 |
+
avec les autres adapters.
|
| 44 |
+
- L'API est appelée **une seule fois** ; le coût est strictement
|
| 45 |
+
identique à l'implémentation historique.
|
| 46 |
+
- +17 tests dans `test_sprint49_mistral_confidences.py` couvrant
|
| 47 |
+
l'extraction (words explicites, propagation lines/blocks,
|
| 48 |
+
combinaison, filtrage texte vide / conf None / négative), les
|
| 49 |
+
cas dégénérés (None, dict vide, pas de pages, markdown sans
|
| 50 |
+
confidences, types invalides), le flag `expose_confidences=False`,
|
| 51 |
+
la surcharge `run()` (mock du chemin réseau, chat/vision sans
|
| 52 |
+
confidences, échec API), et l'intégration runner.
|
| 53 |
+
|
| 54 |
- **Sprint 48 — Adapter Pero OCR : exposition des `token_confidences`
|
| 55 |
natifs.** Suite directe du Sprint 47 (Tesseract). Pero OCR fournit
|
| 56 |
une confidence par ligne (``transcription_confidence``, probabilité
|
|
|
|
| 576 |
|
| 577 |
### Tests
|
| 578 |
|
| 579 |
+
- 1478 → 1904 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
|
| 580 |
+27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
|
| 581 |
+32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
|
| 582 |
+43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
|
| 583 |
+
+9 Sprint 47, +14 Sprint 48, +17 Sprint 49). Aucune régression.
|
| 584 |
+
**Phase 0 close ; Étape 2 du plan d'évolution : inter-moteurs
|
| 585 |
+
(A.II.1.c), NER (A.II.1.a), calibration (A.II.1.b) et
|
| 586 |
+
stratification (A.III) livrés bout-en-bout calcul → runner →
|
| 587 |
+
HTML ; A.I.2 médiane par défaut livré (Sprint 44) ; Tesseract
|
| 588 |
+
(Sprint 47), Pero OCR (Sprint 48) et Mistral OCR (Sprint 49)
|
| 589 |
+
adaptés pour exposer leurs `token_confidences` natifs. Reste à
|
| 590 |
+
adapter Google Vision et Azure DI.**
|
| 591 |
|
| 592 |
---
|
| 593 |
|
|
@@ -207,6 +207,7 @@ AZURE_DOC_INTEL_KEY=...
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
|
|
|
| 210 |
| 48 | **Sprint 17 du plan d'évolution 2026 — Étape 2 / adaptation engines : Pero OCR expose ses `token_confidences` natifs**. Suite directe du Sprint 47 (Tesseract). Pero fournit ``line.transcription_confidence`` (probabilité CTC moyenne par ligne) ; l'adapter la propage à chaque mot de la ligne. ``PeroOCREngine.run()`` est surchargé avec un seul appel ``parser.process_page`` qui produit le ``page_layout`` ; texte ET confidences en sont extraits sans coût supplémentaire (vs Tesseract qui doit faire deux appels distincts). Refactor : ``_run_pero_pipeline(image_path) → (text, page_layout)`` centralise l'appel ; ``_run_ocr`` devient un wrapper trivial pour rétrocompat. ``_extract_token_confidences_from_layout`` parcourt regions/lines, applique ``transcription_confidence`` à chaque mot, ignore transcription vide / conf None / conf négative, retourne ``None`` si aucune ligne n'avait de confidence exploitable. Flag ``expose_confidences: false`` cohérent avec Tesseract. +14 tests dans ``test_sprint48_pero_confidences.py`` (extraction layout, multi-lignes, cas dégénérés, surcharge run avec mocks, intégration runner, fallback Pero absent). **Verrou levé** : un benchmark Pero OCR produit désormais automatiquement ECE/MCE et reliability diagram dans le rapport, sans configuration. Reste Mistral OCR, Google Vision et Azure DI à adapter. |
|
| 211 |
| 47 | **Sprint 16 du plan d'évolution 2026 — Étape 2 / adaptation engines : Tesseract expose ses `token_confidences` natifs**. Premier des engines adaptés au câblage calibration du Sprint 42. `TesseractEngine.run()` est surchargé : appelle `image_to_string` pour le texte (rétrocompat octet par octet) **et** `image_to_data` pour les confidences mot par mot, retourne un `EngineResult` avec `token_confidences = [{"token": str, "confidence": float}, …]` (confidence ∈ [0, 100], le runner Sprint 42 normalise en [0, 1]). Helper `_extract_token_confidences` séparé : si `image_to_data` lève, l'OCR continue et `token_confidences = None` (warning explicite). Filtrage à la source des non-mots Tesseract (conf = -1), tokens vides, longueurs incompatibles. Nouveau paramètre config `expose_confidences: false` pour désactiver le second appel. Coût additionnel : un appel `image_to_data` par image — le texte d'`image_to_string` n'est jamais reconstruit depuis `image_to_data` (préservation stricte du comportement historique). +9 tests dans `test_sprint47_tesseract_confidences.py` (mock pytesseract, exposition, rétrocompat texte, flag `expose_confidences=False`, fallback gracieux, filtrage, intégration runner). **Verrou levé** : un benchmark Tesseract produit désormais automatiquement ECE/MCE/reliability diagram dans le rapport, sans configuration. Reste Pero, Mistral OCR, Google Vision, Azure DI à adapter. |
|
| 212 |
| 46 | **Sprint 15 du plan d'évolution 2026 — Étape 2 / axe A.III : vue HTML stratifiée + détecteur narratif (clôture A.III)**. Suite directe du Sprint 45 (couche backend). Nouveau module `picarones/report/stratification_render.py` : `build_stratified_ranking_html` rend un `<details>` natif (collapsible sans JS) par strate avec tableau moteur × (médiane, moyenne, docs), cellule médiane colorée par gradient vert→rouge, premier `<details>` ouvert par défaut, bandeau d'avertissement en tête si `corpus_homogeneity` fourni. `_build_report_data` expose `available_strata`/`stratified_ranking`/`corpus_homogeneity` au top-level ; `view_ranking.html` insère le bloc après le tableau principal **uniquement si stratification disponible**. Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45, importance MEDIUM ou HIGH selon le gap) + détecteur `detect_stratification_recommended` (seuil 5 points / 10 points de CER inter-strate). Templates FR/EN sans nombres en dur. L'arbitre marque la paire `{GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}` comme complémentaire. +8 clés i18n FR/EN. Anti-injection HTML via `html.escape`. +38 tests dans `test_sprint46_stratification_html.py`. **Verrou levé** : A.III (stratification) est désormais livré bout-en-bout — couche backend (Sprint 45) + vue HTML + détecteur narratif (Sprint 46) ; le lecteur du rapport voit immédiatement quand le corpus est hétérogène et est invité à consulter la vue stratifiée. |
|
|
@@ -266,7 +267,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 266 |
## Contexte développement
|
| 267 |
|
| 268 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 269 |
-
- **Tests** :
|
| 270 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 271 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 272 |
- **Transcript de la conversation de développement** :
|
|
|
|
| 207 |
| 33 | **Sprint 2 du plan d'évolution 2026 — Phase 0.2 : interface module générique**. Nouveau module `picarones/core/modules.py` avec l'enum `ArtifactType` (IMAGE, TEXT, ALTO, PAGE, ENTITIES, READING_ORDER) et la classe abstraite `BaseModule` qui déclare `input_types`/`output_types`, `execution_mode` (`"io"`/`"cpu"`), une méthode `process(dict[ArtifactType, Any]) → dict[ArtifactType, Any]`, et des helpers `validate_inputs`/`validate_outputs`. `BaseOCREngine` (`picarones/engines/base.py`) hérite désormais de `BaseModule` avec `input_types=(IMAGE,)` et `output_types=(TEXT,)` ; sa nouvelle méthode `process` wrappe l'API historique `run()`. Aucun adaptateur OCR existant n'est touché — `test_engines.py` passe à 20/20 sans modification. +23 tests dans `test_sprint33_module_interface.py` (contrat, validation, MockModule TEXT→ALTO démonstratif comme demandé par le plan, délégation `BaseOCREngine.process → run`, cohérence ArtifactType/GTLevel). **Verrou levé** : un même runner peut maintenant exécuter un OCR (image→texte), un mappeur VLM→ALTO, un rewriter ALTO→ALTO, un module NER (texte→entités), etc. — fondation directe pour l'axe B du plan. |
|
| 208 |
| 34 | **Sprint 3 du plan d'évolution 2026 — Phase 0.3 : registre typé de métriques (clôture Phase 0)**. Nouveaux modules `picarones/core/metric_registry.py` (`MetricSpec`, `@register_metric`, `select_metrics`, `compute_at_junction`) et `picarones/core/builtin_metrics.py` qui enregistre `cer`, `wer`, `mer`, `wil` sur `(TEXT, TEXT)` plus un stub `text_preservation_after_reconstruction` sur `(TEXT, ALTO)` comme preuve de concept de jonction hétérogène. **Approche strictement additive** : ni `metrics.py` ni `compute_metrics` ne sont modifiés, le rapport HTML reste identique octet par octet. La sélection par signature de types est exacte (pas de coercion). +21 tests dans `test_sprint34_metric_registry.py`, dont une parité numérique CER/WER/MER/WIL avec `compute_metrics` legacy à 1e-9 près sur 4 paires de textes. **Verrou levé** : le runner d'une pipeline composée peut maintenant calculer automatiquement la métrique adéquate à chaque jonction de son DAG selon les types d'artefacts produits/attendus — fondation directe pour la métrique d'absorption d'erreur (acte B.3) et toutes les métriques structurelles à venir (Layout F1, reading order F1, NER). |
|
| 209 |
| 35 | **Sprint 4 du plan d'évolution 2026 — Étape 2 / axe A : métriques inter-moteurs (couche de calcul)**. Nouveau module `picarones/core/inter_engine.py` qui répond à deux questions distinctes mais liées : *(a) à quel point les moteurs font-ils des erreurs de natures différentes ?* via `kl_divergence`, `jensen_shannon_divergence` (symétrique, bornée `[0, 1]`), et `taxonomy_divergence_matrix` qui construit la matrice triangulaire inter-moteurs ; *(b) quel CER serait atteignable si on combinait les moteurs ?* via `oracle_token_recall` (proxy bag-of-words, borne supérieure du recall atteignable), `complementarity_gap` (oracle vs meilleur moteur seul, gap absolu/relatif), et `pairwise_disagreement_rate`. Fonctions pures, sans I/O ni intégration runner — la couche de calcul est livrée indépendamment, le câblage narratif (`ENSEMBLE_OPPORTUNITY`) et HTML (matrice de divergence, badge oracle) suit au Sprint 36. +27 tests couvrant les invariants mathématiques (KL ≥ 0, KL(p,p) = 0, JS symétrique et bornée, oracle ≥ best_single, multiplicité respectée), les cas concrets (deux moteurs spécialisés sortent comme candidats ensemble, complémentarité parfaite atteint oracle = 1), et les garde-fous (référence vide, hypothèses vides, métrique inconnue). |
|
| 210 |
+
| 49 | **Sprint 18 du plan d'évolution 2026 — Étape 2 / adaptation engines : Mistral OCR expose ses `token_confidences` quand disponibles**. Mistral OCR a deux chemins : endpoint dédié `/v1/ocr` (qui peut exposer des `confidence` au niveau page/block/line/word selon le modèle) et API chat/vision (`pixtral-*`, sans confidences). Refactor : `_run_ocr_with_response(image_path) → (text, raw_response)` centralise les deux chemins. `_extract_token_confidences_from_response` parse la réponse en cascade — words explicites d'abord, puis propagation depuis lines/blocks (pattern Pero Sprint 48). Filtrage cohérent avec Tesseract/Pero (texte vide, conf None, conf négative ignorés). Si la réponse ne contient aucune confidence exploitable (markdown brut) ou si on est sur chat/vision, `token_confidences = None`. Flag `expose_confidences: false`. L'API est appelée une seule fois — coût identique à l'implémentation historique. +17 tests dans `test_sprint49_mistral_confidences.py` (extraction des trois niveaux, combinaison words+lines, cas dégénérés sur 5 cas, flag, surcharge `run()` avec mocks, chat/vision, échec API, intégration runner). **Verrou levé** : un benchmark Mistral OCR produit automatiquement ECE/MCE/reliability quand l'API expose ses confidences. Reste Google Vision et Azure DI à adapter. |
|
| 211 |
| 48 | **Sprint 17 du plan d'évolution 2026 — Étape 2 / adaptation engines : Pero OCR expose ses `token_confidences` natifs**. Suite directe du Sprint 47 (Tesseract). Pero fournit ``line.transcription_confidence`` (probabilité CTC moyenne par ligne) ; l'adapter la propage à chaque mot de la ligne. ``PeroOCREngine.run()`` est surchargé avec un seul appel ``parser.process_page`` qui produit le ``page_layout`` ; texte ET confidences en sont extraits sans coût supplémentaire (vs Tesseract qui doit faire deux appels distincts). Refactor : ``_run_pero_pipeline(image_path) → (text, page_layout)`` centralise l'appel ; ``_run_ocr`` devient un wrapper trivial pour rétrocompat. ``_extract_token_confidences_from_layout`` parcourt regions/lines, applique ``transcription_confidence`` à chaque mot, ignore transcription vide / conf None / conf négative, retourne ``None`` si aucune ligne n'avait de confidence exploitable. Flag ``expose_confidences: false`` cohérent avec Tesseract. +14 tests dans ``test_sprint48_pero_confidences.py`` (extraction layout, multi-lignes, cas dégénérés, surcharge run avec mocks, intégration runner, fallback Pero absent). **Verrou levé** : un benchmark Pero OCR produit désormais automatiquement ECE/MCE et reliability diagram dans le rapport, sans configuration. Reste Mistral OCR, Google Vision et Azure DI à adapter. |
|
| 212 |
| 47 | **Sprint 16 du plan d'évolution 2026 — Étape 2 / adaptation engines : Tesseract expose ses `token_confidences` natifs**. Premier des engines adaptés au câblage calibration du Sprint 42. `TesseractEngine.run()` est surchargé : appelle `image_to_string` pour le texte (rétrocompat octet par octet) **et** `image_to_data` pour les confidences mot par mot, retourne un `EngineResult` avec `token_confidences = [{"token": str, "confidence": float}, …]` (confidence ∈ [0, 100], le runner Sprint 42 normalise en [0, 1]). Helper `_extract_token_confidences` séparé : si `image_to_data` lève, l'OCR continue et `token_confidences = None` (warning explicite). Filtrage à la source des non-mots Tesseract (conf = -1), tokens vides, longueurs incompatibles. Nouveau paramètre config `expose_confidences: false` pour désactiver le second appel. Coût additionnel : un appel `image_to_data` par image — le texte d'`image_to_string` n'est jamais reconstruit depuis `image_to_data` (préservation stricte du comportement historique). +9 tests dans `test_sprint47_tesseract_confidences.py` (mock pytesseract, exposition, rétrocompat texte, flag `expose_confidences=False`, fallback gracieux, filtrage, intégration runner). **Verrou levé** : un benchmark Tesseract produit désormais automatiquement ECE/MCE/reliability diagram dans le rapport, sans configuration. Reste Pero, Mistral OCR, Google Vision, Azure DI à adapter. |
|
| 213 |
| 46 | **Sprint 15 du plan d'évolution 2026 — Étape 2 / axe A.III : vue HTML stratifiée + détecteur narratif (clôture A.III)**. Suite directe du Sprint 45 (couche backend). Nouveau module `picarones/report/stratification_render.py` : `build_stratified_ranking_html` rend un `<details>` natif (collapsible sans JS) par strate avec tableau moteur × (médiane, moyenne, docs), cellule médiane colorée par gradient vert→rouge, premier `<details>` ouvert par défaut, bandeau d'avertissement en tête si `corpus_homogeneity` fourni. `_build_report_data` expose `available_strata`/`stratified_ranking`/`corpus_homogeneity` au top-level ; `view_ranking.html` insère le bloc après le tableau principal **uniquement si stratification disponible**. Nouveau `FactType.STRATIFICATION_RECOMMENDED` (priority 45, importance MEDIUM ou HIGH selon le gap) + détecteur `detect_stratification_recommended` (seuil 5 points / 10 points de CER inter-strate). Templates FR/EN sans nombres en dur. L'arbitre marque la paire `{GLOBAL_LEADER_CER, STRATIFICATION_RECOMMENDED}` comme complémentaire. +8 clés i18n FR/EN. Anti-injection HTML via `html.escape`. +38 tests dans `test_sprint46_stratification_html.py`. **Verrou levé** : A.III (stratification) est désormais livré bout-en-bout — couche backend (Sprint 45) + vue HTML + détecteur narratif (Sprint 46) ; le lecteur du rapport voit immédiatement quand le corpus est hétérogène et est invité à consulter la vue stratifiée. |
|
|
|
|
| 267 |
## Contexte développement
|
| 268 |
|
| 269 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 270 |
+
- **Tests** : 1904 passed, 2 skipped (Sprints 32-34 = Phase 0 close ; Sprints 35-37 = inter-moteurs livrés bout-en-bout ; Sprints 38+40+41 = NER livré bout-en-bout ; Sprints 39+42+43 = calibration livrée bout-en-bout côté rapport ; Sprint 44 = médiane par défaut ; Sprints 45+46 = stratification A.III livrée bout-en-bout ; Sprints 47+48+49 = Tesseract, Pero OCR et Mistral OCR adaptés pour confidences natives)
|
| 271 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 272 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 273 |
- **Transcript de la conversation de développement** :
|
|
@@ -6,16 +6,31 @@ patrimoniaux via le modèle multimodal Mistral.
|
|
| 6 |
Clé API : variable d'environnement ``MISTRAL_API_KEY``.
|
| 7 |
|
| 8 |
Documentation API : https://docs.mistral.ai/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
| 12 |
|
| 13 |
import base64
|
|
|
|
| 14 |
import os
|
|
|
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import Optional
|
|
|
|
|
|
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
|
| 21 |
class MistralOCREngine(BaseOCREngine):
|
|
@@ -31,6 +46,10 @@ class MistralOCREngine(BaseOCREngine):
|
|
| 31 |
Prompt envoyé avec l'image. Défaut : instruction générique de transcription.
|
| 32 |
max_tokens : int
|
| 33 |
Limite de tokens en sortie (défaut : 4096).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
"""
|
| 35 |
|
| 36 |
@property
|
|
@@ -52,6 +71,20 @@ class MistralOCREngine(BaseOCREngine):
|
|
| 52 |
self._max_tokens = int(self.config.get("max_tokens", 4096))
|
| 53 |
|
| 54 |
def _run_ocr(self, image_path: Path) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
if not self._api_key:
|
| 56 |
raise RuntimeError(
|
| 57 |
"Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY"
|
|
@@ -69,10 +102,14 @@ class MistralOCREngine(BaseOCREngine):
|
|
| 69 |
|
| 70 |
if "mistral-ocr" in self._model.lower():
|
| 71 |
return self._run_ocr_native_api(image_url)
|
| 72 |
-
return self._run_ocr_vision_api(image_url)
|
|
|
|
|
|
|
|
|
|
| 73 |
|
| 74 |
-
|
| 75 |
-
|
|
|
|
| 76 |
import json
|
| 77 |
import urllib.request
|
| 78 |
|
|
@@ -92,7 +129,8 @@ class MistralOCREngine(BaseOCREngine):
|
|
| 92 |
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 93 |
data = json.loads(resp.read().decode())
|
| 94 |
pages = data.get("pages", [])
|
| 95 |
-
|
|
|
|
| 96 |
|
| 97 |
def _run_ocr_vision_api(self, image_url: str) -> str:
|
| 98 |
"""API vision/chat Mistral (pour pixtral-12b, pixtral-large, etc.)."""
|
|
@@ -121,3 +159,128 @@ class MistralOCREngine(BaseOCREngine):
|
|
| 121 |
max_tokens=self._max_tokens,
|
| 122 |
)
|
| 123 |
return response.choices[0].message.content or ""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
Clé API : variable d'environnement ``MISTRAL_API_KEY``.
|
| 7 |
|
| 8 |
Documentation API : https://docs.mistral.ai/
|
| 9 |
+
|
| 10 |
+
Sprint 49 — exposition des token_confidences
|
| 11 |
+
---------------------------------------------
|
| 12 |
+
L'API ``/v1/ocr`` peut renvoyer des champs ``confidence`` au niveau
|
| 13 |
+
page, block, line ou word selon le modèle. L'adapter parse la réponse
|
| 14 |
+
brute (``raw_response``) en plus du markdown : il cherche
|
| 15 |
+
récursivement les paires ``(text, confidence)`` exploitables et les
|
| 16 |
+
retourne au format Sprint 42. Si la réponse ne contient aucun champ
|
| 17 |
+
de confidence (cas de l'API chat/vision pour ``pixtral-*``),
|
| 18 |
+
``token_confidences = None``.
|
| 19 |
"""
|
| 20 |
|
| 21 |
from __future__ import annotations
|
| 22 |
|
| 23 |
import base64
|
| 24 |
+
import logging
|
| 25 |
import os
|
| 26 |
+
import time
|
| 27 |
from pathlib import Path
|
| 28 |
+
from typing import Any, Optional
|
| 29 |
+
|
| 30 |
+
from picarones.engines.base import BaseOCREngine, EngineResult
|
| 31 |
+
|
| 32 |
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
|
| 35 |
|
| 36 |
class MistralOCREngine(BaseOCREngine):
|
|
|
|
| 46 |
Prompt envoyé avec l'image. Défaut : instruction générique de transcription.
|
| 47 |
max_tokens : int
|
| 48 |
Limite de tokens en sortie (défaut : 4096).
|
| 49 |
+
expose_confidences : bool
|
| 50 |
+
``True`` (défaut) : extrait les ``confidence`` de la réponse
|
| 51 |
+
``/v1/ocr`` quand elles sont présentes (Sprint 49). ``False`` :
|
| 52 |
+
désactive complètement l'extraction.
|
| 53 |
"""
|
| 54 |
|
| 55 |
@property
|
|
|
|
| 71 |
self._max_tokens = int(self.config.get("max_tokens", 4096))
|
| 72 |
|
| 73 |
def _run_ocr(self, image_path: Path) -> str:
|
| 74 |
+
"""API rétrocompat : retourne uniquement le texte."""
|
| 75 |
+
text, _raw = self._run_ocr_with_response(image_path)
|
| 76 |
+
return text
|
| 77 |
+
|
| 78 |
+
def _run_ocr_with_response(
|
| 79 |
+
self, image_path: Path,
|
| 80 |
+
) -> tuple[str, Optional[dict]]:
|
| 81 |
+
"""Exécute l'OCR et retourne ``(text, raw_response)``.
|
| 82 |
+
|
| 83 |
+
``raw_response`` est le JSON brut de l'API ``/v1/ocr`` (chemin
|
| 84 |
+
natif) ou ``None`` (chemin chat/vision pour ``pixtral-*``).
|
| 85 |
+
Centralisé pour que ``run()`` puisse extraire les
|
| 86 |
+
``token_confidences`` sans dupliquer la requête API.
|
| 87 |
+
"""
|
| 88 |
if not self._api_key:
|
| 89 |
raise RuntimeError(
|
| 90 |
"Clé API Mistral manquante — définissez la variable d'environnement MISTRAL_API_KEY"
|
|
|
|
| 102 |
|
| 103 |
if "mistral-ocr" in self._model.lower():
|
| 104 |
return self._run_ocr_native_api(image_url)
|
| 105 |
+
return self._run_ocr_vision_api(image_url), None
|
| 106 |
+
|
| 107 |
+
def _run_ocr_native_api(self, image_url: str) -> tuple[str, dict]:
|
| 108 |
+
"""Endpoint dédié /v1/ocr (pour mistral-ocr-latest et variantes).
|
| 109 |
|
| 110 |
+
Retourne ``(text, raw_response_dict)`` pour permettre
|
| 111 |
+
l'extraction des confidences en post-traitement.
|
| 112 |
+
"""
|
| 113 |
import json
|
| 114 |
import urllib.request
|
| 115 |
|
|
|
|
| 129 |
with urllib.request.urlopen(req, timeout=60) as resp:
|
| 130 |
data = json.loads(resp.read().decode())
|
| 131 |
pages = data.get("pages", [])
|
| 132 |
+
text = "\n\n".join(p.get("markdown", "") for p in pages).strip()
|
| 133 |
+
return text, data
|
| 134 |
|
| 135 |
def _run_ocr_vision_api(self, image_url: str) -> str:
|
| 136 |
"""API vision/chat Mistral (pour pixtral-12b, pixtral-large, etc.)."""
|
|
|
|
| 159 |
max_tokens=self._max_tokens,
|
| 160 |
)
|
| 161 |
return response.choices[0].message.content or ""
|
| 162 |
+
|
| 163 |
+
def _extract_token_confidences_from_response(
|
| 164 |
+
self, raw_response: Optional[dict],
|
| 165 |
+
) -> Optional[list[dict[str, Any]]]:
|
| 166 |
+
"""Extrait les paires ``(token, confidence)`` de la réponse
|
| 167 |
+
``/v1/ocr`` quand elles existent.
|
| 168 |
+
|
| 169 |
+
Mistral OCR peut exposer ``confidence`` à différents niveaux
|
| 170 |
+
(page, block, line, word) selon le modèle. L'extracteur
|
| 171 |
+
cherche dans les structures suivantes en cascade :
|
| 172 |
+
|
| 173 |
+
1. ``pages[i].words[j]`` avec ``{"text", "confidence"}``
|
| 174 |
+
2. ``pages[i].lines[j]`` avec ``{"text", "confidence"}`` →
|
| 175 |
+
propage la confidence aux mots de la ligne (comme Pero OCR
|
| 176 |
+
Sprint 48)
|
| 177 |
+
3. ``pages[i].blocks[j]`` avec ``{"text", "confidence"}`` →
|
| 178 |
+
idem, propage à chaque mot
|
| 179 |
+
|
| 180 |
+
Retourne ``None`` si aucun champ ``confidence`` exploitable
|
| 181 |
+
n'est trouvé (cas le plus courant si l'API renvoie uniquement
|
| 182 |
+
du markdown sans annotation, ou si on est sur le chemin
|
| 183 |
+
chat/vision ``pixtral-*``).
|
| 184 |
+
|
| 185 |
+
Les exceptions sont absorbées en warning (best-effort).
|
| 186 |
+
"""
|
| 187 |
+
if not self.config.get("expose_confidences", True):
|
| 188 |
+
return None
|
| 189 |
+
if not raw_response or not isinstance(raw_response, dict):
|
| 190 |
+
return None
|
| 191 |
+
try:
|
| 192 |
+
out: list[dict[str, Any]] = []
|
| 193 |
+
pages = raw_response.get("pages") or []
|
| 194 |
+
for page in pages:
|
| 195 |
+
if not isinstance(page, dict):
|
| 196 |
+
continue
|
| 197 |
+
# Niveau 1 : words explicites
|
| 198 |
+
words = page.get("words") or []
|
| 199 |
+
for w in words:
|
| 200 |
+
self._maybe_emit_word(w, out)
|
| 201 |
+
# Niveau 2 : lines avec confidence propagée
|
| 202 |
+
lines = page.get("lines") or []
|
| 203 |
+
for line in lines:
|
| 204 |
+
self._emit_lines_or_blocks(line, out)
|
| 205 |
+
# Niveau 3 : blocks avec confidence propagée
|
| 206 |
+
blocks = page.get("blocks") or []
|
| 207 |
+
for block in blocks:
|
| 208 |
+
self._emit_lines_or_blocks(block, out)
|
| 209 |
+
return out or None
|
| 210 |
+
except Exception as exc: # noqa: BLE001
|
| 211 |
+
logger.warning(
|
| 212 |
+
"[mistral_ocr] extraction des token_confidences dégradée : %s",
|
| 213 |
+
exc,
|
| 214 |
+
)
|
| 215 |
+
return None
|
| 216 |
+
|
| 217 |
+
@staticmethod
|
| 218 |
+
def _maybe_emit_word(word: Any, out: list) -> None:
|
| 219 |
+
if not isinstance(word, dict):
|
| 220 |
+
return
|
| 221 |
+
text = (word.get("text") or "").strip()
|
| 222 |
+
conf = word.get("confidence")
|
| 223 |
+
if not text or conf is None:
|
| 224 |
+
return
|
| 225 |
+
try:
|
| 226 |
+
conf_val = float(conf)
|
| 227 |
+
except (TypeError, ValueError):
|
| 228 |
+
return
|
| 229 |
+
if conf_val < 0:
|
| 230 |
+
return
|
| 231 |
+
out.append({"token": text, "confidence": conf_val})
|
| 232 |
+
|
| 233 |
+
@staticmethod
|
| 234 |
+
def _emit_lines_or_blocks(item: Any, out: list) -> None:
|
| 235 |
+
"""Pour une line/block, propage sa confidence à chaque mot."""
|
| 236 |
+
if not isinstance(item, dict):
|
| 237 |
+
return
|
| 238 |
+
text = (item.get("text") or "").strip()
|
| 239 |
+
conf = item.get("confidence")
|
| 240 |
+
if not text or conf is None:
|
| 241 |
+
return
|
| 242 |
+
try:
|
| 243 |
+
conf_val = float(conf)
|
| 244 |
+
except (TypeError, ValueError):
|
| 245 |
+
return
|
| 246 |
+
if conf_val < 0:
|
| 247 |
+
return
|
| 248 |
+
for word in text.split():
|
| 249 |
+
if word:
|
| 250 |
+
out.append({"token": word, "confidence": conf_val})
|
| 251 |
+
|
| 252 |
+
def run(self, image_path: str | Path) -> EngineResult:
|
| 253 |
+
"""Exécute Mistral OCR et expose les ``token_confidences``
|
| 254 |
+
natifs (Sprint 49).
|
| 255 |
+
|
| 256 |
+
L'API ``/v1/ocr`` est appelée une seule fois ; le texte et la
|
| 257 |
+
réponse brute sont récupérés ensemble. Si la réponse expose
|
| 258 |
+
des ``confidence`` (par mot/ligne/block), elles sont extraites
|
| 259 |
+
au format Sprint 42. Sinon ``token_confidences = None``.
|
| 260 |
+
|
| 261 |
+
Le chemin chat/vision (``pixtral-*``) ne fournit pas de
|
| 262 |
+
confidences ; ``token_confidences`` y est toujours ``None``.
|
| 263 |
+
"""
|
| 264 |
+
image_path = Path(image_path)
|
| 265 |
+
start = time.perf_counter()
|
| 266 |
+
text = ""
|
| 267 |
+
error: Optional[str] = None
|
| 268 |
+
token_confidences: Optional[list[dict[str, Any]]] = None
|
| 269 |
+
try:
|
| 270 |
+
text, raw_response = self._run_ocr_with_response(image_path)
|
| 271 |
+
except Exception as exc: # noqa: BLE001
|
| 272 |
+
error = str(exc)
|
| 273 |
+
else:
|
| 274 |
+
token_confidences = self._extract_token_confidences_from_response(
|
| 275 |
+
raw_response,
|
| 276 |
+
)
|
| 277 |
+
duration = time.perf_counter() - start
|
| 278 |
+
return EngineResult(
|
| 279 |
+
engine_name=self.name,
|
| 280 |
+
image_path=str(image_path),
|
| 281 |
+
text=text,
|
| 282 |
+
duration_seconds=round(duration, 4),
|
| 283 |
+
error=error,
|
| 284 |
+
metadata={"engine_version": self._safe_version()},
|
| 285 |
+
token_confidences=token_confidences,
|
| 286 |
+
)
|
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 49 — adaptation Mistral OCR pour exposer token_confidences.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``_extract_token_confidences_from_response`` :
|
| 6 |
+
- extrait les words explicites avec ``{"text", "confidence"}``
|
| 7 |
+
- propage la confidence d'une ligne / bloc à chaque mot
|
| 8 |
+
- ignore les entrées sans confidence ou avec confidence négative
|
| 9 |
+
2. Réponse vide / None / sans pages → retourne ``None``.
|
| 10 |
+
3. ``expose_confidences=False`` désactive l'extraction.
|
| 11 |
+
4. ``run()`` appelle ``_run_ocr_with_response`` et stocke les
|
| 12 |
+
confidences dans ``EngineResult.token_confidences``.
|
| 13 |
+
5. Le chemin chat/vision (``pixtral-*``) renvoie
|
| 14 |
+
``raw_response = None`` → ``token_confidences = None``.
|
| 15 |
+
6. Si l'API échoue, ``error`` renseigné, ``text=""``,
|
| 16 |
+
``token_confidences = None``.
|
| 17 |
+
7. Intégration bout-en-bout avec ``_compute_document_result``.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
from pathlib import Path
|
| 23 |
+
|
| 24 |
+
import pytest
|
| 25 |
+
|
| 26 |
+
from picarones.engines.mistral_ocr import MistralOCREngine
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 30 |
+
# 1. Extraction depuis une réponse JSON Mistral
|
| 31 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class TestExtractFromResponse:
|
| 35 |
+
def test_extract_words_explicit(self) -> None:
|
| 36 |
+
engine = MistralOCREngine()
|
| 37 |
+
response = {
|
| 38 |
+
"pages": [{
|
| 39 |
+
"words": [
|
| 40 |
+
{"text": "Bonjour", "confidence": 0.95},
|
| 41 |
+
{"text": "monde", "confidence": 0.90},
|
| 42 |
+
],
|
| 43 |
+
}],
|
| 44 |
+
}
|
| 45 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 46 |
+
assert out == [
|
| 47 |
+
{"token": "Bonjour", "confidence": 0.95},
|
| 48 |
+
{"token": "monde", "confidence": 0.90},
|
| 49 |
+
]
|
| 50 |
+
|
| 51 |
+
def test_lines_propagate_confidence_to_words(self) -> None:
|
| 52 |
+
engine = MistralOCREngine()
|
| 53 |
+
response = {
|
| 54 |
+
"pages": [{
|
| 55 |
+
"lines": [
|
| 56 |
+
{"text": "première ligne", "confidence": 0.88},
|
| 57 |
+
{"text": "seconde", "confidence": 0.75},
|
| 58 |
+
],
|
| 59 |
+
}],
|
| 60 |
+
}
|
| 61 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 62 |
+
assert out is not None
|
| 63 |
+
# 3 tokens (2 mots + 1 mot), avec leurs confidences respectives
|
| 64 |
+
assert {"token": "première", "confidence": 0.88} in out
|
| 65 |
+
assert {"token": "ligne", "confidence": 0.88} in out
|
| 66 |
+
assert {"token": "seconde", "confidence": 0.75} in out
|
| 67 |
+
|
| 68 |
+
def test_blocks_propagate_confidence(self) -> None:
|
| 69 |
+
engine = MistralOCREngine()
|
| 70 |
+
response = {
|
| 71 |
+
"pages": [{
|
| 72 |
+
"blocks": [
|
| 73 |
+
{"text": "bloc1 mot2", "confidence": 0.82},
|
| 74 |
+
],
|
| 75 |
+
}],
|
| 76 |
+
}
|
| 77 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 78 |
+
assert out == [
|
| 79 |
+
{"token": "bloc1", "confidence": 0.82},
|
| 80 |
+
{"token": "mot2", "confidence": 0.82},
|
| 81 |
+
]
|
| 82 |
+
|
| 83 |
+
def test_skips_empty_text(self) -> None:
|
| 84 |
+
engine = MistralOCREngine()
|
| 85 |
+
response = {
|
| 86 |
+
"pages": [{
|
| 87 |
+
"words": [
|
| 88 |
+
{"text": "", "confidence": 0.9},
|
| 89 |
+
{"text": "ok", "confidence": 0.9},
|
| 90 |
+
],
|
| 91 |
+
}],
|
| 92 |
+
}
|
| 93 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 94 |
+
assert out == [{"token": "ok", "confidence": 0.9}]
|
| 95 |
+
|
| 96 |
+
def test_skips_none_confidence(self) -> None:
|
| 97 |
+
engine = MistralOCREngine()
|
| 98 |
+
response = {
|
| 99 |
+
"pages": [{
|
| 100 |
+
"words": [
|
| 101 |
+
{"text": "avec_conf", "confidence": 0.85},
|
| 102 |
+
{"text": "sans_conf"},
|
| 103 |
+
{"text": "explicit_none", "confidence": None},
|
| 104 |
+
],
|
| 105 |
+
}],
|
| 106 |
+
}
|
| 107 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 108 |
+
assert out == [{"token": "avec_conf", "confidence": 0.85}]
|
| 109 |
+
|
| 110 |
+
def test_skips_negative_confidence(self) -> None:
|
| 111 |
+
engine = MistralOCREngine()
|
| 112 |
+
response = {
|
| 113 |
+
"pages": [{
|
| 114 |
+
"words": [
|
| 115 |
+
{"text": "ok", "confidence": 0.9},
|
| 116 |
+
{"text": "neg", "confidence": -0.1},
|
| 117 |
+
],
|
| 118 |
+
}],
|
| 119 |
+
}
|
| 120 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 121 |
+
assert out == [{"token": "ok", "confidence": 0.9}]
|
| 122 |
+
|
| 123 |
+
def test_combines_words_and_lines(self) -> None:
|
| 124 |
+
engine = MistralOCREngine()
|
| 125 |
+
response = {
|
| 126 |
+
"pages": [{
|
| 127 |
+
"words": [{"text": "explicit", "confidence": 0.99}],
|
| 128 |
+
"lines": [{"text": "ligne mots", "confidence": 0.7}],
|
| 129 |
+
}],
|
| 130 |
+
}
|
| 131 |
+
out = engine._extract_token_confidences_from_response(response)
|
| 132 |
+
assert out is not None
|
| 133 |
+
assert len(out) == 3 # 1 word explicit + 2 mots de la ligne
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 137 |
+
# 2. Cas dégénérés
|
| 138 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
class TestDegenerateResponses:
|
| 142 |
+
def test_none_response(self) -> None:
|
| 143 |
+
engine = MistralOCREngine()
|
| 144 |
+
assert engine._extract_token_confidences_from_response(None) is None
|
| 145 |
+
|
| 146 |
+
def test_empty_dict(self) -> None:
|
| 147 |
+
engine = MistralOCREngine()
|
| 148 |
+
assert engine._extract_token_confidences_from_response({}) is None
|
| 149 |
+
|
| 150 |
+
def test_no_pages(self) -> None:
|
| 151 |
+
engine = MistralOCREngine()
|
| 152 |
+
assert engine._extract_token_confidences_from_response(
|
| 153 |
+
{"pages": []},
|
| 154 |
+
) is None
|
| 155 |
+
|
| 156 |
+
def test_pages_without_confidences(self) -> None:
|
| 157 |
+
engine = MistralOCREngine()
|
| 158 |
+
response = {
|
| 159 |
+
"pages": [
|
| 160 |
+
{"markdown": "Texte sans annotation de confidence"},
|
| 161 |
+
],
|
| 162 |
+
}
|
| 163 |
+
assert engine._extract_token_confidences_from_response(response) is None
|
| 164 |
+
|
| 165 |
+
def test_non_dict_input(self) -> None:
|
| 166 |
+
engine = MistralOCREngine()
|
| 167 |
+
assert engine._extract_token_confidences_from_response("not a dict") is None
|
| 168 |
+
assert engine._extract_token_confidences_from_response([1, 2, 3]) is None
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 172 |
+
# 3. expose_confidences=False
|
| 173 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
class TestExposeFlag:
|
| 177 |
+
def test_disabled_returns_none(self) -> None:
|
| 178 |
+
engine = MistralOCREngine(config={"expose_confidences": False})
|
| 179 |
+
response = {
|
| 180 |
+
"pages": [{
|
| 181 |
+
"words": [{"text": "ok", "confidence": 0.9}],
|
| 182 |
+
}],
|
| 183 |
+
}
|
| 184 |
+
assert engine._extract_token_confidences_from_response(response) is None
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 188 |
+
# 4-6. run() avec mock du chemin réseau
|
| 189 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
def _mock_run_with_response(
|
| 193 |
+
monkeypatch: pytest.MonkeyPatch,
|
| 194 |
+
text: str,
|
| 195 |
+
raw_response: dict | None,
|
| 196 |
+
*,
|
| 197 |
+
raise_exc: Exception | None = None,
|
| 198 |
+
) -> MistralOCREngine:
|
| 199 |
+
"""Patche ``_run_ocr_with_response`` pour ne pas appeler l'API."""
|
| 200 |
+
engine = MistralOCREngine()
|
| 201 |
+
# On évite la vérification de la clé API (set artificiellement)
|
| 202 |
+
engine._api_key = "test-key"
|
| 203 |
+
|
| 204 |
+
def _fake(self, image_path):
|
| 205 |
+
if raise_exc is not None:
|
| 206 |
+
raise raise_exc
|
| 207 |
+
return text, raw_response
|
| 208 |
+
|
| 209 |
+
monkeypatch.setattr(
|
| 210 |
+
MistralOCREngine, "_run_ocr_with_response", _fake,
|
| 211 |
+
)
|
| 212 |
+
return engine
|
| 213 |
+
|
| 214 |
+
|
| 215 |
+
class TestRunOverride:
|
| 216 |
+
def test_run_exposes_confidences_when_response_has_them(
|
| 217 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 218 |
+
) -> None:
|
| 219 |
+
engine = _mock_run_with_response(
|
| 220 |
+
monkeypatch,
|
| 221 |
+
"Bonjour le monde",
|
| 222 |
+
{"pages": [{
|
| 223 |
+
"words": [
|
| 224 |
+
{"text": "Bonjour", "confidence": 0.95},
|
| 225 |
+
{"text": "le", "confidence": 0.92},
|
| 226 |
+
{"text": "monde", "confidence": 0.90},
|
| 227 |
+
],
|
| 228 |
+
}]},
|
| 229 |
+
)
|
| 230 |
+
img = tmp_path / "p.png"
|
| 231 |
+
img.write_bytes(b"x")
|
| 232 |
+
result = engine.run(img)
|
| 233 |
+
assert result.text == "Bonjour le monde"
|
| 234 |
+
assert result.error is None
|
| 235 |
+
assert result.token_confidences is not None
|
| 236 |
+
assert len(result.token_confidences) == 3
|
| 237 |
+
|
| 238 |
+
def test_run_no_confidences_when_chat_vision(
|
| 239 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 240 |
+
) -> None:
|
| 241 |
+
"""Chemin pixtral : raw_response = None → token_confidences = None."""
|
| 242 |
+
engine = _mock_run_with_response(
|
| 243 |
+
monkeypatch,
|
| 244 |
+
"Texte produit par pixtral",
|
| 245 |
+
None, # le chemin chat/vision ne fournit pas de raw_response
|
| 246 |
+
)
|
| 247 |
+
img = tmp_path / "p.png"
|
| 248 |
+
img.write_bytes(b"x")
|
| 249 |
+
result = engine.run(img)
|
| 250 |
+
assert result.text == "Texte produit par pixtral"
|
| 251 |
+
assert result.token_confidences is None
|
| 252 |
+
|
| 253 |
+
def test_run_api_failure_keeps_error(
|
| 254 |
+
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
|
| 255 |
+
) -> None:
|
| 256 |
+
engine = _mock_run_with_response(
|
| 257 |
+
monkeypatch,
|
| 258 |
+
"",
|
| 259 |
+
None,
|
| 260 |
+
raise_exc=RuntimeError("API timeout"),
|
| 261 |
+
)
|
| 262 |
+
img = tmp_path / "p.png"
|
| 263 |
+
img.write_bytes(b"x")
|
| 264 |
+
result = engine.run(img)
|
| 265 |
+
assert result.error == "API timeout"
|
| 266 |
+
assert result.text == ""
|
| 267 |
+
assert result.token_confidences is None
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 271 |
+
# 7. Intégration runner
|
| 272 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
class TestEndToEndWithRunner:
|
| 276 |
+
def test_runner_picks_up_mistral_confidences(self) -> None:
|
| 277 |
+
from picarones.core.runner import _compute_document_result
|
| 278 |
+
from picarones.engines.base import EngineResult
|
| 279 |
+
|
| 280 |
+
ocr = EngineResult(
|
| 281 |
+
engine_name="mistral_ocr",
|
| 282 |
+
image_path="/tmp/x.png",
|
| 283 |
+
text="alpha beta gamma",
|
| 284 |
+
duration_seconds=0.1,
|
| 285 |
+
token_confidences=[
|
| 286 |
+
{"token": "alpha", "confidence": 0.95},
|
| 287 |
+
{"token": "beta", "confidence": 0.85},
|
| 288 |
+
{"token": "gamma", "confidence": 0.95},
|
| 289 |
+
],
|
| 290 |
+
)
|
| 291 |
+
dr = _compute_document_result(
|
| 292 |
+
doc_id="d1", image_path="/tmp/x.png",
|
| 293 |
+
ground_truth="alpha beta gamma",
|
| 294 |
+
ocr_result=ocr, char_exclude=None,
|
| 295 |
+
)
|
| 296 |
+
assert dr.calibration_metrics is not None
|
| 297 |
+
assert dr.calibration_metrics["overall_accuracy"] == 1.0
|
| 298 |
+
# confidence moyenne = (0.95 + 0.85 + 0.95) / 3
|
| 299 |
+
assert dr.calibration_metrics["overall_confidence"] == pytest.approx(
|
| 300 |
+
(0.95 + 0.85 + 0.95) / 3,
|
| 301 |
+
)
|
|
@@ -1278,9 +1278,12 @@ class TestMistralOCRNativeAPI:
|
|
| 1278 |
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
| 1279 |
native_called = []
|
| 1280 |
vision_called = []
|
|
|
|
|
|
|
|
|
|
| 1281 |
def fake_native(url):
|
| 1282 |
native_called.append(url)
|
| 1283 |
-
return "texte extrait via OCR natif"
|
| 1284 |
def fake_vision(url):
|
| 1285 |
vision_called.append(url)
|
| 1286 |
return "texte extrait via vision"
|
|
|
|
| 1278 |
img.write_bytes(b"\xff\xd8\xff\xe0" + b"\x00" * 100)
|
| 1279 |
native_called = []
|
| 1280 |
vision_called = []
|
| 1281 |
+
# Sprint 49 — _run_ocr_native_api retourne maintenant
|
| 1282 |
+
# ``(text, raw_response_dict)`` pour permettre l'extraction
|
| 1283 |
+
# des confidences ; on aligne le mock.
|
| 1284 |
def fake_native(url):
|
| 1285 |
native_called.append(url)
|
| 1286 |
+
return "texte extrait via OCR natif", {}
|
| 1287 |
def fake_vision(url):
|
| 1288 |
vision_called.append(url)
|
| 1289 |
return "texte extrait via vision"
|