Claude commited on
Commit
e88e70e
·
unverified ·
1 Parent(s): e11f03a

sprint88: A.I.8 vue HTML "Déficit projeté de robustesse" (clôture bout-en-bout)

Browse files

Le module robustness_projection.py (Sprint 81) calculait la
projection des courbes de dégradation synthétique sur les
caractéristiques d'image réelles ; ce sprint livre la vue HTML.

La robustesse étant un workflow CLI séparé (picarones robustness)
et non intégré au benchmark principal, ce sprint livre un module
de rendu pur que l'utilisateur compose lui-même :

analyze_robustness → project_robustness_on_corpus →
aggregate_projection_per_engine → build_robustness_projection_html

picarones/report/robustness_projection_render.py produit deux
tableaux :
- Résumé par moteur : déficit total avec gradient vert→orange→rouge,
n types, pire dégradation, trié par déficit décroissant.
- Détail (moteur × dégradation) : docs, docs avec data, déficit
projeté coloré, docs au-dessus du seuil critique.

Si aggregated non fourni, calculé automatiquement. Adaptive : ""
si projection vide. Anti-injection systématique. Note explicite
que la sommation suppose l'indépendance des dégradations.

13 clés i18n FR/EN. 12 tests dans
test_sprint88_robustness_projection_html.py couvrant rendu, calcul
automatique de l'agrégation, tri, formatage, gestion None,
anti-injection, FR + EN, bout-en-bout avec project_robustness_on_corpus
+ aggregate_projection_per_engine, complétude i18n 13 clés.

A.I.8 livrée bout-en-bout (calcul Sprint 81 + vue HTML Sprint 88).

Tests : 2899 passed, 2 skipped.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,50 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajouté
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 87 — A.II.2 : delta Flesch câblé bout-en-bout
20
  (couche calcul Sprint 52 + runner + vue HTML).** Le module
21
  `picarones/core/readability.py` (Sprint 52) calculait le
 
16
 
17
  ### Ajouté
18
 
19
+ - **Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse
20
+ (clôture A.I.8 bout-en-bout).** Le module
21
+ `picarones/core/robustness_projection.py` (Sprint 81)
22
+ calculait la projection des courbes de dégradation
23
+ synthétique sur les caractéristiques d'image réelles ; ce
24
+ sprint livre la **vue HTML** correspondante. La robustesse
25
+ étant un workflow CLI séparé (`picarones robustness`) et non
26
+ intégré au benchmark principal, ce sprint livre un **module
27
+ de rendu pur** que l'utilisateur compose lui-même
28
+ (`analyze_robustness` → `project_robustness_on_corpus` →
29
+ `aggregate_projection_per_engine` →
30
+ `build_robustness_projection_html`). Nouveau module
31
+ `picarones/report/robustness_projection_render.py` :
32
+ `build_robustness_projection_html(projection, aggregated,
33
+ labels)` produit deux tableaux :
34
+
35
+ 1. **Résumé par moteur** — déficit total attendu (gradient
36
+ vert → orange → rouge sur ±5 pts de CER), nombre de types
37
+ de dégradation évalués, pire dégradation avec sa
38
+ contribution. Trié par déficit décroissant.
39
+ 2. **Détail (moteur × dégradation)** — docs, docs avec data,
40
+ déficit projeté coloré, docs au-dessus du seuil critique.
41
+
42
+ Si `aggregated` n'est pas fourni, calculé automatiquement
43
+ depuis la projection. Adaptive : `""` si la projection est
44
+ vide. Anti-injection systématique sur nom de moteur et type
45
+ de dégradation. Note explicite que la sommation suppose
46
+ l'indépendance des dégradations *« approximation utile pour
47
+ le diagnostic, pas un verdict »*. +13 clés i18n FR/EN
48
+ (`robproj_*`). +12 tests dans
49
+ `test_sprint88_robustness_projection_html.py` couvrant rendu
50
+ vide/None, rendu complet, calcul automatique de
51
+ l'agrégation, tri par déficit décroissant, formatage de la
52
+ cellule « pire dégradation », gestion d'un déficit None
53
+ (cellule —), anti-injection nom moteur + type dégradation,
54
+ rendu en français + anglais, **bout-en-bout** avec le
55
+ pipeline réel `project_robustness_on_corpus` +
56
+ `aggregate_projection_per_engine`, complétude i18n 13 clés.
57
+ **Verrou levé** : un benchmark BnF qui veut savoir *« mon
58
+ corpus de notaires XVIIᵉ siècle est-il à risque face à mon
59
+ moteur OCR ? »* obtient un tableau lisible directement
60
+ intégrable dans le rapport — A.I.8 livrée bout-en-bout
61
+ (calcul Sprint 81 + vue HTML Sprint 88).
62
+
63
  - **Sprint 87 — A.II.2 : delta Flesch câblé bout-en-bout
64
  (couche calcul Sprint 52 + runner + vue HTML).** Le module
65
  `picarones/core/readability.py` (Sprint 52) calculait le
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
  | 87 | **Sprint 56 du plan d'évolution 2026 — A.II.2 (delta Flesch) câblé bout-en-bout : runner adaptive + vue HTML « Lisibilité »**. Le module `picarones/core/readability.py` (Sprint 52) calculait le delta Flesch *« over-normalisation par LLM »* — ce sprint le remonte automatiquement dans le rapport. Helper `picarones/core/readability_runner.py` : `compute_readability_metrics(reference, hypothesis, lang)` avec **adaptive masking ≥ 5 mots GT** (Flesch instable sur très courts textes) ; `aggregate_readability_metrics` retourne `{lang, n_docs, n_docs_with_delta, delta_mean/median/min/max, n_over_normalized, n_under_normalized, over_normalized_rate}` — over-norm défini à Δ > +5 (LLM modernise un texte ancien), under-norm à Δ < -5 (dégradation OCR brutale). `DocumentResult.readability_metrics` + `EngineReport.aggregated_readability` (sérialisation conditionnelle, libérés par `compact`). Câblage runner : langue lue depuis `corpus.metadata.get("language", "fr")`, fallback fr avec warning si valeur non `fr`/`en`, paramètre `corpus_lang` propagé jusqu'aux workers IO et CPU (workers acceptent 7 ou 8 args en mode legacy pour rétrocompat). Erreur isolée par try/except + warning. Module de rendu `picarones/report/readability_render.py` : tableau résumé moteur × {Δ moyen coloré (vert au centre, orange si over-norm, bleu si under-norm), Δ médian, % over-normalisés, docs under-normalisés, docs} ; saturation à ±15 points. Insertion dans `view_analyses.html` derrière les blocs A.II.5. Anti-injection systématique. +8 clés i18n FR/EN. +20 tests dans `test_sprint87_readability_html.py` (adaptive masking GT < 5 mots, langue fr/en, hypothèse vide → flesch_delta None mais flesch_reference conservé, agrégation moyenne + over-norm rate, sérialisation `DocumentResult`/`EngineReport`, `compact`, masquage adaptatif HTML, rendu FR + EN, anti-injection, complétude i18n 8 clés). **Verrou levé** : le rapport remonte désormais *« GPT-4o : Δ moyen +11,5, 85 % des docs over-normalisés »* directement dans la vue Analyses — métrique critique pour repérer les VLM hallucinant du français moderne sur du français médiéval. Reste pour A.II.2 bout-en-bout : `reading_order_f1` et `layout_f1` (Sprints 53-54), qui requièrent un moteur produisant PAGE/ALTO et seront câblés via les pipelines composées (axe B). |
211
  | 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). |
212
  | 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. |
@@ -305,7 +306,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
305
  ## Contexte développement
306
 
307
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
308
- - **Tests** : 2887 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 » ; **Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout runner adaptive + vue HTML « Lisibilité »**)
309
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
310
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
311
  - **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
+ | 88 | **Sprint 57 du plan d'évolution 2026 — A.I.8 vue HTML : déficit projeté de robustesse (clôture A.I.8 bout-en-bout)**. Le module `picarones/core/robustness_projection.py` (Sprint 81) calculait la projection des courbes de dégradation synthétique sur les caractéristiques d'image réelles ; ce sprint livre la **vue HTML**. La robustesse étant un workflow CLI séparé (`picarones robustness`) et non intégré au benchmark principal, ce sprint livre un **module de rendu pur** que l'utilisateur compose lui-même (`analyze_robustness` → `project_robustness_on_corpus` → `aggregate_projection_per_engine` → `build_robustness_projection_html`). Nouveau module `picarones/report/robustness_projection_render.py` : **deux tableaux** — (1) **Résumé par moteur** (déficit total avec gradient vert→orange→rouge sur ±5 pts, n types évalués, pire dégradation avec sa contribution, trié par déficit décroissant) ; (2) **Détail (moteur × dégradation)** (docs, docs avec data, déficit projeté coloré, docs au-dessus du seuil critique). Si `aggregated` non fourni, calculé automatiquement. Adaptive : `""` si projection vide. Anti-injection systématique. Note explicite que la sommation suppose l'indépendance des dégradations *« approximation utile pour le diagnostic, pas un verdict »*. +13 clés i18n FR/EN (`robproj_*`). +12 tests dans `test_sprint88_robustness_projection_html.py` (rendu vide/None, rendu complet, calcul automatique de l'agrégation, tri par déficit décroissant, formatage « pire dégradation », gestion déficit None → cellule —, anti-injection nom moteur + type dégradation, rendu FR + EN, **bout-en-bout** avec le pipeline réel `project_robustness_on_corpus` + `aggregate_projection_per_engine`, complétude i18n 13 clés). **Verrou levé** : A.I.8 livrée bout-en-bout (calcul Sprint 81 + vue HTML Sprint 88) — un benchmark BnF qui veut savoir *« mon corpus de notaires XVIIᵉ siècle est-il à risque face à mon moteur OCR ? »* obtient un tableau lisible directement intégrable dans le rapport. |
211
  | 87 | **Sprint 56 du plan d'évolution 2026 — A.II.2 (delta Flesch) câblé bout-en-bout : runner adaptive + vue HTML « Lisibilité »**. Le module `picarones/core/readability.py` (Sprint 52) calculait le delta Flesch *« over-normalisation par LLM »* — ce sprint le remonte automatiquement dans le rapport. Helper `picarones/core/readability_runner.py` : `compute_readability_metrics(reference, hypothesis, lang)` avec **adaptive masking ≥ 5 mots GT** (Flesch instable sur très courts textes) ; `aggregate_readability_metrics` retourne `{lang, n_docs, n_docs_with_delta, delta_mean/median/min/max, n_over_normalized, n_under_normalized, over_normalized_rate}` — over-norm défini à Δ > +5 (LLM modernise un texte ancien), under-norm à Δ < -5 (dégradation OCR brutale). `DocumentResult.readability_metrics` + `EngineReport.aggregated_readability` (sérialisation conditionnelle, libérés par `compact`). Câblage runner : langue lue depuis `corpus.metadata.get("language", "fr")`, fallback fr avec warning si valeur non `fr`/`en`, paramètre `corpus_lang` propagé jusqu'aux workers IO et CPU (workers acceptent 7 ou 8 args en mode legacy pour rétrocompat). Erreur isolée par try/except + warning. Module de rendu `picarones/report/readability_render.py` : tableau résumé moteur × {Δ moyen coloré (vert au centre, orange si over-norm, bleu si under-norm), Δ médian, % over-normalisés, docs under-normalisés, docs} ; saturation à ±15 points. Insertion dans `view_analyses.html` derrière les blocs A.II.5. Anti-injection systématique. +8 clés i18n FR/EN. +20 tests dans `test_sprint87_readability_html.py` (adaptive masking GT < 5 mots, langue fr/en, hypothèse vide → flesch_delta None mais flesch_reference conservé, agrégation moyenne + over-norm rate, sérialisation `DocumentResult`/`EngineReport`, `compact`, masquage adaptatif HTML, rendu FR + EN, anti-injection, complétude i18n 8 clés). **Verrou levé** : le rapport remonte désormais *« GPT-4o : Δ moyen +11,5, 85 % des docs over-normalisés »* directement dans la vue Analyses — métrique critique pour repérer les VLM hallucinant du français moderne sur du français médiéval. Reste pour A.II.2 bout-en-bout : `reading_order_f1` et `layout_f1` (Sprints 53-54), qui requièrent un moteur produisant PAGE/ALTO et seront câblés via les pipelines composées (axe B). |
212
  | 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). |
213
  | 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. |
 
306
  ## Contexte développement
307
 
308
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
309
+ - **Tests** : 2899 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 couche calcul ; Sprint 82 = A.I.9 — « 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 ; Sprint 85 = A.II.5b — précision séquences numériques ; Sprint 86 = A.II.5 bout-en-bout (câblage runner + vues HTML) ; Sprint 87 = A.II.2 (delta Flesch) câblé bout-en-bout ; **Sprint 88 = A.I.8 — vue HTML « Déficit projeté de robustesse » bout-en-bout**)
310
  - **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
311
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
312
  - **Transcript de la conversation de développement** :
picarones/report/i18n/en.json CHANGED
@@ -303,5 +303,18 @@
303
  "readability_delta_median": "Median Δ",
304
  "readability_over_norm_rate": "% over-normalised",
305
  "readability_under_norm_count": "Under-normalised docs",
306
- "readability_docs": "Docs"
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  }
 
303
  "readability_delta_median": "Median Δ",
304
  "readability_over_norm_rate": "% over-normalised",
305
  "readability_under_norm_count": "Under-normalised docs",
306
+ "readability_docs": "Docs",
307
+ "robproj_title": "Projected robustness deficit on the real corpus",
308
+ "robproj_note": "Projection of synthetic degradation curves onto real image characteristics. The total deficit assumes independence of degradations — a useful diagnostic approximation, not a verdict.",
309
+ "robproj_summary": "Per-engine summary",
310
+ "robproj_detail": "Detail per (engine × degradation) pair",
311
+ "robproj_engine": "Engine",
312
+ "robproj_total": "Total deficit (CER pts)",
313
+ "robproj_n_types": "Types evaluated",
314
+ "robproj_worst": "Worst degradation",
315
+ "robproj_deg_type": "Degradation",
316
+ "robproj_n_docs": "Docs",
317
+ "robproj_n_with_data": "Docs with data",
318
+ "robproj_deficit": "Projected ΔCER (pts)",
319
+ "robproj_above": "Docs ≥ critical threshold"
320
  }
picarones/report/i18n/fr.json CHANGED
@@ -303,5 +303,18 @@
303
  "readability_delta_median": "Δ médian",
304
  "readability_over_norm_rate": "% over-normalisé",
305
  "readability_under_norm_count": "Docs under-normalisés",
306
- "readability_docs": "Docs"
 
 
 
 
 
 
 
 
 
 
 
 
 
307
  }
 
303
  "readability_delta_median": "Δ médian",
304
  "readability_over_norm_rate": "% over-normalisé",
305
  "readability_under_norm_count": "Docs under-normalisés",
306
+ "readability_docs": "Docs",
307
+ "robproj_title": "Déficit projeté de robustesse sur le corpus réel",
308
+ "robproj_note": "Projection des courbes de dégradation synthétique sur les caractéristiques d'image réelles. Le déficit total suppose l'indépendance des dégradations — approximation utile pour le diagnostic, pas un verdict.",
309
+ "robproj_summary": "Résumé par moteur",
310
+ "robproj_detail": "Détail par couple (moteur × dégradation)",
311
+ "robproj_engine": "Moteur",
312
+ "robproj_total": "Déficit total (pts CER)",
313
+ "robproj_n_types": "Types évalués",
314
+ "robproj_worst": "Pire dégradation",
315
+ "robproj_deg_type": "Dégradation",
316
+ "robproj_n_docs": "Docs",
317
+ "robproj_n_with_data": "Docs avec data",
318
+ "robproj_deficit": "Δ CER projeté (pts)",
319
+ "robproj_above": "Docs ≥ seuil critique"
320
  }
picarones/report/robustness_projection_render.py ADDED
@@ -0,0 +1,268 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Déficit projeté de robustesse » — Sprint 88
2
+ (A.I.8 vue HTML).
3
+
4
+ Suite directe ``picarones/core/robustness_projection.py``
5
+ (Sprint 81). Pattern identique aux autres rendus : server-
6
+ side, pas de JS, anti-injection systématique.
7
+
8
+ Note d'intégration
9
+ ------------------
10
+ La robustesse synthétique (``picarones.core.robustness``) est
11
+ exécutée par la CLI ``picarones robustness`` indépendamment du
12
+ benchmark principal. Pour produire la vue de projection,
13
+ l'utilisateur compose :
14
+
15
+ .. code-block:: python
16
+
17
+ from picarones.core.robustness import analyze_robustness
18
+ from picarones.core.robustness_projection import (
19
+ project_robustness_on_corpus,
20
+ aggregate_projection_per_engine,
21
+ )
22
+ from picarones.report.robustness_projection_render import (
23
+ build_robustness_projection_html,
24
+ )
25
+
26
+ rob = analyze_robustness(corpus, [engine]) # Sprint 8
27
+ projection = project_robustness_on_corpus(
28
+ rob.curves,
29
+ [doc.image_quality.as_dict() for doc in benchmark.docs],
30
+ ) # Sprint 81
31
+ aggregated = aggregate_projection_per_engine(projection)
32
+ html = build_robustness_projection_html(
33
+ projection, aggregated, labels,
34
+ )
35
+
36
+ Vue
37
+ ---
38
+ 1. **Tableau résumé par moteur** : déficit total attendu,
39
+ nombre de types de dégradation, pire dégradation.
40
+ 2. **Tableau détaillé par couple (moteur × dégradation)** :
41
+ docs, docs avec data, déficit, % docs au-dessus du seuil
42
+ critique.
43
+
44
+ Les cellules « déficit » sont colorées par gradient vert
45
+ (faible) → orange → rouge (≥ 5 points de CER projetés).
46
+
47
+ Adaptive : ``""`` si la projection est vide (aucune courbe ou
48
+ aucun document avec qualité).
49
+ """
50
+
51
+ from __future__ import annotations
52
+
53
+ from html import escape as _e
54
+ from typing import Optional
55
+
56
+
57
+ def _color_for_deficit(deficit: float) -> str:
58
+ """Vert (≈0) → orange (~3 pts) → rouge (≥ 5 pts)."""
59
+ f = max(0.0, min(1.0, abs(deficit) / 0.05))
60
+ if f < 0.5:
61
+ # vert → orange
62
+ t = f / 0.5
63
+ r = int(167 + (235 - 167) * t)
64
+ g = int(240 + (180 - 240) * t)
65
+ b = int(167 + (60 - 167) * t)
66
+ else:
67
+ # orange → rouge
68
+ t = (f - 0.5) / 0.5
69
+ r = int(235 + (220 - 235) * t)
70
+ g = int(180 + (50 - 180) * t)
71
+ b = int(60 + (50 - 60) * t)
72
+ return f"#{r:02x}{g:02x}{b:02x}"
73
+
74
+
75
+ def _build_summary_table(
76
+ aggregated: dict,
77
+ labels: dict[str, str],
78
+ ) -> str:
79
+ if not aggregated:
80
+ return ""
81
+ h_engine = labels.get("robproj_engine", "Moteur")
82
+ h_total = labels.get("robproj_total", "Déficit total (pts CER)")
83
+ h_n_types = labels.get("robproj_n_types", "Types évalués")
84
+ h_worst = labels.get("robproj_worst", "Pire dégradation")
85
+ parts = [
86
+ '<table style="border-collapse:collapse;width:100%;'
87
+ 'font-size:.9rem;margin-bottom:.8rem">',
88
+ '<thead><tr>',
89
+ ]
90
+ for col in (h_engine, h_total, h_n_types, h_worst):
91
+ parts.append(
92
+ f'<th style="padding:.4rem .6rem;text-align:left;'
93
+ f'border-bottom:1px solid #ccc;font-weight:600">'
94
+ f'{_e(col)}</th>'
95
+ )
96
+ parts.append("</tr></thead><tbody>")
97
+ # Tri par déficit décroissant
98
+ rows = sorted(
99
+ aggregated.items(),
100
+ key=lambda kv: -float(
101
+ kv[1].get("total_expected_deficit") or 0.0
102
+ ),
103
+ )
104
+ for engine, info in rows:
105
+ deficit = float(info.get("total_expected_deficit") or 0.0)
106
+ n_types = int(info.get("n_degradation_types") or 0)
107
+ worst_type = info.get("worst_degradation_type")
108
+ worst_deficit = info.get("worst_degradation_deficit")
109
+ color = _color_for_deficit(deficit)
110
+ worst_str = (
111
+ f"{_e(str(worst_type))} ({worst_deficit * 100:+.1f})"
112
+ if worst_type and isinstance(worst_deficit, (int, float))
113
+ else "—"
114
+ )
115
+ parts.append(
116
+ f'<tr>'
117
+ f'<td style="padding:.4rem .6rem">{_e(str(engine))}</td>'
118
+ f'<td style="padding:.4rem .6rem;text-align:right;'
119
+ f'background:{color};font-family:monospace;font-weight:600">'
120
+ f'{deficit * 100:+.2f}</td>'
121
+ f'<td style="padding:.4rem .6rem;text-align:right;'
122
+ f'font-family:monospace">{n_types}</td>'
123
+ f'<td style="padding:.4rem .6rem">{worst_str}</td>'
124
+ f'</tr>'
125
+ )
126
+ parts.append("</tbody></table>")
127
+ return "".join(parts)
128
+
129
+
130
+ def _build_detail_table(
131
+ projection: dict,
132
+ labels: dict[str, str],
133
+ ) -> str:
134
+ if not projection:
135
+ return ""
136
+ h_engine = labels.get("robproj_engine", "Moteur")
137
+ h_deg_type = labels.get("robproj_deg_type", "Dégradation")
138
+ h_n_docs = labels.get("robproj_n_docs", "Docs")
139
+ h_n_with_data = labels.get("robproj_n_with_data", "Docs avec data")
140
+ h_deficit = labels.get("robproj_deficit", "Δ CER projeté (pts)")
141
+ h_above = labels.get("robproj_above", "Docs ≥ seuil critique")
142
+ parts = [
143
+ '<table style="border-collapse:collapse;width:100%;'
144
+ 'font-size:.9rem">',
145
+ '<thead><tr>',
146
+ ]
147
+ for col in (h_engine, h_deg_type, h_n_docs,
148
+ h_n_with_data, h_deficit, h_above):
149
+ parts.append(
150
+ f'<th style="padding:.4rem .6rem;text-align:left;'
151
+ f'border-bottom:1px solid #ccc;font-weight:600">'
152
+ f'{_e(col)}</th>'
153
+ )
154
+ parts.append("</tr></thead><tbody>")
155
+ # Tri stable : par moteur puis type de dégradation
156
+ for engine in sorted(projection):
157
+ per_type = projection[engine] or {}
158
+ for deg_type in sorted(per_type):
159
+ entry = per_type[deg_type] or {}
160
+ n_docs = int(entry.get("n_docs") or 0)
161
+ n_with_data = int(entry.get("n_docs_with_data") or 0)
162
+ deficit = entry.get("deficit_vs_baseline")
163
+ n_above = int(entry.get("n_docs_above_critical") or 0)
164
+ if isinstance(deficit, (int, float)):
165
+ color = _color_for_deficit(float(deficit))
166
+ deficit_str = f"{float(deficit) * 100:+.2f}"
167
+ deficit_cell = (
168
+ f'<td style="padding:.4rem .6rem;text-align:right;'
169
+ f'background:{color};font-family:monospace">'
170
+ f'{deficit_str}</td>'
171
+ )
172
+ else:
173
+ deficit_cell = (
174
+ '<td style="padding:.4rem .6rem;text-align:right;'
175
+ 'opacity:.4">—</td>'
176
+ )
177
+ parts.append(
178
+ f'<tr>'
179
+ f'<td style="padding:.4rem .6rem">{_e(str(engine))}</td>'
180
+ f'<td style="padding:.4rem .6rem">{_e(str(deg_type))}</td>'
181
+ f'<td style="padding:.4rem .6rem;text-align:right;'
182
+ f'font-family:monospace">{n_docs}</td>'
183
+ f'<td style="padding:.4rem .6rem;text-align:right;'
184
+ f'font-family:monospace">{n_with_data}</td>'
185
+ f'{deficit_cell}'
186
+ f'<td style="padding:.4rem .6rem;text-align:right;'
187
+ f'font-family:monospace">{n_above}</td>'
188
+ f'</tr>'
189
+ )
190
+ parts.append("</tbody></table>")
191
+ return "".join(parts)
192
+
193
+
194
+ def build_robustness_projection_html(
195
+ projection: Optional[dict],
196
+ aggregated: Optional[dict] = None,
197
+ labels: Optional[dict[str, str]] = None,
198
+ ) -> str:
199
+ """Construit la vue HTML « Déficit projeté de robustesse ».
200
+
201
+ Parameters
202
+ ----------
203
+ projection:
204
+ Sortie de ``project_robustness_on_corpus`` (Sprint 81),
205
+ forme ``{engine: {deg_type: {...}}}``. Si ``None`` ou
206
+ vide, retourne ``""``.
207
+ aggregated:
208
+ Sortie de ``aggregate_projection_per_engine`` (Sprint
209
+ 81). Si ``None``, sera calculé à partir de
210
+ ``projection``.
211
+ labels:
212
+ Dict i18n. Clés sous le préfixe ``robproj_*``.
213
+
214
+ Returns
215
+ -------
216
+ str
217
+ Section HTML, ou ``""`` si projection vide.
218
+ """
219
+ if not projection:
220
+ return ""
221
+ if aggregated is None:
222
+ from picarones.core.robustness_projection import (
223
+ aggregate_projection_per_engine,
224
+ )
225
+ aggregated = aggregate_projection_per_engine(projection)
226
+ labels = labels or {}
227
+ title = labels.get(
228
+ "robproj_title",
229
+ "Déficit projeté de robustesse sur le corpus réel",
230
+ )
231
+ note = labels.get(
232
+ "robproj_note",
233
+ "Projection des courbes de dégradation synthétique sur "
234
+ "les caractéristiques d'image réelles. Le déficit total "
235
+ "suppose l'indépendance des dégradations — c'est une "
236
+ "approximation utile pour le diagnostic, pas un verdict.",
237
+ )
238
+ summary_table = _build_summary_table(aggregated or {}, labels)
239
+ detail_table = _build_detail_table(projection, labels)
240
+ if not summary_table and not detail_table:
241
+ return ""
242
+ h_summary = labels.get("robproj_summary", "Résumé par moteur")
243
+ h_detail = labels.get(
244
+ "robproj_detail", "Détail par couple (moteur × dégradation)",
245
+ )
246
+ parts = [
247
+ '<section class="robproj-section" style="margin:1.5rem 0">',
248
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
249
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.7rem">'
250
+ f'{_e(note)}</div>',
251
+ ]
252
+ if summary_table:
253
+ parts.append(
254
+ f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
255
+ f'{_e(h_summary)}</div>'
256
+ )
257
+ parts.append(summary_table)
258
+ if detail_table:
259
+ parts.append(
260
+ f'<div style="font-weight:600;margin:.6rem 0 .3rem 0">'
261
+ f'{_e(h_detail)}</div>'
262
+ )
263
+ parts.append(detail_table)
264
+ parts.append('</section>')
265
+ return "".join(parts)
266
+
267
+
268
+ __all__ = ["build_robustness_projection_html"]
tests/test_sprint88_robustness_projection_html.py ADDED
@@ -0,0 +1,231 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse.
2
+
3
+ Couvre :
4
+
5
+ 1. ``build_robustness_projection_html`` :
6
+ - vide / None → ``""``
7
+ - rendu complet (résumé + détail)
8
+ - calcul automatique de ``aggregated`` si non fourni
9
+ - tri par déficit décroissant
10
+ - colonne « pire dégradation » formatée
11
+ - cellules colorées selon l'amplitude du déficit
12
+ 2. Anti-injection sur nom de moteur + type de dégradation.
13
+ 3. Bout-en-bout : intégration avec
14
+ ``project_robustness_on_corpus`` + ``aggregate_projection_per_engine``.
15
+ 4. Complétude i18n FR/EN.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from pathlib import Path
22
+
23
+ from picarones.core.robustness_projection import (
24
+ aggregate_projection_per_engine,
25
+ project_robustness_on_corpus,
26
+ )
27
+ from picarones.report.robustness_projection_render import (
28
+ build_robustness_projection_html,
29
+ )
30
+
31
+
32
+ def _load_labels(lang: str) -> dict:
33
+ p = (
34
+ Path(__file__).parent.parent
35
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
36
+ )
37
+ return json.loads(p.read_text(encoding="utf-8"))
38
+
39
+
40
+ def _curve(engine: str, deg: str) -> dict:
41
+ return {
42
+ "engine_name": engine,
43
+ "degradation_type": deg,
44
+ "levels": [0, 5, 10, 20],
45
+ "cer_values": [0.05, 0.10, 0.20, 0.50],
46
+ "critical_threshold_level": 10,
47
+ "cer_threshold": 0.20,
48
+ }
49
+
50
+
51
+ # ──────────────────────────────────────────────────────────────────────────
52
+ # 1. build_robustness_projection_html
53
+ # ──────────────────────────────────────────────────────────────────────────
54
+
55
+
56
+ class TestRender:
57
+ def test_none_returns_empty(self) -> None:
58
+ assert build_robustness_projection_html(None) == ""
59
+
60
+ def test_empty_returns_empty(self) -> None:
61
+ assert build_robustness_projection_html({}) == ""
62
+
63
+ def test_renders_summary_and_detail(self) -> None:
64
+ projection = {
65
+ "tess": {
66
+ "noise": {
67
+ "n_docs": 50, "n_docs_with_data": 48,
68
+ "expected_cer_mean": 0.18, "baseline_cer": 0.05,
69
+ "deficit_vs_baseline": 0.13,
70
+ "n_docs_above_critical": 12,
71
+ "critical_threshold_cer": 0.20,
72
+ },
73
+ },
74
+ }
75
+ labels = _load_labels("fr")
76
+ html = build_robustness_projection_html(projection, labels=labels)
77
+ assert "<table" in html
78
+ assert "tess" in html
79
+ assert "noise" in html
80
+ # Déficit total = 0.13 → 13.00 pts
81
+ assert "+13.00" in html
82
+ # Le summary contient le worst type
83
+ assert "Pire dégradation" in html
84
+ assert "Détail" in html
85
+
86
+ def test_auto_computes_aggregate(self) -> None:
87
+ # Ne fournit que projection → aggregated calculé depuis
88
+ projection = {
89
+ "tess": {
90
+ "noise": {
91
+ "n_docs": 10, "n_docs_with_data": 10,
92
+ "deficit_vs_baseline": 0.05,
93
+ "n_docs_above_critical": 0,
94
+ },
95
+ },
96
+ }
97
+ html = build_robustness_projection_html(
98
+ projection, labels=_load_labels("fr"),
99
+ )
100
+ # Total = 0.05 = 5.00 points
101
+ assert "+5.00" in html
102
+
103
+ def test_sorted_by_deficit_descending(self) -> None:
104
+ projection = {
105
+ "low": {
106
+ "noise": {
107
+ "n_docs": 1, "n_docs_with_data": 1,
108
+ "deficit_vs_baseline": 0.01,
109
+ "n_docs_above_critical": 0,
110
+ },
111
+ },
112
+ "high": {
113
+ "noise": {
114
+ "n_docs": 1, "n_docs_with_data": 1,
115
+ "deficit_vs_baseline": 0.10,
116
+ "n_docs_above_critical": 1,
117
+ },
118
+ },
119
+ }
120
+ html = build_robustness_projection_html(
121
+ projection, labels=_load_labels("fr"),
122
+ )
123
+ # « high » apparaît avant « low » dans le résumé
124
+ assert html.index("high") < html.index("low")
125
+
126
+ def test_anti_injection_engine(self) -> None:
127
+ projection = {
128
+ "<script>alert(1)</script>": {
129
+ "noise": {
130
+ "n_docs": 1, "n_docs_with_data": 1,
131
+ "deficit_vs_baseline": 0.05,
132
+ "n_docs_above_critical": 0,
133
+ },
134
+ },
135
+ }
136
+ html = build_robustness_projection_html(
137
+ projection, labels=_load_labels("fr"),
138
+ )
139
+ assert "<script>alert" not in html
140
+ assert "&lt;script&gt;" in html
141
+
142
+ def test_anti_injection_deg_type(self) -> None:
143
+ projection = {
144
+ "tess": {
145
+ "<img/>": {
146
+ "n_docs": 1, "n_docs_with_data": 1,
147
+ "deficit_vs_baseline": 0.05,
148
+ "n_docs_above_critical": 0,
149
+ },
150
+ },
151
+ }
152
+ html = build_robustness_projection_html(
153
+ projection, labels=_load_labels("fr"),
154
+ )
155
+ assert "<img/>" not in html
156
+ assert "&lt;img" in html
157
+
158
+ def test_handles_missing_deficit(self) -> None:
159
+ projection = {
160
+ "tess": {
161
+ "noise": {
162
+ "n_docs": 5, "n_docs_with_data": 5,
163
+ "deficit_vs_baseline": None,
164
+ "n_docs_above_critical": 0,
165
+ },
166
+ },
167
+ }
168
+ html = build_robustness_projection_html(
169
+ projection, labels=_load_labels("fr"),
170
+ )
171
+ assert "—" in html # Cellule déficit vide
172
+
173
+ def test_renders_in_english(self) -> None:
174
+ projection = {
175
+ "tess": {
176
+ "noise": {
177
+ "n_docs": 1, "n_docs_with_data": 1,
178
+ "deficit_vs_baseline": 0.05,
179
+ "n_docs_above_critical": 0,
180
+ },
181
+ },
182
+ }
183
+ html = build_robustness_projection_html(
184
+ projection, labels=_load_labels("en"),
185
+ )
186
+ assert "Projected robustness deficit" in html
187
+
188
+
189
+ # ──────────────────────────────────────────────────────────────────────────
190
+ # 2. Bout-en-bout (Sprint 81 + Sprint 88)
191
+ # ──────────────────────────────────────────────────────────────────────────
192
+
193
+
194
+ class TestEndToEnd:
195
+ def test_full_pipeline_renders(self) -> None:
196
+ curves = [_curve("tess", "noise"), _curve("pero", "noise")]
197
+ qualities = [
198
+ {"noise_level": 7.5}, {"noise_level": 5}, {"noise_level": 15},
199
+ ]
200
+ projection = project_robustness_on_corpus(curves, qualities)
201
+ aggregated = aggregate_projection_per_engine(projection)
202
+ html = build_robustness_projection_html(
203
+ projection, aggregated, _load_labels("fr"),
204
+ )
205
+ assert "<table" in html
206
+ # Les deux moteurs apparaissent
207
+ assert "tess" in html
208
+ assert "pero" in html
209
+
210
+
211
+ # ──────────────────────────────────────────────────────────────────────────
212
+ # 3. Complétude i18n
213
+ # ──────────────────────────────────────────────────────────────────────────
214
+
215
+
216
+ _KEYS = {
217
+ "robproj_title", "robproj_note", "robproj_summary", "robproj_detail",
218
+ "robproj_engine", "robproj_total", "robproj_n_types", "robproj_worst",
219
+ "robproj_deg_type", "robproj_n_docs", "robproj_n_with_data",
220
+ "robproj_deficit", "robproj_above",
221
+ }
222
+
223
+
224
+ class TestI18n:
225
+ def test_fr(self) -> None:
226
+ d = _load_labels("fr")
227
+ assert not _KEYS - d.keys()
228
+
229
+ def test_en(self) -> None:
230
+ d = _load_labels("en")
231
+ assert not _KEYS - d.keys()