Claude commited on
Commit
a6bae97
·
unverified ·
1 Parent(s): ecb8713

sprint86: A.II.5 bout-en-bout — câblage runner + vues HTML

Browse files

Suite directe Sprints 84+85 : la couche calcul livrait deux modules
pour le mode plein-texte patrimonial, ce sprint les remonte
automatiquement dans le rapport.

Helpers runner avec adaptive masking :
- picarones/core/searchability_runner.py
- picarones/core/numerical_sequences_runner.py

Champs DocumentResult.searchability_metrics et
DocumentResult.numerical_sequence_metrics + agrégés sur EngineReport
(sérialisation conditionnelle, libérés par compact).

Câblage runner.py : calcul inconditionnel (coût négligeable),
erreur isolée par try/except + warning explicite, rétrocompat
stricte (aucun champ ajouté quand le corpus est sans signal).

Modules de rendu :
- picarones/report/searchability_render.py : tableau résumé
moteur × {rappel coloré, retrouvés/total, docs}.
- picarones/report/numerical_sequences_render.py : tableau
moteur × catégorie avec adaptive masking par catégorie ;
cellules score strict + valeur en parenthèses + n.

Insertion dans view_analyses.html derrière le profil philologique,
chart-card pleine largeur conditionné.

15 clés i18n FR/EN. 25 tests dans test_sprint86_aii5_html.py
couvrant adaptive masking, agrégation, sérialisation, compact,
rendu FR + EN, anti-injection, complétude i18n.

A.II.5 livrée bout-en-bout (calcul Sprints 84-85, runner et HTML
Sprint 86).

Tests : 2867 passed, 2 skipped.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,53 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 85 — A.II.5b : précision sur séquences numériques
20
  (couche de calcul + registre typé).** Pour un économiste-
21
  historien, un éditeur de chartes ou un archiviste, la
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 86 — A.II.5 : câblage runner + vues HTML (clôture
20
+ bout-en-bout).** Suite directe Sprints 84 et 85 — la couche
21
+ de calcul livrait deux modules pour le mode plein-texte
22
+ patrimonial, ce sprint les remonte automatiquement dans le
23
+ rapport. Deux nouveaux helpers
24
+ `picarones/core/searchability_runner.py` et
25
+ `picarones/core/numerical_sequences_runner.py` qui calculent
26
+ les métriques par document avec **adaptive masking** (rien
27
+ n'apparaît pour un doc sans GT exploitable) et agrègent
28
+ corpus-wide en *micro*-rappel pour la searchability et en
29
+ somme de compteurs par catégorie pour les séquences
30
+ numériques. `DocumentResult` gagne `searchability_metrics`
31
+ et `numerical_sequence_metrics` ; `EngineReport` gagne
32
+ `aggregated_searchability` et `aggregated_numerical_sequences`
33
+ (sérialisation conditionnelle dans `as_dict`, libérés par
34
+ `compact`). Le runner historique calcule désormais les deux
35
+ inconditionnellement (coût négligeable face à l'OCR), erreur
36
+ d'un module isolée par try/except + warning explicite,
37
+ rétrocompat stricte (aucun champ ajouté au JSON quand le
38
+ corpus est sans signal). Deux nouveaux modules de rendu
39
+ `picarones/report/searchability_render.py` et
40
+ `picarones/report/numerical_sequences_render.py` :
41
+ `build_searchability_summary_html` produit un tableau résumé
42
+ moteur × (rappel coloré gradient rouge → jaune → vert,
43
+ retrouvés/total, docs) ;
44
+ `build_numerical_sequences_html` produit un tableau moteur ×
45
+ catégorie (year/roman/foliation/currency/regnal) avec
46
+ **adaptive masking par catégorie** (une catégorie sans signal
47
+ est omise pour tous les moteurs) ; chaque cellule affiche le
48
+ score strict (gradient) + la valeur entre parenthèses + le
49
+ n. Insertion dans `view_analyses.html` derrière le profil
50
+ philologique, `chart-card` pleine largeur conditionné.
51
+ Anti-injection systématique (`html.escape`). +15 nouvelles
52
+ clés i18n FR/EN (`search_*`, `numseq_*`). +25 tests dans
53
+ `test_sprint86_aii5_html.py` couvrant adaptive masking sur
54
+ les helpers, agrégation micro-rappel, somme par catégorie,
55
+ sérialisation `DocumentResult`/`EngineReport`,
56
+ `compact` qui efface bien les champs, masquage adaptatif HTML
57
+ (vide quand sans signal, omission de catégories), rendu en
58
+ FR + EN, anti-injection sur nom de moteur, complétude i18n
59
+ sur 15 clés. **Verrou levé** : un benchmark BnF voit
60
+ désormais sur la vue Analyses *« Recherchabilité fuzzy :
61
+ tess 95,2 %, pero 87,8 % »* + le tableau séquences
62
+ numériques détaillé par catégorie — A.II.5 est livrée
63
+ bout-en-bout en couche calcul (Sprints 84-85), runner et
64
+ HTML (Sprint 86).
65
+
66
  - **Sprint 85 — A.II.5b : précision sur séquences numériques
67
  (couche de calcul + registre typé).** Pour un économiste-
68
  historien, un éditeur de chartes ou un archiviste, la
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
  | 85 | **Sprint 54 du plan d'évolution 2026 — A.II.5b : précision sur séquences numériques (couche de calcul + registre typé)**. Pour un économiste-historien, un éditeur de chartes ou un archiviste, la fidélité aux séquences numériques est un proxy direct de la qualité éditoriale — un OCR qui rate « 1789 » dans une charte révolutionnaire ou « f. 12v » dans une cote d'archives produit un corpus inutilisable, même avec un CER global respectable. Nouveau module `picarones/core/numerical_sequences.py` couvrant **5 catégories** : (1) **dates arabes** années 4 chiffres dans la plage [1000-2099], (2) **numéraux romains** délégués à `roman_numerals.detect_roman_numerals` Sprint 60, (3) **foliotation** (`f.`, `fol.`, `p.`, `pp.`, `n°`) avec suffixe `r`/`v` préservé (recto/verso = information distincte non interchangeable côté valeur), (4) **montants** Ancien Régime (`livres/l.`, `sols/s.`, `deniers/d.`) et modernes (`£`, `€`, `₣`, `écus`, `florins`, `francs`), (5) **années régnales** (`an III`, `l'an V`, `an de grâce 1450`). `compute_numerical_sequence_metrics(reference, hypothesis)` classe chaque GT en `strict_preserved` (forme exacte) / `value_preserved` (`XIV` ↔ `14` accepté ; **mais pas** `f. 12r` ↔ `f. 12v`) / `lost`. Multiplicité respectée. Retourne `{global_strict_score, global_value_score, n_total, per_category{n_total, strict, value, strict_score, value_score, lost_items}}`. `numerical_sequence_strict_score` et `numerical_sequence_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. Limites documentées : regex conservatrices (« mil cinq cens » non détecté comme année), pas de cross-category match (`MDCLXVIII` GT et `1668` hyp sont catégorisés séparément). +27 tests dans `test_sprint85_numerical_sequences.py` couvrant détecteurs individuels, scénarios identité/perte totale/GT vide/recto-verso non interchangeables/multiplicité, **2 cas réalistes** (charte XVIIIᵉ siècle préservée vs registre paroissial où l'OCR modernise XVIII→18 mais préserve l'année 1750 et la foliation), intégration registre 4 cas. **Verrou levé** : un bench d'archive numérique peut classer ses moteurs sur la dimension *« mes dates et cotes seront-elles fiables ? »*, qui complète la **recherchabilité fuzzy** (Sprint 84) pour livrer **A.II.5 en couche de calcul intégrale**. Reste pour clôturer A.II.5 bout-en-bout : câblage runner + colonne HTML « Recherchabilité » + table HTML séquences numériques. |
211
  | 84 | **Sprint 53 du plan d'évolution 2026 — A.II.5a : recherchabilité fuzzy (couche de calcul + registre typé)**. Le CER mesure les erreurs caractère par caractère ; pour la recherche plein-texte (Elastic, Solr, full-text Gallica), la question réelle est *« combien de mots GT sont retrouvables à orthographe approchée près ? »*. Un CER de 8 % peut donner 95 % de findability si les erreurs sont sur des caractères non significatifs ; à l'inverse 4 % distribué sur tous les noms propres rend le corpus inutilisable pour l'indexation prosopographique. Nouveau module `picarones/core/searchability.py` : `levenshtein_distance(a, b)` DP O(|a|·|b|) mémoire O(min(|a|,|b|)) ; `compute_searchability(reference, hypothesis, max_distance=2, case_sensitive=False)` aligne par multi-set (un token hyp utilisé une seule fois, comme `rare_token_recall` Sprint 71), retourne `{n_gt_tokens, n_searchable, recall, missed_tokens, max_distance}` avec `recall=None` quand n_gt=0 (différencie GT vide de zéro match), court-circuit longueur (Levenshtein ≥ |Δlen|) et arrêt précoce sur match exact ; `searchability_recall_metric` enregistré dans le registre typé Sprint 34 pour `(TEXT, TEXT)` (convention float : 0.0 si GT vide pour cohérence runner). Défaut `max_distance=2` aligné sur Elastic `fuzziness: AUTO`. Limites documentées : tokenisation par split whitespace, Levenshtein non pondéré, pas de sémantique. +28 tests (Levenshtein 9 cas standards dont kitten classique, computation 13 cas dont identité/disjoint/GT vide/hypothèse vide/max_distance=0|2|large/casse/multiplicité/missed_tokens préserve casse GT/ValueError max_distance<0, **2 cas réalistes opposés** Charles→Charlemagne non retrouvé vs maistre→maitre retrouvé, intégration registre 4 cas dont `compute_at_junction`). **Verrou levé** : un bench BnF d'archive numérique peut désormais classer ses moteurs sur la dimension *« mes corpus seront-ils retrouvables après OCRisation ? »* — proxy direct de la valeur d'usage. |
212
  | 83 | **Sprint 52 du plan d'évolution 2026 — A.II.4 : métriques de fiabilité (couche de calcul, démarrage Étape 4 post-A.I)**. Une publication scientifique qui rapporte un CER LLM sans stabilité est méthodologiquement faible ; un benchmark qui ignore le plafond humain crée des classements faussement optimistes. Nouveau module `picarones/core/reliability.py` couvrant deux familles : (1) **IAA caractère** — `cohen_kappa(annotations_a, annotations_b)` retourne κ standard avec convention 1.0/0.0 documentée pour `pe=1` indéfini, garde-fous sur tailles/vide ; `krippendorff_alpha(units)` mode nominal généralisé à N annotateurs avec missing values (cellules None autorisées), formule `1 - D_o / D_e` sur paires sans remise, `None` si single label corpus-wide ou aucune unité ≥2 valides ; `_aligned_char_pairs(text_a, text_b)` aligne via `SequenceMatcher` sur opcodes `equal` et `replace` (insert/delete sans alignement bilatéral), `compute_iaa(transcription_a, transcription_b)` retourne `{n_aligned_chars, cohen_kappa, krippendorff_alpha, agreement_rate}`. (2) **Stabilité multi-runs** — `compute_multirun_stability(runs, reference=None)` mesure `pairwise_disagreement_mean/max` (Jaccard token-level), `identical_run_rate`, `n_distinct_outputs` ; si reference fournie, calcule `cer_per_run`, `cer_mean`, `cer_stdev`, `cer_cv` (None si mean=0 pour éviter division par zéro). Retourne None si <2 runs. Pure couche de calcul : pas d'extension du loader pour multi-GT, pas d'option runner `--repeats N`, pas de détecteur narratif `engine_unstable` — reportés à des sprints dédiés. +26 tests dans `test_sprint83_reliability.py` (cohen_kappa 6 cas dont accord parfait/désaccord pire que hasard κ=-1/un seul label, krippendorff 5 cas dont missing/single label corpus-wide, compute_iaa 5 cas dont empty/one-empty, multirun 6 cas dont reference parfaite et CV indéfini, _aligned_char_pairs 4 cas). **Verrou levé** : le rapport pourra demain afficher *« CER de Pero 4,2 % approche le plafond inter-paléographes κ=0,89 »* et signaler les pipelines LLM dont la variance dépasse un seuil. |
@@ -303,7 +304,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
303
  ## Contexte développement
304
 
305
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
306
- - **Tests** : 2842 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-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 — robustesse projetée sur corpus réel ; Sprint 82 = A.I.9 — section « Leviers d'amélioration » bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy (Levenshtein ≤ 2, registre typé) ; **Sprint 85 = A.II.5b — précision séquences numériques (5 catégories, registre typé)**)
307
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
308
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
309
  - **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
+ | 86 | **Sprint 55 du plan d'évolution 2026 — A.II.5 : câblage runner adaptive + vues HTML (clôture A.II.5 bout-en-bout)**. Suite directe Sprints 84+85 — la couche de calcul livrait deux modules pour le mode plein-texte patrimonial, ce sprint les remonte automatiquement dans le rapport. Deux helpers `picarones/core/searchability_runner.py` et `picarones/core/numerical_sequences_runner.py` calculent les métriques par document avec **adaptive masking** (rien n'apparaît pour un doc sans GT exploitable) et agrègent corpus-wide en *micro*-rappel pour searchability et somme par catégorie pour les séquences numériques. `DocumentResult` gagne `searchability_metrics` + `numerical_sequence_metrics` ; `EngineReport` gagne `aggregated_searchability` + `aggregated_numerical_sequences` (sérialisation conditionnelle, libérés par `compact`). Le runner historique calcule les deux inconditionnellement (coût négligeable face à l'OCR), erreur isolée par try/except + warning explicite, rétrocompat stricte. Deux modules de rendu `picarones/report/searchability_render.py` (tableau résumé moteur × {rappel coloré rouge→jaune→vert, retrouvés/total, docs}) et `picarones/report/numerical_sequences_render.py` (tableau moteur × catégorie {year/roman/foliation/currency/regnal} avec **adaptive masking par catégorie** — une catégorie sans signal est omise pour tous les moteurs ; chaque cellule affiche le score strict en gradient + la valeur entre parenthèses + n). Insertion dans `view_analyses.html` derrière le profil philologique, `chart-card` pleine largeur conditionné. Anti-injection systématique. +15 clés i18n FR/EN (`search_*`, `numseq_*`). +25 tests dans `test_sprint86_aii5_html.py` (adaptive masking helpers, agrégation micro-rappel, somme par catégorie, sérialisation `DocumentResult`/`EngineReport`, `compact` qui efface, masquage adaptatif HTML, rendu FR + EN, anti-injection sur nom moteur, complétude i18n 15 clés). **Verrou levé** : un benchmark BnF voit désormais sur la vue Analyses *« Recherchabilité fuzzy : tess 95,2 %, pero 87,8 % »* + le tableau séquences numériques détaillé par catégorie — A.II.5 livrée bout-en-bout (calcul Sprints 84-85, runner et HTML Sprint 86). |
211
  | 85 | **Sprint 54 du plan d'évolution 2026 — A.II.5b : précision sur séquences numériques (couche de calcul + registre typé)**. Pour un économiste-historien, un éditeur de chartes ou un archiviste, la fidélité aux séquences numériques est un proxy direct de la qualité éditoriale — un OCR qui rate « 1789 » dans une charte révolutionnaire ou « f. 12v » dans une cote d'archives produit un corpus inutilisable, même avec un CER global respectable. Nouveau module `picarones/core/numerical_sequences.py` couvrant **5 catégories** : (1) **dates arabes** années 4 chiffres dans la plage [1000-2099], (2) **numéraux romains** délégués à `roman_numerals.detect_roman_numerals` Sprint 60, (3) **foliotation** (`f.`, `fol.`, `p.`, `pp.`, `n°`) avec suffixe `r`/`v` préservé (recto/verso = information distincte non interchangeable côté valeur), (4) **montants** Ancien Régime (`livres/l.`, `sols/s.`, `deniers/d.`) et modernes (`£`, `€`, `₣`, `écus`, `florins`, `francs`), (5) **années régnales** (`an III`, `l'an V`, `an de grâce 1450`). `compute_numerical_sequence_metrics(reference, hypothesis)` classe chaque GT en `strict_preserved` (forme exacte) / `value_preserved` (`XIV` ↔ `14` accepté ; **mais pas** `f. 12r` ↔ `f. 12v`) / `lost`. Multiplicité respectée. Retourne `{global_strict_score, global_value_score, n_total, per_category{n_total, strict, value, strict_score, value_score, lost_items}}`. `numerical_sequence_strict_score` et `numerical_sequence_value_score` enregistrés dans le registre typé Sprint 34 pour `(TEXT, TEXT)`. Limites documentées : regex conservatrices (« mil cinq cens » non détecté comme année), pas de cross-category match (`MDCLXVIII` GT et `1668` hyp sont catégorisés séparément). +27 tests dans `test_sprint85_numerical_sequences.py` couvrant détecteurs individuels, scénarios identité/perte totale/GT vide/recto-verso non interchangeables/multiplicité, **2 cas réalistes** (charte XVIIIᵉ siècle préservée vs registre paroissial où l'OCR modernise XVIII→18 mais préserve l'année 1750 et la foliation), intégration registre 4 cas. **Verrou levé** : un bench d'archive numérique peut classer ses moteurs sur la dimension *« mes dates et cotes seront-elles fiables ? »*, qui complète la **recherchabilité fuzzy** (Sprint 84) pour livrer **A.II.5 en couche de calcul intégrale**. Reste pour clôturer A.II.5 bout-en-bout : câblage runner + colonne HTML « Recherchabilité » + table HTML séquences numériques. |
212
  | 84 | **Sprint 53 du plan d'évolution 2026 — A.II.5a : recherchabilité fuzzy (couche de calcul + registre typé)**. Le CER mesure les erreurs caractère par caractère ; pour la recherche plein-texte (Elastic, Solr, full-text Gallica), la question réelle est *« combien de mots GT sont retrouvables à orthographe approchée près ? »*. Un CER de 8 % peut donner 95 % de findability si les erreurs sont sur des caractères non significatifs ; à l'inverse 4 % distribué sur tous les noms propres rend le corpus inutilisable pour l'indexation prosopographique. Nouveau module `picarones/core/searchability.py` : `levenshtein_distance(a, b)` DP O(|a|·|b|) mémoire O(min(|a|,|b|)) ; `compute_searchability(reference, hypothesis, max_distance=2, case_sensitive=False)` aligne par multi-set (un token hyp utilisé une seule fois, comme `rare_token_recall` Sprint 71), retourne `{n_gt_tokens, n_searchable, recall, missed_tokens, max_distance}` avec `recall=None` quand n_gt=0 (différencie GT vide de zéro match), court-circuit longueur (Levenshtein ≥ |Δlen|) et arrêt précoce sur match exact ; `searchability_recall_metric` enregistré dans le registre typé Sprint 34 pour `(TEXT, TEXT)` (convention float : 0.0 si GT vide pour cohérence runner). Défaut `max_distance=2` aligné sur Elastic `fuzziness: AUTO`. Limites documentées : tokenisation par split whitespace, Levenshtein non pondéré, pas de sémantique. +28 tests (Levenshtein 9 cas standards dont kitten classique, computation 13 cas dont identité/disjoint/GT vide/hypothèse vide/max_distance=0|2|large/casse/multiplicité/missed_tokens préserve casse GT/ValueError max_distance<0, **2 cas réalistes opposés** Charles→Charlemagne non retrouvé vs maistre→maitre retrouvé, intégration registre 4 cas dont `compute_at_junction`). **Verrou levé** : un bench BnF d'archive numérique peut désormais classer ses moteurs sur la dimension *« mes corpus seront-ils retrouvables après OCRisation ? »* — proxy direct de la valeur d'usage. |
213
  | 83 | **Sprint 52 du plan d'évolution 2026 — A.II.4 : métriques de fiabilité (couche de calcul, démarrage Étape 4 post-A.I)**. Une publication scientifique qui rapporte un CER LLM sans stabilité est méthodologiquement faible ; un benchmark qui ignore le plafond humain crée des classements faussement optimistes. Nouveau module `picarones/core/reliability.py` couvrant deux familles : (1) **IAA caractère** — `cohen_kappa(annotations_a, annotations_b)` retourne κ standard avec convention 1.0/0.0 documentée pour `pe=1` indéfini, garde-fous sur tailles/vide ; `krippendorff_alpha(units)` mode nominal généralisé à N annotateurs avec missing values (cellules None autorisées), formule `1 - D_o / D_e` sur paires sans remise, `None` si single label corpus-wide ou aucune unité ≥2 valides ; `_aligned_char_pairs(text_a, text_b)` aligne via `SequenceMatcher` sur opcodes `equal` et `replace` (insert/delete sans alignement bilatéral), `compute_iaa(transcription_a, transcription_b)` retourne `{n_aligned_chars, cohen_kappa, krippendorff_alpha, agreement_rate}`. (2) **Stabilité multi-runs** — `compute_multirun_stability(runs, reference=None)` mesure `pairwise_disagreement_mean/max` (Jaccard token-level), `identical_run_rate`, `n_distinct_outputs` ; si reference fournie, calcule `cer_per_run`, `cer_mean`, `cer_stdev`, `cer_cv` (None si mean=0 pour éviter division par zéro). Retourne None si <2 runs. Pure couche de calcul : pas d'extension du loader pour multi-GT, pas d'option runner `--repeats N`, pas de détecteur narratif `engine_unstable` — reportés à des sprints dédiés. +26 tests dans `test_sprint83_reliability.py` (cohen_kappa 6 cas dont accord parfait/désaccord pire que hasard κ=-1/un seul label, krippendorff 5 cas dont missing/single label corpus-wide, compute_iaa 5 cas dont empty/one-empty, multirun 6 cas dont reference parfaite et CV indéfini, _aligned_char_pairs 4 cas). **Verrou levé** : le rapport pourra demain afficher *« CER de Pero 4,2 % approche le plafond inter-paléographes κ=0,89 »* et signaler les pipelines LLM dont la variance dépasse un seuil. |
 
304
  ## Contexte développement
305
 
306
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
307
+ - **Tests** : 2867 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-51 = les 5 adapters OCR exposent leurs confidences natives ; **Étape 2 close** ; Sprints 52-54 = axe A.II.2 (métriques structurelles) couches de calcul intégralement livrées ; Sprints 55-62 = extension philologique livrée bout-en-bout sur trois périodes + numéraux romains transversaux + câblage runner adaptive + vue HTML « Profil philologique » ; Sprints 63-70 = axe B livré bout-en-bout ; Sprints 71-72 = A.I.1 livré bout-en-bout ; Sprints 73-74 = A.I.3 livré bout-en-bout ; Sprints 75-77 = A.I.4 livré bout-en-bout ; Sprint 78 = A.I.5 couche calcul ; Sprint 79 = A.I.6 couche calcul ; Sprint 80 = A.I.7 ; Sprint 81 = A.I.8 — robustesse projetée sur corpus réel ; Sprint 82 = A.I.9 — section « Leviers d'amélioration �� bout-en-bout ; Sprint 83 = A.II.4 — métriques de fiabilité (IAA Cohen κ + Krippendorff α + stabilité multi-runs, couche calcul) ; Sprint 84 = A.II.5a — recherchabilité fuzzy (Levenshtein ≤ 2, registre typé) ; Sprint 85 = A.II.5b — précision séquences numériques (5 catégories, registre typé) ; **Sprint 86 = A.II.5 livrée bout-en-bout — câblage runner adaptive + vues HTML « Recherchabilité fuzzy » et « Précision sur séquences numériques »**)
308
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
309
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
310
  - **Transcript de la conversation de développement** :
picarones/core/numerical_sequences_runner.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Câblage runner des séquences numériques (Sprint 86).
2
+
3
+ Sprint 86 — A.II.5b (vue HTML + câblage runner).
4
+
5
+ Le module ``picarones/core/numerical_sequences.py`` (Sprint 85)
6
+ a livré la couche de calcul. Ce helper prépare la donnée
7
+ adaptative pour le runner et agrège les compteurs par moteur.
8
+
9
+ Adaptive masking
10
+ ----------------
11
+ On ne stocke le résultat que si la GT contient au moins une
12
+ séquence numérique détectée — sinon le module n'apparaît pas
13
+ dans le rapport.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Iterable, Optional
20
+
21
+ from picarones.core.numerical_sequences import (
22
+ CATEGORIES,
23
+ compute_numerical_sequence_metrics,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def compute_numerical_sequence_metrics_adaptive(
30
+ reference: Optional[str],
31
+ hypothesis: Optional[str],
32
+ ) -> Optional[dict]:
33
+ """Calcule les métriques séquences numériques avec masquage
34
+ adaptatif : retourne ``None`` si la GT n'en contient
35
+ aucune."""
36
+ if not reference:
37
+ return None
38
+ result = compute_numerical_sequence_metrics(reference, hypothesis or "")
39
+ if (result.get("n_total") or 0) == 0:
40
+ return None
41
+ return result
42
+
43
+
44
+ def aggregate_numerical_sequence_metrics(
45
+ per_doc: Iterable[Optional[dict]],
46
+ ) -> Optional[dict]:
47
+ """Agrège par moteur : somme les compteurs par catégorie et
48
+ recalcule les scores globaux et per-category.
49
+
50
+ Format de sortie identique à ``compute_numerical_sequence_metrics``
51
+ pour faciliter le rendu HTML symétrique.
52
+ """
53
+ docs = [d for d in per_doc if d]
54
+ if not docs:
55
+ return None
56
+ total_n = 0
57
+ total_strict = 0
58
+ total_value = 0
59
+ per_cat: dict[str, dict] = {}
60
+ for cat in CATEGORIES:
61
+ per_cat[cat] = {
62
+ "n_total": 0,
63
+ "strict": 0,
64
+ "value": 0,
65
+ "lost_items": [],
66
+ }
67
+ for d in docs:
68
+ for cat in CATEGORIES:
69
+ cat_data = (d.get("per_category") or {}).get(cat) or {}
70
+ per_cat[cat]["n_total"] += int(cat_data.get("n_total") or 0)
71
+ per_cat[cat]["strict"] += int(cat_data.get("strict") or 0)
72
+ per_cat[cat]["value"] += int(cat_data.get("value") or 0)
73
+ per_cat[cat]["lost_items"].extend(
74
+ cat_data.get("lost_items") or [],
75
+ )
76
+ total_n += int(d.get("n_total") or 0)
77
+ # Recalcul des scores
78
+ for cat, slot in per_cat.items():
79
+ n = slot["n_total"]
80
+ slot["strict_score"] = slot["strict"] / n if n else 0.0
81
+ slot["value_score"] = slot["value"] / n if n else 0.0
82
+ # Cap des lost_items à 50 par catégorie
83
+ slot["lost_items"] = slot["lost_items"][:50]
84
+ total_strict += slot["strict"]
85
+ total_value += slot["value"]
86
+ return {
87
+ "n_docs": len(docs),
88
+ "n_total": total_n,
89
+ "global_strict_score": (
90
+ total_strict / total_n if total_n else 0.0
91
+ ),
92
+ "global_value_score": (
93
+ total_value / total_n if total_n else 0.0
94
+ ),
95
+ "per_category": per_cat,
96
+ }
97
+
98
+
99
+ __all__ = [
100
+ "compute_numerical_sequence_metrics_adaptive",
101
+ "aggregate_numerical_sequence_metrics",
102
+ ]
picarones/core/results.py CHANGED
@@ -90,6 +90,25 @@ class DocumentResult:
90
  Cette logique adaptative permet de garder les rapports lisibles
91
  sur les corpus sans marqueurs philologiques.
92
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
 
94
  def as_dict(self) -> dict:
95
  d = {
@@ -125,6 +144,10 @@ class DocumentResult:
125
  d["calibration_metrics"] = self.calibration_metrics
126
  if self.philological_metrics is not None:
127
  d["philological_metrics"] = self.philological_metrics
 
 
 
 
128
  return d
129
 
130
  def compact(self) -> None:
@@ -153,6 +176,8 @@ class DocumentResult:
153
  self.ner_metrics = None
154
  self.calibration_metrics = None
155
  self.philological_metrics = None
 
 
156
 
157
 
158
  @dataclass
@@ -206,6 +231,21 @@ class EngineReport:
206
  globaux ; les structures per_category/per_block/per_status sont
207
  également agrégées. ``None`` si aucun document n'a porté de
208
  ``philological_metrics``."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
 
210
  def __post_init__(self) -> None:
211
  if not self.aggregated_metrics and self.document_results:
@@ -284,6 +324,12 @@ class EngineReport:
284
  d["aggregated_calibration"] = self.aggregated_calibration
285
  if self.aggregated_philological is not None:
286
  d["aggregated_philological"] = self.aggregated_philological
 
 
 
 
 
 
287
  return d
288
 
289
 
 
90
  Cette logique adaptative permet de garder les rapports lisibles
91
  sur les corpus sans marqueurs philologiques.
92
  """
93
+ # Sprint 86 — recherchabilité fuzzy (Sprint 84) calculée
94
+ # automatiquement avec adaptive masking.
95
+ searchability_metrics: Optional[dict] = None
96
+ """Recherchabilité fuzzy (Sprint 84+86).
97
+
98
+ Format : retour de ``compute_searchability`` ({n_gt_tokens,
99
+ n_searchable, recall, missed_tokens, max_distance}). Présent
100
+ uniquement si la GT contient au moins un token.
101
+ """
102
+ # Sprint 86 — précision sur séquences numériques (Sprint 85)
103
+ # calculée automatiquement avec adaptive masking.
104
+ numerical_sequence_metrics: Optional[dict] = None
105
+ """Précision sur séquences numériques (Sprint 85+86).
106
+
107
+ Format : retour de ``compute_numerical_sequence_metrics``
108
+ (global_strict_score, global_value_score, n_total,
109
+ per_category). Présent uniquement si la GT contient au
110
+ moins une séquence détectée.
111
+ """
112
 
113
  def as_dict(self) -> dict:
114
  d = {
 
144
  d["calibration_metrics"] = self.calibration_metrics
145
  if self.philological_metrics is not None:
146
  d["philological_metrics"] = self.philological_metrics
147
+ if self.searchability_metrics is not None:
148
+ d["searchability_metrics"] = self.searchability_metrics
149
+ if self.numerical_sequence_metrics is not None:
150
+ d["numerical_sequence_metrics"] = self.numerical_sequence_metrics
151
  return d
152
 
153
  def compact(self) -> None:
 
176
  self.ner_metrics = None
177
  self.calibration_metrics = None
178
  self.philological_metrics = None
179
+ self.searchability_metrics = None
180
+ self.numerical_sequence_metrics = None
181
 
182
 
183
  @dataclass
 
231
  globaux ; les structures per_category/per_block/per_status sont
232
  également agrégées. ``None`` si aucun document n'a porté de
233
  ``philological_metrics``."""
234
+ # Sprint 86
235
+ aggregated_searchability: Optional[dict] = None
236
+ """Recherchabilité fuzzy agrégée corpus-wide (Sprint 84+86).
237
+
238
+ Format ``{n_docs, n_gt_tokens, n_searchable, recall,
239
+ missed_tokens_sample, max_distance}``. ``None`` si aucun
240
+ document n'a porté de ``searchability_metrics``."""
241
+ aggregated_numerical_sequences: Optional[dict] = None
242
+ """Précision sur séquences numériques agrégée (Sprint 85+86).
243
+
244
+ Format identique à ``compute_numerical_sequence_metrics`` :
245
+ global_strict_score, global_value_score, n_total,
246
+ per_category{n_total, strict, value, strict_score,
247
+ value_score, lost_items}. ``None`` si aucun document n'avait
248
+ de séquence numérique exploitable."""
249
 
250
  def __post_init__(self) -> None:
251
  if not self.aggregated_metrics and self.document_results:
 
324
  d["aggregated_calibration"] = self.aggregated_calibration
325
  if self.aggregated_philological is not None:
326
  d["aggregated_philological"] = self.aggregated_philological
327
+ if self.aggregated_searchability is not None:
328
+ d["aggregated_searchability"] = self.aggregated_searchability
329
+ if self.aggregated_numerical_sequences is not None:
330
+ d["aggregated_numerical_sequences"] = (
331
+ self.aggregated_numerical_sequences
332
+ )
333
  return d
334
 
335
 
picarones/core/runner.py CHANGED
@@ -298,6 +298,35 @@ def _compute_document_result(
298
  except Exception as e:
299
  _logger.warning("[philological] fonctionnalité dégradée : %s", e)
300
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  return DocumentResult(
302
  doc_id=doc_id,
303
  image_path=image_path,
@@ -317,6 +346,8 @@ def _compute_document_result(
317
  hallucination_metrics=hallucination_data,
318
  calibration_metrics=calibration_data,
319
  philological_metrics=philological_data,
 
 
320
  )
321
 
322
 
@@ -735,6 +766,19 @@ def run_benchmark(
735
  agg_philological = aggregate_philological_metrics(
736
  [dr.philological_metrics for dr in document_results],
737
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
738
 
739
  report = EngineReport(
740
  engine_name=engine.name,
@@ -751,6 +795,8 @@ def run_benchmark(
751
  aggregated_hallucination=agg_hallucination,
752
  aggregated_calibration=agg_calibration,
753
  aggregated_philological=agg_philological,
 
 
754
  )
755
  engine_reports.append(report)
756
  logger.info(
 
298
  except Exception as e:
299
  _logger.warning("[philological] fonctionnalité dégradée : %s", e)
300
 
301
+ # Sprint 86 — recherchabilité fuzzy (Sprint 84) avec adaptive
302
+ # masking. Coût O(N_gt × N_hyp × len_max), négligeable sur les
303
+ # tailles de documents typiques.
304
+ searchability_data: Optional[dict] = None
305
+ try:
306
+ from picarones.core.searchability_runner import (
307
+ compute_searchability_metrics,
308
+ )
309
+ searchability_data = compute_searchability_metrics(
310
+ ground_truth, ocr_result.text,
311
+ )
312
+ except Exception as e:
313
+ _logger.warning("[searchability] fonctionnalité dégradée : %s", e)
314
+
315
+ # Sprint 86 — précision sur séquences numériques (Sprint 85)
316
+ # avec adaptive masking.
317
+ numerical_sequence_data: Optional[dict] = None
318
+ try:
319
+ from picarones.core.numerical_sequences_runner import (
320
+ compute_numerical_sequence_metrics_adaptive,
321
+ )
322
+ numerical_sequence_data = compute_numerical_sequence_metrics_adaptive(
323
+ ground_truth, ocr_result.text,
324
+ )
325
+ except Exception as e:
326
+ _logger.warning(
327
+ "[numerical_sequences] fonctionnalité dégradée : %s", e,
328
+ )
329
+
330
  return DocumentResult(
331
  doc_id=doc_id,
332
  image_path=image_path,
 
346
  hallucination_metrics=hallucination_data,
347
  calibration_metrics=calibration_data,
348
  philological_metrics=philological_data,
349
+ searchability_metrics=searchability_data,
350
+ numerical_sequence_metrics=numerical_sequence_data,
351
  )
352
 
353
 
 
766
  agg_philological = aggregate_philological_metrics(
767
  [dr.philological_metrics for dr in document_results],
768
  )
769
+ # Sprint 86 — agrégation A.II.5
770
+ from picarones.core.searchability_runner import (
771
+ aggregate_searchability_metrics,
772
+ )
773
+ from picarones.core.numerical_sequences_runner import (
774
+ aggregate_numerical_sequence_metrics,
775
+ )
776
+ agg_searchability = aggregate_searchability_metrics(
777
+ [dr.searchability_metrics for dr in document_results],
778
+ )
779
+ agg_numerical_sequences = aggregate_numerical_sequence_metrics(
780
+ [dr.numerical_sequence_metrics for dr in document_results],
781
+ )
782
 
783
  report = EngineReport(
784
  engine_name=engine.name,
 
795
  aggregated_hallucination=agg_hallucination,
796
  aggregated_calibration=agg_calibration,
797
  aggregated_philological=agg_philological,
798
+ aggregated_searchability=agg_searchability,
799
+ aggregated_numerical_sequences=agg_numerical_sequences,
800
  )
801
  engine_reports.append(report)
802
  logger.info(
picarones/core/searchability_runner.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Câblage runner de la recherchabilité (Sprint 86).
2
+
3
+ Sprint 86 — A.II.5a (vue HTML + câblage runner).
4
+
5
+ Le module ``picarones/core/searchability.py`` (Sprint 84) a livré
6
+ la couche de calcul. Ce helper prépare la donnée pour le runner
7
+ historique et l'agrégation par moteur.
8
+
9
+ Adaptive masking
10
+ ----------------
11
+ Comme pour les modules philologiques (Sprint 61), on ne calcule
12
+ le rappel que si la GT contient au moins un token — pas de
13
+ calcul vide qui produirait du bruit dans le rapport.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from typing import Iterable, Optional
20
+
21
+ from picarones.core.searchability import (
22
+ _split_words,
23
+ compute_searchability,
24
+ )
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def compute_searchability_metrics(
30
+ reference: Optional[str],
31
+ hypothesis: Optional[str],
32
+ *,
33
+ max_distance: int = 2,
34
+ ) -> Optional[dict]:
35
+ """Recherchabilité d'un document (adaptive).
36
+
37
+ Retourne ``None`` si la GT est vide ou ne contient aucun
38
+ token — ce qui déclenche l'adaptive masking côté HTML.
39
+ """
40
+ if not reference or not _split_words(reference):
41
+ return None
42
+ return compute_searchability(
43
+ reference, hypothesis or "", max_distance=max_distance,
44
+ )
45
+
46
+
47
+ def aggregate_searchability_metrics(
48
+ per_doc: Iterable[Optional[dict]],
49
+ ) -> Optional[dict]:
50
+ """Agrège les métriques par-doc en un score corpus-wide.
51
+
52
+ Convention : on somme les ``n_gt_tokens`` et ``n_searchable``
53
+ et on recalcule un rappel **micro** (cohérent avec ECE/MCE
54
+ Sprint 39 et NER Sprint 38).
55
+ """
56
+ docs = [d for d in per_doc if d]
57
+ if not docs:
58
+ return None
59
+ n_gt = sum(int(d.get("n_gt_tokens") or 0) for d in docs)
60
+ n_search = sum(int(d.get("n_searchable") or 0) for d in docs)
61
+ if n_gt == 0:
62
+ return None
63
+ # On garde l'union des missed_tokens (capped pour ne pas
64
+ # exploser le JSON sur de gros corpus)
65
+ missed: list[str] = []
66
+ for d in docs:
67
+ missed.extend(d.get("missed_tokens") or [])
68
+ return {
69
+ "n_docs": len(docs),
70
+ "n_gt_tokens": n_gt,
71
+ "n_searchable": n_search,
72
+ "recall": n_search / n_gt,
73
+ "missed_tokens_sample": missed[:50],
74
+ "max_distance": docs[0].get("max_distance", 2),
75
+ }
76
+
77
+
78
+ __all__ = [
79
+ "compute_searchability_metrics",
80
+ "aggregate_searchability_metrics",
81
+ ]
picarones/report/generator.py CHANGED
@@ -199,6 +199,12 @@ def _build_report_data(benchmark: BenchmarkResult, images_b64: dict[str, str]) -
199
  # Sprint 62 — profil philologique agrégé (None si aucun
200
  # signal philologique sur le corpus pour ce moteur)
201
  "aggregated_philological": report.aggregated_philological,
 
 
 
 
 
 
202
  "is_vlm": report.pipeline_info.get("is_vlm", False) if report.pipeline_info else False,
203
  }
204
  engines_summary.append(entry)
@@ -787,6 +793,21 @@ class ReportGenerator:
787
  labels=labels,
788
  )
789
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
790
  env = _build_jinja_env()
791
  template = env.get_template("base.html.j2")
792
  html = template.render(
@@ -808,6 +829,8 @@ class ReportGenerator:
808
  reliability_diagrams_html=reliability_diagrams_html,
809
  stratified_ranking_html=stratified_ranking_html,
810
  philological_profile_html=philological_profile_html,
 
 
811
  )
812
 
813
  output_path.write_text(html, encoding="utf-8")
 
199
  # Sprint 62 — profil philologique agrégé (None si aucun
200
  # signal philologique sur le corpus pour ce moteur)
201
  "aggregated_philological": report.aggregated_philological,
202
+ # Sprint 86 — A.II.5 (recherchabilité fuzzy + séquences
203
+ # numériques). None si aucun document n'a de signal.
204
+ "aggregated_searchability": report.aggregated_searchability,
205
+ "aggregated_numerical_sequences": (
206
+ report.aggregated_numerical_sequences
207
+ ),
208
  "is_vlm": report.pipeline_info.get("is_vlm", False) if report.pipeline_info else False,
209
  }
210
  engines_summary.append(entry)
 
793
  labels=labels,
794
  )
795
 
796
+ # Sprint 86 — A.II.5 : recherchabilité fuzzy +
797
+ # séquences numériques. Adaptive : "" si aucun signal.
798
+ from picarones.report.searchability_render import (
799
+ build_searchability_summary_html,
800
+ )
801
+ from picarones.report.numerical_sequences_render import (
802
+ build_numerical_sequences_html,
803
+ )
804
+ searchability_html = build_searchability_summary_html(
805
+ report_data.get("engines", []), labels=labels,
806
+ )
807
+ numerical_sequences_html = build_numerical_sequences_html(
808
+ report_data.get("engines", []), labels=labels,
809
+ )
810
+
811
  env = _build_jinja_env()
812
  template = env.get_template("base.html.j2")
813
  html = template.render(
 
829
  reliability_diagrams_html=reliability_diagrams_html,
830
  stratified_ranking_html=stratified_ranking_html,
831
  philological_profile_html=philological_profile_html,
832
+ searchability_html=searchability_html,
833
+ numerical_sequences_html=numerical_sequences_html,
834
  )
835
 
836
  output_path.write_text(html, encoding="utf-8")
picarones/report/i18n/en.json CHANGED
@@ -280,5 +280,20 @@
280
  "levers_complementarity_phrase_with_engine": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine ({best_engine}).",
281
  "levers_lexical_phrase": "Top GT tokens systematically modernized by {engine}: {items}.",
282
  "levers_robustness_phrase": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations.",
283
- "levers_robustness_phrase_with_worst": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations — worst degradation: {worst_type} ({worst_pct} points)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
 
280
  "levers_complementarity_phrase_with_engine": "The bag-of-words oracle achieves a recall {abs_pct} points higher (+{rel_pct}% relative) than the best single engine ({best_engine}).",
281
  "levers_lexical_phrase": "Top GT tokens systematically modernized by {engine}: {items}.",
282
  "levers_robustness_phrase": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations.",
283
+ "levers_robustness_phrase_with_worst": "Projected deficit of {engine} on the real corpus: {deficit_pct} CER points cumulated over {n_types} degradations — worst degradation: {worst_type} ({worst_pct} points).",
284
+ "search_title": "Fuzzy searchability",
285
+ "search_note": "Fraction of GT tokens recovered in the OCR output within Levenshtein distance ≤ 2 — direct proxy of full-text search quality (Elastic, Solr, Gallica).",
286
+ "search_engine": "Engine",
287
+ "search_recall": "Recall",
288
+ "search_count": "Recovered tokens / total",
289
+ "search_docs": "Docs",
290
+ "numseq_title": "Numerical-sequence precision",
291
+ "numseq_note": "Strict score (form preserved) — the value in parentheses is the score on the value (XIV ↔ 14 accepted). Foliation: recto/verso are not interchangeable.",
292
+ "numseq_engine": "Engine",
293
+ "numseq_global": "Global",
294
+ "numseq_cat_year": "Year",
295
+ "numseq_cat_roman": "Roman",
296
+ "numseq_cat_foliation": "Foliation",
297
+ "numseq_cat_currency": "Amount",
298
+ "numseq_cat_regnal": "Regnal"
299
  }
picarones/report/i18n/fr.json CHANGED
@@ -280,5 +280,20 @@
280
  "levers_complementarity_phrase_with_engine": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul ({best_engine}).",
281
  "levers_lexical_phrase": "Top tokens GT systématiquement modernisés par {engine} : {items}.",
282
  "levers_robustness_phrase": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations.",
283
- "levers_robustness_phrase_with_worst": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations — pire dégradation : {worst_type} ({worst_pct} points)."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  }
 
280
  "levers_complementarity_phrase_with_engine": "L'oracle bag-of-words atteint un rappel supérieur de {abs_pct} points (+{rel_pct}% relatif) à celui du meilleur moteur seul ({best_engine}).",
281
  "levers_lexical_phrase": "Top tokens GT systématiquement modernisés par {engine} : {items}.",
282
  "levers_robustness_phrase": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations.",
283
+ "levers_robustness_phrase_with_worst": "Déficit projeté de {engine} sur le corpus réel : {deficit_pct} points de CER cumulés sur {n_types} dégradations — pire dégradation : {worst_type} ({worst_pct} points).",
284
+ "search_title": "Recherchabilité fuzzy",
285
+ "search_note": "Proportion de tokens GT retrouvés dans la sortie OCR à distance de Levenshtein ≤ 2 — proxy direct de la qualité pour la recherche plein-texte (Elastic, Solr, Gallica).",
286
+ "search_engine": "Moteur",
287
+ "search_recall": "Rappel",
288
+ "search_count": "Tokens retrouvés / total",
289
+ "search_docs": "Docs",
290
+ "numseq_title": "Précision sur séquences numériques",
291
+ "numseq_note": "Score strict (forme préservée) — la valeur entre parenthèses est le score sur la valeur (XIV ↔ 14 accepté). Foliotation : recto/verso non interchangeables.",
292
+ "numseq_engine": "Moteur",
293
+ "numseq_global": "Global",
294
+ "numseq_cat_year": "Année",
295
+ "numseq_cat_roman": "Romain",
296
+ "numseq_cat_foliation": "Foliation",
297
+ "numseq_cat_currency": "Montant",
298
+ "numseq_cat_regnal": "Régnal"
299
  }
picarones/report/numerical_sequences_render.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Précision sur séquences numériques » — Sprint 86.
2
+
3
+ Suite directe ``picarones/core/numerical_sequences.py``
4
+ (Sprint 85) + câblage runner Sprint 86.
5
+
6
+ Pattern identique aux autres rendus : server-side, pas de JS,
7
+ anti-injection systématique.
8
+
9
+ Vue
10
+ ---
11
+ Tableau moteur × catégorie (year / roman / foliation / currency
12
+ / regnal) × score strict ; une ligne par moteur, une cellule
13
+ colorée par cellule. Une seconde ligne donne le score ``value``
14
+ (en plus petit). Catégorie omise si **aucun** moteur n'a de
15
+ GT exploitable pour elle.
16
+
17
+ Adaptative : ``""`` si aucun moteur n'a de
18
+ ``aggregated_numerical_sequences``.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from html import escape as _e
24
+ from typing import Optional
25
+
26
+ from picarones.core.numerical_sequences import CATEGORIES
27
+
28
+
29
+ def _color_for_score(score: float) -> str:
30
+ """Gradient rouge → jaune → vert."""
31
+ f = max(0.0, min(1.0, score))
32
+ if f < 0.5:
33
+ t = f / 0.5
34
+ r = 235
35
+ g = int(70 + (200 - 70) * t)
36
+ b = 70
37
+ else:
38
+ t = (f - 0.5) / 0.5
39
+ r = int(235 + (60 - 235) * t)
40
+ g = int(200 + (160 - 200) * t)
41
+ b = int(70 + (90 - 70) * t)
42
+ return f"#{r:02x}{g:02x}{b:02x}"
43
+
44
+
45
+ def _category_columns_with_signal(rows: list[dict]) -> list[str]:
46
+ """Ne garde que les catégories où ≥ 1 moteur a un n_total > 0."""
47
+ visible: list[str] = []
48
+ for cat in CATEGORIES:
49
+ for r in rows:
50
+ agg = r.get("aggregated_numerical_sequences") or {}
51
+ cat_data = (agg.get("per_category") or {}).get(cat) or {}
52
+ if (cat_data.get("n_total") or 0) > 0:
53
+ visible.append(cat)
54
+ break
55
+ return visible
56
+
57
+
58
+ def build_numerical_sequences_html(
59
+ engines: list[dict],
60
+ labels: Optional[dict[str, str]] = None,
61
+ ) -> str:
62
+ """Construit la section HTML séquences numériques.
63
+
64
+ Returns
65
+ -------
66
+ str
67
+ ``""`` si aucun moteur n'a de signal.
68
+ """
69
+ rows = [
70
+ e for e in engines
71
+ if isinstance(e.get("aggregated_numerical_sequences"), dict)
72
+ ]
73
+ if not rows:
74
+ return ""
75
+ visible_cats = _category_columns_with_signal(rows)
76
+ if not visible_cats:
77
+ return ""
78
+ labels = labels or {}
79
+ title = labels.get(
80
+ "numseq_title", "Précision sur séquences numériques",
81
+ )
82
+ note = labels.get(
83
+ "numseq_note",
84
+ "Score strict (forme préservée) — la valeur entre "
85
+ "parenthèses est le score sur la valeur (XIV ↔ 14 "
86
+ "accepté). Foliotation : recto/verso non interchangeables.",
87
+ )
88
+ col_engine = labels.get("numseq_engine", "Moteur")
89
+ col_global = labels.get("numseq_global", "Global")
90
+ cat_label = {
91
+ "year": labels.get("numseq_cat_year", "Année"),
92
+ "roman": labels.get("numseq_cat_roman", "Romain"),
93
+ "foliation": labels.get("numseq_cat_foliation", "Foliation"),
94
+ "currency": labels.get("numseq_cat_currency", "Montant"),
95
+ "regnal": labels.get("numseq_cat_regnal", "Régnal"),
96
+ }
97
+
98
+ parts = [
99
+ '<div class="numseq-section" style="margin:1rem 0">',
100
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
101
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
102
+ f'{_e(note)}</div>',
103
+ '<table style="border-collapse:collapse;width:100%;'
104
+ 'font-size:.9rem">',
105
+ '<thead><tr>',
106
+ f'<th style="padding:.4rem .6rem;text-align:left;'
107
+ f'border-bottom:1px solid #ccc;font-weight:600">'
108
+ f'{_e(col_engine)}</th>',
109
+ f'<th style="padding:.4rem .6rem;text-align:right;'
110
+ f'border-bottom:1px solid #ccc;font-weight:600">'
111
+ f'{_e(col_global)}</th>',
112
+ ]
113
+ for cat in visible_cats:
114
+ parts.append(
115
+ f'<th style="padding:.4rem .6rem;text-align:right;'
116
+ f'border-bottom:1px solid #ccc;font-weight:600">'
117
+ f'{_e(cat_label.get(cat, cat))}</th>'
118
+ )
119
+ parts.append("</tr></thead><tbody>")
120
+
121
+ for engine in rows:
122
+ agg = engine["aggregated_numerical_sequences"]
123
+ name = engine.get("name") or "?"
124
+ per_cat = agg.get("per_category") or {}
125
+ global_strict = float(agg.get("global_strict_score") or 0.0)
126
+ global_value = float(agg.get("global_value_score") or 0.0)
127
+ n_total = int(agg.get("n_total") or 0)
128
+ global_color = _color_for_score(global_strict)
129
+ parts.append(
130
+ f'<tr>'
131
+ f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
132
+ f'<td style="padding:.4rem .6rem;text-align:right;'
133
+ f'background:{global_color};font-family:monospace;'
134
+ f'font-weight:600">'
135
+ f'{global_strict * 100:.1f}%'
136
+ f'<span style="font-size:.75rem;font-weight:400;'
137
+ f'opacity:.75"> ({global_value * 100:.0f}%, '
138
+ f'n={n_total})</span></td>'
139
+ )
140
+ for cat in visible_cats:
141
+ cat_data = per_cat.get(cat) or {}
142
+ n = int(cat_data.get("n_total") or 0)
143
+ if n == 0:
144
+ parts.append(
145
+ '<td style="padding:.4rem .6rem;text-align:right;'
146
+ 'opacity:.4">—</td>'
147
+ )
148
+ continue
149
+ strict = float(cat_data.get("strict_score") or 0.0)
150
+ value = float(cat_data.get("value_score") or 0.0)
151
+ color = _color_for_score(strict)
152
+ parts.append(
153
+ f'<td style="padding:.4rem .6rem;text-align:right;'
154
+ f'background:{color};font-family:monospace">'
155
+ f'{strict * 100:.0f}%'
156
+ f'<span style="font-size:.75rem;opacity:.75"> '
157
+ f'({value * 100:.0f}%, n={n})</span></td>'
158
+ )
159
+ parts.append("</tr>")
160
+ parts.append("</tbody></table></div>")
161
+ return "".join(parts)
162
+
163
+
164
+ __all__ = ["build_numerical_sequences_html"]
picarones/report/searchability_render.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Recherchabilité fuzzy » — Sprint 86 (A.II.5a HTML).
2
+
3
+ Suite directe ``picarones/core/searchability.py`` (Sprint 84) +
4
+ câblage runner (Sprint 86).
5
+
6
+ Pattern identique aux autres rendus (Sprints 41/43/62/67/72) :
7
+ **server-side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Vue
10
+ ---
11
+ Tableau résumé : moteur × (rappel, n_searchable / n_gt_tokens,
12
+ docs). Cellule rappel colorée par gradient rouge → vert.
13
+ Adaptative : ``""`` si aucun moteur n'a de
14
+ ``aggregated_searchability``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from html import escape as _e
20
+ from typing import Optional
21
+
22
+
23
+ def _color_for_recall(recall: float) -> str:
24
+ """Gradient rouge → jaune → vert pour rappel ∈ [0, 1]."""
25
+ f = max(0.0, min(1.0, recall))
26
+ if f < 0.5:
27
+ # rouge → jaune
28
+ t = f / 0.5
29
+ r = 235
30
+ g = int(70 + (200 - 70) * t)
31
+ b = 70
32
+ else:
33
+ # jaune → vert
34
+ t = (f - 0.5) / 0.5
35
+ r = int(235 + (60 - 235) * t)
36
+ g = int(200 + (160 - 200) * t)
37
+ b = int(70 + (90 - 70) * t)
38
+ return f"#{r:02x}{g:02x}{b:02x}"
39
+
40
+
41
+ def build_searchability_summary_html(
42
+ engines: list[dict],
43
+ labels: Optional[dict[str, str]] = None,
44
+ ) -> str:
45
+ """Construit la table HTML de recherchabilité.
46
+
47
+ Parameters
48
+ ----------
49
+ engines:
50
+ Liste de dicts moteur ; chacun peut avoir
51
+ ``aggregated_searchability``.
52
+ labels:
53
+ Dict i18n, clés ``search_*``.
54
+
55
+ Returns
56
+ -------
57
+ str
58
+ ``""`` si aucun moteur n'a de signal.
59
+ """
60
+ rows = [
61
+ e for e in engines
62
+ if isinstance(e.get("aggregated_searchability"), dict)
63
+ ]
64
+ if not rows:
65
+ return ""
66
+ labels = labels or {}
67
+ title = labels.get("search_title", "Recherchabilité fuzzy")
68
+ note = labels.get(
69
+ "search_note",
70
+ "Proportion de tokens GT retrouvés dans la sortie OCR à "
71
+ "distance de Levenshtein ≤ 2 — proxy direct de la "
72
+ "qualité pour la recherche plein-texte (Elastic, Solr).",
73
+ )
74
+ col_engine = labels.get("search_engine", "Moteur")
75
+ col_recall = labels.get("search_recall", "Rappel")
76
+ col_count = labels.get("search_count", "Tokens retrouvés / total")
77
+ col_docs = labels.get("search_docs", "Docs")
78
+
79
+ parts = [
80
+ '<div class="searchability-section" style="margin:1rem 0">',
81
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
82
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
83
+ f'{_e(note)}</div>',
84
+ '<table style="border-collapse:collapse;width:100%;'
85
+ 'font-size:.9rem">',
86
+ '<thead><tr>',
87
+ ]
88
+ for col in (col_engine, col_recall, col_count, col_docs):
89
+ parts.append(
90
+ f'<th style="padding:.4rem .6rem;text-align:left;'
91
+ f'border-bottom:1px solid #ccc;font-weight:600">'
92
+ f'{_e(col)}</th>'
93
+ )
94
+ parts.append("</tr></thead><tbody>")
95
+ for engine in rows:
96
+ agg = engine["aggregated_searchability"]
97
+ name = engine.get("name") or "?"
98
+ recall = float(agg.get("recall") or 0.0)
99
+ n_search = int(agg.get("n_searchable") or 0)
100
+ n_total = int(agg.get("n_gt_tokens") or 0)
101
+ n_docs = int(agg.get("n_docs") or 0)
102
+ color = _color_for_recall(recall)
103
+ parts.append(
104
+ f'<tr>'
105
+ f'<td style="padding:.4rem .6rem">{_e(str(name))}</td>'
106
+ f'<td style="padding:.4rem .6rem;text-align:right;'
107
+ f'background:{color};font-family:monospace;font-weight:600">'
108
+ f'{recall * 100:.1f}%</td>'
109
+ f'<td style="padding:.4rem .6rem;text-align:right;'
110
+ f'font-family:monospace">{n_search} / {n_total}</td>'
111
+ f'<td style="padding:.4rem .6rem;text-align:right;'
112
+ f'font-family:monospace">{n_docs}</td>'
113
+ f'</tr>'
114
+ )
115
+ parts.append("</tbody></table></div>")
116
+ return "".join(parts)
117
+
118
+
119
+ __all__ = ["build_searchability_summary_html"]
picarones/report/templates/view_analyses.html CHANGED
@@ -209,6 +209,20 @@
209
  </div>
210
  {% endif %}
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  <!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
213
  {% if divergence_matrix_html or oracle_gap_html %}
214
  <div class="chart-card" style="grid-column:1/-1">
 
209
  </div>
210
  {% endif %}
211
 
212
+ <!-- Sprint 86 — A.II.5 : recherchabilité fuzzy + précision sur
213
+ séquences numériques. Adaptive : n'apparaît que si au moins
214
+ un moteur a du signal. -->
215
+ {% if searchability_html %}
216
+ <div class="chart-card" style="grid-column:1/-1">
217
+ {{ searchability_html }}
218
+ </div>
219
+ {% endif %}
220
+ {% if numerical_sequences_html %}
221
+ <div class="chart-card" style="grid-column:1/-1">
222
+ {{ numerical_sequences_html }}
223
+ </div>
224
+ {% endif %}
225
+
226
  <!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
227
  {% if divergence_matrix_html or oracle_gap_html %}
228
  <div class="chart-card" style="grid-column:1/-1">
tests/test_sprint86_aii5_html.py ADDED
@@ -0,0 +1,367 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 86 — A.II.5 bout-en-bout : helpers runner +
2
+ rendu HTML.
3
+
4
+ Couvre :
5
+
6
+ 1. ``compute_searchability_metrics`` adaptive masking.
7
+ 2. ``aggregate_searchability_metrics`` micro-recall.
8
+ 3. ``compute_numerical_sequence_metrics_adaptive`` masking.
9
+ 4. ``aggregate_numerical_sequence_metrics`` somme par catégorie.
10
+ 5. Champs ``DocumentResult.searchability_metrics`` et
11
+ ``EngineReport.aggregated_searchability``.
12
+ 6. Rendu HTML adaptive + anti-injection.
13
+ 7. Complétude i18n FR/EN.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+
21
+ from picarones.core.numerical_sequences_runner import (
22
+ aggregate_numerical_sequence_metrics,
23
+ compute_numerical_sequence_metrics_adaptive,
24
+ )
25
+ from picarones.core.metrics import MetricsResult
26
+ from picarones.core.results import DocumentResult, EngineReport
27
+
28
+
29
+ def _stub_metrics() -> MetricsResult:
30
+ return MetricsResult(
31
+ cer=0.0, cer_nfc=0.0, cer_caseless=0.0,
32
+ wer=0.0, wer_normalized=0.0, mer=0.0, wil=0.0,
33
+ reference_length=0, hypothesis_length=0,
34
+ )
35
+ from picarones.core.searchability_runner import (
36
+ aggregate_searchability_metrics,
37
+ compute_searchability_metrics,
38
+ )
39
+ from picarones.report.numerical_sequences_render import (
40
+ build_numerical_sequences_html,
41
+ )
42
+ from picarones.report.searchability_render import (
43
+ build_searchability_summary_html,
44
+ )
45
+
46
+
47
+ def _load_labels(lang: str) -> dict:
48
+ p = (
49
+ Path(__file__).parent.parent
50
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
51
+ )
52
+ return json.loads(p.read_text(encoding="utf-8"))
53
+
54
+
55
+ # ──────────────────────────────────────────────────────────────────────────
56
+ # 1. Helpers searchability
57
+ # ──────────────────────────────────────────────────────────────────────────
58
+
59
+
60
+ class TestSearchabilityRunner:
61
+ def test_empty_gt_returns_none(self) -> None:
62
+ assert compute_searchability_metrics("", "anything") is None
63
+
64
+ def test_normal(self) -> None:
65
+ r = compute_searchability_metrics("le roi", "le roy")
66
+ assert r is not None
67
+ assert r["recall"] == 1.0
68
+ assert r["n_gt_tokens"] == 2
69
+
70
+ def test_aggregate_micro_recall(self) -> None:
71
+ d1 = {"n_gt_tokens": 10, "n_searchable": 9, "missed_tokens": ["x"]}
72
+ d2 = {"n_gt_tokens": 20, "n_searchable": 15, "missed_tokens": ["y"]}
73
+ agg = aggregate_searchability_metrics([d1, d2])
74
+ assert agg is not None
75
+ assert agg["n_gt_tokens"] == 30
76
+ assert agg["n_searchable"] == 24
77
+ assert agg["recall"] == 24 / 30
78
+ assert agg["n_docs"] == 2
79
+
80
+ def test_aggregate_empty(self) -> None:
81
+ assert aggregate_searchability_metrics([None, None]) is None
82
+ assert aggregate_searchability_metrics([]) is None
83
+
84
+
85
+ # ──────────────────────────────────────────────────────────────────────────
86
+ # 2. Helpers numerical sequences
87
+ # ──────────────────────────────────────────────────────────────────────────
88
+
89
+
90
+ class TestNumericalSequencesRunner:
91
+ def test_no_signal_returns_none(self) -> None:
92
+ # GT sans aucune séquence numérique
93
+ assert compute_numerical_sequence_metrics_adaptive(
94
+ "lorem ipsum dolor", "sit amet",
95
+ ) is None
96
+
97
+ def test_signal_present(self) -> None:
98
+ r = compute_numerical_sequence_metrics_adaptive(
99
+ "an III, 1789", "an III, 1789",
100
+ )
101
+ assert r is not None
102
+ assert r["n_total"] >= 1
103
+
104
+ def test_aggregate_sums_per_category(self) -> None:
105
+ d1 = {
106
+ "n_total": 3,
107
+ "global_strict_score": 1.0,
108
+ "global_value_score": 1.0,
109
+ "per_category": {
110
+ "year": {"n_total": 2, "strict": 2, "value": 2,
111
+ "strict_score": 1.0, "value_score": 1.0,
112
+ "lost_items": []},
113
+ "roman": {"n_total": 1, "strict": 1, "value": 1,
114
+ "strict_score": 1.0, "value_score": 1.0,
115
+ "lost_items": []},
116
+ "foliation": {"n_total": 0, "strict": 0, "value": 0,
117
+ "strict_score": 0.0, "value_score": 0.0,
118
+ "lost_items": []},
119
+ "currency": {"n_total": 0, "strict": 0, "value": 0,
120
+ "strict_score": 0.0, "value_score": 0.0,
121
+ "lost_items": []},
122
+ "regnal": {"n_total": 0, "strict": 0, "value": 0,
123
+ "strict_score": 0.0, "value_score": 0.0,
124
+ "lost_items": []},
125
+ },
126
+ }
127
+ d2 = {
128
+ "n_total": 4,
129
+ "global_strict_score": 0.5,
130
+ "global_value_score": 0.5,
131
+ "per_category": {
132
+ "year": {"n_total": 4, "strict": 2, "value": 2,
133
+ "strict_score": 0.5, "value_score": 0.5,
134
+ "lost_items": ["1500", "1600"]},
135
+ "roman": {"n_total": 0, "strict": 0, "value": 0,
136
+ "strict_score": 0.0, "value_score": 0.0,
137
+ "lost_items": []},
138
+ "foliation": {"n_total": 0, "strict": 0, "value": 0,
139
+ "strict_score": 0.0, "value_score": 0.0,
140
+ "lost_items": []},
141
+ "currency": {"n_total": 0, "strict": 0, "value": 0,
142
+ "strict_score": 0.0, "value_score": 0.0,
143
+ "lost_items": []},
144
+ "regnal": {"n_total": 0, "strict": 0, "value": 0,
145
+ "strict_score": 0.0, "value_score": 0.0,
146
+ "lost_items": []},
147
+ },
148
+ }
149
+ agg = aggregate_numerical_sequence_metrics([d1, d2])
150
+ assert agg["n_total"] == 7
151
+ assert agg["per_category"]["year"]["n_total"] == 6
152
+ assert agg["per_category"]["year"]["strict"] == 4
153
+ assert agg["per_category"]["year"]["strict_score"] == 4 / 6
154
+ # global = (2+1 + 2) / 7 = 5/7
155
+ assert agg["global_strict_score"] == 5 / 7
156
+
157
+ def test_aggregate_empty(self) -> None:
158
+ assert aggregate_numerical_sequence_metrics([None]) is None
159
+
160
+
161
+ # ──────────────────────────────────────────────────────────────────────────
162
+ # 3. Champs results.py
163
+ # ──────────────────────────────────────────────────────────────────────────
164
+
165
+
166
+ class TestResultsFields:
167
+ def test_document_result_serializes_searchability(self) -> None:
168
+ dr = DocumentResult(
169
+ doc_id="doc1", image_path="x.png",
170
+ ground_truth="hello", hypothesis="helo",
171
+ metrics=_stub_metrics(), duration_seconds=1.0,
172
+ searchability_metrics={"recall": 0.9},
173
+ numerical_sequence_metrics={"n_total": 1},
174
+ )
175
+ d = dr.as_dict()
176
+ assert d["searchability_metrics"] == {"recall": 0.9}
177
+ assert d["numerical_sequence_metrics"] == {"n_total": 1}
178
+
179
+ def test_document_result_omits_when_none(self) -> None:
180
+ dr = DocumentResult(
181
+ doc_id="doc1", image_path="x.png",
182
+ ground_truth="hello", hypothesis="helo",
183
+ metrics=_stub_metrics(), duration_seconds=1.0,
184
+ )
185
+ d = dr.as_dict()
186
+ assert "searchability_metrics" not in d
187
+ assert "numerical_sequence_metrics" not in d
188
+
189
+ def test_compact_clears_fields(self) -> None:
190
+ dr = DocumentResult(
191
+ doc_id="doc1", image_path="x.png",
192
+ ground_truth="hello", hypothesis="helo",
193
+ metrics=_stub_metrics(), duration_seconds=1.0,
194
+ searchability_metrics={"recall": 0.9},
195
+ numerical_sequence_metrics={"n_total": 1},
196
+ )
197
+ dr.compact()
198
+ assert dr.searchability_metrics is None
199
+ assert dr.numerical_sequence_metrics is None
200
+
201
+ def test_engine_report_serializes_aggregates(self) -> None:
202
+ er = EngineReport(
203
+ engine_name="t", engine_version="0",
204
+ engine_config={},
205
+ document_results=[],
206
+ pipeline_info=None,
207
+ aggregated_searchability={"recall": 0.85},
208
+ aggregated_numerical_sequences={"global_strict_score": 0.9},
209
+ )
210
+ d = er.as_dict()
211
+ assert d["aggregated_searchability"]["recall"] == 0.85
212
+ assert d["aggregated_numerical_sequences"]["global_strict_score"] == 0.9
213
+
214
+ def test_engine_report_omits_when_none(self) -> None:
215
+ er = EngineReport(
216
+ engine_name="t", engine_version="0",
217
+ engine_config={},
218
+ document_results=[],
219
+ pipeline_info=None,
220
+ )
221
+ d = er.as_dict()
222
+ assert "aggregated_searchability" not in d
223
+ assert "aggregated_numerical_sequences" not in d
224
+
225
+
226
+ # ──────────────────────────────────────────────────────────────────────────
227
+ # 4. Rendu HTML
228
+ # ──────────────────────────────────────────────────────────────────────────
229
+
230
+
231
+ class TestSearchabilityHtml:
232
+ def test_empty_returns_empty(self) -> None:
233
+ assert build_searchability_summary_html([]) == ""
234
+
235
+ def test_no_signal_returns_empty(self) -> None:
236
+ engines = [{"name": "t"}] # pas de aggregated_searchability
237
+ assert build_searchability_summary_html(engines) == ""
238
+
239
+ def test_renders_table_with_recall(self) -> None:
240
+ engines = [{
241
+ "name": "tess",
242
+ "aggregated_searchability": {
243
+ "recall": 0.92, "n_searchable": 92,
244
+ "n_gt_tokens": 100, "n_docs": 5,
245
+ },
246
+ }]
247
+ html = build_searchability_summary_html(
248
+ engines, _load_labels("fr"),
249
+ )
250
+ assert "<table" in html
251
+ assert "92.0%" in html
252
+ assert "92 / 100" in html
253
+ assert "tess" in html
254
+
255
+ def test_anti_injection(self) -> None:
256
+ engines = [{
257
+ "name": "<script>alert(1)</script>",
258
+ "aggregated_searchability": {
259
+ "recall": 0.5, "n_searchable": 5, "n_gt_tokens": 10,
260
+ "n_docs": 1,
261
+ },
262
+ }]
263
+ html = build_searchability_summary_html(
264
+ engines, _load_labels("fr"),
265
+ )
266
+ assert "<script>alert" not in html
267
+ assert "&lt;script&gt;" in html
268
+
269
+ def test_renders_in_english(self) -> None:
270
+ engines = [{
271
+ "name": "tess",
272
+ "aggregated_searchability": {
273
+ "recall": 0.95, "n_searchable": 95,
274
+ "n_gt_tokens": 100, "n_docs": 5,
275
+ },
276
+ }]
277
+ html = build_searchability_summary_html(
278
+ engines, _load_labels("en"),
279
+ )
280
+ assert "Fuzzy searchability" in html
281
+
282
+
283
+ class TestNumericalSequencesHtml:
284
+ def _engine(self, name="tess", **kwargs) -> dict:
285
+ per_cat_default = {
286
+ cat: {"n_total": 0, "strict": 0, "value": 0,
287
+ "strict_score": 0.0, "value_score": 0.0,
288
+ "lost_items": []}
289
+ for cat in ("year", "roman", "foliation", "currency", "regnal")
290
+ }
291
+ per_cat_default.update(kwargs.get("per_cat_overrides", {}))
292
+ return {
293
+ "name": name,
294
+ "aggregated_numerical_sequences": {
295
+ "global_strict_score": kwargs.get("strict", 0.5),
296
+ "global_value_score": kwargs.get("value", 0.5),
297
+ "n_total": kwargs.get("n_total", 1),
298
+ "n_docs": 1,
299
+ "per_category": per_cat_default,
300
+ },
301
+ }
302
+
303
+ def test_empty_returns_empty(self) -> None:
304
+ assert build_numerical_sequences_html([]) == ""
305
+
306
+ def test_no_signal_returns_empty(self) -> None:
307
+ engines = [{"name": "t"}]
308
+ assert build_numerical_sequences_html(engines) == ""
309
+
310
+ def test_omits_categories_without_signal(self) -> None:
311
+ # Seul 'year' a du signal
312
+ e = self._engine(per_cat_overrides={
313
+ "year": {"n_total": 5, "strict": 5, "value": 5,
314
+ "strict_score": 1.0, "value_score": 1.0,
315
+ "lost_items": []},
316
+ })
317
+ html = build_numerical_sequences_html([e], _load_labels("fr"))
318
+ assert "Année" in html
319
+ # Romain absent puisqu'aucun n_total > 0
320
+ assert "Romain" not in html
321
+
322
+ def test_renders_per_category_score(self) -> None:
323
+ e = self._engine(strict=0.8, value=0.9, n_total=20,
324
+ per_cat_overrides={
325
+ "year": {"n_total": 10, "strict": 8, "value": 9,
326
+ "strict_score": 0.8, "value_score": 0.9,
327
+ "lost_items": []},
328
+ })
329
+ html = build_numerical_sequences_html([e], _load_labels("fr"))
330
+ assert "80%" in html # year strict score
331
+ assert "n=20" in html or "n=10" in html
332
+
333
+ def test_anti_injection(self) -> None:
334
+ e = self._engine(name="<img/>", per_cat_overrides={
335
+ "year": {"n_total": 1, "strict": 1, "value": 1,
336
+ "strict_score": 1.0, "value_score": 1.0,
337
+ "lost_items": []},
338
+ })
339
+ html = build_numerical_sequences_html([e], _load_labels("fr"))
340
+ assert "<img/>" not in html
341
+ assert "&lt;img" in html
342
+
343
+
344
+ # ──────────────────────────────────────────────────────────────────────────
345
+ # 5. Complétude i18n
346
+ # ──────────────────────────────────────────────────────────────────────────
347
+
348
+
349
+ _KEYS = {
350
+ "search_title", "search_note", "search_engine", "search_recall",
351
+ "search_count", "search_docs",
352
+ "numseq_title", "numseq_note", "numseq_engine", "numseq_global",
353
+ "numseq_cat_year", "numseq_cat_roman", "numseq_cat_foliation",
354
+ "numseq_cat_currency", "numseq_cat_regnal",
355
+ }
356
+
357
+
358
+ class TestI18nCompleteness:
359
+ def test_fr_has_all(self) -> None:
360
+ d = _load_labels("fr")
361
+ missing = _KEYS - d.keys()
362
+ assert not missing, f"manque FR : {missing}"
363
+
364
+ def test_en_has_all(self) -> None:
365
+ d = _load_labels("en")
366
+ missing = _KEYS - d.keys()
367
+ assert not missing, f"manque EN : {missing}"