Spaces:
Running
sprint89: A.II.8b score de spécialisation inter-moteurs (calcul + HTML)
Browse filesLa matrice de divergence taxonomique (Sprint 35) répondait à
"à quel point ces moteurs se trompent-ils différemment ?".
Ce sprint transforme cette information en un score lisible
et un top-N des paires les plus spécialisées.
Le module ne recommande PAS d'ensemble — observation factuelle.
picarones/core/specialization.py :
- compute_specialization_score : délégué à JS divergence Sprint 35.
- classify_specialization : similar < 0.10, distinct 0.10-0.30,
highly_specialized ≥ 0.30 (seuils éditoriaux surchargeables).
- compute_specialization_matrix : symétrique + max_pair.
- top_specialized_pairs : tri décroissant, n cap, min_score filter.
picarones/report/specialization_render.py : tableau
Moteur A × Moteur B × Score (gradient blanc → bleu profond)
× Lecture (libellé i18n). Adaptive : "" si < 2 moteurs.
Câblage generator : lit aggregated_taxonomy de chaque moteur,
construit la map {engine: counts}. Insertion view_analyses.html.
9 clés i18n FR/EN. 24 tests dans test_sprint89_specialization.py
couvrant symétrie + bornes [0,1], classify 5 cas dont custom,
matrice diagonale 0 + max_pair, top_pairs tri/n/min_score/None,
rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés.
Tests : 2923 passed, 2 skipped.
https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7
- CHANGELOG.md +44 -0
- CLAUDE.md +2 -1
- picarones/core/specialization.py +187 -0
- picarones/report/generator.py +23 -0
- picarones/report/i18n/en.json +10 -1
- picarones/report/i18n/fr.json +10 -1
- picarones/report/specialization_render.py +118 -0
- picarones/report/templates/view_analyses.html +8 -0
- tests/test_sprint89_specialization.py +233 -0
|
@@ -16,6 +16,50 @@ La numérotation de version suit [Semantic Versioning](https://semver.org/lang/f
|
|
| 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)
|
|
|
|
| 16 |
|
| 17 |
### Ajouté
|
| 18 |
|
| 19 |
+
- **Sprint 89 — A.II.8b : score de spécialisation inter-moteurs
|
| 20 |
+
(couche calcul + vue HTML).** La matrice de divergence
|
| 21 |
+
taxonomique (Sprint 35) répondait à *« à quel point ces
|
| 22 |
+
moteurs se trompent-ils différemment ? »* ; ce sprint
|
| 23 |
+
transforme cette information en un score lisible et un
|
| 24 |
+
**top-N des paires les plus spécialisées**, qui répond
|
| 25 |
+
directement à la question *« quels moteurs sont des candidats
|
| 26 |
+
pour un voting ensemble ? »*. Le module **ne recommande
|
| 27 |
+
pas** d'ensemble — il livre l'observation factuelle et
|
| 28 |
+
laisse le chercheur arbitrer. Nouveau module
|
| 29 |
+
`picarones/core/specialization.py` :
|
| 30 |
+
`compute_specialization_score(taxonomy_a, taxonomy_b)`
|
| 31 |
+
retourne un score normalisé ∈ [0, 1] (délégué à
|
| 32 |
+
`inter_engine.jensen_shannon_divergence` Sprint 35, pas de
|
| 33 |
+
double calcul) ;
|
| 34 |
+
`classify_specialization(score, thresholds=DEFAULT_THRESHOLDS)`
|
| 35 |
+
classe en `similar` (< 0,10) / `distinct` (0,10–0,30) /
|
| 36 |
+
`highly_specialized` (≥ 0,30) — seuils éditoriaux pas
|
| 37 |
+
verdict, surchargeables ;
|
| 38 |
+
`compute_specialization_matrix(taxonomies)` retourne une
|
| 39 |
+
matrice symétrique avec `max_pair` ;
|
| 40 |
+
`top_specialized_pairs(matrix, n=5, min_score=0)` retourne
|
| 41 |
+
les paires triées par score décroissant avec leur catégorie.
|
| 42 |
+
Nouveau module `picarones/report/specialization_render.py` :
|
| 43 |
+
`build_specialization_html(taxonomies, labels, top_n=5)`
|
| 44 |
+
rend un tableau Moteur A × Moteur B × Score (gradient blanc
|
| 45 |
+
→ bleu profond) × Lecture (libellé i18n). Adaptive : `""`
|
| 46 |
+
si moins de 2 moteurs avec taxonomie. Anti-injection.
|
| 47 |
+
Câblage générator : lit les `aggregated_taxonomy` exposés
|
| 48 |
+
sur les moteurs (Sprint 5/runner historique), construit la
|
| 49 |
+
map `{engine: counts}` et passe au renderer. Insertion dans
|
| 50 |
+
`view_analyses.html` derrière la lisibilité. +9 clés i18n
|
| 51 |
+
FR/EN (`specialization_*`). +24 tests dans
|
| 52 |
+
`test_sprint89_specialization.py` (score symétrique +
|
| 53 |
+
identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont
|
| 54 |
+
custom thresholds, matrice diagonale 0 + symétrique +
|
| 55 |
+
max_pair correctement identifié, top_pairs tri/n/min_score/
|
| 56 |
+
None, rendu adaptive + anti-injection + FR/EN, complétude
|
| 57 |
+
i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2
|
| 58 |
+
moteurs voit immédiatement *« tess et pero ont une
|
| 59 |
+
spécialisation forte (0,489) — ils font des erreurs de
|
| 60 |
+
natures différentes »* — observation factuelle, le
|
| 61 |
+
chercheur arbitre.
|
| 62 |
+
|
| 63 |
- **Sprint 88 — A.I.8 vue HTML : déficit projeté de robustesse
|
| 64 |
(clôture A.I.8 bout-en-bout).** Le module
|
| 65 |
`picarones/core/robustness_projection.py` (Sprint 81)
|
|
@@ -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 |
| 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). |
|
|
@@ -306,7 +307,7 @@ au template `_narrative_summary.html` (placé entre `_header.html` et `_critical
|
|
| 306 |
## Contexte développement
|
| 307 |
|
| 308 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 309 |
-
- **Tests** :
|
| 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** :
|
|
|
|
| 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 |
+
| 89 | **Sprint 58 du plan d'évolution 2026 — A.II.8b : score de spécialisation inter-moteurs (couche calcul + vue HTML)**. La matrice de divergence taxonomique (Sprint 35) répondait à *« à quel point ces moteurs se trompent-ils différemment ? »* ; ce sprint transforme cette information en un score lisible et un **top-N des paires les plus spécialisées**, qui répond directement à la question *« quels moteurs sont des candidats pour un voting ensemble ? »*. Le module **ne recommande pas** d'ensemble — observation factuelle, le chercheur arbitre. Nouveau module `picarones/core/specialization.py` : `compute_specialization_score(taxonomy_a, taxonomy_b)` retourne un score normalisé ∈ [0, 1] (délégué à `inter_engine.jensen_shannon_divergence` Sprint 35, pas de double calcul) ; `classify_specialization(score)` classe en `similar` (< 0,10) / `distinct` (0,10–0,30) / `highly_specialized` (≥ 0,30) — seuils éditoriaux pas verdict, surchargeables ; `compute_specialization_matrix(taxonomies)` retourne matrice symétrique avec `max_pair` ; `top_specialized_pairs(matrix, n=5, min_score=0)` retourne paires triées par score décroissant + catégorie. Nouveau module `picarones/report/specialization_render.py` : `build_specialization_html` rend tableau Moteur A × Moteur B × Score (gradient blanc → bleu profond) × Lecture (libellé i18n). Adaptive : `""` si < 2 moteurs avec taxonomie. Anti-injection. Câblage générator : lit `aggregated_taxonomy` exposés sur les moteurs (Sprint 5/runner historique), construit map `{engine: counts}`. Insertion `view_analyses.html` derrière la lisibilité. +9 clés i18n FR/EN (`specialization_*`). +24 tests dans `test_sprint89_specialization.py` (score symétrique + identité 0 + disjoint 1 + bornes [0,1], classify 5 cas dont custom thresholds, matrice diagonale 0 + symétrique + max_pair correctement identifié, top_pairs tri/n/min_score/None, rendu adaptive + anti-injection + FR/EN, complétude i18n 9 clés). **Verrou levé** : un benchmark BnF avec ≥ 2 moteurs voit immédiatement *« tess et pero ont une spécialisation forte (0,489) — ils font des erreurs de natures différentes »* — observation factuelle. |
|
| 211 |
| 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. |
|
| 212 |
| 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). |
|
| 213 |
| 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). |
|
|
|
|
| 307 |
## Contexte développement
|
| 308 |
|
| 309 |
- **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
|
| 310 |
+
- **Tests** : 2923 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 ; **Sprint 89 = A.II.8b — score de spécialisation inter-moteurs (couche calcul + vue HTML « Top paires spécialisées »)**)
|
| 311 |
- **Plan d'évolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
|
| 312 |
- **Branche active** : `claude/analyze-project-evolution-KOA56`
|
| 313 |
- **Transcript de la conversation de développement** :
|
|
@@ -0,0 +1,187 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Score de spécialisation inter-moteurs — Sprint 89 (A.II.8b).
|
| 2 |
+
|
| 3 |
+
Sprint 89 — A.II.8b du plan d'évolution 2026.
|
| 4 |
+
|
| 5 |
+
Pourquoi ce module
|
| 6 |
+
------------------
|
| 7 |
+
La matrice de divergence taxonomique (Sprint 35
|
| 8 |
+
``inter_engine.taxonomy_divergence_matrix``) répond à *« à quel
|
| 9 |
+
point ces moteurs se trompent-ils différemment ? »*. Ce
|
| 10 |
+
sprint la transforme en un **score de spécialisation** lisible
|
| 11 |
+
et complète la lecture par :
|
| 12 |
+
|
| 13 |
+
- une **classification** discrète (similar / distinct /
|
| 14 |
+
highly_specialized) que le chercheur peut consommer sans
|
| 15 |
+
avoir à interpréter une distance ;
|
| 16 |
+
- un **top-N des paires** les plus spécialisées, qui répond
|
| 17 |
+
directement à la question *« quels moteurs sont les meilleurs
|
| 18 |
+
candidats pour un voting ensemble ? »*.
|
| 19 |
+
|
| 20 |
+
Ce module **ne recommande pas** de pipeline d'ensemble — il
|
| 21 |
+
fournit l'observation factuelle et laisse le chercheur arbitrer.
|
| 22 |
+
|
| 23 |
+
Convention de score
|
| 24 |
+
-------------------
|
| 25 |
+
On utilise la **Jensen-Shannon divergence** déjà calculée par
|
| 26 |
+
``inter_engine.jensen_shannon_divergence`` : elle est
|
| 27 |
+
symétrique, bornée dans [0, 1], et son interprétation est
|
| 28 |
+
intuitive :
|
| 29 |
+
|
| 30 |
+
- ≈ 0 → profils taxonomiques identiques
|
| 31 |
+
- 1 → distributions totalement disjointes
|
| 32 |
+
|
| 33 |
+
Dépendances
|
| 34 |
+
-----------
|
| 35 |
+
S'appuie strictement sur ``picarones.core.inter_engine`` (Sprint
|
| 36 |
+
35) — pas de double calcul, pas de logique nouvelle de
|
| 37 |
+
divergence.
|
| 38 |
+
"""
|
| 39 |
+
|
| 40 |
+
from __future__ import annotations
|
| 41 |
+
|
| 42 |
+
import logging
|
| 43 |
+
from typing import Optional
|
| 44 |
+
|
| 45 |
+
from picarones.core.inter_engine import jensen_shannon_divergence
|
| 46 |
+
|
| 47 |
+
logger = logging.getLogger(__name__)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
# Seuils par convention éditoriale. La roadmap ne fixe rien :
|
| 51 |
+
# ces seuils sont des **guides de lecture**, pas des verdicts.
|
| 52 |
+
# Le chercheur peut les surcharger via ``classify_specialization``.
|
| 53 |
+
DEFAULT_THRESHOLDS = (
|
| 54 |
+
("similar", 0.10),
|
| 55 |
+
("distinct", 0.30),
|
| 56 |
+
("highly_specialized", 1.01), # tout score ≥ 0.30
|
| 57 |
+
)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def compute_specialization_score(
|
| 61 |
+
taxonomy_a: dict[str, float],
|
| 62 |
+
taxonomy_b: dict[str, float],
|
| 63 |
+
) -> float:
|
| 64 |
+
"""Score de spécialisation entre deux moteurs ∈ [0, 1].
|
| 65 |
+
|
| 66 |
+
0 = mêmes erreurs, 1 = erreurs totalement disjointes.
|
| 67 |
+
Délègue à ``jensen_shannon_divergence`` (Sprint 35).
|
| 68 |
+
"""
|
| 69 |
+
return jensen_shannon_divergence(taxonomy_a, taxonomy_b)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def classify_specialization(
|
| 73 |
+
score: float,
|
| 74 |
+
thresholds: Optional[tuple[tuple[str, float], ...]] = None,
|
| 75 |
+
) -> str:
|
| 76 |
+
"""Classe le score en catégorie discrète.
|
| 77 |
+
|
| 78 |
+
Convention :
|
| 79 |
+
- score < 0.10 → ``similar``
|
| 80 |
+
- 0.10 ≤ score < 0.30 → ``distinct``
|
| 81 |
+
- score ≥ 0.30 → ``highly_specialized``
|
| 82 |
+
|
| 83 |
+
L'utilisateur peut passer ses propres ``thresholds`` (liste
|
| 84 |
+
triée par valeur croissante de tuples ``(label, max_score)``).
|
| 85 |
+
"""
|
| 86 |
+
rules = thresholds or DEFAULT_THRESHOLDS
|
| 87 |
+
for label, max_score in rules:
|
| 88 |
+
if score < max_score:
|
| 89 |
+
return label
|
| 90 |
+
# Garde-fou : si aucun seuil ne match, dernière catégorie
|
| 91 |
+
return rules[-1][0]
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def compute_specialization_matrix(
|
| 95 |
+
taxonomies: dict[str, dict[str, float]],
|
| 96 |
+
) -> Optional[dict]:
|
| 97 |
+
"""Matrice de spécialisation symétrique entre tous les moteurs.
|
| 98 |
+
|
| 99 |
+
Parameters
|
| 100 |
+
----------
|
| 101 |
+
taxonomies:
|
| 102 |
+
Map ``{engine_name: {error_class: count_or_proportion}}``.
|
| 103 |
+
|
| 104 |
+
Returns
|
| 105 |
+
-------
|
| 106 |
+
dict | None
|
| 107 |
+
``{
|
| 108 |
+
"engines": list[str],
|
| 109 |
+
"matrix": list[list[float]], # carrée, symétrique
|
| 110 |
+
"n_pairs": int, # paires distinctes
|
| 111 |
+
"max_score": float,
|
| 112 |
+
"max_pair": (str, str) | None,
|
| 113 |
+
}`` ; ``None`` si moins de 2 moteurs.
|
| 114 |
+
"""
|
| 115 |
+
if not taxonomies or len(taxonomies) < 2:
|
| 116 |
+
return None
|
| 117 |
+
engines = sorted(taxonomies.keys())
|
| 118 |
+
n = len(engines)
|
| 119 |
+
matrix = [[0.0] * n for _ in range(n)]
|
| 120 |
+
n_pairs = 0
|
| 121 |
+
max_score = 0.0
|
| 122 |
+
max_pair: Optional[tuple[str, str]] = None
|
| 123 |
+
for i in range(n):
|
| 124 |
+
for j in range(i + 1, n):
|
| 125 |
+
score = compute_specialization_score(
|
| 126 |
+
taxonomies[engines[i]], taxonomies[engines[j]],
|
| 127 |
+
)
|
| 128 |
+
matrix[i][j] = score
|
| 129 |
+
matrix[j][i] = score
|
| 130 |
+
n_pairs += 1
|
| 131 |
+
if score > max_score:
|
| 132 |
+
max_score = score
|
| 133 |
+
max_pair = (engines[i], engines[j])
|
| 134 |
+
return {
|
| 135 |
+
"engines": engines,
|
| 136 |
+
"matrix": matrix,
|
| 137 |
+
"n_pairs": n_pairs,
|
| 138 |
+
"max_score": max_score,
|
| 139 |
+
"max_pair": max_pair,
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def top_specialized_pairs(
|
| 144 |
+
matrix_data: Optional[dict],
|
| 145 |
+
n: int = 5,
|
| 146 |
+
*,
|
| 147 |
+
min_score: float = 0.0,
|
| 148 |
+
) -> list[dict]:
|
| 149 |
+
"""Top-N paires de moteurs triées par score décroissant.
|
| 150 |
+
|
| 151 |
+
Returns
|
| 152 |
+
-------
|
| 153 |
+
list[dict]
|
| 154 |
+
Une liste de ``{
|
| 155 |
+
"engine_a": str, "engine_b": str,
|
| 156 |
+
"score": float, "category": str,
|
| 157 |
+
}`` triée par score décroissant. Liste vide si
|
| 158 |
+
``matrix_data`` est ``None`` ou que toutes les paires
|
| 159 |
+
sont sous ``min_score``.
|
| 160 |
+
"""
|
| 161 |
+
if not matrix_data:
|
| 162 |
+
return []
|
| 163 |
+
engines = matrix_data["engines"]
|
| 164 |
+
matrix = matrix_data["matrix"]
|
| 165 |
+
pairs: list[dict] = []
|
| 166 |
+
for i, engine_a in enumerate(engines):
|
| 167 |
+
for j in range(i + 1, len(engines)):
|
| 168 |
+
score = matrix[i][j]
|
| 169 |
+
if score < min_score:
|
| 170 |
+
continue
|
| 171 |
+
pairs.append({
|
| 172 |
+
"engine_a": engine_a,
|
| 173 |
+
"engine_b": engines[j],
|
| 174 |
+
"score": score,
|
| 175 |
+
"category": classify_specialization(score),
|
| 176 |
+
})
|
| 177 |
+
pairs.sort(key=lambda p: -p["score"])
|
| 178 |
+
return pairs[:n]
|
| 179 |
+
|
| 180 |
+
|
| 181 |
+
__all__ = [
|
| 182 |
+
"DEFAULT_THRESHOLDS",
|
| 183 |
+
"compute_specialization_score",
|
| 184 |
+
"classify_specialization",
|
| 185 |
+
"compute_specialization_matrix",
|
| 186 |
+
"top_specialized_pairs",
|
| 187 |
+
]
|
|
@@ -819,6 +819,28 @@ class ReportGenerator:
|
|
| 819 |
report_data.get("engines", []), labels=labels,
|
| 820 |
)
|
| 821 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
env = _build_jinja_env()
|
| 823 |
template = env.get_template("base.html.j2")
|
| 824 |
html = template.render(
|
|
@@ -843,6 +865,7 @@ class ReportGenerator:
|
|
| 843 |
searchability_html=searchability_html,
|
| 844 |
numerical_sequences_html=numerical_sequences_html,
|
| 845 |
readability_html=readability_html,
|
|
|
|
| 846 |
)
|
| 847 |
|
| 848 |
output_path.write_text(html, encoding="utf-8")
|
|
|
|
| 819 |
report_data.get("engines", []), labels=labels,
|
| 820 |
)
|
| 821 |
|
| 822 |
+
# Sprint 89 — A.II.8b : spécialisation inter-moteurs.
|
| 823 |
+
# Adaptive : "" si moins de 2 moteurs avec taxonomie.
|
| 824 |
+
from picarones.report.specialization_render import (
|
| 825 |
+
build_specialization_html,
|
| 826 |
+
)
|
| 827 |
+
# Construit une map {engine: counts} depuis les
|
| 828 |
+
# ``aggregated_taxonomy`` ; un moteur sans taxonomie
|
| 829 |
+
# est exclu.
|
| 830 |
+
_taxos: dict = {}
|
| 831 |
+
for eng in report_data.get("engines", []):
|
| 832 |
+
tax = eng.get("aggregated_taxonomy")
|
| 833 |
+
if isinstance(tax, dict):
|
| 834 |
+
counts = tax.get("counts") if "counts" in tax else tax
|
| 835 |
+
if isinstance(counts, dict) and counts:
|
| 836 |
+
_taxos[eng.get("name", "?")] = {
|
| 837 |
+
k: float(v) for k, v in counts.items()
|
| 838 |
+
if isinstance(v, (int, float))
|
| 839 |
+
}
|
| 840 |
+
specialization_html = build_specialization_html(
|
| 841 |
+
_taxos, labels=labels,
|
| 842 |
+
)
|
| 843 |
+
|
| 844 |
env = _build_jinja_env()
|
| 845 |
template = env.get_template("base.html.j2")
|
| 846 |
html = template.render(
|
|
|
|
| 865 |
searchability_html=searchability_html,
|
| 866 |
numerical_sequences_html=numerical_sequences_html,
|
| 867 |
readability_html=readability_html,
|
| 868 |
+
specialization_html=specialization_html,
|
| 869 |
)
|
| 870 |
|
| 871 |
output_path.write_text(html, encoding="utf-8")
|
|
@@ -316,5 +316,14 @@
|
|
| 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 |
}
|
|
|
|
| 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 |
+
"specialization_title": "Inter-engine specialisation",
|
| 321 |
+
"specialization_note": "Jensen-Shannon divergence between the taxonomic profiles of each pair of engines (0 = identical profiles, 1 = fully disjoint). A highly specialised pair signals error categories of different natures — it is for the researcher to act on it, not for the tool to prescribe an ensemble.",
|
| 322 |
+
"specialization_engine_a": "Engine A",
|
| 323 |
+
"specialization_engine_b": "Engine B",
|
| 324 |
+
"specialization_score": "Score",
|
| 325 |
+
"specialization_category": "Reading",
|
| 326 |
+
"specialization_cat_similar": "Similar profiles",
|
| 327 |
+
"specialization_cat_distinct": "Distinct profiles",
|
| 328 |
+
"specialization_cat_highly_specialized": "Highly specialised"
|
| 329 |
}
|
|
@@ -316,5 +316,14 @@
|
|
| 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 |
}
|
|
|
|
| 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 |
+
"specialization_title": "Spécialisation inter-moteurs",
|
| 321 |
+
"specialization_note": "Score de divergence Jensen-Shannon entre les profils taxonomiques de chaque paire de moteurs (0 = profils identiques, 1 = totalement disjoints). Une paire très spécialisée signale des erreurs de natures différentes — c'est au chercheur d'en tirer parti, pas à l'outil de prescrire un ensemble.",
|
| 322 |
+
"specialization_engine_a": "Moteur A",
|
| 323 |
+
"specialization_engine_b": "Moteur B",
|
| 324 |
+
"specialization_score": "Score",
|
| 325 |
+
"specialization_category": "Lecture",
|
| 326 |
+
"specialization_cat_similar": "Profils similaires",
|
| 327 |
+
"specialization_cat_distinct": "Profils distincts",
|
| 328 |
+
"specialization_cat_highly_specialized": "Forte spécialisation"
|
| 329 |
}
|
|
@@ -0,0 +1,118 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Rendu HTML « Spécialisation inter-moteurs » — Sprint 89
|
| 2 |
+
(A.II.8b).
|
| 3 |
+
|
| 4 |
+
Suite directe ``picarones/core/specialization.py``. Vue
|
| 5 |
+
**factuelle** sans recommandation : on liste les paires de
|
| 6 |
+
moteurs les plus spécialisées, le chercheur arbitre.
|
| 7 |
+
|
| 8 |
+
Pattern identique aux autres rendus : server-side, pas de JS,
|
| 9 |
+
anti-injection systématique.
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from html import escape as _e
|
| 15 |
+
from typing import Optional
|
| 16 |
+
|
| 17 |
+
from picarones.core.specialization import (
|
| 18 |
+
compute_specialization_matrix,
|
| 19 |
+
top_specialized_pairs,
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _color_for_score(score: float) -> str:
|
| 24 |
+
"""Gradient blanc → bleu profond."""
|
| 25 |
+
f = max(0.0, min(1.0, score))
|
| 26 |
+
r = int(255 + (50 - 255) * f)
|
| 27 |
+
g = int(255 + (110 - 255) * f)
|
| 28 |
+
b = int(255 + (180 - 255) * f)
|
| 29 |
+
return f"#{r:02x}{g:02x}{b:02x}"
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def _category_label(cat: str, labels: dict[str, str]) -> str:
|
| 33 |
+
return labels.get(f"specialization_cat_{cat}", cat)
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def build_specialization_html(
|
| 37 |
+
taxonomies: Optional[dict[str, dict[str, float]]],
|
| 38 |
+
labels: Optional[dict[str, str]] = None,
|
| 39 |
+
*,
|
| 40 |
+
top_n: int = 5,
|
| 41 |
+
) -> str:
|
| 42 |
+
"""Construit la vue HTML de spécialisation inter-moteurs.
|
| 43 |
+
|
| 44 |
+
Parameters
|
| 45 |
+
----------
|
| 46 |
+
taxonomies:
|
| 47 |
+
Map ``{engine: {error_class: count}}``. Si ``None`` ou
|
| 48 |
+
moins de 2 moteurs, retourne ``""``.
|
| 49 |
+
labels:
|
| 50 |
+
Dict i18n. Clés sous le préfixe ``specialization_*``.
|
| 51 |
+
top_n:
|
| 52 |
+
Nombre de paires à afficher (défaut 5).
|
| 53 |
+
"""
|
| 54 |
+
if not taxonomies or len(taxonomies) < 2:
|
| 55 |
+
return ""
|
| 56 |
+
matrix_data = compute_specialization_matrix(taxonomies)
|
| 57 |
+
if not matrix_data:
|
| 58 |
+
return ""
|
| 59 |
+
pairs = top_specialized_pairs(matrix_data, n=top_n)
|
| 60 |
+
if not pairs:
|
| 61 |
+
return ""
|
| 62 |
+
labels = labels or {}
|
| 63 |
+
title = labels.get(
|
| 64 |
+
"specialization_title", "Spécialisation inter-moteurs",
|
| 65 |
+
)
|
| 66 |
+
note = labels.get(
|
| 67 |
+
"specialization_note",
|
| 68 |
+
"Score de divergence Jensen-Shannon entre les profils "
|
| 69 |
+
"taxonomiques de chaque paire de moteurs (0 = profils "
|
| 70 |
+
"identiques, 1 = totalement disjoints). Une paire très "
|
| 71 |
+
"spécialisée signale des erreurs de natures différentes "
|
| 72 |
+
"— c'est au chercheur d'en tirer parti, pas à l'outil "
|
| 73 |
+
"de prescrire un ensemble.",
|
| 74 |
+
)
|
| 75 |
+
h_a = labels.get("specialization_engine_a", "Moteur A")
|
| 76 |
+
h_b = labels.get("specialization_engine_b", "Moteur B")
|
| 77 |
+
h_score = labels.get("specialization_score", "Score")
|
| 78 |
+
h_cat = labels.get("specialization_category", "Lecture")
|
| 79 |
+
|
| 80 |
+
parts = [
|
| 81 |
+
'<section class="specialization-section" '
|
| 82 |
+
'style="margin:1rem 0">',
|
| 83 |
+
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
|
| 84 |
+
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
|
| 85 |
+
f'{_e(note)}</div>',
|
| 86 |
+
'<table style="border-collapse:collapse;width:100%;'
|
| 87 |
+
'font-size:.9rem">',
|
| 88 |
+
'<thead><tr>',
|
| 89 |
+
]
|
| 90 |
+
for col in (h_a, h_b, h_score, h_cat):
|
| 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 |
+
for pair in pairs:
|
| 98 |
+
score = float(pair.get("score") or 0.0)
|
| 99 |
+
cat = pair.get("category") or "?"
|
| 100 |
+
color = _color_for_score(score)
|
| 101 |
+
parts.append(
|
| 102 |
+
f'<tr>'
|
| 103 |
+
f'<td style="padding:.4rem .6rem">'
|
| 104 |
+
f'{_e(str(pair.get("engine_a", "?")))}</td>'
|
| 105 |
+
f'<td style="padding:.4rem .6rem">'
|
| 106 |
+
f'{_e(str(pair.get("engine_b", "?")))}</td>'
|
| 107 |
+
f'<td style="padding:.4rem .6rem;text-align:right;'
|
| 108 |
+
f'background:{color};font-family:monospace;font-weight:600">'
|
| 109 |
+
f'{score:.3f}</td>'
|
| 110 |
+
f'<td style="padding:.4rem .6rem">'
|
| 111 |
+
f'{_e(_category_label(cat, labels))}</td>'
|
| 112 |
+
f'</tr>'
|
| 113 |
+
)
|
| 114 |
+
parts.append("</tbody></table></section>")
|
| 115 |
+
return "".join(parts)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
__all__ = ["build_specialization_html"]
|
|
@@ -231,6 +231,14 @@
|
|
| 231 |
</div>
|
| 232 |
{% endif %}
|
| 233 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
<!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
|
| 235 |
{% if divergence_matrix_html or oracle_gap_html %}
|
| 236 |
<div class="chart-card" style="grid-column:1/-1">
|
|
|
|
| 231 |
</div>
|
| 232 |
{% endif %}
|
| 233 |
|
| 234 |
+
<!-- Sprint 89 — A.II.8b : spécialisation inter-moteurs.
|
| 235 |
+
Adaptive : n'apparaît que si ≥ 2 moteurs avec taxonomie. -->
|
| 236 |
+
{% if specialization_html %}
|
| 237 |
+
<div class="chart-card" style="grid-column:1/-1">
|
| 238 |
+
{{ specialization_html }}
|
| 239 |
+
</div>
|
| 240 |
+
{% endif %}
|
| 241 |
+
|
| 242 |
<!-- Sprint 37 — Analyse inter-moteurs (divergence taxonomique + oracle gap) -->
|
| 243 |
{% if divergence_matrix_html or oracle_gap_html %}
|
| 244 |
<div class="chart-card" style="grid-column:1/-1">
|
|
@@ -0,0 +1,233 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tests Sprint 89 — A.II.8b : spécialisation inter-moteurs.
|
| 2 |
+
|
| 3 |
+
Couvre :
|
| 4 |
+
|
| 5 |
+
1. ``compute_specialization_score`` : symétrie, plage [0, 1].
|
| 6 |
+
2. ``classify_specialization`` : seuils par défaut + custom.
|
| 7 |
+
3. ``compute_specialization_matrix`` : structure, symétrie, max_pair.
|
| 8 |
+
4. ``top_specialized_pairs`` : tri, n, min_score.
|
| 9 |
+
5. Vue HTML : adaptive, anti-injection, FR + EN.
|
| 10 |
+
6. Complétude i18n FR/EN.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import json
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from picarones.core.specialization import (
|
| 19 |
+
DEFAULT_THRESHOLDS,
|
| 20 |
+
classify_specialization,
|
| 21 |
+
compute_specialization_matrix,
|
| 22 |
+
compute_specialization_score,
|
| 23 |
+
top_specialized_pairs,
|
| 24 |
+
)
|
| 25 |
+
from picarones.report.specialization_render import (
|
| 26 |
+
build_specialization_html,
|
| 27 |
+
)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def _load_labels(lang: str) -> dict:
|
| 31 |
+
p = (
|
| 32 |
+
Path(__file__).parent.parent
|
| 33 |
+
/ "picarones" / "report" / "i18n" / f"{lang}.json"
|
| 34 |
+
)
|
| 35 |
+
return json.loads(p.read_text(encoding="utf-8"))
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 39 |
+
# 1. compute_specialization_score
|
| 40 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
class TestScore:
|
| 44 |
+
def test_identical_profiles_zero(self) -> None:
|
| 45 |
+
tax = {"a": 50, "b": 50}
|
| 46 |
+
assert compute_specialization_score(tax, tax) < 0.001
|
| 47 |
+
|
| 48 |
+
def test_disjoint_profiles_one(self) -> None:
|
| 49 |
+
tax_a = {"a": 100}
|
| 50 |
+
tax_b = {"b": 100}
|
| 51 |
+
assert compute_specialization_score(tax_a, tax_b) > 0.95
|
| 52 |
+
|
| 53 |
+
def test_symmetric(self) -> None:
|
| 54 |
+
a = {"x": 70, "y": 30}
|
| 55 |
+
b = {"x": 20, "y": 80}
|
| 56 |
+
s_ab = compute_specialization_score(a, b)
|
| 57 |
+
s_ba = compute_specialization_score(b, a)
|
| 58 |
+
assert abs(s_ab - s_ba) < 1e-9
|
| 59 |
+
|
| 60 |
+
def test_bounded_zero_one(self) -> None:
|
| 61 |
+
a = {"x": 1, "y": 0, "z": 0}
|
| 62 |
+
b = {"x": 0, "y": 0, "z": 1}
|
| 63 |
+
score = compute_specialization_score(a, b)
|
| 64 |
+
assert 0.0 <= score <= 1.0
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 68 |
+
# 2. classify_specialization
|
| 69 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
class TestClassify:
|
| 73 |
+
def test_below_similar_threshold(self) -> None:
|
| 74 |
+
assert classify_specialization(0.05) == "similar"
|
| 75 |
+
|
| 76 |
+
def test_distinct_band(self) -> None:
|
| 77 |
+
assert classify_specialization(0.20) == "distinct"
|
| 78 |
+
|
| 79 |
+
def test_highly_specialized_above(self) -> None:
|
| 80 |
+
assert classify_specialization(0.50) == "highly_specialized"
|
| 81 |
+
|
| 82 |
+
def test_custom_thresholds(self) -> None:
|
| 83 |
+
custom = (("low", 0.5), ("high", 1.01))
|
| 84 |
+
assert classify_specialization(0.30, custom) == "low"
|
| 85 |
+
assert classify_specialization(0.80, custom) == "high"
|
| 86 |
+
|
| 87 |
+
def test_default_thresholds_exposed(self) -> None:
|
| 88 |
+
assert isinstance(DEFAULT_THRESHOLDS, tuple)
|
| 89 |
+
assert len(DEFAULT_THRESHOLDS) >= 2
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 93 |
+
# 3. compute_specialization_matrix
|
| 94 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
class TestMatrix:
|
| 98 |
+
def test_returns_none_when_lt_two(self) -> None:
|
| 99 |
+
assert compute_specialization_matrix({}) is None
|
| 100 |
+
assert compute_specialization_matrix({"a": {"x": 1}}) is None
|
| 101 |
+
|
| 102 |
+
def test_diagonal_zero(self) -> None:
|
| 103 |
+
tax = {
|
| 104 |
+
"a": {"x": 1, "y": 0},
|
| 105 |
+
"b": {"x": 0, "y": 1},
|
| 106 |
+
}
|
| 107 |
+
m = compute_specialization_matrix(tax)
|
| 108 |
+
for i in range(len(m["engines"])):
|
| 109 |
+
assert m["matrix"][i][i] == 0.0
|
| 110 |
+
|
| 111 |
+
def test_symmetric(self) -> None:
|
| 112 |
+
tax = {
|
| 113 |
+
"a": {"x": 1, "y": 0},
|
| 114 |
+
"b": {"x": 0, "y": 1},
|
| 115 |
+
"c": {"x": 1, "y": 1},
|
| 116 |
+
}
|
| 117 |
+
m = compute_specialization_matrix(tax)
|
| 118 |
+
n = len(m["engines"])
|
| 119 |
+
for i in range(n):
|
| 120 |
+
for j in range(n):
|
| 121 |
+
assert m["matrix"][i][j] == m["matrix"][j][i]
|
| 122 |
+
|
| 123 |
+
def test_max_pair_identifies_most_specialized(self) -> None:
|
| 124 |
+
# A vs B totalement disjoints, C similaire à A.
|
| 125 |
+
tax = {
|
| 126 |
+
"a": {"x": 100, "y": 0},
|
| 127 |
+
"b": {"x": 0, "y": 100},
|
| 128 |
+
"c": {"x": 95, "y": 5},
|
| 129 |
+
}
|
| 130 |
+
m = compute_specialization_matrix(tax)
|
| 131 |
+
# La paire la plus spécialisée doit être (a, b)
|
| 132 |
+
assert set(m["max_pair"]) == {"a", "b"}
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 136 |
+
# 4. top_specialized_pairs
|
| 137 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
class TestTop:
|
| 141 |
+
def _matrix(self) -> dict:
|
| 142 |
+
return compute_specialization_matrix({
|
| 143 |
+
"a": {"x": 100, "y": 0},
|
| 144 |
+
"b": {"x": 0, "y": 100},
|
| 145 |
+
"c": {"x": 95, "y": 5},
|
| 146 |
+
})
|
| 147 |
+
|
| 148 |
+
def test_sorted_descending(self) -> None:
|
| 149 |
+
pairs = top_specialized_pairs(self._matrix(), n=10)
|
| 150 |
+
scores = [p["score"] for p in pairs]
|
| 151 |
+
assert scores == sorted(scores, reverse=True)
|
| 152 |
+
|
| 153 |
+
def test_caps_at_n(self) -> None:
|
| 154 |
+
pairs = top_specialized_pairs(self._matrix(), n=1)
|
| 155 |
+
assert len(pairs) == 1
|
| 156 |
+
|
| 157 |
+
def test_min_score_filter(self) -> None:
|
| 158 |
+
pairs = top_specialized_pairs(
|
| 159 |
+
self._matrix(), n=10, min_score=0.99,
|
| 160 |
+
)
|
| 161 |
+
# Seules les paires (a,b) et éventuellement (b,c) au-dessus
|
| 162 |
+
assert all(p["score"] >= 0.99 for p in pairs)
|
| 163 |
+
|
| 164 |
+
def test_none_input_returns_empty(self) -> None:
|
| 165 |
+
assert top_specialized_pairs(None) == []
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 169 |
+
# 5. Vue HTML
|
| 170 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class TestRender:
|
| 174 |
+
def test_empty_returns_empty(self) -> None:
|
| 175 |
+
assert build_specialization_html(None) == ""
|
| 176 |
+
assert build_specialization_html({}) == ""
|
| 177 |
+
|
| 178 |
+
def test_single_engine_returns_empty(self) -> None:
|
| 179 |
+
assert build_specialization_html({"a": {"x": 1}}) == ""
|
| 180 |
+
|
| 181 |
+
def test_renders_table(self) -> None:
|
| 182 |
+
tax = {
|
| 183 |
+
"tess": {"visual_confusion": 80, "lacuna": 20},
|
| 184 |
+
"pero": {"visual_confusion": 5, "lacuna": 95},
|
| 185 |
+
}
|
| 186 |
+
html = build_specialization_html(tax, _load_labels("fr"))
|
| 187 |
+
assert "<table" in html
|
| 188 |
+
assert "tess" in html
|
| 189 |
+
assert "pero" in html
|
| 190 |
+
# Catégorie traduite
|
| 191 |
+
assert "Forte spécialisation" in html
|
| 192 |
+
|
| 193 |
+
def test_anti_injection(self) -> None:
|
| 194 |
+
tax = {
|
| 195 |
+
"<script>alert(1)</script>": {"x": 100},
|
| 196 |
+
"pero": {"y": 100},
|
| 197 |
+
}
|
| 198 |
+
html = build_specialization_html(tax, _load_labels("fr"))
|
| 199 |
+
assert "<script>alert" not in html
|
| 200 |
+
assert "<script>" in html
|
| 201 |
+
|
| 202 |
+
def test_renders_in_english(self) -> None:
|
| 203 |
+
tax = {
|
| 204 |
+
"a": {"x": 100, "y": 0},
|
| 205 |
+
"b": {"x": 0, "y": 100},
|
| 206 |
+
}
|
| 207 |
+
html = build_specialization_html(tax, _load_labels("en"))
|
| 208 |
+
assert "Inter-engine specialisation" in html
|
| 209 |
+
assert "Highly specialised" in html
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 213 |
+
# 6. Complétude i18n
|
| 214 |
+
# ──────────────────────────────────────────────────────────────────────────
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
_KEYS = {
|
| 218 |
+
"specialization_title", "specialization_note",
|
| 219 |
+
"specialization_engine_a", "specialization_engine_b",
|
| 220 |
+
"specialization_score", "specialization_category",
|
| 221 |
+
"specialization_cat_similar", "specialization_cat_distinct",
|
| 222 |
+
"specialization_cat_highly_specialized",
|
| 223 |
+
}
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
class TestI18n:
|
| 227 |
+
def test_fr(self) -> None:
|
| 228 |
+
d = _load_labels("fr")
|
| 229 |
+
assert not _KEYS - d.keys()
|
| 230 |
+
|
| 231 |
+
def test_en(self) -> None:
|
| 232 |
+
d = _load_labels("en")
|
| 233 |
+
assert not _KEYS - d.keys()
|