Claude commited on
Commit
ae79fd3
ยท
unverified ยท
1 Parent(s): d816883

sprint80: sur-normalisation lexicale (A.I.7 couche calcul + HTML)

Browse files

Le dรฉtecteur llm_hallucination_flag (Sprint 19) signale via un
score agrรฉgรฉ mais ne dit pas QUOI corriger dans le prompt. Ce
sprint produit une table de frรฉquences dรฉtaillรฉe par token GT.

- Nouveau module picarones/core/lexical_modernization.py :
- compute_lexical_modernization(reference, hypothesis, stop_list,
case_sensitive) aligne mot-ร -mot via difflib et accumule par
token GT {n_total, n_modernized, rate_modernized, variants}.
- aggregate_lexical_modernization corpus-wide.
- top_modernized_tokens(n=20, min_total=1) tri descendant rate
avec filtre anecdotiques.
- Stop-list paramรฉtrable (par dรฉfaut vide).
- Suppression GT โ†’ variant โˆ….
- Nouveau module picarones/report/lexical_modernization_render.py :
- build_lexical_modernization_html : tableau 4 colonnes (forme
GT, variantes top-3, n GT, % gradient blancโ†’orange).
- Adaptive : "" si data None ou aucun modernisรฉ.
- +6 clรฉs i18n FR/EN.
- +20 tests dans test_sprint80_lexical_modernization.py.

Verrou levรฉ : ยซ maistre โ†’ maรฎtre modernisรฉ dans 100 % des cas ยป
permet d'ajuster le prompt โ€” info exploitable au lieu d'un score
agrรฉgรฉ abstrait.

Tests : 2699 passed, 2 skipped, 0 failed.

https://claude.ai/code/session_01RusTQYcSfXqTsbFNvwmCV7

CHANGELOG.md CHANGED
@@ -16,6 +16,49 @@ La numรฉrotation de version suit [Semantic Versioning](https://semver.org/lang/f
16
 
17
  ### Ajoutรฉ
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  - **Sprint 79 โ€” A.I.6 : projection de coรปt en volume cible
20
  (couche de calcul).** La vue Pareto (Sprint 20) trace CER vs
21
  coรปt mais le coรปt est par unitรฉ (1 000 pages). Pour dรฉcider
 
16
 
17
  ### Ajoutรฉ
18
 
19
+ - **Sprint 80 โ€” A.I.7 : sur-normalisation lexicale en vue
20
+ analytique dรฉdiรฉe (couche calcul + table HTML).** Le dรฉtecteur
21
+ ``llm_hallucination_flag`` (Sprint 19) signale qu'un moteur
22
+ sur-normalise via un score agrรฉgรฉ. Mais ce score ne dit rien
23
+ sur **quoi** corriger dans le prompt. Ce sprint produit une
24
+ **table de frรฉquences dรฉtaillรฉe** par token GT.
25
+ - Nouveau module `picarones/core/lexical_modernization.py` :
26
+ - ``compute_lexical_modernization(reference, hypothesis,
27
+ stop_list, case_sensitive)`` aligne mot-ร -mot via
28
+ ``difflib.SequenceMatcher`` et accumule par token GT :
29
+ ``{n_total, n_modernized, rate_modernized, variants}``.
30
+ - ``aggregate_lexical_modernization(per_doc_results)`` somme
31
+ les compteurs corpus-wide.
32
+ - ``top_modernized_tokens(data, n=20, min_total=1)`` retourne
33
+ les N tokens GT les plus modernisรฉs (tri dรฉcroissant par
34
+ taux, tie-break par n_total). Filtre les anecdotiques
35
+ via ``min_total``.
36
+ - Stop-list paramรฉtrable (tokens GT ร  ignorer mรชme s'ils
37
+ sont modifiรฉs) โ€” par dรฉfaut vide, le module ne devine pas
38
+ ce qui est ยซ moderne ยป.
39
+ - Cas particuliers : token GT supprimรฉ โ†’ variant ``โˆ…``.
40
+ - Nouveau module `picarones/report/lexical_modernization_render.py` :
41
+ - ``build_lexical_modernization_html(data, labels, top_n,
42
+ min_total)`` produit un tableau HTML 4 colonnes (forme
43
+ historique GT, variantes OCR, n GT, % modernisรฉ).
44
+ - Cellule ``% modernisรฉ`` colorรฉe en gradient blanc โ†’ orange.
45
+ - Compactage des variants : top 3 affichรฉs + ``+N`` pour le
46
+ reste.
47
+ - Adaptive : ``""`` si ``data is None`` ou aucun token
48
+ modernisรฉ.
49
+ - +6 clรฉs i18n FR/EN (``lexmod_*``).
50
+ - +20 tests dans `test_sprint80_lexical_modernization.py` :
51
+ couche calcul (9 cas โ€” systรฉmatique, prรฉservรฉ, partiel,
52
+ multi-variants, stop-list, casse, suppression, vide, None) ;
53
+ agrรฉgation (2 cas) ; top (2 cas โ€” tri, min_total) ; rendu
54
+ (5 cas โ€” None, no_modernization, table, %, anti-injection) ;
55
+ complรฉtude i18n FR + EN.
56
+ - **Verrou levรฉ** : le chercheur peut dรฉsormais lire ยซ maistre
57
+ โ†’ maรฎtre modernisรฉ dans 100 % des cas ยป et ajuster son prompt
58
+ en consรฉquence pour prรฉserver l'orthographe historique.
59
+ L'information est exploitable au lieu d'un score agrรฉgรฉ
60
+ abstrait.
61
+
62
  - **Sprint 79 โ€” A.I.6 : projection de coรปt en volume cible
63
  (couche de calcul).** La vue Pareto (Sprint 20) trace CER vs
64
  coรปt mais le coรปt est par unitรฉ (1 000 pages). Pour dรฉcider
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
  | 79 | **Sprint 48 du plan d'รฉvolution 2026 โ€” A.I.6 : projection de coรปt en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coรปt mais le coรปt est par unitรฉ (1 000 pages) ; payer 50 โ‚ฌ de plus sur 50 pages est trivial, sur 5 millions รงa change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linรฉaire en pages avec `None` si donnรฉes insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans donnรฉes conservรฉs avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, COโ‚‚ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas rรฉaliste BnF 80 000 pages BMS** Tesseract=3.20โ‚ฌ/Pero=0โ‚ฌ/Mistral=280โ‚ฌ/GPT-4o=600โ‚ฌ). **Verrou levรฉ** : couche calcul prรชte pour cรขbler le panneau ยซ Avancรฉ ยป avec champ ยซ Volume cible ยป qui recalcule Pareto et table coรปt en valeur totale projetรฉe. UX HTML suivra. |
211
  | 78 | **Sprint 47 du plan d'รฉvolution 2026 โ€” A.I.5 : รฉquivalences diplomatiques en curseur fin (couche de calcul)**. Les profils `DIPLOMATIC_*` de `normalization.py` appliquent un bloc entier ; un รฉditeur peut vouloir nuancer (ยซ je tolรจre ลฟโ†’s mais pas uโ†’v ยป). Nouveau module `picarones/core/equivalence_profile.py` : dataclass `EquivalenceRule(name, source, target, description, profile_tag)`, catalogue `BUILTIN_EQUIVALENCES` dรฉrivรฉ automatiquement des 4 profils intรฉgrรฉs avec 15 rรจgles canoniques nommรฉes (`longs_s`, `u_eq_v`, `i_eq_j`, `ae_ligature`, `thorn_th`, `vv_eq_w`, etc.), `list_equivalences_by_profile`, `apply_selected_equivalences(text, selected_names)` (rรจgles inconnues ignorรฉes + warning, texte vide โ†’ ""), `compute_cer_with_equivalences(reference, hypothesis, selected_names)` qui applique les deux cรดtรฉs puis renvoie CER. Aucune modification de normalization.py โ€” purement additif. +17 tests (catalogue 4 cas, liste 3 cas, apply 6 cas dont sรฉlectif/exclu/multi/inconnue, compute_cer 4 cas dont application bilatรฉrale). **Verrou levรฉ** : la couche calcul est prรชte pour cรขbler le panneau ยซ Avancรฉ ยป du rapport avec cases ร  cocher granulaires et recalcul JS client. UX (URL state + debounce) suivra dans un sprint dรฉdiรฉ. |
212
  | 77 | **Sprint 46 du plan d'รฉvolution 2026 โ€” A.I.4 chantier 3 : taxonomie comparative cรดte-ร -cรดte (clรดture A.I.4)**. Troisiรจme chantier d'A.I.4. Rรฉpond ร  ยซ deux moteurs ont le mรชme CER global, mais lequel fait des erreurs plus rรฉcupรฉrables ? ยป. Nouveau module `picarones/core/taxonomy_comparison.py` : `compare_taxonomies(engine_a, counts_a, engine_b, counts_b)` normalise en proportions, calcule deltas signรฉs, agrรจge par niveau de **rรฉcupรฉrabilitรฉ รฉditoriale** (recoverable: case/ligature/abbreviation ; difficult: diacritic/visual/hapax ; irrecoverable: lacuna/oov/segmentation). Constante `RECOVERABILITY` exportรฉe. Retourne None si vide. Nouveau module `picarones/report/taxonomy_comparison_render.py` : `build_taxonomy_comparison_html` produit titre + note + diagramme miroir SVG + tableau rรฉsumรฉ par catรฉgorie. `_build_mirror_chart_svg` server-side : ligne par classe, barres horizontales A ร  gauche / B ร  droite, รฉtiquette au centre, %, couleur selon rรฉcupรฉrabilitรฉ (vert/orange/rouge), รฉchelle normalisรฉe. `_build_recoverability_summary_html` : tableau 3ร—2 avec pastilles colorรฉes. Adaptive : "" si None ou pas de classes. +6 clรฉs i18n FR/EN. +18 tests (calcul 7 cas dont sanitรฉ RECOVERABILITY couvre ERROR_CLASSES, rendu 7 cas, anti-injection, i18n). **Choix รฉditorial assumรฉ** : classification recoverable/difficult/irrecoverable est un guide pragmatique, pas un verdict โ€” note explicative dit ยซ ร  CER รฉgal, un moteur dont les erreurs sont majoritairement vertes est prรฉfรฉrable pour une รฉdition critique ยป. **A.I.4 livrรฉ bout-en-bout** (Sprints 75-77). |
@@ -297,7 +298,7 @@ au template `_narrative_summary.html` (placรฉ entre `_header.html` et `_critical
297
  ## Contexte dรฉveloppement
298
 
299
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
300
- - **Tests** : 2679 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 โ€” projection de coรปt sur volume cible**)
301
  - **Plan d'รฉvolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
302
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
303
  - **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
+ | 80 | **Sprint 49 du plan d'รฉvolution 2026 โ€” A.I.7 : sur-normalisation lexicale (couche calcul + table HTML)**. Le dรฉtecteur `llm_hallucination_flag` (Sprint 19) signale via un score agrรฉgรฉ mais ne dit pas **quoi** corriger dans le prompt. Nouveau module `picarones/core/lexical_modernization.py` : `compute_lexical_modernization(reference, hypothesis, stop_list, case_sensitive)` aligne mot-ร -mot via `difflib.SequenceMatcher` et accumule par token GT `{n_total, n_modernized, rate_modernized, variants}` ; `aggregate_lexical_modernization` somme corpus-wide ; `top_modernized_tokens(data, n=20, min_total=1)` retourne les N tokens GT les plus modernisรฉs (tri dรฉcroissant par taux, tie-break par n_total, filtre anecdotiques via min_total). Stop-list paramรฉtrable (par dรฉfaut vide). Suppression GT โ†’ variant โˆ…. Nouveau module `picarones/report/lexical_modernization_render.py` : `build_lexical_modernization_html(data, labels, top_n, min_total)` tableau 4 colonnes (forme GT, variantes OCR top-3, n GT, % modernisรฉ gradient blancโ†’orange). Adaptive : "" si data None ou aucun modernisรฉ. +6 clรฉs i18n FR/EN. +20 tests (calcul 9 cas dont systรฉmatique/prรฉservรฉ/partiel/multi-variants/stop-list/casse/suppression/vide, agrรฉgation 2 cas, top 2 cas, rendu 5 cas dont anti-injection, complรฉtude i18n). **Verrou levรฉ** : le chercheur lit ยซ maistre โ†’ maรฎtre modernisรฉ dans 100 % des cas ยป et ajuste son prompt โ€” info exploitable au lieu d'un score agrรฉgรฉ. |
211
  | 79 | **Sprint 48 du plan d'รฉvolution 2026 โ€” A.I.6 : projection de coรปt en volume cible (couche de calcul)**. La vue Pareto (Sprint 20) trace CER vs coรปt mais le coรปt est par unitรฉ (1 000 pages) ; payer 50 โ‚ฌ de plus sur 50 pages est trivial, sur 5 millions รงa change tout. Nouveau module `picarones/core/cost_projection.py` : `ProjectedCost(engine_key, target_pages, cost_total_eur, co2_total_g, cost_per_1k_pages_eur, co2_per_1k_pages_g, type)`, `project_cost_total/co2_total` linรฉaire en pages avec `None` si donnรฉes insuffisantes ou target<0, `project_engine` retourne le ProjectedCost complet, `project_all_engines(engine_costs, target_pages)` projette N moteurs (ValueError si target<0, moteurs sans donnรฉes conservรฉs avec cost_total=None), `cost_gap_table(projections, baseline)` retourne `{engine: {total, delta_abs, delta_rel}}` vs baseline (KeyError si baseline inconnue, delta_rel=None si baseline=0). +17 tests (calcul 5 cas, COโ‚‚ 2 cas, engine 2 cas, all_engines 3 cas, gap_table 4 cas, **cas rรฉaliste BnF 80 000 pages BMS** Tesseract=3.20โ‚ฌ/Pero=0โ‚ฌ/Mistral=280โ‚ฌ/GPT-4o=600โ‚ฌ). **Verrou levรฉ** : couche calcul prรชte pour cรขbler le panneau ยซ Avancรฉ ยป avec champ ยซ Volume cible ยป qui recalcule Pareto et table coรปt en valeur totale projetรฉe. UX HTML suivra. |
212
  | 78 | **Sprint 47 du plan d'รฉvolution 2026 โ€” A.I.5 : รฉquivalences diplomatiques en curseur fin (couche de calcul)**. Les profils `DIPLOMATIC_*` de `normalization.py` appliquent un bloc entier ; un รฉditeur peut vouloir nuancer (ยซ je tolรจre ลฟโ†’s mais pas uโ†’v ยป). Nouveau module `picarones/core/equivalence_profile.py` : dataclass `EquivalenceRule(name, source, target, description, profile_tag)`, catalogue `BUILTIN_EQUIVALENCES` dรฉrivรฉ automatiquement des 4 profils intรฉgrรฉs avec 15 rรจgles canoniques nommรฉes (`longs_s`, `u_eq_v`, `i_eq_j`, `ae_ligature`, `thorn_th`, `vv_eq_w`, etc.), `list_equivalences_by_profile`, `apply_selected_equivalences(text, selected_names)` (rรจgles inconnues ignorรฉes + warning, texte vide โ†’ ""), `compute_cer_with_equivalences(reference, hypothesis, selected_names)` qui applique les deux cรดtรฉs puis renvoie CER. Aucune modification de normalization.py โ€” purement additif. +17 tests (catalogue 4 cas, liste 3 cas, apply 6 cas dont sรฉlectif/exclu/multi/inconnue, compute_cer 4 cas dont application bilatรฉrale). **Verrou levรฉ** : la couche calcul est prรชte pour cรขbler le panneau ยซ Avancรฉ ยป du rapport avec cases ร  cocher granulaires et recalcul JS client. UX (URL state + debounce) suivra dans un sprint dรฉdiรฉ. |
213
  | 77 | **Sprint 46 du plan d'รฉvolution 2026 โ€” A.I.4 chantier 3 : taxonomie comparative cรดte-ร -cรดte (clรดture A.I.4)**. Troisiรจme chantier d'A.I.4. Rรฉpond ร  ยซ deux moteurs ont le mรชme CER global, mais lequel fait des erreurs plus rรฉcupรฉrables ? ยป. Nouveau module `picarones/core/taxonomy_comparison.py` : `compare_taxonomies(engine_a, counts_a, engine_b, counts_b)` normalise en proportions, calcule deltas signรฉs, agrรจge par niveau de **rรฉcupรฉrabilitรฉ รฉditoriale** (recoverable: case/ligature/abbreviation ; difficult: diacritic/visual/hapax ; irrecoverable: lacuna/oov/segmentation). Constante `RECOVERABILITY` exportรฉe. Retourne None si vide. Nouveau module `picarones/report/taxonomy_comparison_render.py` : `build_taxonomy_comparison_html` produit titre + note + diagramme miroir SVG + tableau rรฉsumรฉ par catรฉgorie. `_build_mirror_chart_svg` server-side : ligne par classe, barres horizontales A ร  gauche / B ร  droite, รฉtiquette au centre, %, couleur selon rรฉcupรฉrabilitรฉ (vert/orange/rouge), รฉchelle normalisรฉe. `_build_recoverability_summary_html` : tableau 3ร—2 avec pastilles colorรฉes. Adaptive : "" si None ou pas de classes. +6 clรฉs i18n FR/EN. +18 tests (calcul 7 cas dont sanitรฉ RECOVERABILITY couvre ERROR_CLASSES, rendu 7 cas, anti-injection, i18n). **Choix รฉditorial assumรฉ** : classification recoverable/difficult/irrecoverable est un guide pragmatique, pas un verdict โ€” note explicative dit ยซ ร  CER รฉgal, un moteur dont les erreurs sont majoritairement vertes est prรฉfรฉrable pour une รฉdition critique ยป. **A.I.4 livrรฉ bout-en-bout** (Sprints 75-77). |
 
298
  ## Contexte dรฉveloppement
299
 
300
  - **Environnement** : GitHub Codespaces (`/workspaces/Picarones`), Python 3.12
301
+ - **Tests** : 2699 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 โ€” sur-normalisation lexicale couche calcul + table HTML**)
302
  - **Plan d'รฉvolution actif** : [`docs/roadmap/evolution-2026.md`](docs/roadmap/evolution-2026.md)
303
  - **Branche active** : `claude/analyze-project-evolution-KOA56`
304
  - **Transcript de la conversation de dรฉveloppement** :
picarones/core/lexical_modernization.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dรฉtection de la sur-normalisation lexicale par les LLM/VLM โ€”
2
+ Sprint 80 (A.I.7).
3
+
4
+ Sprint 80 โ€” A.I.7 du plan d'รฉvolution 2026.
5
+
6
+ Pourquoi ce module
7
+ ------------------
8
+ Le dรฉtecteur ``llm_hallucination_flag`` (Sprint 19) signale qu'un
9
+ moteur sur-normalise (ยซ 0,05 % ยป). Mais ce score agrรฉgรฉ ne dit
10
+ rien sur **quoi** corriger dans le prompt. Ce module produit
11
+ une **table de frรฉquences dรฉtaillรฉe** :
12
+
13
+ +----------------------+--------------------+------+----------+
14
+ | Forme historique GT | Forme modernisรฉe | n GT | % modern |
15
+ +======================+====================+======+==========+
16
+ | maistre | maรฎtre | 47 | 85 % |
17
+ | nostre | nostre | 92 | 8 % |
18
+ | veoir | voir | 23 | 100 % |
19
+ +----------------------+--------------------+------+----------+
20
+
21
+ Lecture immรฉdiate : *ยซ le LLM modernise systรฉmatiquement
22
+ maistre โ†’ maรฎtre ; pour prรฉserver l'orthographe historique, ajouter
23
+ au prompt "ne pas moderniser maistre, nostre, veoir" ยป*.
24
+
25
+ Mรฉthode
26
+ -------
27
+ Alignement mot-ร -mot via ``difflib.SequenceMatcher``. Chaque
28
+ ``replace`` ou ``equal`` produit une paire ``(gt_token,
29
+ hyp_token)``. On accumule pour chaque ``gt_token`` :
30
+
31
+ - ``n_total`` : nombre d'occurrences du token dans la GT
32
+ - ``n_modernized`` : nombre d'occurrences oรน ``hyp_token != gt_token``
33
+ - ``variants`` : dict des hyp_tokens observรฉs avec leur count
34
+
35
+ Stop-list
36
+ ---------
37
+ L'utilisateur peut passer ``stop_list`` (ensemble de tokens GT ร 
38
+ ignorer). Par dรฉfaut, vide โ€” le module ne tente pas de deviner ce
39
+ qui est ยซ moderne ยป ou ยซ historique ยป, c'est au chercheur de
40
+ fournir le filtre adaptรฉ ร  son corpus.
41
+
42
+ Sortie
43
+ ------
44
+ ``compute_lexical_modernization`` retourne une structure adaptรฉe
45
+ au rendu HTML. ``aggregate_lexical_modernization`` agrรจge
46
+ plusieurs documents.
47
+
48
+ Limites documentรฉes
49
+ -------------------
50
+ - Tokenisation au niveau mot (split sur espace) โ€” cohรฉrent avec
51
+ ``taxonomy.py`` et autres modules. Pas de stemming ni de
52
+ lemmatisation.
53
+ - La mรฉtrique mesure la **rรฉรฉcriture lexicale** ; elle n'attrape
54
+ pas les modernisations infra-mot (perte du s long ลฟ qui se
55
+ fond dans la mรชme forme). Pour รงa, voir ``early_modern_typography``
56
+ (Sprint 58) et ``equivalence_profile`` (Sprint 78).
57
+ """
58
+
59
+ from __future__ import annotations
60
+
61
+ import difflib
62
+ import logging
63
+ from typing import Iterable, Optional
64
+
65
+ logger = logging.getLogger(__name__)
66
+
67
+
68
+ def _split_words(text: Optional[str]) -> list[str]:
69
+ """Tokenisation simple par split sur whitespace."""
70
+ if not text:
71
+ return []
72
+ return text.split()
73
+
74
+
75
+ def compute_lexical_modernization(
76
+ reference: Optional[str],
77
+ hypothesis: Optional[str],
78
+ *,
79
+ stop_list: Optional[Iterable[str]] = None,
80
+ case_sensitive: bool = False,
81
+ ) -> dict:
82
+ """Calcule le tableau de modernisation lexicale pour un document.
83
+
84
+ Returns
85
+ -------
86
+ dict
87
+ ``{
88
+ "n_gt_tokens": int,
89
+ "tokens": {
90
+ gt_token: {
91
+ "n_total": int,
92
+ "n_modernized": int,
93
+ "rate_modernized": float, # โˆˆ [0, 1]
94
+ "variants": {hyp_token: count, ...},
95
+ },
96
+ ...
97
+ },
98
+ }``
99
+ Si ``reference`` est vide โ†’ ``tokens == {}``.
100
+ """
101
+ ref_tokens = _split_words(reference)
102
+ hyp_tokens = _split_words(hypothesis)
103
+ if not ref_tokens:
104
+ return {"n_gt_tokens": 0, "tokens": {}}
105
+
106
+ if not case_sensitive:
107
+ ref_for_match = [t.lower() for t in ref_tokens]
108
+ hyp_for_match = [t.lower() for t in hyp_tokens]
109
+ else:
110
+ ref_for_match = ref_tokens
111
+ hyp_for_match = hyp_tokens
112
+
113
+ stop = frozenset(
114
+ (t.lower() if not case_sensitive else t)
115
+ for t in (stop_list or [])
116
+ )
117
+
118
+ # On accumule par gt_token (forme display = forme originale,
119
+ # match key = forme casรฉe selon ``case_sensitive``).
120
+ tokens_data: dict[str, dict] = {}
121
+
122
+ matcher = difflib.SequenceMatcher(
123
+ None, ref_for_match, hyp_for_match, autojunk=False,
124
+ )
125
+ for tag, i1, i2, j1, j2 in matcher.get_opcodes():
126
+ if tag == "equal":
127
+ for k in range(i2 - i1):
128
+ gt_orig = ref_tokens[i1 + k]
129
+ gt_match = ref_for_match[i1 + k]
130
+ if gt_match in stop:
131
+ continue
132
+ slot = tokens_data.setdefault(
133
+ gt_orig,
134
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
135
+ )
136
+ slot["n_total"] += 1
137
+ elif tag == "replace":
138
+ # Apparier 1-ร -1 quand possible
139
+ paired = min(i2 - i1, j2 - j1)
140
+ for k in range(paired):
141
+ gt_orig = ref_tokens[i1 + k]
142
+ gt_match = ref_for_match[i1 + k]
143
+ if gt_match in stop:
144
+ continue
145
+ hyp_orig = hyp_tokens[j1 + k]
146
+ slot = tokens_data.setdefault(
147
+ gt_orig,
148
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
149
+ )
150
+ slot["n_total"] += 1
151
+ slot["n_modernized"] += 1
152
+ slot["variants"][hyp_orig] = slot["variants"].get(hyp_orig, 0) + 1
153
+ # Si plus de gt que de hyp, le reste des gt_tokens est
154
+ # ยซ perdu ยป โ€” on les compte comme totaux mais pas comme
155
+ # modernisรฉs (on ne sait pas en quoi).
156
+ for k in range(paired, i2 - i1):
157
+ gt_orig = ref_tokens[i1 + k]
158
+ gt_match = ref_for_match[i1 + k]
159
+ if gt_match in stop:
160
+ continue
161
+ slot = tokens_data.setdefault(
162
+ gt_orig,
163
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
164
+ )
165
+ slot["n_total"] += 1
166
+ slot["n_modernized"] += 1
167
+ slot["variants"]["โˆ…"] = slot["variants"].get("โˆ…", 0) + 1
168
+ elif tag == "delete":
169
+ # gt prรฉsent, pas en hyp โ†’ modernisation par
170
+ # suppression (ou perte pure)
171
+ for k in range(i2 - i1):
172
+ gt_orig = ref_tokens[i1 + k]
173
+ gt_match = ref_for_match[i1 + k]
174
+ if gt_match in stop:
175
+ continue
176
+ slot = tokens_data.setdefault(
177
+ gt_orig,
178
+ {"n_total": 0, "n_modernized": 0, "variants": {}},
179
+ )
180
+ slot["n_total"] += 1
181
+ slot["n_modernized"] += 1
182
+ slot["variants"]["โˆ…"] = slot["variants"].get("โˆ…", 0) + 1
183
+
184
+ # Calcul du taux par token
185
+ for slot in tokens_data.values():
186
+ total = slot["n_total"]
187
+ slot["rate_modernized"] = (
188
+ slot["n_modernized"] / total if total > 0 else 0.0
189
+ )
190
+
191
+ return {
192
+ "n_gt_tokens": len(ref_tokens),
193
+ "tokens": tokens_data,
194
+ }
195
+
196
+
197
+ def aggregate_lexical_modernization(
198
+ per_doc_results: Iterable[dict],
199
+ ) -> dict:
200
+ """Agrรจge des ``compute_lexical_modernization`` per-doc.
201
+
202
+ Renvoie la structure agrรฉgรฉe corpus-wide avec la mรชme forme
203
+ que ``compute_lexical_modernization``.
204
+ """
205
+ agg_tokens: dict[str, dict] = {}
206
+ n_gt_total = 0
207
+ for doc_result in per_doc_results:
208
+ if not doc_result:
209
+ continue
210
+ n_gt_total += doc_result.get("n_gt_tokens", 0)
211
+ for gt, data in (doc_result.get("tokens") or {}).items():
212
+ slot = agg_tokens.setdefault(
213
+ gt, {"n_total": 0, "n_modernized": 0, "variants": {}},
214
+ )
215
+ slot["n_total"] += data.get("n_total", 0)
216
+ slot["n_modernized"] += data.get("n_modernized", 0)
217
+ for hyp_t, count in (data.get("variants") or {}).items():
218
+ slot["variants"][hyp_t] = slot["variants"].get(hyp_t, 0) + count
219
+
220
+ for slot in agg_tokens.values():
221
+ total = slot["n_total"]
222
+ slot["rate_modernized"] = (
223
+ slot["n_modernized"] / total if total > 0 else 0.0
224
+ )
225
+ return {
226
+ "n_gt_tokens": n_gt_total,
227
+ "tokens": agg_tokens,
228
+ }
229
+
230
+
231
+ def top_modernized_tokens(
232
+ data: dict,
233
+ *,
234
+ n: int = 20,
235
+ min_total: int = 1,
236
+ ) -> list[tuple[str, dict]]:
237
+ """Top-N tokens GT par taux de modernisation.
238
+
239
+ Filtre les tokens dont ``n_total < min_total`` (anecdotiques).
240
+ Tri par ``rate_modernized`` dรฉcroissant, tie-break par
241
+ ``n_total`` dรฉcroissant.
242
+ """
243
+ tokens = data.get("tokens") or {}
244
+ candidates = [
245
+ (gt, slot) for gt, slot in tokens.items()
246
+ if slot.get("n_total", 0) >= min_total
247
+ and slot.get("n_modernized", 0) > 0
248
+ ]
249
+ candidates.sort(
250
+ key=lambda pair: (
251
+ -pair[1].get("rate_modernized", 0.0),
252
+ -pair[1].get("n_total", 0),
253
+ pair[0],
254
+ ),
255
+ )
256
+ return candidates[:n]
257
+
258
+
259
+ __all__ = [
260
+ "compute_lexical_modernization",
261
+ "aggregate_lexical_modernization",
262
+ "top_modernized_tokens",
263
+ ]
picarones/report/i18n/en.json CHANGED
@@ -256,5 +256,11 @@
256
  "taxocomp_level_label": "Category",
257
  "taxocomp_recoverable": "Recoverable",
258
  "taxocomp_difficult": "Difficult",
259
- "taxocomp_irrecoverable": "Irrecoverable"
 
 
 
 
 
 
260
  }
 
256
  "taxocomp_level_label": "Category",
257
  "taxocomp_recoverable": "Recoverable",
258
  "taxocomp_difficult": "Difficult",
259
+ "taxocomp_irrecoverable": "Irrecoverable",
260
+ "lexmod_title": "Lexical modernization (top tokens)",
261
+ "lexmod_note": "GT tokens that the engine rewrites most often. Reading: ยซ maistre โ†’ maรฎtre modernized in 85 % of cases ยป tells you what to fix in the prompt to preserve historical spelling.",
262
+ "lexmod_gt_label": "Historical GT form",
263
+ "lexmod_hyp_label": "OCR variants",
264
+ "lexmod_n_label": "n GT",
265
+ "lexmod_rate_label": "% modernized"
266
  }
picarones/report/i18n/fr.json CHANGED
@@ -256,5 +256,11 @@
256
  "taxocomp_level_label": "Catรฉgorie",
257
  "taxocomp_recoverable": "Rรฉcupรฉrable",
258
  "taxocomp_difficult": "Difficile",
259
- "taxocomp_irrecoverable": "Irrรฉcupรฉrable"
 
 
 
 
 
 
260
  }
 
256
  "taxocomp_level_label": "Catรฉgorie",
257
  "taxocomp_recoverable": "Rรฉcupรฉrable",
258
  "taxocomp_difficult": "Difficile",
259
+ "taxocomp_irrecoverable": "Irrรฉcupรฉrable",
260
+ "lexmod_title": "Modernisation lexicale (top tokens)",
261
+ "lexmod_note": "Tokens GT que le moteur rรฉรฉcrit le plus souvent. Lecture : ยซ maistre โ†’ maรฎtre modernisรฉ dans 85 % des cas ยป indique de quoi corriger dans le prompt pour prรฉserver l'orthographe historique.",
262
+ "lexmod_gt_label": "Forme historique GT",
263
+ "lexmod_hyp_label": "Variantes OCR",
264
+ "lexmod_n_label": "n GT",
265
+ "lexmod_rate_label": "% modernisรฉ"
266
  }
picarones/report/lexical_modernization_render.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la vue ยซ Modernisation lexicale ยป โ€” Sprint 80.
2
+
3
+ A.I.7 du plan d'รฉvolution 2026.
4
+
5
+ Suite directe ``picarones/core/lexical_modernization.py``.
6
+ Pattern identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76/77) :
7
+ **server-side**, pas de JavaScript, anti-injection systรฉmatique.
8
+
9
+ Vue
10
+ ---
11
+ Tableau triรฉ par taux de modernisation dรฉcroissant : forme
12
+ historique GT โ†’ forme(s) modernisรฉe(s), occurrences GT, %.
13
+ Couleur de cellule pour le %.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from html import escape as _e
19
+ from typing import Optional
20
+
21
+ from picarones.core.lexical_modernization import top_modernized_tokens
22
+
23
+
24
+ def _color_for_rate(rate: float) -> str:
25
+ """Gradient blanc โ†’ orange profond pour rate โˆˆ [0, 1]."""
26
+ f = max(0.0, min(1.0, rate))
27
+ r = int(255 + (194 - 255) * f)
28
+ g = int(255 + (65 - 255) * f)
29
+ b = int(255 + (12 - 255) * f)
30
+ return f"#{r:02x}{g:02x}{b:02x}"
31
+
32
+
33
+ def _format_variants(variants: dict, max_show: int = 3) -> str:
34
+ """Liste compacte des variants modernisรฉs."""
35
+ items = sorted(variants.items(), key=lambda kv: -kv[1])
36
+ shown = items[:max_show]
37
+ rest = len(items) - max_show
38
+ parts = [
39
+ f"{_e(form)} ({count})"
40
+ for form, count in shown
41
+ ]
42
+ if rest > 0:
43
+ parts.append(f"+{rest}")
44
+ return ", ".join(parts)
45
+
46
+
47
+ def build_lexical_modernization_html(
48
+ data: Optional[dict],
49
+ labels: Optional[dict[str, str]] = None,
50
+ *,
51
+ top_n: int = 20,
52
+ min_total: int = 1,
53
+ ) -> str:
54
+ """Construit la table HTML de modernisation lexicale.
55
+
56
+ Retourne ``""`` si ``data is None`` ou si aucun token modernisรฉ.
57
+ """
58
+ if not data:
59
+ return ""
60
+ rows = top_modernized_tokens(data, n=top_n, min_total=min_total)
61
+ if not rows:
62
+ return ""
63
+ labels = labels or {}
64
+ title = labels.get(
65
+ "lexmod_title", "Modernisation lexicale (top tokens)",
66
+ )
67
+ note = labels.get(
68
+ "lexmod_note",
69
+ "Tokens GT que le moteur rรฉรฉcrit le plus souvent. "
70
+ "Lecture : ยซ maistre โ†’ maรฎtre modernisรฉ dans 85 % des cas ยป "
71
+ "indique de quoi corriger dans le prompt pour prรฉserver "
72
+ "l'orthographe historique.",
73
+ )
74
+ gt_label = labels.get("lexmod_gt_label", "Forme historique GT")
75
+ hyp_label = labels.get("lexmod_hyp_label", "Variantes OCR")
76
+ n_label = labels.get("lexmod_n_label", "n GT")
77
+ rate_label = labels.get("lexmod_rate_label", "% modernisรฉ")
78
+
79
+ parts = [
80
+ '<div class="lexmod" style="margin:1rem 0">',
81
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
82
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
83
+ f'{_e(note)}</div>',
84
+ '<table style="border-collapse:collapse;width:100%;'
85
+ 'font-size:.85rem">',
86
+ '<thead><tr>',
87
+ ]
88
+ for col in (gt_label, hyp_label, n_label, rate_label):
89
+ parts.append(
90
+ f'<th style="padding:.3rem .5rem;text-align:left;'
91
+ f'border-bottom:1px solid #ccc;font-weight:600">'
92
+ f'{_e(col)}</th>'
93
+ )
94
+ parts.append("</tr></thead><tbody>")
95
+ for gt_token, slot in rows:
96
+ rate = slot.get("rate_modernized", 0.0)
97
+ n_total = slot.get("n_total", 0)
98
+ variants_str = _format_variants(slot.get("variants") or {})
99
+ rate_color = _color_for_rate(rate)
100
+ parts.append(
101
+ f'<tr>'
102
+ f'<td style="padding:.3rem .5rem;font-family:monospace">'
103
+ f'{_e(gt_token)}</td>'
104
+ f'<td style="padding:.3rem .5rem;font-size:.85rem">'
105
+ f'{variants_str}</td>'
106
+ f'<td style="padding:.3rem .5rem;text-align:right;'
107
+ f'font-family:monospace">{n_total}</td>'
108
+ f'<td style="padding:.3rem .5rem;text-align:right;'
109
+ f'background:{rate_color};font-family:monospace">'
110
+ f'{rate * 100:.0f}%</td>'
111
+ f'</tr>'
112
+ )
113
+ parts.append("</tbody></table></div>")
114
+ return "".join(parts)
115
+
116
+
117
+ __all__ = [
118
+ "build_lexical_modernization_html",
119
+ ]
tests/test_sprint80_lexical_modernization.py ADDED
@@ -0,0 +1,242 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests Sprint 80 โ€” A.I.7 : sur-normalisation lexicale.
2
+
3
+ Couvre :
4
+
5
+ 1. ``compute_lexical_modernization`` :
6
+ - Token GT modernisรฉ systรฉmatiquement โ†’ 100 %
7
+ - Token GT prรฉservรฉ โ†’ 0 %
8
+ - Plusieurs variantes hyp pour un mรชme gt
9
+ - Stop-list filtre les tokens
10
+ - Casse insensible par dรฉfaut
11
+ - Token GT supprimรฉ (lacuna) โ†’ modernisรฉ vers โˆ…
12
+ - GT vide โ†’ tokens vide
13
+ 2. ``aggregate_lexical_modernization`` :
14
+ - Somme correcte sur N docs
15
+ 3. ``top_modernized_tokens`` :
16
+ - Tri dรฉcroissant par rate
17
+ - ``min_total`` filtre les anecdotiques
18
+ - Tokens ร  0 % exclus
19
+ 4. Rendu HTML :
20
+ - Tableau, ``""`` si data None ou aucun modernisรฉ
21
+ - Anti-injection
22
+ 5. Complรฉtude i18n FR/EN.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from pathlib import Path
29
+
30
+ from picarones.core.lexical_modernization import (
31
+ aggregate_lexical_modernization,
32
+ compute_lexical_modernization,
33
+ top_modernized_tokens,
34
+ )
35
+ from picarones.report.lexical_modernization_render import (
36
+ build_lexical_modernization_html,
37
+ )
38
+
39
+
40
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
41
+ # 1. compute_lexical_modernization
42
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
43
+
44
+
45
+ class TestCompute:
46
+ def test_systematic_modernization(self) -> None:
47
+ gt = "maistre maistre maistre"
48
+ hyp = "maรฎtre maรฎtre maรฎtre"
49
+ result = compute_lexical_modernization(gt, hyp)
50
+ slot = result["tokens"]["maistre"]
51
+ assert slot["n_total"] == 3
52
+ assert slot["n_modernized"] == 3
53
+ assert slot["rate_modernized"] == 1.0
54
+ assert slot["variants"] == {"maรฎtre": 3}
55
+
56
+ def test_preserved_token(self) -> None:
57
+ gt = "nostre nostre"
58
+ hyp = "nostre nostre"
59
+ result = compute_lexical_modernization(gt, hyp)
60
+ slot = result["tokens"]["nostre"]
61
+ assert slot["n_total"] == 2
62
+ assert slot["n_modernized"] == 0
63
+ assert slot["rate_modernized"] == 0.0
64
+
65
+ def test_partial_modernization(self) -> None:
66
+ gt = "maistre maistre maistre maistre"
67
+ hyp = "maรฎtre maistre maรฎtre maรฎtre"
68
+ result = compute_lexical_modernization(gt, hyp)
69
+ slot = result["tokens"]["maistre"]
70
+ assert slot["n_total"] == 4
71
+ assert slot["n_modernized"] == 3
72
+ assert slot["rate_modernized"] == 0.75
73
+
74
+ def test_multiple_variants(self) -> None:
75
+ gt = "veoir veoir veoir"
76
+ hyp = "voir voyr voir"
77
+ result = compute_lexical_modernization(gt, hyp)
78
+ slot = result["tokens"]["veoir"]
79
+ assert slot["n_total"] == 3
80
+ assert slot["n_modernized"] == 3
81
+ assert slot["variants"] == {"voir": 2, "voyr": 1}
82
+
83
+ def test_stop_list_filter(self) -> None:
84
+ gt = "maistre le veoir"
85
+ hyp = "maรฎtre la voir"
86
+ result = compute_lexical_modernization(
87
+ gt, hyp, stop_list=["le"],
88
+ )
89
+ # ยซ le ยป filtrรฉ, mais maistre et veoir prรฉsents
90
+ assert "le" not in result["tokens"]
91
+ assert "maistre" in result["tokens"]
92
+ assert "veoir" in result["tokens"]
93
+
94
+ def test_case_insensitive_default(self) -> None:
95
+ gt = "Maistre maistre"
96
+ hyp = "Maรฎtre maรฎtre"
97
+ result = compute_lexical_modernization(gt, hyp)
98
+ # Les deux formes sont distinctes en sortie display mais
99
+ # appariรฉes correctement en match
100
+ assert result["tokens"]["Maistre"]["n_modernized"] == 1
101
+ assert result["tokens"]["maistre"]["n_modernized"] == 1
102
+
103
+ def test_deletion_counted_as_modernized(self) -> None:
104
+ gt = "maistre veoir"
105
+ hyp = "maรฎtre" # veoir manque
106
+ result = compute_lexical_modernization(gt, hyp)
107
+ # veoir โ†’ โˆ… comptรฉ comme modernisรฉ
108
+ slot = result["tokens"]["veoir"]
109
+ assert slot["n_modernized"] == 1
110
+ assert "โˆ…" in slot["variants"]
111
+
112
+ def test_empty_gt(self) -> None:
113
+ result = compute_lexical_modernization("", "anything")
114
+ assert result["tokens"] == {}
115
+ assert result["n_gt_tokens"] == 0
116
+
117
+ def test_none_inputs(self) -> None:
118
+ result = compute_lexical_modernization(None, None)
119
+ assert result["tokens"] == {}
120
+
121
+
122
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
123
+ # 2. aggregate
124
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
125
+
126
+
127
+ class TestAggregate:
128
+ def test_sum_across_docs(self) -> None:
129
+ d1 = compute_lexical_modernization(
130
+ "maistre maistre", "maรฎtre maรฎtre",
131
+ )
132
+ d2 = compute_lexical_modernization(
133
+ "maistre", "maรฎtre",
134
+ )
135
+ agg = aggregate_lexical_modernization([d1, d2])
136
+ assert agg["tokens"]["maistre"]["n_total"] == 3
137
+ assert agg["tokens"]["maistre"]["n_modernized"] == 3
138
+ assert agg["tokens"]["maistre"]["rate_modernized"] == 1.0
139
+
140
+ def test_empty_iterable(self) -> None:
141
+ agg = aggregate_lexical_modernization([])
142
+ assert agg["tokens"] == {}
143
+ assert agg["n_gt_tokens"] == 0
144
+
145
+
146
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
147
+ # 3. top_modernized_tokens
148
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
149
+
150
+
151
+ class TestTop:
152
+ def test_sorted_by_rate_desc(self) -> None:
153
+ gt = "a a b b c c d d"
154
+ hyp = "x x y b z c d d"
155
+ # a: 100% (2/2 modernisรฉ), b: 50%, c: 50%, d: 0%
156
+ result = compute_lexical_modernization(gt, hyp)
157
+ top = top_modernized_tokens(result, n=10)
158
+ # a en premier
159
+ assert top[0][0] == "a"
160
+ # d exclu (0%)
161
+ names = [t[0] for t in top]
162
+ assert "d" not in names
163
+
164
+ def test_min_total_filter(self) -> None:
165
+ gt = "rare maistre maistre maistre"
166
+ hyp = "moderne maรฎtre maรฎtre maรฎtre"
167
+ result = compute_lexical_modernization(gt, hyp)
168
+ # Avec min_total=2 : rare (1) exclu, maistre (3) conservรฉ
169
+ top = top_modernized_tokens(result, min_total=2)
170
+ names = [t[0] for t in top]
171
+ assert "rare" not in names
172
+ assert "maistre" in names
173
+
174
+
175
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
176
+ # 4. Rendu HTML
177
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
178
+
179
+
180
+ class TestRender:
181
+ def test_returns_empty_when_none(self) -> None:
182
+ assert build_lexical_modernization_html(None) == ""
183
+
184
+ def test_returns_empty_when_no_modernizations(self) -> None:
185
+ result = compute_lexical_modernization("a b c", "a b c")
186
+ # Aucun modernisรฉ
187
+ assert build_lexical_modernization_html(result) == ""
188
+
189
+ def test_renders_table(self) -> None:
190
+ result = compute_lexical_modernization(
191
+ "maistre veoir", "maรฎtre voir",
192
+ )
193
+ html = build_lexical_modernization_html(result)
194
+ assert "<table" in html
195
+ assert "maistre" in html
196
+ assert "maรฎtre" in html
197
+
198
+ def test_rate_displayed_as_percent(self) -> None:
199
+ result = compute_lexical_modernization(
200
+ "maistre maistre maistre maistre",
201
+ "maรฎtre maistre maรฎtre maรฎtre",
202
+ )
203
+ html = build_lexical_modernization_html(result)
204
+ # 75% prรฉsent
205
+ assert "75%" in html
206
+
207
+ def test_anti_injection_token(self) -> None:
208
+ gt = "<script>alert(1)</script> normal"
209
+ hyp = "MODERNIZED normal"
210
+ result = compute_lexical_modernization(gt, hyp)
211
+ html = build_lexical_modernization_html(result)
212
+ assert "<script>alert" not in html
213
+ assert "&lt;script&gt;" in html
214
+
215
+
216
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
217
+ # 5. Complรฉtude i18n
218
+ # โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€
219
+
220
+
221
+ class TestI18nCompleteness:
222
+ def _load(self, lang: str) -> dict:
223
+ path = (
224
+ Path(__file__).parent.parent
225
+ / "picarones" / "report" / "i18n" / f"{lang}.json"
226
+ )
227
+ return json.loads(path.read_text(encoding="utf-8"))
228
+
229
+ def test_all_keys_fr(self) -> None:
230
+ d = self._load("fr")
231
+ for key in (
232
+ "lexmod_title", "lexmod_note", "lexmod_gt_label",
233
+ "lexmod_hyp_label", "lexmod_n_label", "lexmod_rate_label",
234
+ ):
235
+ assert key in d, f"manque clรฉ FR : {key}"
236
+
237
+ def test_all_keys_en(self) -> None:
238
+ d_fr = self._load("fr")
239
+ d_en = self._load("en")
240
+ for key in d_fr:
241
+ if key.startswith("lexmod_"):
242
+ assert key in d_en, f"manque clรฉ EN : {key}"