Spaces:
Running
sprint86: A.II.5 bout-en-bout — câblage runner + vues HTML
Browse filesSuite 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 +47 -0
- CLAUDE.md +2 -1
- picarones/core/numerical_sequences_runner.py +102 -0
- picarones/core/results.py +46 -0
- picarones/core/runner.py +46 -0
- picarones/core/searchability_runner.py +81 -0
- picarones/report/generator.py +23 -0
- picarones/report/i18n/en.json +16 -1
- picarones/report/i18n/fr.json +16 -1
- picarones/report/numerical_sequences_render.py +164 -0
- picarones/report/searchability_render.py +119 -0
- picarones/report/templates/view_analyses.html +14 -0
- tests/test_sprint86_aii5_html.py +367 -0
|
@@ -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
|
|
@@ -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** :
|
| 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** :
|
|
@@ -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 |
+
]
|
|
@@ -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 |
|
|
@@ -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(
|
|
@@ -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 |
+
]
|
|
@@ -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")
|
|
@@ -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 |
}
|
|
@@ -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 |
}
|
|
@@ -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"]
|
|
@@ -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"]
|
|
@@ -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">
|
|
@@ -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 "<script>" 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 "<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}"
|