Claude commited on
Commit
dfd3feb
·
unverified ·
1 Parent(s): 49ee0be

sprint49: Mistral OCR — exposition des token_confidences quand disponibles

Browse files

Suite 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 CHANGED
@@ -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 → 1887 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
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. **Phase 0
549
- close ; Étape 2 du plan d'évolution : inter-moteurs (A.II.1.c),
550
- NER (A.II.1.a), calibration (A.II.1.b) et stratification (A.III)
551
- livrés bout-en-bout calcul → runner → HTML ; A.I.2 médiane par
552
- défaut livré (Sprint 44) ; Tesseract (Sprint 47) et Pero OCR
553
- (Sprint 48) adaptés pour exposer leurs `token_confidences`
554
- natifs. Reste à adapter Mistral OCR, Google Vision et Azure DI.**
 
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
 
CLAUDE.md CHANGED
@@ -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** : 1887 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 = Tesseract et Pero OCR adaptés pour confidences natives)
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** :
picarones/engines/mistral_ocr.py CHANGED
@@ -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
- from picarones.engines.base import BaseOCREngine
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
- def _run_ocr_native_api(self, image_url: str) -> str:
75
- """Endpoint dédié /v1/ocr (pour mistral-ocr-latest et variantes)."""
 
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
- return "\n\n".join(p.get("markdown", "") for p in pages).strip()
 
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
+ )
tests/test_sprint49_mistral_confidences.py ADDED
@@ -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
+ )
tests/test_sprint6_web_interface.py CHANGED
@@ -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"