Claude commited on
Commit
4287328
·
unverified ·
1 Parent(s): 4eea41b

feat(migration): Phase 5.C batch 6 — levers + philological vers reports_v2/html/

Browse files

Sixième vague. Migre le plus gros renderer non-bloqué
(``philological``, 527 LOC) et ``levers`` (249 LOC).

Migrations effectuées
---------------------
| Source legacy | Destination canonique |
|---------------------------------------------|------------------------------------------------------|
| ``report/levers_render.py`` (249) | ``reports_v2/html/renderers/levers.py`` |
| ``report/philological_render.py`` (527) | ``reports_v2/html/renderers/philological.py`` |

Total : ~776 lignes relocalisées.

Adaptations transverses
-----------------------
- ``test_sprint82_levers.py`` : monkeypatch sur ``_FORMATTERS``
pointe désormais vers le module canonique
``picarones.reports_v2.html.renderers.levers``.
- ``test_file_budgets.py`` : entrée
``report/philological_render.py`` retirée, remplacée par
``reports_v2/html/renderers/philological.py``.

Cumul Phase 5.C (batches 1-6) : 27 / 29 renderers migrés
(~5232 lignes). 2 renderers restants pour batch 7 :
``pipeline_render`` (707 l) et ``numerical_sequences_render`` (149 l).
Pré-requis batch 7 : migration de ``measurements/pipeline_benchmark``,
``measurements/pipeline_comparison``, ``measurements/numerical_sequences``,
``measurements/roman_numerals`` vers ``evaluation/metrics/``.

Acceptance
----------
5019 tests passent, lint vert, architecture vérifiée.

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

docs/migration/legacy-retirement-plan.md CHANGED
@@ -695,8 +695,13 @@ architecture vérifiée.
695
  - Batch 3 ✅ (cf. ci-dessous) — 5 renderers (173-222 LOC).
696
  - Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
  - Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
698
- - Batch 6 (XXL + restants) : ``pipeline_render`` (707 l),
699
- ``philological_render`` (595 l), ``levers`` (284 l).
 
 
 
 
 
700
  - Phase 5.D : 5 vues (``views/*.py``).
701
  - Phase 5.E : ``generator.py``, ``comparison.py``,
702
  ``snapshot.py``, ``report_data/``, templates Jinja2.
@@ -843,15 +848,43 @@ migrés**, soit l'intégralité moins ``pipeline_render`` et
843
  ``philological_render`` (XXL) et ``levers`` (oublié dans le plan
844
  initial). Reste batch 6 (3 renderers) puis Phase 5.D (5 vues).
845
 
846
- Wait le compte exact : 22 originaux moins ``pipeline_render``,
847
- ``philological_render`` et ``levers`` = 19 attendus. Or on en a
848
- migré 20 + 5 = 25 dans 5 batches. Vérification : on a fait
849
- batch 1 (5) + batch 2 (5) + batch 3 (5) + batch 4 (5) + batch 5 (5)
850
- = 25. Le plan initial listait 22 renderers ; en pratique le
851
- ``report/`` en contient ~28 (cf. ``ls report/*_render.py``) — la
852
- liste du plan était incomplète. L'inventaire exact restant :
853
- ``levers_render.py`` + ``pipeline_render.py`` +
854
- ``philological_render.py`` à finir (3 renderers, ~1586 LOC).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
855
 
856
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
857
 
 
695
  - Batch 3 ✅ (cf. ci-dessous) — 5 renderers (173-222 LOC).
696
  - Batch 4 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
  - Batch 5 ✅ (cf. ci-dessous) — 5 renderers (148-314 LOC).
698
+ - Batch 6 (cf. ci-dessous) 2 renderers (``levers``, ``philological``).
699
+ - Batch 7 (final) : ``pipeline_render`` (707 l) +
700
+ ``numerical_sequences_render`` (149 l).
701
+ Pré-requis : migration de ``measurements/pipeline_benchmark``,
702
+ ``measurements/pipeline_comparison``,
703
+ ``measurements/numerical_sequences``,
704
+ ``measurements/roman_numerals`` vers ``evaluation/metrics/``.
705
  - Phase 5.D : 5 vues (``views/*.py``).
706
  - Phase 5.E : ``generator.py``, ``comparison.py``,
707
  ``snapshot.py``, ``report_data/``, templates Jinja2.
 
848
  ``philological_render`` (XXL) et ``levers`` (oublié dans le plan
849
  initial). Reste batch 6 (3 renderers) puis Phase 5.D (5 vues).
850
 
851
+ #### Phase 5.C.batch6 Lot 6 : levers + philological (2026-05)
852
+
853
+ Sixième vague. Inclut le plus gros renderer non-bloqué
854
+ (``philological``, 527 LOC) et ``levers`` (249 LOC).
855
+ ``pipeline_render`` (707 l) reporté à un batch 7 dédié car il
856
+ dépend de ``measurements/pipeline_benchmark`` et
857
+ ``measurements/pipeline_comparison`` non encore migrés vers
858
+ ``evaluation/`` (rejetés par layer-dependencies).
859
+ ``numerical_sequences_render`` (149 l) reporté pour la même
860
+ raison (dépendance vers ``measurements/numerical_sequences``
861
+ qui dépend de ``measurements/roman_numerals``).
862
+
863
+ **Migrations effectuées** :
864
+
865
+ | Source legacy | Destination canonique |
866
+ |---------------------------------------------|------------------------------------------------------|
867
+ | ``report/levers_render.py`` (249) | ``reports_v2/html/renderers/levers.py`` |
868
+ | ``report/philological_render.py`` (527) | ``reports_v2/html/renderers/philological.py`` |
869
+
870
+ Total : ~776 lignes relocalisées.
871
+
872
+ **Adaptations transverses** :
873
+
874
+ - ``test_sprint82_levers.py`` : monkeypatch sur `_FORMATTERS`
875
+ pointe désormais vers le module canonique
876
+ ``picarones.reports_v2.html.renderers.levers``.
877
+ - ``test_file_budgets.py`` : entrée
878
+ ``report/philological_render.py`` retirée, remplacée par
879
+ ``reports_v2/html/renderers/philological.py`` (budget
880
+ inchangé à 700).
881
+
882
+ **Cumul Phase 5.C** (batches 1-6) : 27 / 29 renderers migrés
883
+ (~5232 lignes). 2 renderers restants pour batch 7 :
884
+ ``pipeline_render`` (707) et ``numerical_sequences_render`` (149).
885
+
886
+ **Acceptance batch 6** : 5019 tests passent, lint vert,
887
+ architecture vérifiée.
888
 
889
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
890
 
picarones/report/generator.py CHANGED
@@ -283,7 +283,7 @@ class ReportGenerator:
283
  build_stratified_ranking_html,
284
  )
285
  # Sprint 62 — profil philologique (6 sections adaptive).
286
- from picarones.report.philological_render import (
287
  build_philological_profile_html,
288
  )
289
  # Sprint 86 — A.II.5 : recherchabilité fuzzy + séquences numériques.
 
283
  build_stratified_ranking_html,
284
  )
285
  # Sprint 62 — profil philologique (6 sections adaptive).
286
+ from picarones.reports_v2.html.renderers.philological import (
287
  build_philological_profile_html,
288
  )
289
  # Sprint 86 — A.II.5 : recherchabilité fuzzy + séquences numériques.
picarones/report/levers_render.py CHANGED
@@ -1,284 +1,18 @@
1
- """Rendu HTML de la section « Leviers d'amélioration » — Sprint 82.
2
 
3
- A.I.9 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/levers.py``. Pattern identique aux
6
- autres rendus (Sprints 41/43/62/67/72/74/75/76/77/80) : **server-
7
- side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Vue
10
- ---
11
- Une section composée de **cards** : une par levier, triée par
12
- importance décroissante. Chaque card affiche :
13
-
14
- - une *étiquette* (libellé i18n du type de levier) ;
15
- - une *phrase factuelle* qui réutilise les chiffres du
16
- ``payload`` (anti-hallucination : aucun chiffre n'est calculé
17
- dans le rendu) ;
18
- - éventuellement un **détail compact** (top-N tokens, top-3
19
- classes, etc.) ;
20
- - une *note* d'importance : HIGH / MEDIUM / LOW.
21
-
22
- Aucune classification automatique « bon » / « mauvais » et aucune
23
- recommandation : la phrase est purement descriptive.
24
  """
25
 
26
  from __future__ import annotations
27
 
28
- import logging
29
- from html import escape as _e
30
- from typing import Iterable, Optional
31
-
32
- logger = logging.getLogger(__name__)
33
-
34
-
35
- def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
36
- return labels.get(f"levers_label_{lever_type}", lever_type)
37
-
38
-
39
- def _format_dominant_recoverable(payload: dict, labels: dict[str, str]) -> str:
40
- engine = _e(str(payload.get("engine", "?")))
41
- pct = payload.get("share_recoverable_pct")
42
- n_recov = payload.get("n_recoverable")
43
- n_total = payload.get("n_total_errors")
44
- template = labels.get(
45
- "levers_dominant_recoverable_phrase",
46
- "{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont "
47
- "classifiées récupérables (case_error, ligature_error, "
48
- "abbreviation_error).",
49
- )
50
- sentence = template.format(
51
- engine=engine,
52
- pct=pct,
53
- n_recov=n_recov,
54
- n_total=n_total,
55
- )
56
- top_classes = payload.get("top_classes") or []
57
- if top_classes:
58
- breakdown = ", ".join(
59
- f"{_e(str(c.get('class', '?')))} ({c.get('count', 0)})"
60
- for c in top_classes
61
- )
62
- detail_label = labels.get("levers_top_classes", "Principales :")
63
- sentence += (
64
- f' <span style="opacity:.8">— {_e(detail_label)} '
65
- f'{breakdown}</span>'
66
- )
67
- return sentence
68
-
69
-
70
- def _format_pareto_concentration(payload: dict, labels: dict[str, str]) -> str:
71
- engine = _e(str(payload.get("engine", "?")))
72
- n_top = payload.get("n_docs_top")
73
- n_total = payload.get("n_docs")
74
- top_pct = payload.get("top_share_pct")
75
- cer_pct = payload.get("cer_share_pct")
76
- template = labels.get(
77
- "levers_pareto_phrase",
78
- "Sur {engine}, {n_top} documents ({top_pct}% du corpus) "
79
- "concentrent {cer_pct}% du CER cumulé "
80
- "(sur {n_total} documents au total).",
81
- )
82
- return template.format(
83
- engine=engine,
84
- n_top=n_top,
85
- n_total=n_total,
86
- top_pct=top_pct,
87
- cer_pct=cer_pct,
88
- )
89
-
90
-
91
- def _format_complementarity(payload: dict, labels: dict[str, str]) -> str:
92
- abs_pct = payload.get("absolute_gap_pct")
93
- rel_pct = payload.get("relative_gap_pct")
94
- best_engine = payload.get("best_engine")
95
- if best_engine:
96
- template = labels.get(
97
- "levers_complementarity_phrase_with_engine",
98
- "L'oracle bag-of-words atteint un rappel supérieur de "
99
- "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
100
- "moteur seul ({best_engine}).",
101
- )
102
- return template.format(
103
- abs_pct=abs_pct,
104
- rel_pct=rel_pct,
105
- best_engine=_e(str(best_engine)),
106
- )
107
- template = labels.get(
108
- "levers_complementarity_phrase",
109
- "L'oracle bag-of-words atteint un rappel supérieur de "
110
- "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
111
- "moteur seul.",
112
- )
113
- return template.format(abs_pct=abs_pct, rel_pct=rel_pct)
114
-
115
-
116
- def _format_lexical_modernization(payload: dict, labels: dict[str, str]) -> str:
117
- engine = _e(str(payload.get("engine", "?")))
118
- top_tokens = payload.get("top_tokens") or []
119
- if not top_tokens:
120
- return ""
121
- items = ", ".join(
122
- f"{_e(str(t.get('gt_token', '?')))} "
123
- f"({t.get('rate_modernized_pct', 0)}%, "
124
- f"n={t.get('n_total', 0)})"
125
- for t in top_tokens
126
- )
127
- template = labels.get(
128
- "levers_lexical_phrase",
129
- "Top tokens GT systématiquement modernisés par {engine} : {items}.",
130
- )
131
- return template.format(engine=engine, items=items)
132
-
133
-
134
- def _format_robustness_projection(payload: dict, labels: dict[str, str]) -> str:
135
- engine = _e(str(payload.get("engine", "?")))
136
- deficit_pct = payload.get("total_expected_deficit_pct")
137
- n_types = payload.get("n_degradation_types", 0)
138
- worst_type = payload.get("worst_degradation_type")
139
- worst_pct = payload.get("worst_degradation_deficit_pct")
140
- if worst_type and worst_pct is not None:
141
- template = labels.get(
142
- "levers_robustness_phrase_with_worst",
143
- "Déficit projeté de {engine} sur le corpus réel : "
144
- "{deficit_pct} points de CER cumulés sur {n_types} "
145
- "dégradations — pire dégradation : {worst_type} "
146
- "({worst_pct} points).",
147
- )
148
- return template.format(
149
- engine=engine,
150
- deficit_pct=deficit_pct,
151
- n_types=n_types,
152
- worst_type=_e(str(worst_type)),
153
- worst_pct=worst_pct,
154
- )
155
- template = labels.get(
156
- "levers_robustness_phrase",
157
- "Déficit projeté de {engine} sur le corpus réel : "
158
- "{deficit_pct} points de CER cumulés sur {n_types} dégradations.",
159
- )
160
- return template.format(
161
- engine=engine, deficit_pct=deficit_pct, n_types=n_types,
162
- )
163
-
164
-
165
- _FORMATTERS = {
166
- "dominant_recoverable_class": _format_dominant_recoverable,
167
- "pareto_concentration": _format_pareto_concentration,
168
- "complementarity_observation": _format_complementarity,
169
- "lexical_modernization_observation": _format_lexical_modernization,
170
- "robustness_projection_observation": _format_robustness_projection,
171
- }
172
-
173
-
174
- def _importance_label(importance: int, labels: dict[str, str]) -> str:
175
- if importance >= 70:
176
- return labels.get("levers_importance_high", "Important")
177
- if importance >= 40:
178
- return labels.get("levers_importance_medium", "À noter")
179
- return labels.get("levers_importance_low", "Mineur")
180
-
181
-
182
- def _importance_color(importance: int) -> str:
183
- if importance >= 70:
184
- return "#c2410c" # orange profond
185
- if importance >= 40:
186
- return "#0369a1" # bleu
187
- return "#6b7280" # gris
188
-
189
-
190
- def build_levers_section_html(
191
- levers: Iterable,
192
- labels: Optional[dict[str, str]] = None,
193
- ) -> str:
194
- """Construit la section HTML des leviers.
195
-
196
- Parameters
197
- ----------
198
- levers:
199
- Itérable de ``Lever`` (ou de dicts avec ``type``,
200
- ``importance``, ``payload``).
201
- labels:
202
- Dict i18n. Clés attendues sous le préfixe ``levers_``.
203
-
204
- Returns
205
- -------
206
- str
207
- Section HTML, ou ``""`` si aucun levier exploitable.
208
- """
209
- labels = labels or {}
210
- cards: list[str] = []
211
- for lever in levers:
212
- # Accepter Lever ou dict
213
- if hasattr(lever, "as_dict"):
214
- data = lever.as_dict()
215
- elif isinstance(lever, dict):
216
- data = lever
217
- else:
218
- continue
219
- lv_type = data.get("type")
220
- importance = int(data.get("importance") or 0)
221
- payload = data.get("payload") or {}
222
- if not lv_type:
223
- continue
224
- formatter = _FORMATTERS.get(lv_type)
225
- if formatter is None:
226
- continue
227
- try:
228
- sentence = formatter(payload, labels)
229
- except Exception as exc: # noqa: BLE001 — un formatter cassé ne doit pas casser la section
230
- logger.warning(
231
- "[levers_render] formatter %r a échoué sur payload=%r : %s — "
232
- "ce levier sera omis du rapport",
233
- lv_type, payload, exc,
234
- )
235
- continue
236
- if not sentence:
237
- continue
238
- type_label = _lever_label(lv_type, labels)
239
- imp_label = _importance_label(importance, labels)
240
- imp_color = _importance_color(importance)
241
- cards.append(
242
- '<div class="lever-card" style="border:1px solid #e5e7eb;'
243
- 'border-left:4px solid ' + imp_color + ';'
244
- 'border-radius:.4rem;padding:.7rem .9rem;'
245
- 'margin:.5rem 0;background:#fafafa">'
246
- f'<div style="display:flex;justify-content:space-between;'
247
- f'align-items:center;margin-bottom:.3rem;font-size:.8rem">'
248
- f'<span style="font-weight:600;text-transform:uppercase;'
249
- f'letter-spacing:.5px;color:#374151">'
250
- f'{_e(type_label)}</span>'
251
- f'<span style="color:{imp_color};font-weight:600">'
252
- f'{_e(imp_label)}</span>'
253
- f'</div>'
254
- f'<div style="font-size:.95rem;line-height:1.45">'
255
- f'{sentence}</div>'
256
- '</div>'
257
- )
258
-
259
- if not cards:
260
- return ""
261
-
262
- title = labels.get("levers_title", "Leviers d'amélioration")
263
- note = labels.get(
264
- "levers_note",
265
- "Observations factuelles synthétisées depuis les modules "
266
- "d'analyse. Aucune recommandation imposée — c'est au "
267
- "chercheur de juger ce qui est exploitable selon son "
268
- "workflow.",
269
- )
270
-
271
- parts = [
272
- '<section class="levers-section" style="margin:1.5rem 0">',
273
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
274
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
275
- f'{_e(note)}</div>',
276
- ]
277
- parts.extend(cards)
278
- parts.append('</section>')
279
- return "".join(parts)
280
 
 
281
 
282
- __all__ = [
283
- "build_levers_section_html",
284
- ]
 
 
 
 
1
+ """``picarones.report.levers_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.levers`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.levers import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.levers_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.levers instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/philological_render.py CHANGED
@@ -1,595 +1,18 @@
1
- """Rendu HTML server-side du profil philologique (Sprint 62).
2
 
3
- Suite directe Sprint 61 (câblage backend) — produit les blocs HTML
4
- qui exposent les six modules philologiques (Sprints 55-60) dans le
5
- rapport :
6
-
7
- - ``unicode_blocks`` (Sprint 55) — précision par bloc Unicode
8
- - ``abbreviations`` (Sprint 56) — score strict + expansion par
9
- abréviation médiévale Capelli
10
- - ``mufi`` (Sprint 57) — couverture MUFI globale + par
11
- caractère
12
- - ``early_modern`` (Sprint 58) — préservation des marqueurs
13
- typographiques imprimé ancien
14
- - ``modern_archives`` (Sprint 59) — strict + expansion par
15
- catégorie d'archive moderne
16
- - ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
17
- restitution
18
-
19
- Principe identique aux Sprints 41 (NER) et 43 (calibration) :
20
-
21
- - Rendu **server-side**, pas de JavaScript, déterministe.
22
- - Section adaptive : si aucun moteur n'a de signal pour un module
23
- donné, la sous-section est silencieusement omise.
24
- - Si **aucun module** n'a de signal sur l'ensemble des moteurs,
25
- ``build_philological_profile_html`` retourne une chaîne vide et
26
- le bloc complet n'apparaît pas dans la vue analyses.
27
- - **Aucune classification automatique** : on affiche les chiffres
28
- bruts par catégorie/bloc/statut, le chercheur juge lui-même la
29
- convention adoptée.
30
- - Anti-injection : tous les noms de moteurs, catégories, statuts,
31
- caractères passent par ``html.escape`` avant insertion.
32
  """
33
 
34
  from __future__ import annotations
35
 
36
- from html import escape as _e
37
- from typing import Optional
38
-
39
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
40
-
41
-
42
- # ──────────────────────────────────────────────────────────────────────────
43
- # Helpers de coloration
44
- # ──────────────────────────────────────────────────────────────────────────
45
-
46
-
47
- def _engines_with_module(
48
- engines_summary: list[dict], module: str,
49
- ) -> list[dict]:
50
- """Filtre les moteurs ayant des données pour le module donné."""
51
- out: list[dict] = []
52
- for eng in engines_summary:
53
- agg = eng.get("aggregated_philological") or {}
54
- if module in agg and agg[module]:
55
- out.append(eng)
56
- return out
57
-
58
-
59
- def _score_cell(score: Optional[float], extra: str = "") -> str:
60
- """Rend une cellule colorée. ``None`` → cellule grise « — »."""
61
- if score is None:
62
- return (
63
- '<td style="padding:.3rem .5rem;text-align:center;'
64
- 'background:#f0f0f0;color:#999">—</td>'
65
- )
66
- color = color_traffic_light(score)
67
- text = f"{score * 100:.1f}%"
68
- if extra:
69
- text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
70
- return (
71
- f'<td style="padding:.3rem .5rem;text-align:center;'
72
- f'background:{color}">{text}</td>'
73
- )
74
-
75
-
76
- def _table_header(
77
- columns: list[str], engine_label: str,
78
- ) -> str:
79
- """Construit l'entête d'un tableau moteur × colonnes."""
80
- parts = [
81
- '<thead><tr>',
82
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
83
- f'border-bottom:1px solid var(--border);font-weight:600">'
84
- f'{_e(engine_label)}</th>',
85
- ]
86
- for col in columns:
87
- parts.append(
88
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
89
- f'border-bottom:1px solid var(--border);font-weight:600">'
90
- f'{_e(col)}</th>'
91
- )
92
- parts.append('</tr></thead>')
93
- return "".join(parts)
94
-
95
-
96
- def _engine_label_cell(name: str) -> str:
97
- return (
98
- f'<td style="padding:.3rem .5rem;font-weight:500;'
99
- f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
100
- )
101
-
102
-
103
- def _section_open(title: str, note: str = "") -> str:
104
- parts = [
105
- '<div class="philological-section" '
106
- 'style="margin:1rem 0;padding:.75rem;'
107
- 'background:var(--bg-secondary);border-radius:6px">',
108
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
109
- ]
110
- if note:
111
- parts.append(
112
- f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
113
- f'{_e(note)}</div>'
114
- )
115
- return "".join(parts)
116
-
117
-
118
- def _section_close() -> str:
119
- return "</div>"
120
-
121
-
122
- def _table_open() -> str:
123
- return (
124
- '<table style="border-collapse:collapse;width:100%;'
125
- 'font-size:.85rem">'
126
- )
127
-
128
-
129
- def _table_close() -> str:
130
- return "</table>"
131
-
132
-
133
- # ──────────────────────────────────────────────────────────────────────────
134
- # Sprint 55 — Précision par bloc Unicode
135
- # ──────────────────────────────────────────────────────────────────────────
136
-
137
-
138
- def build_unicode_blocks_section(
139
- engines_summary: list[dict],
140
- labels: Optional[dict[str, str]] = None,
141
- ) -> str:
142
- relevant = _engines_with_module(engines_summary, "unicode_blocks")
143
- if not relevant:
144
- return ""
145
- labels = labels or {}
146
- title = labels.get(
147
- "philo_unicode_blocks_title", "Précision par bloc Unicode",
148
- )
149
- note = labels.get(
150
- "philo_unicode_blocks_note",
151
- "Pourcentage de caractères correctement restitués par bloc "
152
- "Unicode rencontré dans la GT (hors Basic Latin).",
153
- )
154
- engine_label = labels.get("philo_engine_label", "Moteur")
155
- global_label = labels.get("philo_global_label", "Global")
156
-
157
- # Collecte tous les blocs présents (hors Basic Latin déjà filtré
158
- # par adaptive masking, mais on défilte ici si Basic Latin
159
- # apparaît malgré tout chez certains moteurs).
160
- all_blocks: set[str] = set()
161
- for eng in relevant:
162
- per_block = eng["aggregated_philological"]["unicode_blocks"].get(
163
- "per_block", {},
164
- )
165
- for block in per_block:
166
- if block != "Basic Latin":
167
- all_blocks.add(block)
168
- blocks = sorted(all_blocks)
169
- if not blocks:
170
- return ""
171
-
172
- parts = [_section_open(title, note), _table_open()]
173
- parts.append(_table_header([global_label] + blocks, engine_label))
174
- parts.append("<tbody>")
175
- for eng in relevant:
176
- agg = eng["aggregated_philological"]["unicode_blocks"]
177
- global_acc = agg.get("global_accuracy", 0.0)
178
- n_chars = agg.get("n_chars_total", 0)
179
- parts.append("<tr>")
180
- parts.append(_engine_label_cell(eng["name"]))
181
- parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
182
- per_block = agg.get("per_block", {})
183
- for block in blocks:
184
- stats = per_block.get(block)
185
- if stats and stats.get("total", 0) > 0:
186
- parts.append(_score_cell(
187
- stats["accuracy"], extra=f"n={stats['total']}",
188
- ))
189
- else:
190
- parts.append(_score_cell(None))
191
- parts.append("</tr>")
192
- parts.append("</tbody>")
193
- parts.append(_table_close())
194
- parts.append(_section_close())
195
- return "".join(parts)
196
-
197
-
198
- # (sections suivantes définies plus loin)
199
-
200
-
201
- # ──────────────────────────────────────────────────────────────────────────
202
- # Sprint 56 — Abréviations Capelli médiévales
203
- # ──────────────────────────────────────────────────────────────────────────
204
-
205
-
206
- def build_abbreviations_section(
207
- engines_summary: list[dict],
208
- labels: Optional[dict[str, str]] = None,
209
- ) -> str:
210
- relevant = _engines_with_module(engines_summary, "abbreviations")
211
- if not relevant:
212
- return ""
213
- labels = labels or {}
214
- title = labels.get(
215
- "philo_abbreviations_title",
216
- "Abréviations médiévales (Capelli)",
217
- )
218
- note = labels.get(
219
- "philo_abbreviations_note",
220
- "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
221
- "Expansion = abrégée OU forme développée (per, pro, et…) "
222
- "présente. Le ratio strict/expansion par moteur indique la "
223
- "convention adoptée (diplomatique / modernisante).",
224
- )
225
- engine_label = labels.get("philo_engine_label", "Moteur")
226
- strict_label = labels.get("philo_strict_label", "Strict")
227
- expansion_label = labels.get("philo_expansion_label", "Expansion")
228
- n_label = labels.get("philo_n_total_label", "n total")
229
-
230
- parts = [_section_open(title, note), _table_open()]
231
- parts.append(_table_header(
232
- [strict_label, expansion_label, n_label], engine_label,
233
- ))
234
- parts.append("<tbody>")
235
- for eng in relevant:
236
- agg = eng["aggregated_philological"]["abbreviations"]
237
- parts.append("<tr>")
238
- parts.append(_engine_label_cell(eng["name"]))
239
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
240
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
241
- parts.append(
242
- f'<td style="padding:.3rem .5rem;text-align:center">'
243
- f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
244
- )
245
- parts.append("</tr>")
246
- parts.append("</tbody>")
247
- parts.append(_table_close())
248
- parts.append(_section_close())
249
- return "".join(parts)
250
-
251
-
252
- # ──────────────────────────────────────────────────────────────────────────
253
- # Sprint 57 — Couverture MUFI
254
- # ──────────────────────────────────────────────────────────────────────────
255
-
256
-
257
- def build_mufi_section(
258
- engines_summary: list[dict],
259
- labels: Optional[dict[str, str]] = None,
260
- ) -> str:
261
- relevant = _engines_with_module(engines_summary, "mufi")
262
- if not relevant:
263
- return ""
264
- labels = labels or {}
265
- title = labels.get(
266
- "philo_mufi_title",
267
- "Couverture MUFI (Medieval Unicode Font Initiative)",
268
- )
269
- note = labels.get(
270
- "philo_mufi_note",
271
- "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
272
- "PUA…) correctement restitués dans l'OCR. Critère éditorial "
273
- "central pour les médiévistes.",
274
- )
275
- engine_label = labels.get("philo_engine_label", "Moteur")
276
- coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
277
- n_label = labels.get("philo_n_total_label", "n total")
278
-
279
- parts = [_section_open(title, note), _table_open()]
280
- parts.append(_table_header(
281
- [coverage_label, n_label], engine_label,
282
- ))
283
- parts.append("<tbody>")
284
- for eng in relevant:
285
- agg = eng["aggregated_philological"]["mufi"]
286
- parts.append("<tr>")
287
- parts.append(_engine_label_cell(eng["name"]))
288
- parts.append(_score_cell(agg.get("coverage", 0.0)))
289
- parts.append(
290
- f'<td style="padding:.3rem .5rem;text-align:center">'
291
- f'{agg.get("n_mufi_chars_reference", 0)}</td>'
292
- )
293
- parts.append("</tr>")
294
- parts.append("</tbody>")
295
- parts.append(_table_close())
296
- parts.append(_section_close())
297
- return "".join(parts)
298
-
299
-
300
- # ──────────────────────────────────────────────────────────────────────────
301
- # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
302
- # ──────────────────────────────────────────────────────────────────────────
303
-
304
-
305
- def build_early_modern_section(
306
- engines_summary: list[dict],
307
- labels: Optional[dict[str, str]] = None,
308
- ) -> str:
309
- relevant = _engines_with_module(engines_summary, "early_modern")
310
- if not relevant:
311
- return ""
312
- labels = labels or {}
313
- title = labels.get(
314
- "philo_early_modern_title",
315
- "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
316
- )
317
- note = labels.get(
318
- "philo_early_modern_note",
319
- "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
320
- "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
321
- "Une ligne par moteur, une colonne par catégorie.",
322
- )
323
- engine_label = labels.get("philo_engine_label", "Moteur")
324
- global_label = labels.get("philo_global_label", "Global")
325
-
326
- all_cats: set[str] = set()
327
- for eng in relevant:
328
- all_cats.update(
329
- eng["aggregated_philological"]["early_modern"]
330
- .get("per_category", {}).keys(),
331
- )
332
- cats = sorted(all_cats)
333
- if not cats:
334
- return ""
335
-
336
- parts = [_section_open(title, note), _table_open()]
337
- parts.append(_table_header([global_label] + cats, engine_label))
338
- parts.append("<tbody>")
339
- for eng in relevant:
340
- agg = eng["aggregated_philological"]["early_modern"]
341
- n_total = agg.get("n_markers_reference", 0)
342
- parts.append("<tr>")
343
- parts.append(_engine_label_cell(eng["name"]))
344
- parts.append(_score_cell(
345
- agg.get("global_preservation", 0.0), extra=f"n={n_total}",
346
- ))
347
- per_cat = agg.get("per_category", {})
348
- for cat in cats:
349
- stats = per_cat.get(cat)
350
- if stats and stats.get("total", 0) > 0:
351
- parts.append(_score_cell(
352
- stats["preservation"], extra=f"n={stats['total']}",
353
- ))
354
- else:
355
- parts.append(_score_cell(None))
356
- parts.append("</tr>")
357
- parts.append("</tbody>")
358
- parts.append(_table_close())
359
- parts.append(_section_close())
360
- return "".join(parts)
361
-
362
-
363
- # ──────────────────────────────────────────────────────────────────────────
364
- # Sprint 59 — Archives modernes : strict + expansion par catégorie
365
- # ──────────────────────────────────────────────────────────────────────────
366
-
367
-
368
- def build_modern_archives_section(
369
- engines_summary: list[dict],
370
- labels: Optional[dict[str, str]] = None,
371
- ) -> str:
372
- relevant = _engines_with_module(engines_summary, "modern_archives")
373
- if not relevant:
374
- return ""
375
- labels = labels or {}
376
- title = labels.get(
377
- "philo_modern_archives_title",
378
- "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
379
- )
380
- note = labels.get(
381
- "philo_modern_archives_note",
382
- "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
383
- "Expansion = abrégé OU forme développée. Affiché par "
384
- "catégorie : civilité, ordinaux, monnaie, administratif, "
385
- "état civil, ponctuation typo, latin, biblio, adresse.",
386
- )
387
- engine_label = labels.get("philo_engine_label", "Moteur")
388
- global_label = labels.get("philo_global_label", "Global")
389
- strict_label = labels.get("philo_strict_label", "Strict")
390
- expansion_label = labels.get("philo_expansion_label", "Expansion")
391
-
392
- all_cats: set[str] = set()
393
- for eng in relevant:
394
- all_cats.update(
395
- eng["aggregated_philological"]["modern_archives"]
396
- .get("per_category", {}).keys(),
397
- )
398
- cats = sorted(all_cats)
399
-
400
- parts = [_section_open(title, note)]
401
- parts.append(
402
- '<table style="border-collapse:collapse;width:100%;'
403
- 'font-size:.85rem">'
404
- )
405
- parts.append("<thead><tr>")
406
- parts.append(
407
- f'<th scope=\"col\" rowspan="2" style="padding:.3rem .5rem;text-align:left;'
408
- f'border-bottom:1px solid var(--border);font-weight:600">'
409
- f'{_e(engine_label)}</th>'
410
- )
411
- parts.append(
412
- f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
413
- f'border-bottom:1px solid var(--border);font-weight:600">'
414
- f'{_e(global_label)}</th>'
415
- )
416
- for cat in cats:
417
- parts.append(
418
- f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
419
- f'border-bottom:1px solid var(--border);font-weight:600">'
420
- f'{_e(cat)}</th>'
421
- )
422
- parts.append("</tr><tr>")
423
- for _ in range(1 + len(cats)):
424
- parts.append(
425
- f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
426
- f'font-size:.75rem;font-weight:500;opacity:.7">'
427
- f'{_e(strict_label)}</th>'
428
- )
429
- parts.append(
430
- f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
431
- f'font-size:.75rem;font-weight:500;opacity:.7">'
432
- f'{_e(expansion_label)}</th>'
433
- )
434
- parts.append("</tr></thead>")
435
- parts.append("<tbody>")
436
- for eng in relevant:
437
- agg = eng["aggregated_philological"]["modern_archives"]
438
- parts.append("<tr>")
439
- parts.append(_engine_label_cell(eng["name"]))
440
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
441
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
442
- per_cat = agg.get("per_category", {})
443
- for cat in cats:
444
- stats = per_cat.get(cat)
445
- if stats and stats.get("n_total", 0) > 0:
446
- parts.append(_score_cell(
447
- stats["strict_score"],
448
- extra=f"n={stats['n_total']}",
449
- ))
450
- parts.append(_score_cell(stats["expansion_score"]))
451
- else:
452
- parts.append(_score_cell(None))
453
- parts.append(_score_cell(None))
454
- parts.append("</tr>")
455
- parts.append("</tbody>")
456
- parts.append(_table_close())
457
- parts.append(_section_close())
458
- return "".join(parts)
459
-
460
-
461
- # ──────────────────────────────────────────────────────────────────────────
462
- # Sprint 60 — Numéraux romains : breakdown 5 statuts
463
- # ──────────────────────────────────────────────────────────────────────────
464
-
465
-
466
- def build_roman_numerals_section(
467
- engines_summary: list[dict],
468
- labels: Optional[dict[str, str]] = None,
469
- ) -> str:
470
- relevant = _engines_with_module(engines_summary, "roman_numerals")
471
- if not relevant:
472
- return ""
473
- labels = labels or {}
474
- title = labels.get(
475
- "philo_roman_numerals_title",
476
- "Numéraux romains : restitution par statut",
477
- )
478
- note = labels.get(
479
- "philo_roman_numerals_note",
480
- "Pour chaque numéral romain de la GT, statut de restitution : "
481
- "strict (forme exacte), case_changed (casse modifiée), "
482
- "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
483
- "Le breakdown indique la convention : majoritaire strict → "
484
- "diplomatique ; majoritaire arabic → modernisation profonde.",
485
- )
486
- engine_label = labels.get("philo_engine_label", "Moteur")
487
- n_label = labels.get("philo_n_total_label", "n total")
488
-
489
- statuses = (
490
- "strict_preserved", "case_changed", "j_dropped",
491
- "converted_to_arabic", "lost",
492
- )
493
- status_labels = {
494
- s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
495
- }
496
-
497
- parts = [_section_open(title, note), _table_open()]
498
- parts.append(_table_header(
499
- [n_label] + [status_labels[s] for s in statuses],
500
- engine_label,
501
- ))
502
- parts.append("<tbody>")
503
- for eng in relevant:
504
- agg = eng["aggregated_philological"]["roman_numerals"]
505
- n_total = agg.get("n_numerals_reference", 0)
506
- per_status = agg.get("per_status", {})
507
- parts.append("<tr>")
508
- parts.append(_engine_label_cell(eng["name"]))
509
- parts.append(
510
- f'<td style="padding:.3rem .5rem;text-align:center">'
511
- f'{n_total}</td>'
512
- )
513
- for status in statuses:
514
- count = per_status.get(status, 0)
515
- if n_total > 0:
516
- ratio = count / n_total
517
- # Pour « lost » on inverse la couleur (un haut taux
518
- # de perte est mauvais). Pour les autres on garde
519
- # la sémantique « plus c'est haut, plus l'OCR a
520
- # adopté ce statut ».
521
- color = (
522
- color_traffic_light(1.0 - ratio) if status == "lost"
523
- else color_traffic_light(ratio)
524
- )
525
- parts.append(
526
- f'<td style="padding:.3rem .5rem;text-align:center;'
527
- f'background:{color}">{count} '
528
- f'<span style="opacity:.6;font-size:.85em">'
529
- f'({ratio * 100:.0f}%)</span></td>'
530
- )
531
- else:
532
- parts.append(_score_cell(None))
533
- parts.append("</tr>")
534
- parts.append("</tbody>")
535
- parts.append(_table_close())
536
- parts.append(_section_close())
537
- return "".join(parts)
538
-
539
-
540
- # ──────────────────────────────────────────────────────────────────────────
541
- # Agrégateur principal
542
- # ──────────────────────────────────────────────────────────────────────────
543
-
544
-
545
- def build_philological_profile_html(
546
- engines_summary: list[dict],
547
- labels: Optional[dict[str, str]] = None,
548
- ) -> str:
549
- """Assemble les six sections en un bloc unique.
550
-
551
- Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
552
- aucun moteur n'a de signal philologique sur le corpus).
553
- """
554
- sections = [
555
- build_unicode_blocks_section(engines_summary, labels),
556
- build_abbreviations_section(engines_summary, labels),
557
- build_mufi_section(engines_summary, labels),
558
- build_early_modern_section(engines_summary, labels),
559
- build_modern_archives_section(engines_summary, labels),
560
- build_roman_numerals_section(engines_summary, labels),
561
- ]
562
- non_empty = [s for s in sections if s]
563
- if not non_empty:
564
- return ""
565
- labels = labels or {}
566
- main_title = labels.get(
567
- "philo_profile_title", "Profil philologique",
568
- )
569
- main_note = labels.get(
570
- "philo_profile_note",
571
- "Données brutes par catégorie de marqueur philologique. "
572
- "L'outil ne classifie pas la convention adoptée par chaque "
573
- "moteur — c'est au chercheur de lire les chiffres et de "
574
- "conclure selon ses critères éditoriaux.",
575
- )
576
- parts = [
577
- '<div class="philological-profile">',
578
- f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
579
- f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
580
- f'{_e(main_note)}</p>',
581
- ]
582
- parts.extend(non_empty)
583
- parts.append("</div>")
584
- return "".join(parts)
585
 
 
586
 
587
- __all__ = [
588
- "build_philological_profile_html",
589
- "build_unicode_blocks_section",
590
- "build_abbreviations_section",
591
- "build_mufi_section",
592
- "build_early_modern_section",
593
- "build_modern_archives_section",
594
- "build_roman_numerals_section",
595
- ]
 
1
+ """``picarones.report.philological_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.philological`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.philological import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.philological_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.philological instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
picarones/report/views/diagnostics.py CHANGED
@@ -103,7 +103,7 @@ def build_diagnostics_view_html(
103
  # Sous-section 1 : leviers (calculés automatiquement)
104
  try:
105
  from picarones.measurements.levers import detect_levers
106
- from picarones.report.levers_render import build_levers_section_html
107
  levers = detect_levers(report_data)
108
  html = build_levers_section_html(levers, labels=labels)
109
  if html:
 
103
  # Sous-section 1 : leviers (calculés automatiquement)
104
  try:
105
  from picarones.measurements.levers import detect_levers
106
+ from picarones.reports_v2.html.renderers.levers import build_levers_section_html
107
  levers = detect_levers(report_data)
108
  html = build_levers_section_html(levers, labels=labels)
109
  if html:
picarones/reports_v2/html/renderers/levers.py ADDED
@@ -0,0 +1,290 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la section « Leviers d'amélioration » — Sprint 82.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.levers_render`` vers
5
+ ``picarones.reports_v2.html.renderers.levers``. Le chemin legacy
6
+ reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ A.I.9 du plan d'évolution 2026.
10
+
11
+ Suite directe ``picarones/core/levers.py``. Pattern identique aux
12
+ autres rendus (Sprints 41/43/62/67/72/74/75/76/77/80) : **server-
13
+ side**, pas de JavaScript, anti-injection systématique.
14
+
15
+ Vue
16
+ ---
17
+ Une section composée de **cards** : une par levier, triée par
18
+ importance décroissante. Chaque card affiche :
19
+
20
+ - une *étiquette* (libellé i18n du type de levier) ;
21
+ - une *phrase factuelle* qui réutilise les chiffres du
22
+ ``payload`` (anti-hallucination : aucun chiffre n'est calculé
23
+ dans le rendu) ;
24
+ - éventuellement un **détail compact** (top-N tokens, top-3
25
+ classes, etc.) ;
26
+ - une *note* d'importance : HIGH / MEDIUM / LOW.
27
+
28
+ Aucune classification automatique « bon » / « mauvais » et aucune
29
+ recommandation : la phrase est purement descriptive.
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import logging
35
+ from html import escape as _e
36
+ from typing import Iterable, Optional
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+
41
+ def _lever_label(lever_type: str, labels: dict[str, str]) -> str:
42
+ return labels.get(f"levers_label_{lever_type}", lever_type)
43
+
44
+
45
+ def _format_dominant_recoverable(payload: dict, labels: dict[str, str]) -> str:
46
+ engine = _e(str(payload.get("engine", "?")))
47
+ pct = payload.get("share_recoverable_pct")
48
+ n_recov = payload.get("n_recoverable")
49
+ n_total = payload.get("n_total_errors")
50
+ template = labels.get(
51
+ "levers_dominant_recoverable_phrase",
52
+ "{pct}% des erreurs de {engine} ({n_recov}/{n_total}) sont "
53
+ "classifiées récupérables (case_error, ligature_error, "
54
+ "abbreviation_error).",
55
+ )
56
+ sentence = template.format(
57
+ engine=engine,
58
+ pct=pct,
59
+ n_recov=n_recov,
60
+ n_total=n_total,
61
+ )
62
+ top_classes = payload.get("top_classes") or []
63
+ if top_classes:
64
+ breakdown = ", ".join(
65
+ f"{_e(str(c.get('class', '?')))} ({c.get('count', 0)})"
66
+ for c in top_classes
67
+ )
68
+ detail_label = labels.get("levers_top_classes", "Principales :")
69
+ sentence += (
70
+ f' <span style="opacity:.8">— {_e(detail_label)} '
71
+ f'{breakdown}</span>'
72
+ )
73
+ return sentence
74
+
75
+
76
+ def _format_pareto_concentration(payload: dict, labels: dict[str, str]) -> str:
77
+ engine = _e(str(payload.get("engine", "?")))
78
+ n_top = payload.get("n_docs_top")
79
+ n_total = payload.get("n_docs")
80
+ top_pct = payload.get("top_share_pct")
81
+ cer_pct = payload.get("cer_share_pct")
82
+ template = labels.get(
83
+ "levers_pareto_phrase",
84
+ "Sur {engine}, {n_top} documents ({top_pct}% du corpus) "
85
+ "concentrent {cer_pct}% du CER cumulé "
86
+ "(sur {n_total} documents au total).",
87
+ )
88
+ return template.format(
89
+ engine=engine,
90
+ n_top=n_top,
91
+ n_total=n_total,
92
+ top_pct=top_pct,
93
+ cer_pct=cer_pct,
94
+ )
95
+
96
+
97
+ def _format_complementarity(payload: dict, labels: dict[str, str]) -> str:
98
+ abs_pct = payload.get("absolute_gap_pct")
99
+ rel_pct = payload.get("relative_gap_pct")
100
+ best_engine = payload.get("best_engine")
101
+ if best_engine:
102
+ template = labels.get(
103
+ "levers_complementarity_phrase_with_engine",
104
+ "L'oracle bag-of-words atteint un rappel supérieur de "
105
+ "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
106
+ "moteur seul ({best_engine}).",
107
+ )
108
+ return template.format(
109
+ abs_pct=abs_pct,
110
+ rel_pct=rel_pct,
111
+ best_engine=_e(str(best_engine)),
112
+ )
113
+ template = labels.get(
114
+ "levers_complementarity_phrase",
115
+ "L'oracle bag-of-words atteint un rappel supérieur de "
116
+ "{abs_pct} points (+{rel_pct}% relatif) à celui du meilleur "
117
+ "moteur seul.",
118
+ )
119
+ return template.format(abs_pct=abs_pct, rel_pct=rel_pct)
120
+
121
+
122
+ def _format_lexical_modernization(payload: dict, labels: dict[str, str]) -> str:
123
+ engine = _e(str(payload.get("engine", "?")))
124
+ top_tokens = payload.get("top_tokens") or []
125
+ if not top_tokens:
126
+ return ""
127
+ items = ", ".join(
128
+ f"{_e(str(t.get('gt_token', '?')))} "
129
+ f"({t.get('rate_modernized_pct', 0)}%, "
130
+ f"n={t.get('n_total', 0)})"
131
+ for t in top_tokens
132
+ )
133
+ template = labels.get(
134
+ "levers_lexical_phrase",
135
+ "Top tokens GT systématiquement modernisés par {engine} : {items}.",
136
+ )
137
+ return template.format(engine=engine, items=items)
138
+
139
+
140
+ def _format_robustness_projection(payload: dict, labels: dict[str, str]) -> str:
141
+ engine = _e(str(payload.get("engine", "?")))
142
+ deficit_pct = payload.get("total_expected_deficit_pct")
143
+ n_types = payload.get("n_degradation_types", 0)
144
+ worst_type = payload.get("worst_degradation_type")
145
+ worst_pct = payload.get("worst_degradation_deficit_pct")
146
+ if worst_type and worst_pct is not None:
147
+ template = labels.get(
148
+ "levers_robustness_phrase_with_worst",
149
+ "Déficit projeté de {engine} sur le corpus réel : "
150
+ "{deficit_pct} points de CER cumulés sur {n_types} "
151
+ "dégradations — pire dégradation : {worst_type} "
152
+ "({worst_pct} points).",
153
+ )
154
+ return template.format(
155
+ engine=engine,
156
+ deficit_pct=deficit_pct,
157
+ n_types=n_types,
158
+ worst_type=_e(str(worst_type)),
159
+ worst_pct=worst_pct,
160
+ )
161
+ template = labels.get(
162
+ "levers_robustness_phrase",
163
+ "Déficit projeté de {engine} sur le corpus réel : "
164
+ "{deficit_pct} points de CER cumulés sur {n_types} dégradations.",
165
+ )
166
+ return template.format(
167
+ engine=engine, deficit_pct=deficit_pct, n_types=n_types,
168
+ )
169
+
170
+
171
+ _FORMATTERS = {
172
+ "dominant_recoverable_class": _format_dominant_recoverable,
173
+ "pareto_concentration": _format_pareto_concentration,
174
+ "complementarity_observation": _format_complementarity,
175
+ "lexical_modernization_observation": _format_lexical_modernization,
176
+ "robustness_projection_observation": _format_robustness_projection,
177
+ }
178
+
179
+
180
+ def _importance_label(importance: int, labels: dict[str, str]) -> str:
181
+ if importance >= 70:
182
+ return labels.get("levers_importance_high", "Important")
183
+ if importance >= 40:
184
+ return labels.get("levers_importance_medium", "À noter")
185
+ return labels.get("levers_importance_low", "Mineur")
186
+
187
+
188
+ def _importance_color(importance: int) -> str:
189
+ if importance >= 70:
190
+ return "#c2410c" # orange profond
191
+ if importance >= 40:
192
+ return "#0369a1" # bleu
193
+ return "#6b7280" # gris
194
+
195
+
196
+ def build_levers_section_html(
197
+ levers: Iterable,
198
+ labels: Optional[dict[str, str]] = None,
199
+ ) -> str:
200
+ """Construit la section HTML des leviers.
201
+
202
+ Parameters
203
+ ----------
204
+ levers:
205
+ Itérable de ``Lever`` (ou de dicts avec ``type``,
206
+ ``importance``, ``payload``).
207
+ labels:
208
+ Dict i18n. Clés attendues sous le préfixe ``levers_``.
209
+
210
+ Returns
211
+ -------
212
+ str
213
+ Section HTML, ou ``""`` si aucun levier exploitable.
214
+ """
215
+ labels = labels or {}
216
+ cards: list[str] = []
217
+ for lever in levers:
218
+ # Accepter Lever ou dict
219
+ if hasattr(lever, "as_dict"):
220
+ data = lever.as_dict()
221
+ elif isinstance(lever, dict):
222
+ data = lever
223
+ else:
224
+ continue
225
+ lv_type = data.get("type")
226
+ importance = int(data.get("importance") or 0)
227
+ payload = data.get("payload") or {}
228
+ if not lv_type:
229
+ continue
230
+ formatter = _FORMATTERS.get(lv_type)
231
+ if formatter is None:
232
+ continue
233
+ try:
234
+ sentence = formatter(payload, labels)
235
+ except Exception as exc: # noqa: BLE001 — un formatter cassé ne doit pas casser la section
236
+ logger.warning(
237
+ "[levers_render] formatter %r a échoué sur payload=%r : %s — "
238
+ "ce levier sera omis du rapport",
239
+ lv_type, payload, exc,
240
+ )
241
+ continue
242
+ if not sentence:
243
+ continue
244
+ type_label = _lever_label(lv_type, labels)
245
+ imp_label = _importance_label(importance, labels)
246
+ imp_color = _importance_color(importance)
247
+ cards.append(
248
+ '<div class="lever-card" style="border:1px solid #e5e7eb;'
249
+ 'border-left:4px solid ' + imp_color + ';'
250
+ 'border-radius:.4rem;padding:.7rem .9rem;'
251
+ 'margin:.5rem 0;background:#fafafa">'
252
+ f'<div style="display:flex;justify-content:space-between;'
253
+ f'align-items:center;margin-bottom:.3rem;font-size:.8rem">'
254
+ f'<span style="font-weight:600;text-transform:uppercase;'
255
+ f'letter-spacing:.5px;color:#374151">'
256
+ f'{_e(type_label)}</span>'
257
+ f'<span style="color:{imp_color};font-weight:600">'
258
+ f'{_e(imp_label)}</span>'
259
+ f'</div>'
260
+ f'<div style="font-size:.95rem;line-height:1.45">'
261
+ f'{sentence}</div>'
262
+ '</div>'
263
+ )
264
+
265
+ if not cards:
266
+ return ""
267
+
268
+ title = labels.get("levers_title", "Leviers d'amélioration")
269
+ note = labels.get(
270
+ "levers_note",
271
+ "Observations factuelles synthétisées depuis les modules "
272
+ "d'analyse. Aucune recommandation imposée — c'est au "
273
+ "chercheur de juger ce qui est exploitable selon son "
274
+ "workflow.",
275
+ )
276
+
277
+ parts = [
278
+ '<section class="levers-section" style="margin:1.5rem 0">',
279
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
280
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
281
+ f'{_e(note)}</div>',
282
+ ]
283
+ parts.extend(cards)
284
+ parts.append('</section>')
285
+ return "".join(parts)
286
+
287
+
288
+ __all__ = [
289
+ "build_levers_section_html",
290
+ ]
picarones/reports_v2/html/renderers/philological.py ADDED
@@ -0,0 +1,601 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML server-side du profil philologique (Sprint 62).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.philological_render`` vers
5
+ ``picarones.reports_v2.html.renderers.philological``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe Sprint 61 (câblage backend) — produit les blocs HTML
10
+ qui exposent les six modules philologiques (Sprints 55-60) dans le
11
+ rapport :
12
+
13
+ - ``unicode_blocks`` (Sprint 55) — précision par bloc Unicode
14
+ - ``abbreviations`` (Sprint 56) — score strict + expansion par
15
+ abréviation médiévale Capelli
16
+ - ``mufi`` (Sprint 57) — couverture MUFI globale + par
17
+ caractère
18
+ - ``early_modern`` (Sprint 58) — préservation des marqueurs
19
+ typographiques imprimé ancien
20
+ - ``modern_archives`` (Sprint 59) — strict + expansion par
21
+ catégorie d'archive moderne
22
+ - ``roman_numerals`` (Sprint 60) — breakdown 5 statuts de
23
+ restitution
24
+
25
+ Principe identique aux Sprints 41 (NER) et 43 (calibration) :
26
+
27
+ - Rendu **server-side**, pas de JavaScript, déterministe.
28
+ - Section adaptive : si aucun moteur n'a de signal pour un module
29
+ donné, la sous-section est silencieusement omise.
30
+ - Si **aucun module** n'a de signal sur l'ensemble des moteurs,
31
+ ``build_philological_profile_html`` retourne une chaîne vide et
32
+ le bloc complet n'apparaît pas dans la vue analyses.
33
+ - **Aucune classification automatique** : on affiche les chiffres
34
+ bruts par catégorie/bloc/statut, le chercheur juge lui-même la
35
+ convention adoptée.
36
+ - Anti-injection : tous les noms de moteurs, catégories, statuts,
37
+ caractères passent par ``html.escape`` avant insertion.
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from html import escape as _e
43
+ from typing import Optional
44
+
45
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
46
+
47
+
48
+ # ──────────────────────────────────────────────────────────────────────────
49
+ # Helpers de coloration
50
+ # ──────────────────────────────────────────────────────────────────────────
51
+
52
+
53
+ def _engines_with_module(
54
+ engines_summary: list[dict], module: str,
55
+ ) -> list[dict]:
56
+ """Filtre les moteurs ayant des données pour le module donné."""
57
+ out: list[dict] = []
58
+ for eng in engines_summary:
59
+ agg = eng.get("aggregated_philological") or {}
60
+ if module in agg and agg[module]:
61
+ out.append(eng)
62
+ return out
63
+
64
+
65
+ def _score_cell(score: Optional[float], extra: str = "") -> str:
66
+ """Rend une cellule colorée. ``None`` → cellule grise « — »."""
67
+ if score is None:
68
+ return (
69
+ '<td style="padding:.3rem .5rem;text-align:center;'
70
+ 'background:#f0f0f0;color:#999">—</td>'
71
+ )
72
+ color = color_traffic_light(score)
73
+ text = f"{score * 100:.1f}%"
74
+ if extra:
75
+ text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
76
+ return (
77
+ f'<td style="padding:.3rem .5rem;text-align:center;'
78
+ f'background:{color}">{text}</td>'
79
+ )
80
+
81
+
82
+ def _table_header(
83
+ columns: list[str], engine_label: str,
84
+ ) -> str:
85
+ """Construit l'entête d'un tableau moteur × colonnes."""
86
+ parts = [
87
+ '<thead><tr>',
88
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
89
+ f'border-bottom:1px solid var(--border);font-weight:600">'
90
+ f'{_e(engine_label)}</th>',
91
+ ]
92
+ for col in columns:
93
+ parts.append(
94
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
95
+ f'border-bottom:1px solid var(--border);font-weight:600">'
96
+ f'{_e(col)}</th>'
97
+ )
98
+ parts.append('</tr></thead>')
99
+ return "".join(parts)
100
+
101
+
102
+ def _engine_label_cell(name: str) -> str:
103
+ return (
104
+ f'<td style="padding:.3rem .5rem;font-weight:500;'
105
+ f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
106
+ )
107
+
108
+
109
+ def _section_open(title: str, note: str = "") -> str:
110
+ parts = [
111
+ '<div class="philological-section" '
112
+ 'style="margin:1rem 0;padding:.75rem;'
113
+ 'background:var(--bg-secondary);border-radius:6px">',
114
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
115
+ ]
116
+ if note:
117
+ parts.append(
118
+ f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
119
+ f'{_e(note)}</div>'
120
+ )
121
+ return "".join(parts)
122
+
123
+
124
+ def _section_close() -> str:
125
+ return "</div>"
126
+
127
+
128
+ def _table_open() -> str:
129
+ return (
130
+ '<table style="border-collapse:collapse;width:100%;'
131
+ 'font-size:.85rem">'
132
+ )
133
+
134
+
135
+ def _table_close() -> str:
136
+ return "</table>"
137
+
138
+
139
+ # ──────────────────────────────────────────────────────────────────────────
140
+ # Sprint 55 — Précision par bloc Unicode
141
+ # ──────────────────────────────────────────────────────────────────────────
142
+
143
+
144
+ def build_unicode_blocks_section(
145
+ engines_summary: list[dict],
146
+ labels: Optional[dict[str, str]] = None,
147
+ ) -> str:
148
+ relevant = _engines_with_module(engines_summary, "unicode_blocks")
149
+ if not relevant:
150
+ return ""
151
+ labels = labels or {}
152
+ title = labels.get(
153
+ "philo_unicode_blocks_title", "Précision par bloc Unicode",
154
+ )
155
+ note = labels.get(
156
+ "philo_unicode_blocks_note",
157
+ "Pourcentage de caractères correctement restitués par bloc "
158
+ "Unicode rencontré dans la GT (hors Basic Latin).",
159
+ )
160
+ engine_label = labels.get("philo_engine_label", "Moteur")
161
+ global_label = labels.get("philo_global_label", "Global")
162
+
163
+ # Collecte tous les blocs présents (hors Basic Latin déjà filtré
164
+ # par adaptive masking, mais on défilte ici si Basic Latin
165
+ # apparaît malgré tout chez certains moteurs).
166
+ all_blocks: set[str] = set()
167
+ for eng in relevant:
168
+ per_block = eng["aggregated_philological"]["unicode_blocks"].get(
169
+ "per_block", {},
170
+ )
171
+ for block in per_block:
172
+ if block != "Basic Latin":
173
+ all_blocks.add(block)
174
+ blocks = sorted(all_blocks)
175
+ if not blocks:
176
+ return ""
177
+
178
+ parts = [_section_open(title, note), _table_open()]
179
+ parts.append(_table_header([global_label] + blocks, engine_label))
180
+ parts.append("<tbody>")
181
+ for eng in relevant:
182
+ agg = eng["aggregated_philological"]["unicode_blocks"]
183
+ global_acc = agg.get("global_accuracy", 0.0)
184
+ n_chars = agg.get("n_chars_total", 0)
185
+ parts.append("<tr>")
186
+ parts.append(_engine_label_cell(eng["name"]))
187
+ parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
188
+ per_block = agg.get("per_block", {})
189
+ for block in blocks:
190
+ stats = per_block.get(block)
191
+ if stats and stats.get("total", 0) > 0:
192
+ parts.append(_score_cell(
193
+ stats["accuracy"], extra=f"n={stats['total']}",
194
+ ))
195
+ else:
196
+ parts.append(_score_cell(None))
197
+ parts.append("</tr>")
198
+ parts.append("</tbody>")
199
+ parts.append(_table_close())
200
+ parts.append(_section_close())
201
+ return "".join(parts)
202
+
203
+
204
+ # (sections suivantes définies plus loin)
205
+
206
+
207
+ # ──────────────────────────────────────────────────────────────────────────
208
+ # Sprint 56 — Abréviations Capelli médiévales
209
+ # ──────────────────────────────────────────────────────────────────────────
210
+
211
+
212
+ def build_abbreviations_section(
213
+ engines_summary: list[dict],
214
+ labels: Optional[dict[str, str]] = None,
215
+ ) -> str:
216
+ relevant = _engines_with_module(engines_summary, "abbreviations")
217
+ if not relevant:
218
+ return ""
219
+ labels = labels or {}
220
+ title = labels.get(
221
+ "philo_abbreviations_title",
222
+ "Abréviations médiévales (Capelli)",
223
+ )
224
+ note = labels.get(
225
+ "philo_abbreviations_note",
226
+ "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
227
+ "Expansion = abrégée OU forme développée (per, pro, et…) "
228
+ "présente. Le ratio strict/expansion par moteur indique la "
229
+ "convention adoptée (diplomatique / modernisante).",
230
+ )
231
+ engine_label = labels.get("philo_engine_label", "Moteur")
232
+ strict_label = labels.get("philo_strict_label", "Strict")
233
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
234
+ n_label = labels.get("philo_n_total_label", "n total")
235
+
236
+ parts = [_section_open(title, note), _table_open()]
237
+ parts.append(_table_header(
238
+ [strict_label, expansion_label, n_label], engine_label,
239
+ ))
240
+ parts.append("<tbody>")
241
+ for eng in relevant:
242
+ agg = eng["aggregated_philological"]["abbreviations"]
243
+ parts.append("<tr>")
244
+ parts.append(_engine_label_cell(eng["name"]))
245
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
246
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
247
+ parts.append(
248
+ f'<td style="padding:.3rem .5rem;text-align:center">'
249
+ f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
250
+ )
251
+ parts.append("</tr>")
252
+ parts.append("</tbody>")
253
+ parts.append(_table_close())
254
+ parts.append(_section_close())
255
+ return "".join(parts)
256
+
257
+
258
+ # ──────────────────────────────────────────────────────────────────────────
259
+ # Sprint 57 — Couverture MUFI
260
+ # ──────────────────────────────────────────────────────────────────────────
261
+
262
+
263
+ def build_mufi_section(
264
+ engines_summary: list[dict],
265
+ labels: Optional[dict[str, str]] = None,
266
+ ) -> str:
267
+ relevant = _engines_with_module(engines_summary, "mufi")
268
+ if not relevant:
269
+ return ""
270
+ labels = labels or {}
271
+ title = labels.get(
272
+ "philo_mufi_title",
273
+ "Couverture MUFI (Medieval Unicode Font Initiative)",
274
+ )
275
+ note = labels.get(
276
+ "philo_mufi_note",
277
+ "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
278
+ "PUA…) correctement restitués dans l'OCR. Critère éditorial "
279
+ "central pour les médiévistes.",
280
+ )
281
+ engine_label = labels.get("philo_engine_label", "Moteur")
282
+ coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
283
+ n_label = labels.get("philo_n_total_label", "n total")
284
+
285
+ parts = [_section_open(title, note), _table_open()]
286
+ parts.append(_table_header(
287
+ [coverage_label, n_label], engine_label,
288
+ ))
289
+ parts.append("<tbody>")
290
+ for eng in relevant:
291
+ agg = eng["aggregated_philological"]["mufi"]
292
+ parts.append("<tr>")
293
+ parts.append(_engine_label_cell(eng["name"]))
294
+ parts.append(_score_cell(agg.get("coverage", 0.0)))
295
+ parts.append(
296
+ f'<td style="padding:.3rem .5rem;text-align:center">'
297
+ f'{agg.get("n_mufi_chars_reference", 0)}</td>'
298
+ )
299
+ parts.append("</tr>")
300
+ parts.append("</tbody>")
301
+ parts.append(_table_close())
302
+ parts.append(_section_close())
303
+ return "".join(parts)
304
+
305
+
306
+ # ──────────────────────────────────────────────────────────────────────────
307
+ # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
308
+ # ──────────────────────────────────────────────────────────────────────────
309
+
310
+
311
+ def build_early_modern_section(
312
+ engines_summary: list[dict],
313
+ labels: Optional[dict[str, str]] = None,
314
+ ) -> str:
315
+ relevant = _engines_with_module(engines_summary, "early_modern")
316
+ if not relevant:
317
+ return ""
318
+ labels = labels or {}
319
+ title = labels.get(
320
+ "philo_early_modern_title",
321
+ "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
322
+ )
323
+ note = labels.get(
324
+ "philo_early_modern_note",
325
+ "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
326
+ "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
327
+ "Une ligne par moteur, une colonne par catégorie.",
328
+ )
329
+ engine_label = labels.get("philo_engine_label", "Moteur")
330
+ global_label = labels.get("philo_global_label", "Global")
331
+
332
+ all_cats: set[str] = set()
333
+ for eng in relevant:
334
+ all_cats.update(
335
+ eng["aggregated_philological"]["early_modern"]
336
+ .get("per_category", {}).keys(),
337
+ )
338
+ cats = sorted(all_cats)
339
+ if not cats:
340
+ return ""
341
+
342
+ parts = [_section_open(title, note), _table_open()]
343
+ parts.append(_table_header([global_label] + cats, engine_label))
344
+ parts.append("<tbody>")
345
+ for eng in relevant:
346
+ agg = eng["aggregated_philological"]["early_modern"]
347
+ n_total = agg.get("n_markers_reference", 0)
348
+ parts.append("<tr>")
349
+ parts.append(_engine_label_cell(eng["name"]))
350
+ parts.append(_score_cell(
351
+ agg.get("global_preservation", 0.0), extra=f"n={n_total}",
352
+ ))
353
+ per_cat = agg.get("per_category", {})
354
+ for cat in cats:
355
+ stats = per_cat.get(cat)
356
+ if stats and stats.get("total", 0) > 0:
357
+ parts.append(_score_cell(
358
+ stats["preservation"], extra=f"n={stats['total']}",
359
+ ))
360
+ else:
361
+ parts.append(_score_cell(None))
362
+ parts.append("</tr>")
363
+ parts.append("</tbody>")
364
+ parts.append(_table_close())
365
+ parts.append(_section_close())
366
+ return "".join(parts)
367
+
368
+
369
+ # ──────────────────────────────────────────────────────────────────────────
370
+ # Sprint 59 — Archives modernes : strict + expansion par catégorie
371
+ # ──────────────────────────────────────────────────────────────────────────
372
+
373
+
374
+ def build_modern_archives_section(
375
+ engines_summary: list[dict],
376
+ labels: Optional[dict[str, str]] = None,
377
+ ) -> str:
378
+ relevant = _engines_with_module(engines_summary, "modern_archives")
379
+ if not relevant:
380
+ return ""
381
+ labels = labels or {}
382
+ title = labels.get(
383
+ "philo_modern_archives_title",
384
+ "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
385
+ )
386
+ note = labels.get(
387
+ "philo_modern_archives_note",
388
+ "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
389
+ "Expansion = abrégé OU forme développée. Affiché par "
390
+ "catégorie : civilité, ordinaux, monnaie, administratif, "
391
+ "état civil, ponctuation typo, latin, biblio, adresse.",
392
+ )
393
+ engine_label = labels.get("philo_engine_label", "Moteur")
394
+ global_label = labels.get("philo_global_label", "Global")
395
+ strict_label = labels.get("philo_strict_label", "Strict")
396
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
397
+
398
+ all_cats: set[str] = set()
399
+ for eng in relevant:
400
+ all_cats.update(
401
+ eng["aggregated_philological"]["modern_archives"]
402
+ .get("per_category", {}).keys(),
403
+ )
404
+ cats = sorted(all_cats)
405
+
406
+ parts = [_section_open(title, note)]
407
+ parts.append(
408
+ '<table style="border-collapse:collapse;width:100%;'
409
+ 'font-size:.85rem">'
410
+ )
411
+ parts.append("<thead><tr>")
412
+ parts.append(
413
+ f'<th scope=\"col\" rowspan="2" style="padding:.3rem .5rem;text-align:left;'
414
+ f'border-bottom:1px solid var(--border);font-weight:600">'
415
+ f'{_e(engine_label)}</th>'
416
+ )
417
+ parts.append(
418
+ f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
419
+ f'border-bottom:1px solid var(--border);font-weight:600">'
420
+ f'{_e(global_label)}</th>'
421
+ )
422
+ for cat in cats:
423
+ parts.append(
424
+ f'<th scope=\"col\" colspan="2" style="padding:.3rem .5rem;text-align:center;'
425
+ f'border-bottom:1px solid var(--border);font-weight:600">'
426
+ f'{_e(cat)}</th>'
427
+ )
428
+ parts.append("</tr><tr>")
429
+ for _ in range(1 + len(cats)):
430
+ parts.append(
431
+ f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
432
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
433
+ f'{_e(strict_label)}</th>'
434
+ )
435
+ parts.append(
436
+ f'<th scope=\"col\" style="padding:.2rem .4rem;text-align:center;'
437
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
438
+ f'{_e(expansion_label)}</th>'
439
+ )
440
+ parts.append("</tr></thead>")
441
+ parts.append("<tbody>")
442
+ for eng in relevant:
443
+ agg = eng["aggregated_philological"]["modern_archives"]
444
+ parts.append("<tr>")
445
+ parts.append(_engine_label_cell(eng["name"]))
446
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
447
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
448
+ per_cat = agg.get("per_category", {})
449
+ for cat in cats:
450
+ stats = per_cat.get(cat)
451
+ if stats and stats.get("n_total", 0) > 0:
452
+ parts.append(_score_cell(
453
+ stats["strict_score"],
454
+ extra=f"n={stats['n_total']}",
455
+ ))
456
+ parts.append(_score_cell(stats["expansion_score"]))
457
+ else:
458
+ parts.append(_score_cell(None))
459
+ parts.append(_score_cell(None))
460
+ parts.append("</tr>")
461
+ parts.append("</tbody>")
462
+ parts.append(_table_close())
463
+ parts.append(_section_close())
464
+ return "".join(parts)
465
+
466
+
467
+ # ──────────────────────────────────────────────────────────────────────────
468
+ # Sprint 60 — Numéraux romains : breakdown 5 statuts
469
+ # ──────────────────────────────────────────────────────────────────────────
470
+
471
+
472
+ def build_roman_numerals_section(
473
+ engines_summary: list[dict],
474
+ labels: Optional[dict[str, str]] = None,
475
+ ) -> str:
476
+ relevant = _engines_with_module(engines_summary, "roman_numerals")
477
+ if not relevant:
478
+ return ""
479
+ labels = labels or {}
480
+ title = labels.get(
481
+ "philo_roman_numerals_title",
482
+ "Numéraux romains : restitution par statut",
483
+ )
484
+ note = labels.get(
485
+ "philo_roman_numerals_note",
486
+ "Pour chaque numéral romain de la GT, statut de restitution : "
487
+ "strict (forme exacte), case_changed (casse modifiée), "
488
+ "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
489
+ "Le breakdown indique la convention : majoritaire strict → "
490
+ "diplomatique ; majoritaire arabic → modernisation profonde.",
491
+ )
492
+ engine_label = labels.get("philo_engine_label", "Moteur")
493
+ n_label = labels.get("philo_n_total_label", "n total")
494
+
495
+ statuses = (
496
+ "strict_preserved", "case_changed", "j_dropped",
497
+ "converted_to_arabic", "lost",
498
+ )
499
+ status_labels = {
500
+ s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
501
+ }
502
+
503
+ parts = [_section_open(title, note), _table_open()]
504
+ parts.append(_table_header(
505
+ [n_label] + [status_labels[s] for s in statuses],
506
+ engine_label,
507
+ ))
508
+ parts.append("<tbody>")
509
+ for eng in relevant:
510
+ agg = eng["aggregated_philological"]["roman_numerals"]
511
+ n_total = agg.get("n_numerals_reference", 0)
512
+ per_status = agg.get("per_status", {})
513
+ parts.append("<tr>")
514
+ parts.append(_engine_label_cell(eng["name"]))
515
+ parts.append(
516
+ f'<td style="padding:.3rem .5rem;text-align:center">'
517
+ f'{n_total}</td>'
518
+ )
519
+ for status in statuses:
520
+ count = per_status.get(status, 0)
521
+ if n_total > 0:
522
+ ratio = count / n_total
523
+ # Pour « lost » on inverse la couleur (un haut taux
524
+ # de perte est mauvais). Pour les autres on garde
525
+ # la sémantique « plus c'est haut, plus l'OCR a
526
+ # adopté ce statut ».
527
+ color = (
528
+ color_traffic_light(1.0 - ratio) if status == "lost"
529
+ else color_traffic_light(ratio)
530
+ )
531
+ parts.append(
532
+ f'<td style="padding:.3rem .5rem;text-align:center;'
533
+ f'background:{color}">{count} '
534
+ f'<span style="opacity:.6;font-size:.85em">'
535
+ f'({ratio * 100:.0f}%)</span></td>'
536
+ )
537
+ else:
538
+ parts.append(_score_cell(None))
539
+ parts.append("</tr>")
540
+ parts.append("</tbody>")
541
+ parts.append(_table_close())
542
+ parts.append(_section_close())
543
+ return "".join(parts)
544
+
545
+
546
+ # ──────────────────────────────────────────────────────────────────────────
547
+ # Agrégateur principal
548
+ # ──────────────────────────────────────────────────────────────────────────
549
+
550
+
551
+ def build_philological_profile_html(
552
+ engines_summary: list[dict],
553
+ labels: Optional[dict[str, str]] = None,
554
+ ) -> str:
555
+ """Assemble les six sections en un bloc unique.
556
+
557
+ Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
558
+ aucun moteur n'a de signal philologique sur le corpus).
559
+ """
560
+ sections = [
561
+ build_unicode_blocks_section(engines_summary, labels),
562
+ build_abbreviations_section(engines_summary, labels),
563
+ build_mufi_section(engines_summary, labels),
564
+ build_early_modern_section(engines_summary, labels),
565
+ build_modern_archives_section(engines_summary, labels),
566
+ build_roman_numerals_section(engines_summary, labels),
567
+ ]
568
+ non_empty = [s for s in sections if s]
569
+ if not non_empty:
570
+ return ""
571
+ labels = labels or {}
572
+ main_title = labels.get(
573
+ "philo_profile_title", "Profil philologique",
574
+ )
575
+ main_note = labels.get(
576
+ "philo_profile_note",
577
+ "Données brutes par catégorie de marqueur philologique. "
578
+ "L'outil ne classifie pas la convention adoptée par chaque "
579
+ "moteur — c'est au chercheur de lire les chiffres et de "
580
+ "conclure selon ses critères éditoriaux.",
581
+ )
582
+ parts = [
583
+ '<div class="philological-profile">',
584
+ f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
585
+ f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
586
+ f'{_e(main_note)}</p>',
587
+ ]
588
+ parts.extend(non_empty)
589
+ parts.append("</div>")
590
+ return "".join(parts)
591
+
592
+
593
+ __all__ = [
594
+ "build_philological_profile_html",
595
+ "build_unicode_blocks_section",
596
+ "build_abbreviations_section",
597
+ "build_mufi_section",
598
+ "build_early_modern_section",
599
+ "build_modern_archives_section",
600
+ "build_roman_numerals_section",
601
+ ]
tests/architecture/test_file_budgets.py CHANGED
@@ -58,7 +58,10 @@ FILE_BUDGETS: dict[str, int] = {
58
  # même budget pour la même raison historique (modèles
59
  # BenchmarkResult/EngineReport/DocumentResult).
60
  "picarones/evaluation/benchmark_result.py": 750, # actuel 702
61
- "picarones/report/philological_render.py": 700, # actuel 595 (rétréci)
 
 
 
62
  "picarones/measurements/history.py": 725, # actuel 615
63
  "picarones/measurements/modern_archives.py": 700, # actuel 599
64
  "picarones/measurements/builtin_hooks.py": 700, # actuel 590
 
58
  # même budget pour la même raison historique (modèles
59
  # BenchmarkResult/EngineReport/DocumentResult).
60
  "picarones/evaluation/benchmark_result.py": 750, # actuel 702
61
+ # Phase 5.C : ``report/philological_render.py`` est désormais
62
+ # un shim (≤ 25 l). Le contenu canonique vit dans
63
+ # ``reports_v2/html/renderers/philological.py``.
64
+ "picarones/reports_v2/html/renderers/philological.py": 700, # actuel 601
65
  "picarones/measurements/history.py": 725, # actuel 615
66
  "picarones/measurements/modern_archives.py": 700, # actuel 599
67
  "picarones/measurements/builtin_hooks.py": 700, # actuel 590
tests/report/test_sprint62_philological_html.py CHANGED
@@ -22,7 +22,7 @@ from __future__ import annotations
22
  import json
23
  from pathlib import Path
24
 
25
- from picarones.report.philological_render import (
26
  build_abbreviations_section,
27
  build_early_modern_section,
28
  build_modern_archives_section,
 
22
  import json
23
  from pathlib import Path
24
 
25
+ from picarones.reports_v2.html.renderers.philological import (
26
  build_abbreviations_section,
27
  build_early_modern_section,
28
  build_modern_archives_section,
tests/report/test_sprint82_levers.py CHANGED
@@ -31,7 +31,7 @@ from picarones.measurements.levers import (
31
  detect_robustness_projection_observation,
32
  iter_lever_detectors,
33
  )
34
- from picarones.report.levers_render import build_levers_section_html
35
 
36
 
37
  # ──────────────────────────────────────────────────────────────────────────
@@ -435,7 +435,7 @@ class TestRender:
435
  """
436
  import logging
437
 
438
- from picarones.report import levers_render
439
 
440
  # Patche un des formatters pour qu'il lève une exception
441
  original = levers_render._FORMATTERS.copy()
@@ -453,7 +453,10 @@ class TestRender:
453
  "importance": 40,
454
  "payload": {"foo": "bar"},
455
  }
456
- with caplog.at_level(logging.WARNING, logger="picarones.report.levers_render"):
 
 
 
457
  html = build_levers_section_html([d], _load_labels("fr"))
458
 
459
  # 1. Le levier cassé est omis (HTML ne le contient pas).
 
31
  detect_robustness_projection_observation,
32
  iter_lever_detectors,
33
  )
34
+ from picarones.reports_v2.html.renderers.levers import build_levers_section_html
35
 
36
 
37
  # ──────────────────────────────────────────────────────────────────────────
 
435
  """
436
  import logging
437
 
438
+ from picarones.reports_v2.html.renderers import levers as levers_render
439
 
440
  # Patche un des formatters pour qu'il lève une exception
441
  original = levers_render._FORMATTERS.copy()
 
453
  "importance": 40,
454
  "payload": {"foo": "bar"},
455
  }
456
+ with caplog.at_level(
457
+ logging.WARNING,
458
+ logger="picarones.reports_v2.html.renderers.levers",
459
+ ):
460
  html = build_levers_section_html([d], _load_labels("fr"))
461
 
462
  # 1. Le levier cassé est omis (HTML ne le contient pas).