Claude commited on
Commit
e1ba4ac
·
unverified ·
1 Parent(s): 6fef74e

sprint47: Tesseract — exposition des token_confidences natifs

Browse files

Premier des engines adaptés au câblage calibration du Sprint 42.
L'utilisateur qui benchmarke avec Tesseract obtient désormais
automatiquement ECE/MCE et reliability diagram dans le rapport, sans
configuration supplémentaire.

TesseractEngine.run() surchargé
- Appelle image_to_string pour le texte (rétrocompat octet par octet)
ET image_to_data pour les confidences mot par mot.
- Retourne 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é du chemin OCR principal :
si image_to_data lève, l'OCR continue normalement et
token_confidences = None (warning explicite, pas except: pass).
- Filtrage à la source : non-mots Tesseract (conf = -1), tokens
vides, longueurs incompatibles → ignorés.
- Nouveau paramètre config expose_confidences: false pour désactiver
le second appel Tesseract (économie d'un appel par image).

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.

Tests : +9 dans test_sprint47_tesseract_confidences.py couvrant (avec
mock pytesseract) :
- exposition des token_confidences quand pytesseract présent
- préservation octet par octet du texte (rétrocompat)
- flag expose_confidences=False désactive le second appel
- fallback gracieux quand image_to_data lève (warning + None)
- échec d'image_to_string : OCR.error renseigné, pas de tentative
d'extraction
- filtrage des non-mots (conf = -1) et tokens vides
- format inattendu (longueurs incompatibles) → None
- intégration bout-en-bout avec _compute_document_result :
calibration_metrics calculée correctement
- pytesseract absent → None sans crash
Suite complète : 1864 → 1873 passed, 2 skipped, 0 failed.

Reste à adapter Pero, Mistral OCR, Google Vision et Azure DI sur le
même pattern.

CHANGELOG.md CHANGED
@@ -16,6 +16,38 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 46 — A.III stratification par `script_type` : vue HTML +
20
  détecteur narratif (clôture A.III)**. Suite directe du Sprint 45
21
  (couche backend). La vue stratifiée est désormais rendue dans le
@@ -479,16 +511,18 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
479
 
480
  ### Tests
481
 
482
- - 1478 → 1864 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
483
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
484
  +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
485
- +43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46).
486
- Aucune régression. **Phase 0 close ; Étape 2 du plan d'évolution :
487
- inter-moteurs (A.II.1.c), NER (A.II.1.a), calibration (A.II.1.b)
488
- et stratification (A.III) livrés bout-en-bout calcul → runner →
489
- HTML ; A.I.2 médiane par défaut livré (Sprint 44). Reste
490
- l'adaptation effective des engines pour exposer leurs confidences
491
- natives (un sprint par adapter).**
 
 
492
 
493
  ---
494
 
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 47 — Adapter Tesseract : exposition des `token_confidences`
20
+ natifs.** Premier des engines adaptés au câblage calibration
21
+ (Sprint 42). L'utilisateur qui benchmarke avec Tesseract obtient
22
+ désormais automatiquement ECE/MCE et reliability diagram dans le
23
+ rapport, sans configuration supplémentaire.
24
+ - `TesseractEngine.run()` est surchargé : appelle `image_to_string`
25
+ pour le texte (rétrocompat octet par octet) **et** `image_to_data`
26
+ pour les confidences mot par mot, retourne un `EngineResult` avec
27
+ `token_confidences = [{"token": str, "confidence": float}, …]`
28
+ (confidence ∈ [0, 100], le runner Sprint 42 normalise en [0, 1]).
29
+ - Helper `_extract_token_confidences()` séparé du chemin OCR
30
+ principal : si `image_to_data` lève, l'OCR continue normalement
31
+ et `token_confidences = None` (warning explicite, pas
32
+ `except: pass`).
33
+ - Filtrage à la source : non-mots Tesseract (conf = -1), tokens
34
+ vides, longueurs incompatibles → ignorés.
35
+ - Nouveau paramètre config `expose_confidences: false` pour
36
+ désactiver le second appel Tesseract (économie d'un appel par
37
+ image en cas de besoin).
38
+ - Coût additionnel : un appel `image_to_data` par image. Le texte
39
+ de `image_to_string` n'est jamais reconstruit depuis
40
+ `image_to_data` — préservation stricte du comportement
41
+ historique.
42
+ - +9 tests dans `test_sprint47_tesseract_confidences.py` couvrant
43
+ l'exposition des confidences (avec mock pytesseract), la
44
+ préservation octet par octet du texte, le flag
45
+ `expose_confidences=False`, le fallback gracieux quand
46
+ `image_to_data` lève (warning + `None`), le filtrage des
47
+ non-mots/longueurs incompatibles, l'intégration bout-en-bout
48
+ avec le runner (`calibration_metrics` calculé), et le cas
49
+ pytesseract absent.
50
+
51
  - **Sprint 46 — A.III stratification par `script_type` : vue HTML +
52
  détecteur narratif (clôture A.III)**. Suite directe du Sprint 45
53
  (couche backend). La vue stratifiée est désormais rendue dans le
 
511
 
512
  ### Tests
513
 
514
+ - 1478 → 1873 tests (+17 Sprint 32, +23 Sprint 33, +21 Sprint 34,
515
  +27 Sprint 35, +22 Sprint 36, +42 Sprint 37, +19 Sprint 38,
516
  +32 Sprint 39, +16 Sprint 40, +38 Sprint 41, +17 Sprint 42,
517
+ +43 Sprint 43, +15 Sprint 44, +16 Sprint 45, +38 Sprint 46,
518
+ +9 Sprint 47). Aucune régression. **Phase 0 close ; Étape 2 du
519
+ plan d'évolution : inter-moteurs (A.II.1.c), NER (A.II.1.a),
520
+ calibration (A.II.1.b) et stratification (A.III) livrés
521
+ bout-en-bout calcul → runner → HTML ; A.I.2 médiane par défaut
522
+ livré (Sprint 44) ; Tesseract adapté pour exposer ses
523
+ `token_confidences` natifs (Sprint 47, première brique de
524
+ l'adaptation engines). Reste à adapter Pero, Mistral OCR, Google
525
+ Vision et Azure DI (un sprint par adapter).**
526
 
527
  ---
528
 
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
  | 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. |
211
  | 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
212
  | 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
@@ -264,7 +265,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
264
  ## Contexte développement
265
 
266
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
267
- - **Tests** : 1864 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)
268
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
269
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
270
  - **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
+ | 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. |
211
  | 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. |
212
  | 45 | **Sprint 14 du plan d'évolution 2026 — Étape 2 / axe A.III : stratification par `script_type` (couche backend)**. Première brique de la « plus haute valeur ajoutée transversale » du plan. `BenchmarkResult.doc_strata: Optional[dict[str, str]]` ajouté (map `{doc_id: script_type}` capturée par le runner avant `compact()` qui efface `image_quality`). Trois nouvelles méthodes : `available_strata()` (liste triée des strates distinctes, ignore les vides) ; `stratified_ranking()` qui retourne `{stratum: [ranking_entry]}` avec mean/median CER recalculés par strate, tri par médiane (Sprint 44), inclut les moteurs absents d'une strate sous forme d'entrée dégénérée (mean/median = None) ; `corpus_homogeneity()` qui pour le moteur leader global retourne l'écart inter-strate de la médiane CER et la paire min/max — base du futur avertissement « ce corpus est hétérogène ». `as_dict()` expose les nouveaux champs quand renseignés (rétrocompat stricte sinon). +16 tests dans `test_sprint45_stratification.py` couvrant champ, available_strata, stratified_ranking (1 entrée/moteur/strate, métriques per-strate, tri par médiane, moteurs absents), corpus_homogeneity, sérialisation, et un **test propriété réaliste** : le leader global peut perdre sur une strate (Tesseract domine globalement mais Pero gagne sur le manuscrit). **Verrou levé** : la couche d'agrégation par strate est en place ; la vue HTML stratifiée + toggle UI viendront dans un sprint dédié, et un détecteur narratif `STRATIFICATION_RECOMMENDED` peut maintenant lire `corpus_homogeneity()` pour suggérer la vue stratifiée. |
213
  | 44 | **Sprint 13 du plan d'évolution 2026 — Étape 2 / axe A.I.2 : tri par médiane par défaut + détecteur d'asymétrie**. Réponse à la critique structurelle 2 du plan : sur les corpus patrimoniaux, la moyenne est tirée par quelques documents catastrophiques et masque les performances réelles. `EngineReport.median_cer` ajouté (lit `aggregated_metrics["cer"]["median"]`). `BenchmarkResult.ranking()` inclut désormais `median_cer` dans chaque entrée et **trie par médiane CER croissante par défaut** (fallback sur `mean_cer` si médiane absente). Nouveau `FactType.MEDIAN_MEAN_GAP_WARNING` + détecteur `detect_median_mean_gap_warning` (priority 140) : émet un Fact quand `\|mean - median\| / median > 30 %` pour le moteur leader, importance HIGH si gap relatif ≥ 100 % (sinon MEDIUM). Garde-fou : ne déclenche pas si médiane nulle. Templates FR/EN sans nombres en dur (vérifié). L'arbitre marque la paire `{GLOBAL_LEADER_CER, MEDIAN_MEAN_GAP_WARNING}` comme **complémentaire** : les deux phrases peuvent coexister dans la synthèse pour nuancer le leader. +15 tests dans `test_sprint44_median_default.py` (propriété, tri sur cas asymétrique réaliste, fallback, déclenchement détecteur sur 4 cas dégénérés, importance, traçabilité anti-hallucination FR + EN, intégration build_synthesis). **Verrou levé** : la critique « le rapport classe sur la moyenne alors que les distributions patrimoniales sont asymétriques » est résolue ; le lecteur voit immédiatement le moteur le plus représentatif et est averti quand l'écart médiane/moyenne est suspect. |
 
265
  ## Contexte développement
266
 
267
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
268
+ - **Tests** : 1873 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 ; Sprint 47 = Tesseract adapté pour confidences natives)
269
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
270
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
271
  - **Transcript de la conversation de développement** :
picarones/engines/tesseract.py CHANGED
@@ -2,10 +2,12 @@
2
 
3
  from __future__ import annotations
4
 
 
 
5
  from pathlib import Path
6
- from typing import Optional
7
 
8
- from picarones.engines.base import BaseOCREngine
9
 
10
  try:
11
  import pytesseract
@@ -16,6 +18,9 @@ except ImportError:
16
  _PYTESSERACT_AVAILABLE = False
17
 
18
 
 
 
 
19
  # Correspondance des valeurs PSM acceptées en argument YAML/CLI
20
  _PSM_LABELS = {
21
  0: "Orientation and script detection only",
@@ -47,7 +52,23 @@ class TesseractEngine(BaseOCREngine):
47
  psm: 6 # Page Segmentation Mode (0-13)
48
  oem: 3 # OCR Engine Mode (0=legacy, 3=LSTM, 3=default)
49
  tesseract_cmd: tesseract # chemin vers l'exécutable si non standard
 
 
50
  ```
 
 
 
 
 
 
 
 
 
 
 
 
 
 
51
  """
52
 
53
  execution_mode = "cpu"
@@ -61,6 +82,17 @@ class TesseractEngine(BaseOCREngine):
61
  raise RuntimeError("pytesseract n'est pas installé.")
62
  return pytesseract.get_tesseract_version().vstring
63
 
 
 
 
 
 
 
 
 
 
 
 
64
  def _run_ocr(self, image_path: Path) -> str:
65
  if not _PYTESSERACT_AVAILABLE:
66
  raise RuntimeError(
@@ -73,16 +105,107 @@ class TesseractEngine(BaseOCREngine):
73
  if tesseract_cmd:
74
  pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
75
 
76
- lang = self.config.get("lang", "fra")
77
- psm = int(self.config.get("psm", 6))
78
- oem = int(self.config.get("oem", 3))
79
-
80
- custom_config = f"--oem {oem} --psm {psm}"
81
 
82
  image = Image.open(image_path)
83
  text: str = pytesseract.image_to_string(image, lang=lang, config=custom_config)
84
  return text.strip()
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  @classmethod
87
  def from_config(cls, config: Optional[dict] = None) -> "TesseractEngine":
88
  return cls(config=config or {})
 
2
 
3
  from __future__ import annotations
4
 
5
+ import logging
6
+ import time
7
  from pathlib import Path
8
+ from typing import Any, Optional
9
 
10
+ from picarones.engines.base import BaseOCREngine, EngineResult
11
 
12
  try:
13
  import pytesseract
 
18
  _PYTESSERACT_AVAILABLE = False
19
 
20
 
21
+ logger = logging.getLogger(__name__)
22
+
23
+
24
  # Correspondance des valeurs PSM acceptées en argument YAML/CLI
25
  _PSM_LABELS = {
26
  0: "Orientation and script detection only",
 
52
  psm: 6 # Page Segmentation Mode (0-13)
53
  oem: 3 # OCR Engine Mode (0=legacy, 3=LSTM, 3=default)
54
  tesseract_cmd: tesseract # chemin vers l'exécutable si non standard
55
+ expose_confidences: true # défaut ; mettre à false pour économiser
56
+ # un appel image_to_data par document
57
  ```
58
+
59
+ Sprint 47 — exposition des token_confidences
60
+ --------------------------------------------
61
+ L'adapter appelle ``image_to_data`` en parallèle de
62
+ ``image_to_string`` pour produire ``EngineResult.token_confidences``
63
+ (liste de ``{"token": str, "confidence": float}``). Le runner
64
+ Sprint 42 calcule alors automatiquement la calibration ECE/MCE.
65
+
66
+ Le texte ``EngineResult.text`` reste **strictement identique** à
67
+ celui produit par ``image_to_string`` (pas de reconstruction depuis
68
+ ``image_to_data``) — rétrocompatibilité octet par octet.
69
+
70
+ Le coût supplémentaire est d'un second appel Tesseract par image.
71
+ Pour le désactiver : ``expose_confidences: false`` dans la config.
72
  """
73
 
74
  execution_mode = "cpu"
 
82
  raise RuntimeError("pytesseract n'est pas installé.")
83
  return pytesseract.get_tesseract_version().vstring
84
 
85
+ def _tesseract_args(self) -> tuple[str, str]:
86
+ """Retourne ``(lang, custom_config)`` selon la config courante.
87
+
88
+ Centralisé pour rester cohérent entre ``_run_ocr`` et
89
+ ``_extract_token_confidences``.
90
+ """
91
+ lang = self.config.get("lang", "fra")
92
+ psm = int(self.config.get("psm", 6))
93
+ oem = int(self.config.get("oem", 3))
94
+ return lang, f"--oem {oem} --psm {psm}"
95
+
96
  def _run_ocr(self, image_path: Path) -> str:
97
  if not _PYTESSERACT_AVAILABLE:
98
  raise RuntimeError(
 
105
  if tesseract_cmd:
106
  pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
107
 
108
+ lang, custom_config = self._tesseract_args()
 
 
 
 
109
 
110
  image = Image.open(image_path)
111
  text: str = pytesseract.image_to_string(image, lang=lang, config=custom_config)
112
  return text.strip()
113
 
114
+ def _extract_token_confidences(
115
+ self, image_path: Path,
116
+ ) -> Optional[list[dict[str, Any]]]:
117
+ """Extrait les confidences mot par mot via ``image_to_data``.
118
+
119
+ Retourne ``None`` quand pytesseract n'est pas disponible OU si
120
+ l'extraction échoue (best-effort — on ne casse pas l'OCR si
121
+ seule la calibration est indisponible).
122
+
123
+ Format de sortie compatible Sprint 42 : liste de dicts
124
+ ``{"token": str, "confidence": float}`` avec confidence ∈
125
+ [0, 100] (Tesseract). Les non-mots (conf = -1) et tokens
126
+ vides sont ignorés.
127
+ """
128
+ if not _PYTESSERACT_AVAILABLE:
129
+ return None
130
+ if not self.config.get("expose_confidences", True):
131
+ return None
132
+
133
+ try:
134
+ tesseract_cmd = self.config.get("tesseract_cmd")
135
+ if tesseract_cmd:
136
+ pytesseract.pytesseract.tesseract_cmd = tesseract_cmd
137
+
138
+ lang, custom_config = self._tesseract_args()
139
+ image = Image.open(image_path)
140
+ data = pytesseract.image_to_data(
141
+ image,
142
+ lang=lang,
143
+ config=custom_config,
144
+ output_type=pytesseract.Output.DICT,
145
+ )
146
+ except Exception as exc: # noqa: BLE001
147
+ logger.warning(
148
+ "[tesseract] extraction des token_confidences dégradée : %s",
149
+ exc,
150
+ )
151
+ return None
152
+
153
+ texts = data.get("text") or []
154
+ confs = data.get("conf") or []
155
+ if not texts or len(texts) != len(confs):
156
+ return None
157
+
158
+ out: list[dict[str, Any]] = []
159
+ for tok_text, conf in zip(texts, confs):
160
+ tok_text = (tok_text or "").strip()
161
+ if not tok_text:
162
+ continue
163
+ try:
164
+ conf_val = float(conf)
165
+ except (TypeError, ValueError):
166
+ continue
167
+ # Tesseract met -1 pour les segments non-mots ; le runner
168
+ # Sprint 42 les filtre aussi mais on les écarte ici pour
169
+ # éviter le bruit dans les diagnostics.
170
+ if conf_val < 0:
171
+ continue
172
+ out.append({"token": tok_text, "confidence": conf_val})
173
+ return out or None
174
+
175
+ def run(self, image_path: str | Path) -> EngineResult:
176
+ """Exécute Tesseract et expose les ``token_confidences`` natifs
177
+ (via ``image_to_data``) en plus du texte.
178
+
179
+ Surcharge du ``BaseOCREngine.run()`` (Sprint 33) qui ne
180
+ mettait pas de confidences. On garde la mesure du temps et la
181
+ gestion des erreurs. Si l'extraction des confidences échoue,
182
+ on retourne quand même le texte avec ``token_confidences =
183
+ None`` — le runner saute simplement le calcul de calibration
184
+ sur ce document.
185
+ """
186
+ image_path = Path(image_path)
187
+ start = time.perf_counter()
188
+ text = ""
189
+ error: Optional[str] = None
190
+ token_confidences: Optional[list[dict[str, Any]]] = None
191
+ try:
192
+ text = self._run_ocr(image_path)
193
+ except Exception as exc: # noqa: BLE001
194
+ error = str(exc)
195
+ else:
196
+ # On n'extrait les confidences que si l'OCR de base a réussi
197
+ token_confidences = self._extract_token_confidences(image_path)
198
+ duration = time.perf_counter() - start
199
+ return EngineResult(
200
+ engine_name=self.name,
201
+ image_path=str(image_path),
202
+ text=text,
203
+ duration_seconds=round(duration, 4),
204
+ error=error,
205
+ metadata={"engine_version": self._safe_version()},
206
+ token_confidences=token_confidences,
207
+ )
208
+
209
  @classmethod
210
  def from_config(cls, config: Optional[dict] = None) -> "TesseractEngine":
211
  return cls(config=config or {})
tests/test_sprint47_tesseract_confidences.py ADDED
@@ -0,0 +1,293 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 47 — adaptation Tesseract pour exposer token_confidences.
2
+
3
+ Couvre :
4
+
5
+ 1. ``run()`` retourne ``EngineResult.token_confidences`` non-vide
6
+ quand pytesseract est disponible et qu'``image_to_data`` produit
7
+ des confidences.
8
+ 2. Le ``text`` retourné reste **strictement identique** à ce que
9
+ produit ``image_to_string`` (rétrocompat octet par octet —
10
+ l'extraction des confidences n'altère jamais le texte).
11
+ 3. ``expose_confidences=False`` désactive l'extraction (économie
12
+ d'un appel Tesseract par image).
13
+ 4. Si ``image_to_data`` lève, l'OCR continue : ``text`` retourné,
14
+ ``token_confidences = None``, warning loggé.
15
+ 5. Les non-mots (conf = -1) et tokens vides sont filtrés.
16
+ 6. Les confidences passent le runner Sprint 42 et alimentent
17
+ ``DocumentResult.calibration_metrics``.
18
+ 7. Si pytesseract n'est pas installé, ``token_confidences = None``
19
+ sans crash (fallback gracieux).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from pathlib import Path
25
+
26
+ import pytest
27
+
28
+ import picarones.engines.tesseract as tesseract_module
29
+ from picarones.engines.tesseract import TesseractEngine
30
+
31
+
32
+ # ──────────────────────────────────────────────────────────────────────────
33
+ # Mocks
34
+ # ──────────────────────────────────────────────────────────────────────────
35
+
36
+
37
+ class _MockPytesseract:
38
+ """Mock minimal de pytesseract qui simule une réponse réaliste."""
39
+
40
+ class Output:
41
+ DICT = "DICT"
42
+
43
+ class pytesseract: # noqa: N801 (imite le namespace réel)
44
+ tesseract_cmd: str = "tesseract"
45
+
46
+ def __init__(
47
+ self,
48
+ text: str = "Bonjour le monde",
49
+ data: dict | None = None,
50
+ raise_on_data: bool = False,
51
+ raise_on_string: bool = False,
52
+ ) -> None:
53
+ self._text = text
54
+ self._data = data or {
55
+ "text": ["Bonjour", "le", "monde"],
56
+ "conf": [95.5, 88.0, 91.3],
57
+ }
58
+ self.raise_on_data = raise_on_data
59
+ self.raise_on_string = raise_on_string
60
+
61
+ def image_to_string(self, image, lang=None, config=None) -> str:
62
+ if self.raise_on_string:
63
+ raise RuntimeError("simulated OCR failure")
64
+ return self._text
65
+
66
+ def image_to_data(self, image, lang=None, config=None, output_type=None) -> dict:
67
+ if self.raise_on_data:
68
+ raise RuntimeError("simulated image_to_data failure")
69
+ return self._data
70
+
71
+ def get_tesseract_version(self):
72
+ class _V:
73
+ vstring = "5.0.0-mock"
74
+ return _V()
75
+
76
+
77
+ class _MockImage:
78
+ @staticmethod
79
+ def open(path):
80
+ return object() # placeholder
81
+
82
+
83
+ @pytest.fixture
84
+ def patched_tesseract(monkeypatch: pytest.MonkeyPatch) -> _MockPytesseract:
85
+ """Patche le module pour utiliser le mock."""
86
+ mock = _MockPytesseract()
87
+ monkeypatch.setattr(tesseract_module, "pytesseract", mock)
88
+ monkeypatch.setattr(tesseract_module, "Image", _MockImage)
89
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
90
+ return mock
91
+
92
+
93
+ # ──────────────────────────────────────────────────────────────────────────
94
+ # 1-2. run() expose token_confidences sans modifier le texte
95
+ # ──────────────────────────────────────────────────────────────────────────
96
+
97
+
98
+ class TestRunExposesConfidences:
99
+ def test_run_returns_token_confidences(
100
+ self, patched_tesseract: _MockPytesseract, tmp_path: Path,
101
+ ) -> None:
102
+ img = tmp_path / "p.png"
103
+ img.write_bytes(b"x")
104
+ engine = TesseractEngine()
105
+ result = engine.run(img)
106
+
107
+ assert result.token_confidences is not None
108
+ assert len(result.token_confidences) == 3
109
+ assert result.token_confidences[0] == {
110
+ "token": "Bonjour", "confidence": pytest.approx(95.5),
111
+ }
112
+
113
+ def test_text_matches_image_to_string(
114
+ self, patched_tesseract: _MockPytesseract, tmp_path: Path,
115
+ ) -> None:
116
+ """Le texte de l'EngineResult doit être strictement celui de
117
+ image_to_string, pas une reconstruction depuis image_to_data."""
118
+ img = tmp_path / "p.png"
119
+ img.write_bytes(b"x")
120
+ engine = TesseractEngine()
121
+ result = engine.run(img)
122
+
123
+ assert result.text == "Bonjour le monde"
124
+
125
+
126
+ # ──────────────────────────────────────────────────────────────────────────
127
+ # 3. expose_confidences=False désactive
128
+ # ──────────────────────────────────────────────────────────────────────────
129
+
130
+
131
+ class TestExposeConfidencesFlag:
132
+ def test_disabled_returns_no_confidences(
133
+ self, patched_tesseract: _MockPytesseract, tmp_path: Path,
134
+ ) -> None:
135
+ img = tmp_path / "p.png"
136
+ img.write_bytes(b"x")
137
+ engine = TesseractEngine(config={"expose_confidences": False})
138
+ result = engine.run(img)
139
+
140
+ assert result.text == "Bonjour le monde"
141
+ assert result.token_confidences is None
142
+
143
+
144
+ # ──────────────────────────────────────────────────────────────────────────
145
+ # 4. image_to_data échoue → fallback gracieux
146
+ # ──────────────────────────────────────────────────────────────────────────
147
+
148
+
149
+ class TestExtractionFailureFallback:
150
+ def test_image_to_data_failure_returns_none_confidences(
151
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
152
+ caplog: pytest.LogCaptureFixture,
153
+ ) -> None:
154
+ mock = _MockPytesseract(raise_on_data=True)
155
+ monkeypatch.setattr(tesseract_module, "pytesseract", mock)
156
+ monkeypatch.setattr(tesseract_module, "Image", _MockImage)
157
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
158
+
159
+ img = tmp_path / "p.png"
160
+ img.write_bytes(b"x")
161
+ engine = TesseractEngine()
162
+ with caplog.at_level("WARNING", logger="picarones.engines.tesseract"):
163
+ result = engine.run(img)
164
+
165
+ # OCR a réussi sur le texte
166
+ assert result.text == "Bonjour le monde"
167
+ assert result.error is None
168
+ # Mais les confidences sont None
169
+ assert result.token_confidences is None
170
+ # Et un warning explicite a été émis
171
+ assert any("token_confidences" in rec.message for rec in caplog.records)
172
+
173
+ def test_image_to_string_failure_keeps_error(
174
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
175
+ ) -> None:
176
+ """Si l'OCR principal lève, on n'essaie même pas d'extraire les
177
+ confidences (cohérent avec le contrat de BaseOCREngine.run)."""
178
+ mock = _MockPytesseract(raise_on_string=True)
179
+ monkeypatch.setattr(tesseract_module, "pytesseract", mock)
180
+ monkeypatch.setattr(tesseract_module, "Image", _MockImage)
181
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
182
+
183
+ img = tmp_path / "p.png"
184
+ img.write_bytes(b"x")
185
+ engine = TesseractEngine()
186
+ result = engine.run(img)
187
+
188
+ assert result.error == "simulated OCR failure"
189
+ assert result.text == ""
190
+ assert result.token_confidences is None
191
+
192
+
193
+ # ──────────────────────────────────────────────────────────────────────────
194
+ # 5. Filtrage des non-mots et tokens vides
195
+ # ──────────────────────────────────────────────────────────────────────────
196
+
197
+
198
+ class TestTokenFiltering:
199
+ def test_negative_conf_filtered(
200
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
201
+ ) -> None:
202
+ mock = _MockPytesseract(
203
+ text="Bonjour monde",
204
+ data={
205
+ "text": ["Bonjour", "", "monde", "."],
206
+ "conf": [95.0, -1.0, 88.0, -1.0],
207
+ },
208
+ )
209
+ monkeypatch.setattr(tesseract_module, "pytesseract", mock)
210
+ monkeypatch.setattr(tesseract_module, "Image", _MockImage)
211
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
212
+
213
+ img = tmp_path / "p.png"
214
+ img.write_bytes(b"x")
215
+ engine = TesseractEngine()
216
+ result = engine.run(img)
217
+
218
+ assert result.token_confidences is not None
219
+ # Seuls "Bonjour" et "monde" sont retenus (conf > 0 et token non vide)
220
+ tokens = [tc["token"] for tc in result.token_confidences]
221
+ assert tokens == ["Bonjour", "monde"]
222
+
223
+ def test_mismatched_lengths_returns_none(
224
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
225
+ ) -> None:
226
+ # text et conf de longueurs différentes → format inattendu
227
+ mock = _MockPytesseract(
228
+ text="Bonjour",
229
+ data={"text": ["Bonjour", "le"], "conf": [95.0]},
230
+ )
231
+ monkeypatch.setattr(tesseract_module, "pytesseract", mock)
232
+ monkeypatch.setattr(tesseract_module, "Image", _MockImage)
233
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", True)
234
+
235
+ img = tmp_path / "p.png"
236
+ img.write_bytes(b"x")
237
+ engine = TesseractEngine()
238
+ result = engine.run(img)
239
+
240
+ assert result.token_confidences is None
241
+
242
+
243
+ # ──────────────────────────────────────────────────────────────────────────
244
+ # 6. Bout-en-bout avec le runner : calibration_metrics calculée
245
+ # ──────────────────────────────────────────────────────────────────────────
246
+
247
+
248
+ class TestEndToEndWithRunner:
249
+ def test_runner_picks_up_confidences_and_computes_calibration(
250
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
251
+ ) -> None:
252
+ from picarones.core.runner import _compute_document_result
253
+ from picarones.engines.base import EngineResult
254
+
255
+ # Simulation : on appelle directement _compute_document_result
256
+ # avec un EngineResult mocké qui porte des confidences. On
257
+ # vérifie que la calibration_metrics est bien attachée.
258
+ ocr = EngineResult(
259
+ engine_name="tess",
260
+ image_path="/tmp/x.png",
261
+ text="alpha beta gamma",
262
+ duration_seconds=0.1,
263
+ token_confidences=[
264
+ {"token": "alpha", "confidence": 95.0},
265
+ {"token": "beta", "confidence": 95.0},
266
+ {"token": "gamma", "confidence": 95.0},
267
+ ],
268
+ )
269
+ dr = _compute_document_result(
270
+ doc_id="d1", image_path="/tmp/x.png",
271
+ ground_truth="alpha beta gamma",
272
+ ocr_result=ocr, char_exclude=None,
273
+ )
274
+ assert dr.calibration_metrics is not None
275
+ # 3 tokens, tous corrects → accuracy = 1, conf = 0.95
276
+ assert dr.calibration_metrics["overall_accuracy"] == 1.0
277
+ assert dr.calibration_metrics["overall_confidence"] == pytest.approx(0.95)
278
+
279
+
280
+ # ──────────────────────────────────────────────────────────────────────────
281
+ # 7. pytesseract absent → fallback gracieux
282
+ # ──────────────────────────────────────────────────────────────────────────
283
+
284
+
285
+ class TestPytesseractAbsent:
286
+ def test_extraction_returns_none_without_pytesseract(
287
+ self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path,
288
+ ) -> None:
289
+ monkeypatch.setattr(tesseract_module, "_PYTESSERACT_AVAILABLE", False)
290
+
291
+ engine = TesseractEngine()
292
+ result = engine._extract_token_confidences(tmp_path / "p.png")
293
+ assert result is None