Claude commited on
Commit
79574cc
·
unverified ·
1 Parent(s): 7a072e2

refactor(report): regrouper renderers depuis extras/render/

Browse files

Les 6 renderers HTML qui vivaient dans ``picarones/extras/render/``
sont déplacés dans ``picarones/report/`` (où vivent déjà les ~16
autres renderers). Le rendu HTML est l'identité visible de Picarones —
pas un plugin tiers — et n'a aucune raison d'être éparpillé entre
deux dossiers.

Renderers déplacés :
- image_predictive_render
- lexical_modernization_render
- module_audit_render
- philological_render
- taxonomy_cooccurrence_render
- taxonomy_intra_doc_render

Le sous-package ``extras/render/`` est supprimé. Les fichiers
historiquement présents dans ``report/`` (qui étaient des shims
pointant vers ``extras/render/``) sont écrasés par les vraies
sources.

https://claude.ai/code/session_01Hsd7kL8yeCbXn1mA7GQK9L

picarones/extras/render/__init__.py DELETED
@@ -1,13 +0,0 @@
1
- """Renderers atomiques pour les modules ``extras/``.
2
-
3
- Importés conditionnellement par les vues thématiques du chantier 3
4
- (``picarones.report.views.advanced_taxonomy``, etc.) qui restent
5
- dans le Cercle 2. Si les modules ``extras/academic/`` ou
6
- ``extras/governance/`` sont absents, ces renderers ne sont pas
7
- sollicités et la vue masque la sous-section.
8
-
9
- Rétrocompat
10
- -----------
11
- Imports historiques ``from picarones.report.taxonomy_intra_doc_render
12
- import ...`` continuent à fonctionner via des fichiers-shims.
13
- """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/image_predictive_render.py DELETED
@@ -1,221 +0,0 @@
1
- """Rendu HTML « Profil d'image du corpus » — Sprint 93 (A.II.7).
2
-
3
- Suite directe ``picarones/core/image_predictive.py``. Pattern
4
- identique aux autres rendus : server-side, pas de JS, anti-
5
- injection systématique.
6
-
7
- Vue
8
- ---
9
- Deux blocs dans une section unique :
10
-
11
- 1. **Complexité paléographique** : moyenne, médiane, min, max,
12
- écart-type sur l'ensemble du corpus.
13
- 2. **Homogénéité du corpus** : score combiné + détail par
14
- feature (mean, stdev, contribution normalisée).
15
-
16
- Adaptive : ``""`` si pas de données.
17
-
18
- Note d'intégration
19
- ------------------
20
- Module pur — l'utilisateur compose :
21
-
22
- .. code-block:: python
23
-
24
- from picarones.core.image_predictive import aggregate_corpus_predictive
25
- from picarones.report.image_predictive_render import (
26
- build_image_predictive_html,
27
- )
28
-
29
- qualities = [doc.image_quality.as_dict() for doc in benchmark.docs]
30
- agg = aggregate_corpus_predictive(qualities)
31
- html = build_image_predictive_html(agg, labels)
32
- """
33
-
34
- from __future__ import annotations
35
-
36
- from html import escape as _e
37
- from typing import Optional
38
-
39
-
40
- def _color_for_score(score: float) -> str:
41
- """Vert (faible) → orange → rouge (élevé)."""
42
- f = max(0.0, min(1.0, score))
43
- if f < 0.5:
44
- t = f / 0.5
45
- r = int(167 + (235 - 167) * t)
46
- g = int(240 + (180 - 240) * t)
47
- b = int(167 + (60 - 167) * t)
48
- else:
49
- t = (f - 0.5) / 0.5
50
- r = int(235 + (220 - 235) * t)
51
- g = int(180 + (50 - 180) * t)
52
- b = int(60 + (50 - 60) * t)
53
- return f"#{r:02x}{g:02x}{b:02x}"
54
-
55
-
56
- _FEATURE_LABEL_KEYS = {
57
- "noise_level": "imgpred_feat_noise",
58
- "sharpness_score": "imgpred_feat_sharpness",
59
- "contrast_score": "imgpred_feat_contrast",
60
- "rotation_degrees": "imgpred_feat_rotation",
61
- }
62
-
63
-
64
- def _render_complexity_block(
65
- aggregated: dict, labels: dict[str, str],
66
- ) -> str:
67
- h_complex = labels.get(
68
- "imgpred_complexity", "Complexité paléographique",
69
- )
70
- h_mean = labels.get("imgpred_mean", "Moyenne")
71
- h_median = labels.get("imgpred_median", "Médiane")
72
- h_min = labels.get("imgpred_min", "Min")
73
- h_max = labels.get("imgpred_max", "Max")
74
- h_stdev = labels.get("imgpred_stdev", "Écart-type")
75
- h_docs = labels.get("imgpred_docs", "Docs")
76
- mean = float(aggregated.get("complexity_mean") or 0.0)
77
- median = float(aggregated.get("complexity_median") or 0.0)
78
- mn = float(aggregated.get("complexity_min") or 0.0)
79
- mx = float(aggregated.get("complexity_max") or 0.0)
80
- sd = float(aggregated.get("complexity_stdev") or 0.0)
81
- n_docs = int(aggregated.get("n_docs") or 0)
82
- color_mean = _color_for_score(mean)
83
- return (
84
- f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
85
- f'{_e(h_complex)}</div>'
86
- '<table style="border-collapse:collapse;width:100%;'
87
- 'font-size:.9rem;margin-bottom:.8rem">'
88
- f'<thead><tr>'
89
- f'<th style="padding:.4rem .6rem;text-align:right;'
90
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
91
- f'<th style="padding:.4rem .6rem;text-align:right;'
92
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
93
- f'<th style="padding:.4rem .6rem;text-align:right;'
94
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
95
- f'<th style="padding:.4rem .6rem;text-align:right;'
96
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
97
- f'<th style="padding:.4rem .6rem;text-align:right;'
98
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
99
- f'<th style="padding:.4rem .6rem;text-align:right;'
100
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
101
- f'</tr></thead>'
102
- f'<tbody><tr>'
103
- f'<td style="padding:.4rem .6rem;text-align:right;'
104
- f'background:{color_mean};font-family:monospace;font-weight:600">'
105
- f'{mean:.3f}</td>'
106
- f'<td style="padding:.4rem .6rem;text-align:right;'
107
- f'font-family:monospace">{median:.3f}</td>'
108
- f'<td style="padding:.4rem .6rem;text-align:right;'
109
- f'font-family:monospace">{mn:.3f}</td>'
110
- f'<td style="padding:.4rem .6rem;text-align:right;'
111
- f'font-family:monospace">{mx:.3f}</td>'
112
- f'<td style="padding:.4rem .6rem;text-align:right;'
113
- f'font-family:monospace">{sd:.3f}</td>'
114
- f'<td style="padding:.4rem .6rem;text-align:right;'
115
- f'font-family:monospace">{n_docs}</td>'
116
- f'</tr></tbody></table>'
117
- )
118
-
119
-
120
- def _render_homogeneity_block(
121
- homogeneity: dict, labels: dict[str, str],
122
- ) -> str:
123
- h_homo = labels.get(
124
- "imgpred_homogeneity", "Homogénéité du corpus",
125
- )
126
- h_feat = labels.get("imgpred_feature", "Feature")
127
- h_mean = labels.get("imgpred_feat_mean", "Moyenne")
128
- h_stdev = labels.get("imgpred_feat_stdev", "Écart-type")
129
- h_norm = labels.get(
130
- "imgpred_feat_norm", "Contribution normalisée",
131
- )
132
- score = float(homogeneity.get("score") or 0.0)
133
- color = _color_for_score(score)
134
- parts = [
135
- f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
136
- f'{_e(h_homo)} : '
137
- f'<span style="background:{color};padding:.1rem .4rem;'
138
- f'border-radius:.3rem;font-family:monospace">{score:.3f}</span>'
139
- f'</div>',
140
- '<table style="border-collapse:collapse;width:100%;'
141
- 'font-size:.9rem">',
142
- '<thead><tr>',
143
- ]
144
- for col in (h_feat, h_mean, h_stdev, h_norm):
145
- parts.append(
146
- f'<th style="padding:.4rem .6rem;text-align:left;'
147
- f'border-bottom:1px solid #ccc;font-weight:600">'
148
- f'{_e(col)}</th>'
149
- )
150
- parts.append("</tr></thead><tbody>")
151
- per_feat = homogeneity.get("per_feature") or {}
152
- for key, label_key in _FEATURE_LABEL_KEYS.items():
153
- if key not in per_feat:
154
- continue
155
- slot = per_feat[key]
156
- feat_label = labels.get(label_key, key)
157
- feat_mean = float(slot.get("mean") or 0.0)
158
- feat_stdev = float(slot.get("stdev") or 0.0)
159
- feat_norm = float(slot.get("normalised") or 0.0)
160
- norm_color = _color_for_score(feat_norm)
161
- parts.append(
162
- f'<tr>'
163
- f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
164
- f'<td style="padding:.4rem .6rem;text-align:right;'
165
- f'font-family:monospace">{feat_mean:.3f}</td>'
166
- f'<td style="padding:.4rem .6rem;text-align:right;'
167
- f'font-family:monospace">{feat_stdev:.3f}</td>'
168
- f'<td style="padding:.4rem .6rem;text-align:right;'
169
- f'background:{norm_color};font-family:monospace">'
170
- f'{feat_norm:.3f}</td>'
171
- f'</tr>'
172
- )
173
- parts.append("</tbody></table>")
174
- return "".join(parts)
175
-
176
-
177
- def build_image_predictive_html(
178
- aggregated: Optional[dict],
179
- labels: Optional[dict[str, str]] = None,
180
- ) -> str:
181
- """Construit la vue HTML « Profil d'image du corpus ».
182
-
183
- Parameters
184
- ----------
185
- aggregated:
186
- Sortie de ``aggregate_corpus_predictive``. Si ``None``
187
- ou ``n_docs == 0``, retourne ``""``.
188
- labels:
189
- Dict i18n. Clés sous le préfixe ``imgpred_*``.
190
- """
191
- if not aggregated:
192
- return ""
193
- if not aggregated.get("n_docs"):
194
- return ""
195
- labels = labels or {}
196
- title = labels.get(
197
- "imgpred_title", "Profil d'image du corpus",
198
- )
199
- note = labels.get(
200
- "imgpred_note",
201
- "Score de complexité paléographique combinant bruit, "
202
- "flou, faible contraste et rotation. Le score "
203
- "d'homogénéité signale si la moyenne globale est fiable "
204
- "(corpus uniforme) ou trompeuse (corpus hétérogène — "
205
- "voir alors la vue stratifiée).",
206
- )
207
- parts = [
208
- '<section class="imgpred-section" style="margin:1rem 0">',
209
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
210
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
211
- f'{_e(note)}</div>',
212
- ]
213
- parts.append(_render_complexity_block(aggregated, labels))
214
- homo = aggregated.get("homogeneity")
215
- if isinstance(homo, dict):
216
- parts.append(_render_homogeneity_block(homo, labels))
217
- parts.append("</section>")
218
- return "".join(parts)
219
-
220
-
221
- __all__ = ["build_image_predictive_html"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/lexical_modernization_render.py DELETED
@@ -1,119 +0,0 @@
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
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/module_audit_render.py DELETED
@@ -1,173 +0,0 @@
1
- """Rendu HTML « Modules audités » — Sprint 97 (B.6).
2
-
3
- Suite directe ``picarones/core/module_policy.py``. Pattern
4
- identique aux autres rendus : server-side, pas de JS, anti-
5
- injection systématique.
6
-
7
- Vue
8
- ---
9
- Tableau récapitulatif des modules utilisés dans une pipeline
10
- composée, chacun avec :
11
-
12
- - Statut d'audit (✓ vert si tous les checks passent, ✗ rouge
13
- sinon, avec compte des échecs) ;
14
- - Métadonnées : version, auteur, licence ;
15
- - Citation académique si fournie ;
16
- - Lien vers la homepage si fourni.
17
-
18
- Adaptive : ``""`` si la liste est vide.
19
-
20
- Note d'intégration
21
- ------------------
22
- Module pur — l'utilisateur compose la liste depuis sa
23
- ``PipelineSpec`` augmentée des ``ModuleManifest`` :
24
-
25
- .. code-block:: python
26
-
27
- from picarones.core.module_policy import audit_module
28
- from picarones.report.module_audit_render import build_module_audit_html
29
-
30
- audits = []
31
- for step in pipeline.steps:
32
- manifest = step.module.manifest # convention applicative
33
- result = audit_module(step.module, manifest)
34
- audits.append({
35
- "manifest": manifest.as_dict(),
36
- "audit": result.as_dict(),
37
- })
38
- html = build_module_audit_html(audits, labels)
39
- """
40
-
41
- from __future__ import annotations
42
-
43
- from html import escape as _e
44
- from typing import Optional
45
-
46
-
47
- def _passed_badge(passed: bool, n_failed: int, label_pass: str,
48
- label_fail: str) -> str:
49
- if passed:
50
- return (
51
- f'<span style="color:#16a34a;font-weight:700">'
52
- f'✓ {_e(label_pass)}</span>'
53
- )
54
- return (
55
- f'<span style="color:#dc2626;font-weight:700">'
56
- f'✗ {_e(label_fail)} ({n_failed})</span>'
57
- )
58
-
59
-
60
- def build_module_audit_html(
61
- audits: Optional[list],
62
- labels: Optional[dict[str, str]] = None,
63
- ) -> str:
64
- """Construit la vue HTML « Modules audités ».
65
-
66
- Parameters
67
- ----------
68
- audits:
69
- Liste de dicts ``{"manifest": ManifestDict, "audit":
70
- AuditResultDict}``. Si vide ou ``None``, retourne ``""``.
71
- labels:
72
- Dict i18n. Clés sous le préfixe ``audit_*``.
73
- """
74
- if not audits:
75
- return ""
76
- rows = [
77
- a for a in audits
78
- if isinstance(a, dict)
79
- and isinstance(a.get("manifest"), dict)
80
- and isinstance(a.get("audit"), dict)
81
- ]
82
- if not rows:
83
- return ""
84
- labels = labels or {}
85
- title = labels.get("audit_title", "Modules audités")
86
- note = labels.get(
87
- "audit_note",
88
- "Récapitulatif des modules utilisés dans la pipeline "
89
- "composée. Un module qui ne passe pas l'audit n'est "
90
- "pas exécutable. Métadonnées issues du manifest fourni "
91
- "par le contributeur (auteur, licence, citation).",
92
- )
93
- label_pass = labels.get("audit_pass", "audit OK")
94
- label_fail = labels.get("audit_fail", "checks échoués")
95
- h_module = labels.get("audit_module", "Module")
96
- h_status = labels.get("audit_status", "Audit")
97
- h_version = labels.get("audit_version", "Version")
98
- h_author = labels.get("audit_author", "Auteur")
99
- h_license = labels.get("audit_license", "Licence")
100
- h_io = labels.get("audit_io", "Entrée → sortie")
101
- h_citation = labels.get("audit_citation", "Citation")
102
- h_homepage = labels.get("audit_homepage", "Page projet")
103
-
104
- parts = [
105
- '<section class="audit-section" style="margin:1rem 0">',
106
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
107
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
108
- f'{_e(note)}</div>',
109
- '<table style="border-collapse:collapse;width:100%;'
110
- 'font-size:.9rem">',
111
- '<thead><tr>',
112
- ]
113
- for col in (h_module, h_status, h_version, h_author,
114
- h_license, h_io, h_citation, h_homepage):
115
- parts.append(
116
- f'<th style="padding:.4rem .6rem;text-align:left;'
117
- f'border-bottom:1px solid #ccc;font-weight:600">'
118
- f'{_e(col)}</th>'
119
- )
120
- parts.append("</tr></thead><tbody>")
121
-
122
- for entry in rows:
123
- manifest = entry["manifest"]
124
- audit = entry["audit"]
125
- name = str(manifest.get("name") or "?")
126
- version = str(manifest.get("version") or "—")
127
- author = str(manifest.get("author") or "—")
128
- license_ = str(manifest.get("license") or "—")
129
- in_types = ", ".join(manifest.get("input_types") or []) or "—"
130
- out_types = ", ".join(manifest.get("output_types") or []) or "—"
131
- citation = manifest.get("citation") or ""
132
- homepage = manifest.get("homepage") or ""
133
- passed = bool(audit.get("passed"))
134
- n_failed = int(audit.get("n_failed") or 0)
135
- status_cell = _passed_badge(
136
- passed, n_failed, label_pass, label_fail,
137
- )
138
- # Citation : tronqué si trop long
139
- citation_str = str(citation)[:120]
140
- if len(str(citation)) > 120:
141
- citation_str += "…"
142
- citation_cell = (
143
- _e(citation_str) if citation_str.strip() else "—"
144
- )
145
- # Homepage : on n'auto-link **pas** (anti-injection +
146
- # honnêteté : l'URL peut pointer ailleurs). On affiche
147
- # le texte échappé tel quel.
148
- homepage_cell = (
149
- _e(str(homepage))[:80] + ("…" if len(str(homepage)) > 80 else "")
150
- ) if str(homepage).strip() else "—"
151
- parts.append(
152
- f'<tr>'
153
- f'<td style="padding:.4rem .6rem;font-family:monospace">'
154
- f'{_e(name)}</td>'
155
- f'<td style="padding:.4rem .6rem">{status_cell}</td>'
156
- f'<td style="padding:.4rem .6rem;font-family:monospace">'
157
- f'{_e(version)}</td>'
158
- f'<td style="padding:.4rem .6rem">{_e(author)}</td>'
159
- f'<td style="padding:.4rem .6rem;font-family:monospace">'
160
- f'{_e(license_)}</td>'
161
- f'<td style="padding:.4rem .6rem;font-family:monospace;'
162
- f'font-size:.8rem">{_e(in_types)} → {_e(out_types)}</td>'
163
- f'<td style="padding:.4rem .6rem;font-size:.8rem;'
164
- f'opacity:.85">{citation_cell}</td>'
165
- f'<td style="padding:.4rem .6rem;font-family:monospace;'
166
- f'font-size:.8rem">{homepage_cell}</td>'
167
- f'</tr>'
168
- )
169
- parts.append("</tbody></table></section>")
170
- return "".join(parts)
171
-
172
-
173
- __all__ = ["build_module_audit_html"]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/philological_render.py DELETED
@@ -1,615 +0,0 @@
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
-
40
- # ──────────────────────────────────────────────────────────────────────────
41
- # Helpers de coloration
42
- # ──────────────────────────────────────────────────────────────────────────
43
-
44
-
45
- def _color_for_score(score: float) -> str:
46
- """Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
47
-
48
- Identique à ``ner_render._color_for_f1``. Les scores
49
- philologiques (preservation, coverage, accuracy) suivent la même
50
- sémantique « plus c'est haut, mieux c'est » donc le gradient
51
- est valide.
52
- """
53
- f = max(0.0, min(1.0, score))
54
- if f <= 0.5:
55
- ratio = f / 0.5
56
- r = int(220 + (240 - 220) * ratio)
57
- g = int(100 + (220 - 100) * ratio)
58
- b = int(100 + (130 - 100) * ratio)
59
- else:
60
- ratio = (f - 0.5) / 0.5
61
- r = int(240 + (130 - 240) * ratio)
62
- g = int(220 + (200 - 220) * ratio)
63
- b = int(130 + (130 - 130) * ratio)
64
- return f"#{r:02x}{g:02x}{b:02x}"
65
-
66
-
67
- def _engines_with_module(
68
- engines_summary: list[dict], module: str,
69
- ) -> list[dict]:
70
- """Filtre les moteurs ayant des données pour le module donné."""
71
- out: list[dict] = []
72
- for eng in engines_summary:
73
- agg = eng.get("aggregated_philological") or {}
74
- if module in agg and agg[module]:
75
- out.append(eng)
76
- return out
77
-
78
-
79
- def _score_cell(score: Optional[float], extra: str = "") -> str:
80
- """Rend une cellule colorée. ``None`` → cellule grise « — »."""
81
- if score is None:
82
- return (
83
- '<td style="padding:.3rem .5rem;text-align:center;'
84
- 'background:#f0f0f0;color:#999">—</td>'
85
- )
86
- color = _color_for_score(score)
87
- text = f"{score * 100:.1f}%"
88
- if extra:
89
- text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
90
- return (
91
- f'<td style="padding:.3rem .5rem;text-align:center;'
92
- f'background:{color}">{text}</td>'
93
- )
94
-
95
-
96
- def _table_header(
97
- columns: list[str], engine_label: str,
98
- ) -> str:
99
- """Construit l'entête d'un tableau moteur × colonnes."""
100
- parts = [
101
- '<thead><tr>',
102
- f'<th style="padding:.3rem .5rem;text-align:left;'
103
- f'border-bottom:1px solid var(--border);font-weight:600">'
104
- f'{_e(engine_label)}</th>',
105
- ]
106
- for col in columns:
107
- parts.append(
108
- f'<th style="padding:.3rem .5rem;text-align:center;'
109
- f'border-bottom:1px solid var(--border);font-weight:600">'
110
- f'{_e(col)}</th>'
111
- )
112
- parts.append('</tr></thead>')
113
- return "".join(parts)
114
-
115
-
116
- def _engine_label_cell(name: str) -> str:
117
- return (
118
- f'<td style="padding:.3rem .5rem;font-weight:500;'
119
- f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
120
- )
121
-
122
-
123
- def _section_open(title: str, note: str = "") -> str:
124
- parts = [
125
- '<div class="philological-section" '
126
- 'style="margin:1rem 0;padding:.75rem;'
127
- 'background:var(--bg-secondary);border-radius:6px">',
128
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
129
- ]
130
- if note:
131
- parts.append(
132
- f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
133
- f'{_e(note)}</div>'
134
- )
135
- return "".join(parts)
136
-
137
-
138
- def _section_close() -> str:
139
- return "</div>"
140
-
141
-
142
- def _table_open() -> str:
143
- return (
144
- '<table style="border-collapse:collapse;width:100%;'
145
- 'font-size:.85rem">'
146
- )
147
-
148
-
149
- def _table_close() -> str:
150
- return "</table>"
151
-
152
-
153
- # ──────────────────────────────────────────────────────────────────────────
154
- # Sprint 55 — Précision par bloc Unicode
155
- # ──────────────────────────────────────────────────────────────────────────
156
-
157
-
158
- def build_unicode_blocks_section(
159
- engines_summary: list[dict],
160
- labels: Optional[dict[str, str]] = None,
161
- ) -> str:
162
- relevant = _engines_with_module(engines_summary, "unicode_blocks")
163
- if not relevant:
164
- return ""
165
- labels = labels or {}
166
- title = labels.get(
167
- "philo_unicode_blocks_title", "Précision par bloc Unicode",
168
- )
169
- note = labels.get(
170
- "philo_unicode_blocks_note",
171
- "Pourcentage de caractères correctement restitués par bloc "
172
- "Unicode rencontré dans la GT (hors Basic Latin).",
173
- )
174
- engine_label = labels.get("philo_engine_label", "Moteur")
175
- global_label = labels.get("philo_global_label", "Global")
176
-
177
- # Collecte tous les blocs présents (hors Basic Latin déjà filtré
178
- # par adaptive masking, mais on défilte ici si Basic Latin
179
- # apparaît malgré tout chez certains moteurs).
180
- all_blocks: set[str] = set()
181
- for eng in relevant:
182
- per_block = eng["aggregated_philological"]["unicode_blocks"].get(
183
- "per_block", {},
184
- )
185
- for block in per_block:
186
- if block != "Basic Latin":
187
- all_blocks.add(block)
188
- blocks = sorted(all_blocks)
189
- if not blocks:
190
- return ""
191
-
192
- parts = [_section_open(title, note), _table_open()]
193
- parts.append(_table_header([global_label] + blocks, engine_label))
194
- parts.append("<tbody>")
195
- for eng in relevant:
196
- agg = eng["aggregated_philological"]["unicode_blocks"]
197
- global_acc = agg.get("global_accuracy", 0.0)
198
- n_chars = agg.get("n_chars_total", 0)
199
- parts.append("<tr>")
200
- parts.append(_engine_label_cell(eng["name"]))
201
- parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
202
- per_block = agg.get("per_block", {})
203
- for block in blocks:
204
- stats = per_block.get(block)
205
- if stats and stats.get("total", 0) > 0:
206
- parts.append(_score_cell(
207
- stats["accuracy"], extra=f"n={stats['total']}",
208
- ))
209
- else:
210
- parts.append(_score_cell(None))
211
- parts.append("</tr>")
212
- parts.append("</tbody>")
213
- parts.append(_table_close())
214
- parts.append(_section_close())
215
- return "".join(parts)
216
-
217
-
218
- # (sections suivantes définies plus loin)
219
-
220
-
221
- # ──────────────────────────────────────────────────────────────────────────
222
- # Sprint 56 — Abréviations Capelli médiévales
223
- # ──────────────────────────────────────────────────────────────────────────
224
-
225
-
226
- def build_abbreviations_section(
227
- engines_summary: list[dict],
228
- labels: Optional[dict[str, str]] = None,
229
- ) -> str:
230
- relevant = _engines_with_module(engines_summary, "abbreviations")
231
- if not relevant:
232
- return ""
233
- labels = labels or {}
234
- title = labels.get(
235
- "philo_abbreviations_title",
236
- "Abréviations médiévales (Capelli)",
237
- )
238
- note = labels.get(
239
- "philo_abbreviations_note",
240
- "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
241
- "Expansion = abrégée OU forme développée (per, pro, et…) "
242
- "présente. Le ratio strict/expansion par moteur indique la "
243
- "convention adoptée (diplomatique / modernisante).",
244
- )
245
- engine_label = labels.get("philo_engine_label", "Moteur")
246
- strict_label = labels.get("philo_strict_label", "Strict")
247
- expansion_label = labels.get("philo_expansion_label", "Expansion")
248
- n_label = labels.get("philo_n_total_label", "n total")
249
-
250
- parts = [_section_open(title, note), _table_open()]
251
- parts.append(_table_header(
252
- [strict_label, expansion_label, n_label], engine_label,
253
- ))
254
- parts.append("<tbody>")
255
- for eng in relevant:
256
- agg = eng["aggregated_philological"]["abbreviations"]
257
- parts.append("<tr>")
258
- parts.append(_engine_label_cell(eng["name"]))
259
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
260
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
261
- parts.append(
262
- f'<td style="padding:.3rem .5rem;text-align:center">'
263
- f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
264
- )
265
- parts.append("</tr>")
266
- parts.append("</tbody>")
267
- parts.append(_table_close())
268
- parts.append(_section_close())
269
- return "".join(parts)
270
-
271
-
272
- # ──────────────────────────────────────────────────────────────────────────
273
- # Sprint 57 — Couverture MUFI
274
- # ──────────────────────────────────────────────────────────────────────────
275
-
276
-
277
- def build_mufi_section(
278
- engines_summary: list[dict],
279
- labels: Optional[dict[str, str]] = None,
280
- ) -> str:
281
- relevant = _engines_with_module(engines_summary, "mufi")
282
- if not relevant:
283
- return ""
284
- labels = labels or {}
285
- title = labels.get(
286
- "philo_mufi_title",
287
- "Couverture MUFI (Medieval Unicode Font Initiative)",
288
- )
289
- note = labels.get(
290
- "philo_mufi_note",
291
- "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
292
- "PUA…) correctement restitués dans l'OCR. Critère éditorial "
293
- "central pour les médiévistes.",
294
- )
295
- engine_label = labels.get("philo_engine_label", "Moteur")
296
- coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
297
- n_label = labels.get("philo_n_total_label", "n total")
298
-
299
- parts = [_section_open(title, note), _table_open()]
300
- parts.append(_table_header(
301
- [coverage_label, n_label], engine_label,
302
- ))
303
- parts.append("<tbody>")
304
- for eng in relevant:
305
- agg = eng["aggregated_philological"]["mufi"]
306
- parts.append("<tr>")
307
- parts.append(_engine_label_cell(eng["name"]))
308
- parts.append(_score_cell(agg.get("coverage", 0.0)))
309
- parts.append(
310
- f'<td style="padding:.3rem .5rem;text-align:center">'
311
- f'{agg.get("n_mufi_chars_reference", 0)}</td>'
312
- )
313
- parts.append("</tr>")
314
- parts.append("</tbody>")
315
- parts.append(_table_close())
316
- parts.append(_section_close())
317
- return "".join(parts)
318
-
319
-
320
- # ──────────────────────────────────────────────────────────────────────────
321
- # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
322
- # ──────────────────────────────────────────────────────────────────────────
323
-
324
-
325
- def build_early_modern_section(
326
- engines_summary: list[dict],
327
- labels: Optional[dict[str, str]] = None,
328
- ) -> str:
329
- relevant = _engines_with_module(engines_summary, "early_modern")
330
- if not relevant:
331
- return ""
332
- labels = labels or {}
333
- title = labels.get(
334
- "philo_early_modern_title",
335
- "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
336
- )
337
- note = labels.get(
338
- "philo_early_modern_note",
339
- "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
340
- "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
341
- "Une ligne par moteur, une colonne par catégorie.",
342
- )
343
- engine_label = labels.get("philo_engine_label", "Moteur")
344
- global_label = labels.get("philo_global_label", "Global")
345
-
346
- all_cats: set[str] = set()
347
- for eng in relevant:
348
- all_cats.update(
349
- eng["aggregated_philological"]["early_modern"]
350
- .get("per_category", {}).keys(),
351
- )
352
- cats = sorted(all_cats)
353
- if not cats:
354
- return ""
355
-
356
- parts = [_section_open(title, note), _table_open()]
357
- parts.append(_table_header([global_label] + cats, engine_label))
358
- parts.append("<tbody>")
359
- for eng in relevant:
360
- agg = eng["aggregated_philological"]["early_modern"]
361
- n_total = agg.get("n_markers_reference", 0)
362
- parts.append("<tr>")
363
- parts.append(_engine_label_cell(eng["name"]))
364
- parts.append(_score_cell(
365
- agg.get("global_preservation", 0.0), extra=f"n={n_total}",
366
- ))
367
- per_cat = agg.get("per_category", {})
368
- for cat in cats:
369
- stats = per_cat.get(cat)
370
- if stats and stats.get("total", 0) > 0:
371
- parts.append(_score_cell(
372
- stats["preservation"], extra=f"n={stats['total']}",
373
- ))
374
- else:
375
- parts.append(_score_cell(None))
376
- parts.append("</tr>")
377
- parts.append("</tbody>")
378
- parts.append(_table_close())
379
- parts.append(_section_close())
380
- return "".join(parts)
381
-
382
-
383
- # ──────────────────────────────────────────────────────────────────────────
384
- # Sprint 59 — Archives modernes : strict + expansion par catégorie
385
- # ──────────────────────────────────────────────────────────────────────────
386
-
387
-
388
- def build_modern_archives_section(
389
- engines_summary: list[dict],
390
- labels: Optional[dict[str, str]] = None,
391
- ) -> str:
392
- relevant = _engines_with_module(engines_summary, "modern_archives")
393
- if not relevant:
394
- return ""
395
- labels = labels or {}
396
- title = labels.get(
397
- "philo_modern_archives_title",
398
- "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
399
- )
400
- note = labels.get(
401
- "philo_modern_archives_note",
402
- "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
403
- "Expansion = abrégé OU forme développée. Affiché par "
404
- "catégorie : civilité, ordinaux, monnaie, administratif, "
405
- "état civil, ponctuation typo, latin, biblio, adresse.",
406
- )
407
- engine_label = labels.get("philo_engine_label", "Moteur")
408
- global_label = labels.get("philo_global_label", "Global")
409
- strict_label = labels.get("philo_strict_label", "Strict")
410
- expansion_label = labels.get("philo_expansion_label", "Expansion")
411
-
412
- all_cats: set[str] = set()
413
- for eng in relevant:
414
- all_cats.update(
415
- eng["aggregated_philological"]["modern_archives"]
416
- .get("per_category", {}).keys(),
417
- )
418
- cats = sorted(all_cats)
419
-
420
- parts = [_section_open(title, note)]
421
- parts.append(
422
- '<table style="border-collapse:collapse;width:100%;'
423
- 'font-size:.85rem">'
424
- )
425
- parts.append("<thead><tr>")
426
- parts.append(
427
- f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
- f'border-bottom:1px solid var(--border);font-weight:600">'
429
- f'{_e(engine_label)}</th>'
430
- )
431
- parts.append(
432
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
- f'border-bottom:1px solid var(--border);font-weight:600">'
434
- f'{_e(global_label)}</th>'
435
- )
436
- for cat in cats:
437
- parts.append(
438
- f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
- f'border-bottom:1px solid var(--border);font-weight:600">'
440
- f'{_e(cat)}</th>'
441
- )
442
- parts.append("</tr><tr>")
443
- for _ in range(1 + len(cats)):
444
- parts.append(
445
- f'<th style="padding:.2rem .4rem;text-align:center;'
446
- f'font-size:.75rem;font-weight:500;opacity:.7">'
447
- f'{_e(strict_label)}</th>'
448
- )
449
- parts.append(
450
- f'<th style="padding:.2rem .4rem;text-align:center;'
451
- f'font-size:.75rem;font-weight:500;opacity:.7">'
452
- f'{_e(expansion_label)}</th>'
453
- )
454
- parts.append("</tr></thead>")
455
- parts.append("<tbody>")
456
- for eng in relevant:
457
- agg = eng["aggregated_philological"]["modern_archives"]
458
- parts.append("<tr>")
459
- parts.append(_engine_label_cell(eng["name"]))
460
- parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
461
- parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
462
- per_cat = agg.get("per_category", {})
463
- for cat in cats:
464
- stats = per_cat.get(cat)
465
- if stats and stats.get("n_total", 0) > 0:
466
- parts.append(_score_cell(
467
- stats["strict_score"],
468
- extra=f"n={stats['n_total']}",
469
- ))
470
- parts.append(_score_cell(stats["expansion_score"]))
471
- else:
472
- parts.append(_score_cell(None))
473
- parts.append(_score_cell(None))
474
- parts.append("</tr>")
475
- parts.append("</tbody>")
476
- parts.append(_table_close())
477
- parts.append(_section_close())
478
- return "".join(parts)
479
-
480
-
481
- # ──────────────────────────────────────────────────────────────────────────
482
- # Sprint 60 — Numéraux romains : breakdown 5 statuts
483
- # ──────────────────────────────────────────────────────────────────────────
484
-
485
-
486
- def build_roman_numerals_section(
487
- engines_summary: list[dict],
488
- labels: Optional[dict[str, str]] = None,
489
- ) -> str:
490
- relevant = _engines_with_module(engines_summary, "roman_numerals")
491
- if not relevant:
492
- return ""
493
- labels = labels or {}
494
- title = labels.get(
495
- "philo_roman_numerals_title",
496
- "Numéraux romains : restitution par statut",
497
- )
498
- note = labels.get(
499
- "philo_roman_numerals_note",
500
- "Pour chaque numéral romain de la GT, statut de restitution : "
501
- "strict (forme exacte), case_changed (casse modifiée), "
502
- "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
503
- "Le breakdown indique la convention : majoritaire strict → "
504
- "diplomatique ; majoritaire arabic → modernisation profonde.",
505
- )
506
- engine_label = labels.get("philo_engine_label", "Moteur")
507
- n_label = labels.get("philo_n_total_label", "n total")
508
-
509
- statuses = (
510
- "strict_preserved", "case_changed", "j_dropped",
511
- "converted_to_arabic", "lost",
512
- )
513
- status_labels = {
514
- s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
515
- }
516
-
517
- parts = [_section_open(title, note), _table_open()]
518
- parts.append(_table_header(
519
- [n_label] + [status_labels[s] for s in statuses],
520
- engine_label,
521
- ))
522
- parts.append("<tbody>")
523
- for eng in relevant:
524
- agg = eng["aggregated_philological"]["roman_numerals"]
525
- n_total = agg.get("n_numerals_reference", 0)
526
- per_status = agg.get("per_status", {})
527
- parts.append("<tr>")
528
- parts.append(_engine_label_cell(eng["name"]))
529
- parts.append(
530
- f'<td style="padding:.3rem .5rem;text-align:center">'
531
- f'{n_total}</td>'
532
- )
533
- for status in statuses:
534
- count = per_status.get(status, 0)
535
- if n_total > 0:
536
- ratio = count / n_total
537
- # Pour « lost » on inverse la couleur (un haut taux
538
- # de perte est mauvais). Pour les autres on garde
539
- # la sémantique « plus c'est haut, plus l'OCR a
540
- # adopté ce statut ».
541
- color = (
542
- _color_for_score(1.0 - ratio) if status == "lost"
543
- else _color_for_score(ratio)
544
- )
545
- parts.append(
546
- f'<td style="padding:.3rem .5rem;text-align:center;'
547
- f'background:{color}">{count} '
548
- f'<span style="opacity:.6;font-size:.85em">'
549
- f'({ratio * 100:.0f}%)</span></td>'
550
- )
551
- else:
552
- parts.append(_score_cell(None))
553
- parts.append("</tr>")
554
- parts.append("</tbody>")
555
- parts.append(_table_close())
556
- parts.append(_section_close())
557
- return "".join(parts)
558
-
559
-
560
- # ──────────────────────────────────────────────────────────────────────────
561
- # Agrégateur principal
562
- # ──────────────────────────────────────────────────────────────────────────
563
-
564
-
565
- def build_philological_profile_html(
566
- engines_summary: list[dict],
567
- labels: Optional[dict[str, str]] = None,
568
- ) -> str:
569
- """Assemble les six sections en un bloc unique.
570
-
571
- Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
572
- aucun moteur n'a de signal philologique sur le corpus).
573
- """
574
- sections = [
575
- build_unicode_blocks_section(engines_summary, labels),
576
- build_abbreviations_section(engines_summary, labels),
577
- build_mufi_section(engines_summary, labels),
578
- build_early_modern_section(engines_summary, labels),
579
- build_modern_archives_section(engines_summary, labels),
580
- build_roman_numerals_section(engines_summary, labels),
581
- ]
582
- non_empty = [s for s in sections if s]
583
- if not non_empty:
584
- return ""
585
- labels = labels or {}
586
- main_title = labels.get(
587
- "philo_profile_title", "Profil philologique",
588
- )
589
- main_note = labels.get(
590
- "philo_profile_note",
591
- "Données brutes par catégorie de marqueur philologique. "
592
- "L'outil ne classifie pas la convention adoptée par chaque "
593
- "moteur — c'est au chercheur de lire les chiffres et de "
594
- "conclure selon ses critères éditoriaux.",
595
- )
596
- parts = [
597
- '<div class="philological-profile">',
598
- f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
599
- f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
600
- f'{_e(main_note)}</p>',
601
- ]
602
- parts.extend(non_empty)
603
- parts.append("</div>")
604
- return "".join(parts)
605
-
606
-
607
- __all__ = [
608
- "build_philological_profile_html",
609
- "build_unicode_blocks_section",
610
- "build_abbreviations_section",
611
- "build_mufi_section",
612
- "build_early_modern_section",
613
- "build_modern_archives_section",
614
- "build_roman_numerals_section",
615
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/taxonomy_cooccurrence_render.py DELETED
@@ -1,199 +0,0 @@
1
- """Rendu HTML de la heatmap de co-occurrence taxonomique — Sprint 75.
2
-
3
- A.I.4 chantier 1 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/taxonomy_cooccurrence.py``. Pattern
6
- identique aux autres rendus (Sprints 41/43/62/67/72/74) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Sortie typique
10
- --------------
11
- - ``build_taxonomy_cooccurrence_html(data, labels)`` produit un
12
- bloc complet : titre + note d'usage + heatmap SVG + table des
13
- paires les plus co-occurrentes.
14
- - ``""`` retourné si ``data is None`` ou si la matrice est vide
15
- (rapport adaptatif).
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- from html import escape as _e
21
- from typing import Optional
22
-
23
-
24
- def _color_for_jaccard(j: float) -> str:
25
- """Gradient blanc → bleu profond pour Jaccard ∈ [0, 1].
26
-
27
- Interpolation entre #ffffff (j=0) et #1e3a8a (j=1).
28
- """
29
- f = max(0.0, min(1.0, j))
30
- r = int(255 + (30 - 255) * f)
31
- g = int(255 + (58 - 255) * f)
32
- b = int(255 + (138 - 255) * f)
33
- return f"#{r:02x}{g:02x}{b:02x}"
34
-
35
-
36
- def _text_color_for_bg(j: float) -> str:
37
- """Texte blanc si fond foncé, noir sinon (lisibilité)."""
38
- return "#fff" if j > 0.55 else "#222"
39
-
40
-
41
- def _build_heatmap_svg(
42
- classes: list[str],
43
- matrix: dict[str, dict[str, float]],
44
- *,
45
- cell_size: int = 36,
46
- label_left: int = 130,
47
- label_top: int = 80,
48
- ) -> str:
49
- """Construit la heatmap SVG.
50
-
51
- Cellule = carré coloré ``_color_for_jaccard``, valeur Jaccard
52
- affichée en chiffres si > 0,05. Étiquettes des classes en
53
- colonne (haut) et en ligne (gauche).
54
- """
55
- n = len(classes)
56
- if n == 0:
57
- return ""
58
- width = label_left + n * cell_size + 10
59
- height = label_top + n * cell_size + 10
60
-
61
- parts = [
62
- f'<svg xmlns="http://www.w3.org/2000/svg" '
63
- f'width="{width}" height="{height}" '
64
- f'viewBox="0 0 {width} {height}" '
65
- f'role="img" aria-label="Heatmap Jaccard co-occurrence taxonomique">',
66
- ]
67
- # Étiquettes de colonnes (rotées -45°)
68
- for j, cls in enumerate(classes):
69
- cx = label_left + j * cell_size + cell_size // 2
70
- cy = label_top - 6
71
- parts.append(
72
- f'<text x="{cx}" y="{cy}" '
73
- f'transform="rotate(-45 {cx} {cy})" '
74
- f'font-size="11" fill="#333" text-anchor="start">'
75
- f'{_e(cls)}</text>'
76
- )
77
- # Étiquettes de lignes
78
- for i, cls in enumerate(classes):
79
- rx = label_left - 6
80
- ry = label_top + i * cell_size + cell_size // 2 + 4
81
- parts.append(
82
- f'<text x="{rx}" y="{ry}" '
83
- f'font-size="11" fill="#333" text-anchor="end">'
84
- f'{_e(cls)}</text>'
85
- )
86
- # Cellules
87
- for i, ca in enumerate(classes):
88
- for j, cb in enumerate(classes):
89
- value = matrix.get(ca, {}).get(cb, 0.0)
90
- x = label_left + j * cell_size
91
- y = label_top + i * cell_size
92
- color = _color_for_jaccard(value)
93
- text_color = _text_color_for_bg(value)
94
- parts.append(
95
- f'<rect x="{x}" y="{y}" '
96
- f'width="{cell_size}" height="{cell_size}" '
97
- f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
98
- )
99
- if value > 0.05:
100
- parts.append(
101
- f'<text x="{x + cell_size // 2}" '
102
- f'y="{y + cell_size // 2 + 4}" '
103
- f'font-size="10" fill="{text_color}" '
104
- f'text-anchor="middle">'
105
- f'{value:.2f}</text>'
106
- )
107
- parts.append("</svg>")
108
- return "".join(parts)
109
-
110
-
111
- def _build_top_pairs_table(
112
- top_pairs: list,
113
- labels: dict,
114
- ) -> str:
115
- """Construit la table HTML des paires les plus co-occurrentes."""
116
- if not top_pairs:
117
- return ""
118
- pair_label = labels.get("taxocooc_pair_label", "Paire")
119
- jaccard_label = labels.get("taxocooc_jaccard_label", "Jaccard")
120
-
121
- parts = [
122
- '<table style="border-collapse:collapse;font-size:.85rem;'
123
- 'margin-top:.5rem">',
124
- '<thead><tr>',
125
- f'<th style="padding:.3rem .5rem;text-align:left;'
126
- f'border-bottom:1px solid #ccc;font-weight:600">'
127
- f'{_e(pair_label)}</th>',
128
- f'<th style="padding:.3rem .5rem;text-align:right;'
129
- f'border-bottom:1px solid #ccc;font-weight:600">'
130
- f'{_e(jaccard_label)}</th>',
131
- '</tr></thead><tbody>',
132
- ]
133
- for ca, cb, j in top_pairs:
134
- parts.append(
135
- f'<tr>'
136
- f'<td style="padding:.2rem .5rem">'
137
- f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
138
- f'<td style="padding:.2rem .5rem;text-align:right;'
139
- f'font-family:monospace;background:{_color_for_jaccard(j)};'
140
- f'color:{_text_color_for_bg(j)}">{j:.2f}</td>'
141
- f'</tr>'
142
- )
143
- parts.append("</tbody></table>")
144
- return "".join(parts)
145
-
146
-
147
- def build_taxonomy_cooccurrence_html(
148
- data: Optional[dict],
149
- labels: Optional[dict[str, str]] = None,
150
- ) -> str:
151
- """Construit le bloc HTML complet de co-occurrence taxonomique.
152
-
153
- Retourne ``""`` si ``data is None`` ou matrice vide.
154
- """
155
- if not data:
156
- return ""
157
- classes = data.get("classes") or []
158
- matrix = data.get("cooccurrence_matrix") or {}
159
- if not classes or not matrix:
160
- return ""
161
- labels = labels or {}
162
- title = labels.get(
163
- "taxocooc_title",
164
- "Co-occurrence des classes d'erreur",
165
- )
166
- note = labels.get(
167
- "taxocooc_note",
168
- "Indice de Jaccard au niveau document : 1,00 = ces deux classes "
169
- "apparaissent toujours ensemble ; 0,00 = jamais. Lecture par paires "
170
- "co-occurrentes ci-dessous.",
171
- )
172
- n_docs = data.get("n_documents", 0)
173
- n_docs_label_template = labels.get(
174
- "taxocooc_n_docs", "Calculé sur {n_docs} documents.",
175
- )
176
- n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
177
-
178
- svg = _build_heatmap_svg(classes, matrix)
179
- top_table = _build_top_pairs_table(
180
- data.get("top_pairs") or [], labels,
181
- )
182
-
183
- parts = [
184
- '<div class="taxocooc" style="margin:1rem 0">',
185
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
186
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
187
- f'{_e(note)}</div>',
188
- f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
189
- f'{_e(n_docs_phrase)}</div>',
190
- svg,
191
- top_table,
192
- "</div>",
193
- ]
194
- return "".join(parts)
195
-
196
-
197
- __all__ = [
198
- "build_taxonomy_cooccurrence_html",
199
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/extras/render/taxonomy_intra_doc_render.py DELETED
@@ -1,182 +0,0 @@
1
- """Rendu HTML de la heatmap class × position — Sprint 76.
2
-
3
- A.I.4 chantier 2 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/taxonomy_intra_doc.py``. Pattern
6
- identique aux autres rendus (Sprints 41/43/62/67/72/74/75) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Sortie typique
10
- --------------
11
- Une grille N_classes × N_bins où chaque cellule indique la densité
12
- d'erreurs de cette classe à cette position dans le document.
13
- Lecture immédiate : « ligature_error concentré dans la première
14
- tranche → erreur de marge ; visual_confusion uniformément réparti
15
- → erreur de scribe ».
16
-
17
- Adaptive : si ``data is None`` ou si toutes les classes ont 0
18
- erreur, retourne ``""``.
19
- """
20
-
21
- from __future__ import annotations
22
-
23
- from html import escape as _e
24
- from typing import Optional
25
-
26
-
27
- def _color_for_density(density: float) -> str:
28
- """Gradient blanc → orange profond pour densité ∈ [0, 1].
29
-
30
- Interpolation entre #ffffff (0) et #c2410c (1).
31
- """
32
- f = max(0.0, min(1.0, density))
33
- r = int(255 + (194 - 255) * f)
34
- g = int(255 + (65 - 255) * f)
35
- b = int(255 + (12 - 255) * f)
36
- return f"#{r:02x}{g:02x}{b:02x}"
37
-
38
-
39
- def _text_color_for_bg(density: float) -> str:
40
- return "#fff" if density > 0.55 else "#222"
41
-
42
-
43
- def _build_heatmap_svg(
44
- classes_with_errors: list[str],
45
- per_class: dict[str, list[int]],
46
- n_bins: int,
47
- *,
48
- cell_w: int = 36,
49
- cell_h: int = 26,
50
- label_left: int = 150,
51
- label_top: int = 30,
52
- ) -> str:
53
- """Construit la heatmap SVG class × position."""
54
- n_rows = len(classes_with_errors)
55
- if n_rows == 0:
56
- return ""
57
- width = label_left + n_bins * cell_w + 10
58
- height = label_top + n_rows * cell_h + 30 # +30 pour étiquette X
59
-
60
- # Normalisation : pour chaque classe, densité relative au max
61
- # de cette classe (mise en évidence des positions concentrées).
62
- parts = [
63
- f'<svg xmlns="http://www.w3.org/2000/svg" '
64
- f'width="{width}" height="{height}" '
65
- f'viewBox="0 0 {width} {height}" '
66
- f'role="img" aria-label="Heatmap class taxonomique × position">',
67
- ]
68
- # Étiquettes des colonnes (positions)
69
- for j in range(n_bins):
70
- cx = label_left + j * cell_w + cell_w // 2
71
- cy = label_top - 6
72
- parts.append(
73
- f'<text x="{cx}" y="{cy}" '
74
- f'font-size="10" fill="#666" text-anchor="middle">'
75
- f'{j + 1}</text>'
76
- )
77
- # Cellules
78
- for i, cls in enumerate(classes_with_errors):
79
- # Étiquette de ligne (classe)
80
- rx = label_left - 6
81
- ry = label_top + i * cell_h + cell_h // 2 + 4
82
- parts.append(
83
- f'<text x="{rx}" y="{ry}" '
84
- f'font-size="11" fill="#333" text-anchor="end">'
85
- f'{_e(cls)}</text>'
86
- )
87
- counts = per_class.get(cls, [0] * n_bins)
88
- max_count = max(counts) if counts else 0
89
- for j in range(n_bins):
90
- x = label_left + j * cell_w
91
- y = label_top + i * cell_h
92
- count = counts[j] if j < len(counts) else 0
93
- density = (count / max_count) if max_count > 0 else 0.0
94
- color = _color_for_density(density)
95
- text_color = _text_color_for_bg(density)
96
- parts.append(
97
- f'<rect x="{x}" y="{y}" '
98
- f'width="{cell_w}" height="{cell_h}" '
99
- f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
100
- )
101
- if count > 0:
102
- parts.append(
103
- f'<text x="{x + cell_w // 2}" '
104
- f'y="{y + cell_h // 2 + 4}" '
105
- f'font-size="10" fill="{text_color}" '
106
- f'text-anchor="middle">{count}</text>'
107
- )
108
- # Étiquette axe X en bas
109
- cx_axis = label_left + (n_bins * cell_w) // 2
110
- cy_axis = height - 6
111
- parts.append(
112
- f'<text x="{cx_axis}" y="{cy_axis}" '
113
- f'font-size="11" fill="#666" text-anchor="middle" '
114
- f'font-style="italic">'
115
- f'Position dans le document (1 = début)</text>'
116
- )
117
- parts.append("</svg>")
118
- return "".join(parts)
119
-
120
-
121
- def build_taxonomy_intra_doc_html(
122
- data: Optional[dict],
123
- labels: Optional[dict[str, str]] = None,
124
- ) -> str:
125
- """Construit le bloc HTML complet de la heatmap intra-document.
126
-
127
- Retourne ``""`` si ``data is None`` ou aucune erreur.
128
- """
129
- if not data:
130
- return ""
131
- n_bins = data.get("n_bins", 0)
132
- per_class = data.get("per_class") or {}
133
- total_errors = data.get("total_errors", 0)
134
- if total_errors == 0 or n_bins <= 0:
135
- return ""
136
- # Filtre : uniquement les classes ayant au moins une erreur
137
- classes_with_errors = [
138
- cls for cls, counts in per_class.items()
139
- if isinstance(counts, list) and sum(counts) > 0
140
- ]
141
- if not classes_with_errors:
142
- return ""
143
-
144
- labels = labels or {}
145
- title = labels.get(
146
- "intradoc_title",
147
- "Évolution intra-document des classes d'erreur",
148
- )
149
- note = labels.get(
150
- "intradoc_note",
151
- "Heatmap class × position : densité relative par classe "
152
- "(plus foncé = concentré). Une classe concentrée dans la "
153
- "première colonne suggère une erreur de marge ; "
154
- "une distribution uniforme suggère une erreur de scribe.",
155
- )
156
- n_words_gt = data.get("n_words_gt", 0)
157
- n_words_template = labels.get(
158
- "intradoc_n_words",
159
- "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
160
- )
161
- n_words_phrase = n_words_template.format(
162
- n_words_gt=n_words_gt, n_bins=n_bins,
163
- )
164
-
165
- svg = _build_heatmap_svg(classes_with_errors, per_class, n_bins)
166
-
167
- parts = [
168
- '<div class="intradoc" style="margin:1rem 0">',
169
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
170
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
171
- f'{_e(note)}</div>',
172
- f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
173
- f'{_e(n_words_phrase)}</div>',
174
- svg,
175
- "</div>",
176
- ]
177
- return "".join(parts)
178
-
179
-
180
- __all__ = [
181
- "build_taxonomy_intra_doc_html",
182
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/image_predictive_render.py CHANGED
@@ -1,26 +1,221 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.image_predictive_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.image_predictive_render import * # noqa: F401, F403
11
-
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.image_predictive_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
-
24
- __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
26
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Profil d'image du corpus » — Sprint 93 (A.II.7).
2
 
3
+ Suite directe ``picarones/core/image_predictive.py``. Pattern
4
+ identique aux autres rendus : server-side, pas de JS, anti-
5
+ injection systématique.
6
 
7
+ Vue
8
+ ---
9
+ Deux blocs dans une section unique :
10
+
11
+ 1. **Complexité paléographique** : moyenne, médiane, min, max,
12
+ écart-type sur l'ensemble du corpus.
13
+ 2. **Homogénéité du corpus** : score combiné + détail par
14
+ feature (mean, stdev, contribution normalisée).
15
+
16
+ Adaptive : ``""`` si pas de données.
17
+
18
+ Note d'intégration
19
+ ------------------
20
+ Module pur — l'utilisateur compose :
21
+
22
+ .. code-block:: python
23
+
24
+ from picarones.measurements.image_predictive import aggregate_corpus_predictive
25
+ from picarones.report.image_predictive_render import (
26
+ build_image_predictive_html,
27
+ )
28
+
29
+ qualities = [doc.image_quality.as_dict() for doc in benchmark.docs]
30
+ agg = aggregate_corpus_predictive(qualities)
31
+ html = build_image_predictive_html(agg, labels)
32
  """
33
 
34
+ from __future__ import annotations
35
+
36
+ from html import escape as _e
37
+ from typing import Optional
38
+
39
+
40
+ def _color_for_score(score: float) -> str:
41
+ """Vert (faible) orange → rouge (élevé)."""
42
+ f = max(0.0, min(1.0, score))
43
+ if f < 0.5:
44
+ t = f / 0.5
45
+ r = int(167 + (235 - 167) * t)
46
+ g = int(240 + (180 - 240) * t)
47
+ b = int(167 + (60 - 167) * t)
48
+ else:
49
+ t = (f - 0.5) / 0.5
50
+ r = int(235 + (220 - 235) * t)
51
+ g = int(180 + (50 - 180) * t)
52
+ b = int(60 + (50 - 60) * t)
53
+ return f"#{r:02x}{g:02x}{b:02x}"
54
+
55
+
56
+ _FEATURE_LABEL_KEYS = {
57
+ "noise_level": "imgpred_feat_noise",
58
+ "sharpness_score": "imgpred_feat_sharpness",
59
+ "contrast_score": "imgpred_feat_contrast",
60
+ "rotation_degrees": "imgpred_feat_rotation",
61
+ }
62
+
63
+
64
+ def _render_complexity_block(
65
+ aggregated: dict, labels: dict[str, str],
66
+ ) -> str:
67
+ h_complex = labels.get(
68
+ "imgpred_complexity", "Complexité paléographique",
69
+ )
70
+ h_mean = labels.get("imgpred_mean", "Moyenne")
71
+ h_median = labels.get("imgpred_median", "Médiane")
72
+ h_min = labels.get("imgpred_min", "Min")
73
+ h_max = labels.get("imgpred_max", "Max")
74
+ h_stdev = labels.get("imgpred_stdev", "Écart-type")
75
+ h_docs = labels.get("imgpred_docs", "Docs")
76
+ mean = float(aggregated.get("complexity_mean") or 0.0)
77
+ median = float(aggregated.get("complexity_median") or 0.0)
78
+ mn = float(aggregated.get("complexity_min") or 0.0)
79
+ mx = float(aggregated.get("complexity_max") or 0.0)
80
+ sd = float(aggregated.get("complexity_stdev") or 0.0)
81
+ n_docs = int(aggregated.get("n_docs") or 0)
82
+ color_mean = _color_for_score(mean)
83
+ return (
84
+ f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
85
+ f'{_e(h_complex)}</div>'
86
+ '<table style="border-collapse:collapse;width:100%;'
87
+ 'font-size:.9rem;margin-bottom:.8rem">'
88
+ f'<thead><tr>'
89
+ f'<th style="padding:.4rem .6rem;text-align:right;'
90
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
91
+ f'<th style="padding:.4rem .6rem;text-align:right;'
92
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
93
+ f'<th style="padding:.4rem .6rem;text-align:right;'
94
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
95
+ f'<th style="padding:.4rem .6rem;text-align:right;'
96
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
97
+ f'<th style="padding:.4rem .6rem;text-align:right;'
98
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
99
+ f'<th style="padding:.4rem .6rem;text-align:right;'
100
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
101
+ f'</tr></thead>'
102
+ f'<tbody><tr>'
103
+ f'<td style="padding:.4rem .6rem;text-align:right;'
104
+ f'background:{color_mean};font-family:monospace;font-weight:600">'
105
+ f'{mean:.3f}</td>'
106
+ f'<td style="padding:.4rem .6rem;text-align:right;'
107
+ f'font-family:monospace">{median:.3f}</td>'
108
+ f'<td style="padding:.4rem .6rem;text-align:right;'
109
+ f'font-family:monospace">{mn:.3f}</td>'
110
+ f'<td style="padding:.4rem .6rem;text-align:right;'
111
+ f'font-family:monospace">{mx:.3f}</td>'
112
+ f'<td style="padding:.4rem .6rem;text-align:right;'
113
+ f'font-family:monospace">{sd:.3f}</td>'
114
+ f'<td style="padding:.4rem .6rem;text-align:right;'
115
+ f'font-family:monospace">{n_docs}</td>'
116
+ f'</tr></tbody></table>'
117
+ )
118
+
119
+
120
+ def _render_homogeneity_block(
121
+ homogeneity: dict, labels: dict[str, str],
122
+ ) -> str:
123
+ h_homo = labels.get(
124
+ "imgpred_homogeneity", "Homogénéité du corpus",
125
+ )
126
+ h_feat = labels.get("imgpred_feature", "Feature")
127
+ h_mean = labels.get("imgpred_feat_mean", "Moyenne")
128
+ h_stdev = labels.get("imgpred_feat_stdev", "Écart-type")
129
+ h_norm = labels.get(
130
+ "imgpred_feat_norm", "Contribution normalisée",
131
+ )
132
+ score = float(homogeneity.get("score") or 0.0)
133
+ color = _color_for_score(score)
134
+ parts = [
135
+ f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
136
+ f'{_e(h_homo)} : '
137
+ f'<span style="background:{color};padding:.1rem .4rem;'
138
+ f'border-radius:.3rem;font-family:monospace">{score:.3f}</span>'
139
+ f'</div>',
140
+ '<table style="border-collapse:collapse;width:100%;'
141
+ 'font-size:.9rem">',
142
+ '<thead><tr>',
143
+ ]
144
+ for col in (h_feat, h_mean, h_stdev, h_norm):
145
+ parts.append(
146
+ f'<th style="padding:.4rem .6rem;text-align:left;'
147
+ f'border-bottom:1px solid #ccc;font-weight:600">'
148
+ f'{_e(col)}</th>'
149
+ )
150
+ parts.append("</tr></thead><tbody>")
151
+ per_feat = homogeneity.get("per_feature") or {}
152
+ for key, label_key in _FEATURE_LABEL_KEYS.items():
153
+ if key not in per_feat:
154
+ continue
155
+ slot = per_feat[key]
156
+ feat_label = labels.get(label_key, key)
157
+ feat_mean = float(slot.get("mean") or 0.0)
158
+ feat_stdev = float(slot.get("stdev") or 0.0)
159
+ feat_norm = float(slot.get("normalised") or 0.0)
160
+ norm_color = _color_for_score(feat_norm)
161
+ parts.append(
162
+ f'<tr>'
163
+ f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
164
+ f'<td style="padding:.4rem .6rem;text-align:right;'
165
+ f'font-family:monospace">{feat_mean:.3f}</td>'
166
+ f'<td style="padding:.4rem .6rem;text-align:right;'
167
+ f'font-family:monospace">{feat_stdev:.3f}</td>'
168
+ f'<td style="padding:.4rem .6rem;text-align:right;'
169
+ f'background:{norm_color};font-family:monospace">'
170
+ f'{feat_norm:.3f}</td>'
171
+ f'</tr>'
172
+ )
173
+ parts.append("</tbody></table>")
174
+ return "".join(parts)
175
+
176
+
177
+ def build_image_predictive_html(
178
+ aggregated: Optional[dict],
179
+ labels: Optional[dict[str, str]] = None,
180
+ ) -> str:
181
+ """Construit la vue HTML « Profil d'image du corpus ».
182
+
183
+ Parameters
184
+ ----------
185
+ aggregated:
186
+ Sortie de ``aggregate_corpus_predictive``. Si ``None``
187
+ ou ``n_docs == 0``, retourne ``""``.
188
+ labels:
189
+ Dict i18n. Clés sous le préfixe ``imgpred_*``.
190
+ """
191
+ if not aggregated:
192
+ return ""
193
+ if not aggregated.get("n_docs"):
194
+ return ""
195
+ labels = labels or {}
196
+ title = labels.get(
197
+ "imgpred_title", "Profil d'image du corpus",
198
+ )
199
+ note = labels.get(
200
+ "imgpred_note",
201
+ "Score de complexité paléographique combinant bruit, "
202
+ "flou, faible contraste et rotation. Le score "
203
+ "d'homogénéité signale si la moyenne globale est fiable "
204
+ "(corpus uniforme) ou trompeuse (corpus hétérogène — "
205
+ "voir alors la vue stratifiée).",
206
+ )
207
+ parts = [
208
+ '<section class="imgpred-section" style="margin:1rem 0">',
209
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
210
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
211
+ f'{_e(note)}</div>',
212
+ ]
213
+ parts.append(_render_complexity_block(aggregated, labels))
214
+ homo = aggregated.get("homogeneity")
215
+ if isinstance(homo, dict):
216
+ parts.append(_render_homogeneity_block(homo, labels))
217
+ parts.append("</section>")
218
+ return "".join(parts)
219
+
220
+
221
+ __all__ = ["build_image_predictive_html"]
picarones/report/lexical_modernization_render.py CHANGED
@@ -1,26 +1,119 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.lexical_modernization_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.lexical_modernization_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.lexical_modernization_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
 
24
  __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
26
  ]
 
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.measurements.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
  ]
picarones/report/module_audit_render.py CHANGED
@@ -1,26 +1,173 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.module_audit_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.module_audit_render import * # noqa: F401, F403
11
-
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.module_audit_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
-
24
- __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
26
- ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Modules audités » — Sprint 97 (B.6).
2
 
3
+ Suite directe ``picarones/core/module_policy.py``. Pattern
4
+ identique aux autres rendus : server-side, pas de JS, anti-
5
+ injection systématique.
6
 
7
+ Vue
8
+ ---
9
+ Tableau récapitulatif des modules utilisés dans une pipeline
10
+ composée, chacun avec :
11
+
12
+ - Statut d'audit (✓ vert si tous les checks passent, ✗ rouge
13
+ sinon, avec compte des échecs) ;
14
+ - Métadonnées : version, auteur, licence ;
15
+ - Citation académique si fournie ;
16
+ - Lien vers la homepage si fourni.
17
+
18
+ Adaptive : ``""`` si la liste est vide.
19
+
20
+ Note d'intégration
21
+ ------------------
22
+ Module pur — l'utilisateur compose la liste depuis sa
23
+ ``PipelineSpec`` augmentée des ``ModuleManifest`` :
24
+
25
+ .. code-block:: python
26
+
27
+ from picarones.measurements.module_policy import audit_module
28
+ from picarones.report.module_audit_render import build_module_audit_html
29
+
30
+ audits = []
31
+ for step in pipeline.steps:
32
+ manifest = step.module.manifest # convention applicative
33
+ result = audit_module(step.module, manifest)
34
+ audits.append({
35
+ "manifest": manifest.as_dict(),
36
+ "audit": result.as_dict(),
37
+ })
38
+ html = build_module_audit_html(audits, labels)
39
  """
40
 
41
+ from __future__ import annotations
42
+
43
+ from html import escape as _e
44
+ from typing import Optional
45
+
46
+
47
+ def _passed_badge(passed: bool, n_failed: int, label_pass: str,
48
+ label_fail: str) -> str:
49
+ if passed:
50
+ return (
51
+ f'<span style="color:#16a34a;font-weight:700">'
52
+ f'✓ {_e(label_pass)}</span>'
53
+ )
54
+ return (
55
+ f'<span style="color:#dc2626;font-weight:700">'
56
+ f'✗ {_e(label_fail)} ({n_failed})</span>'
57
+ )
58
+
59
+
60
+ def build_module_audit_html(
61
+ audits: Optional[list],
62
+ labels: Optional[dict[str, str]] = None,
63
+ ) -> str:
64
+ """Construit la vue HTML « Modules audités ».
65
+
66
+ Parameters
67
+ ----------
68
+ audits:
69
+ Liste de dicts ``{"manifest": ManifestDict, "audit":
70
+ AuditResultDict}``. Si vide ou ``None``, retourne ``""``.
71
+ labels:
72
+ Dict i18n. Clés sous le préfixe ``audit_*``.
73
+ """
74
+ if not audits:
75
+ return ""
76
+ rows = [
77
+ a for a in audits
78
+ if isinstance(a, dict)
79
+ and isinstance(a.get("manifest"), dict)
80
+ and isinstance(a.get("audit"), dict)
81
+ ]
82
+ if not rows:
83
+ return ""
84
+ labels = labels or {}
85
+ title = labels.get("audit_title", "Modules audités")
86
+ note = labels.get(
87
+ "audit_note",
88
+ "Récapitulatif des modules utilisés dans la pipeline "
89
+ "composée. Un module qui ne passe pas l'audit n'est "
90
+ "pas exécutable. Métadonnées issues du manifest fourni "
91
+ "par le contributeur (auteur, licence, citation).",
92
+ )
93
+ label_pass = labels.get("audit_pass", "audit OK")
94
+ label_fail = labels.get("audit_fail", "checks échoués")
95
+ h_module = labels.get("audit_module", "Module")
96
+ h_status = labels.get("audit_status", "Audit")
97
+ h_version = labels.get("audit_version", "Version")
98
+ h_author = labels.get("audit_author", "Auteur")
99
+ h_license = labels.get("audit_license", "Licence")
100
+ h_io = labels.get("audit_io", "Entrée → sortie")
101
+ h_citation = labels.get("audit_citation", "Citation")
102
+ h_homepage = labels.get("audit_homepage", "Page projet")
103
+
104
+ parts = [
105
+ '<section class="audit-section" style="margin:1rem 0">',
106
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
107
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
108
+ f'{_e(note)}</div>',
109
+ '<table style="border-collapse:collapse;width:100%;'
110
+ 'font-size:.9rem">',
111
+ '<thead><tr>',
112
+ ]
113
+ for col in (h_module, h_status, h_version, h_author,
114
+ h_license, h_io, h_citation, h_homepage):
115
+ parts.append(
116
+ f'<th style="padding:.4rem .6rem;text-align:left;'
117
+ f'border-bottom:1px solid #ccc;font-weight:600">'
118
+ f'{_e(col)}</th>'
119
+ )
120
+ parts.append("</tr></thead><tbody>")
121
+
122
+ for entry in rows:
123
+ manifest = entry["manifest"]
124
+ audit = entry["audit"]
125
+ name = str(manifest.get("name") or "?")
126
+ version = str(manifest.get("version") or "—")
127
+ author = str(manifest.get("author") or "—")
128
+ license_ = str(manifest.get("license") or "—")
129
+ in_types = ", ".join(manifest.get("input_types") or []) or "—"
130
+ out_types = ", ".join(manifest.get("output_types") or []) or "—"
131
+ citation = manifest.get("citation") or ""
132
+ homepage = manifest.get("homepage") or ""
133
+ passed = bool(audit.get("passed"))
134
+ n_failed = int(audit.get("n_failed") or 0)
135
+ status_cell = _passed_badge(
136
+ passed, n_failed, label_pass, label_fail,
137
+ )
138
+ # Citation : tronqué si trop long
139
+ citation_str = str(citation)[:120]
140
+ if len(str(citation)) > 120:
141
+ citation_str += "…"
142
+ citation_cell = (
143
+ _e(citation_str) if citation_str.strip() else "—"
144
+ )
145
+ # Homepage : on n'auto-link **pas** (anti-injection +
146
+ # honnêteté : l'URL peut pointer ailleurs). On affiche
147
+ # le texte échappé tel quel.
148
+ homepage_cell = (
149
+ _e(str(homepage))[:80] + ("…" if len(str(homepage)) > 80 else "")
150
+ ) if str(homepage).strip() else "—"
151
+ parts.append(
152
+ f'<tr>'
153
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
154
+ f'{_e(name)}</td>'
155
+ f'<td style="padding:.4rem .6rem">{status_cell}</td>'
156
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
157
+ f'{_e(version)}</td>'
158
+ f'<td style="padding:.4rem .6rem">{_e(author)}</td>'
159
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
160
+ f'{_e(license_)}</td>'
161
+ f'<td style="padding:.4rem .6rem;font-family:monospace;'
162
+ f'font-size:.8rem">{_e(in_types)} → {_e(out_types)}</td>'
163
+ f'<td style="padding:.4rem .6rem;font-size:.8rem;'
164
+ f'opacity:.85">{citation_cell}</td>'
165
+ f'<td style="padding:.4rem .6rem;font-family:monospace;'
166
+ f'font-size:.8rem">{homepage_cell}</td>'
167
+ f'</tr>'
168
+ )
169
+ parts.append("</tbody></table></section>")
170
+ return "".join(parts)
171
+
172
+
173
+ __all__ = ["build_module_audit_html"]
picarones/report/philological_render.py CHANGED
@@ -1,26 +1,615 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.philological_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.philological_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.philological_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
 
24
  __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
 
 
 
 
 
 
26
  ]
 
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
+
40
+ # ──────────────────────────────────────────────────────────────────────────
41
+ # Helpers de coloration
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+
44
+
45
+ def _color_for_score(score: float) -> str:
46
+ """Gradient rouge → jaune → vert proportionnel à ``score`` ∈ [0, 1].
47
+
48
+ Identique à ``ner_render._color_for_f1``. Les scores
49
+ philologiques (preservation, coverage, accuracy) suivent la même
50
+ sémantique « plus c'est haut, mieux c'est » donc le gradient
51
+ est valide.
52
+ """
53
+ f = max(0.0, min(1.0, score))
54
+ if f <= 0.5:
55
+ ratio = f / 0.5
56
+ r = int(220 + (240 - 220) * ratio)
57
+ g = int(100 + (220 - 100) * ratio)
58
+ b = int(100 + (130 - 100) * ratio)
59
+ else:
60
+ ratio = (f - 0.5) / 0.5
61
+ r = int(240 + (130 - 240) * ratio)
62
+ g = int(220 + (200 - 220) * ratio)
63
+ b = int(130 + (130 - 130) * ratio)
64
+ return f"#{r:02x}{g:02x}{b:02x}"
65
+
66
+
67
+ def _engines_with_module(
68
+ engines_summary: list[dict], module: str,
69
+ ) -> list[dict]:
70
+ """Filtre les moteurs ayant des données pour le module donné."""
71
+ out: list[dict] = []
72
+ for eng in engines_summary:
73
+ agg = eng.get("aggregated_philological") or {}
74
+ if module in agg and agg[module]:
75
+ out.append(eng)
76
+ return out
77
+
78
+
79
+ def _score_cell(score: Optional[float], extra: str = "") -> str:
80
+ """Rend une cellule colorée. ``None`` → cellule grise « — »."""
81
+ if score is None:
82
+ return (
83
+ '<td style="padding:.3rem .5rem;text-align:center;'
84
+ 'background:#f0f0f0;color:#999">—</td>'
85
+ )
86
+ color = _color_for_score(score)
87
+ text = f"{score * 100:.1f}%"
88
+ if extra:
89
+ text += f" <span style=\"opacity:.6;font-size:.85em\">({_e(extra)})</span>"
90
+ return (
91
+ f'<td style="padding:.3rem .5rem;text-align:center;'
92
+ f'background:{color}">{text}</td>'
93
+ )
94
+
95
+
96
+ def _table_header(
97
+ columns: list[str], engine_label: str,
98
+ ) -> str:
99
+ """Construit l'entête d'un tableau moteur × colonnes."""
100
+ parts = [
101
+ '<thead><tr>',
102
+ f'<th style="padding:.3rem .5rem;text-align:left;'
103
+ f'border-bottom:1px solid var(--border);font-weight:600">'
104
+ f'{_e(engine_label)}</th>',
105
+ ]
106
+ for col in columns:
107
+ parts.append(
108
+ f'<th style="padding:.3rem .5rem;text-align:center;'
109
+ f'border-bottom:1px solid var(--border);font-weight:600">'
110
+ f'{_e(col)}</th>'
111
+ )
112
+ parts.append('</tr></thead>')
113
+ return "".join(parts)
114
+
115
+
116
+ def _engine_label_cell(name: str) -> str:
117
+ return (
118
+ f'<td style="padding:.3rem .5rem;font-weight:500;'
119
+ f'border-bottom:1px solid var(--border-light)">{_e(name)}</td>'
120
+ )
121
+
122
+
123
+ def _section_open(title: str, note: str = "") -> str:
124
+ parts = [
125
+ '<div class="philological-section" '
126
+ 'style="margin:1rem 0;padding:.75rem;'
127
+ 'background:var(--bg-secondary);border-radius:6px">',
128
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
129
+ ]
130
+ if note:
131
+ parts.append(
132
+ f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
133
+ f'{_e(note)}</div>'
134
+ )
135
+ return "".join(parts)
136
+
137
+
138
+ def _section_close() -> str:
139
+ return "</div>"
140
+
141
+
142
+ def _table_open() -> str:
143
+ return (
144
+ '<table style="border-collapse:collapse;width:100%;'
145
+ 'font-size:.85rem">'
146
+ )
147
+
148
+
149
+ def _table_close() -> str:
150
+ return "</table>"
151
+
152
+
153
+ # ──────────────────────────────────────────────────────────────────────────
154
+ # Sprint 55 — Précision par bloc Unicode
155
+ # ──────────────────────────────────────────────────────────────────────────
156
+
157
+
158
+ def build_unicode_blocks_section(
159
+ engines_summary: list[dict],
160
+ labels: Optional[dict[str, str]] = None,
161
+ ) -> str:
162
+ relevant = _engines_with_module(engines_summary, "unicode_blocks")
163
+ if not relevant:
164
+ return ""
165
+ labels = labels or {}
166
+ title = labels.get(
167
+ "philo_unicode_blocks_title", "Précision par bloc Unicode",
168
+ )
169
+ note = labels.get(
170
+ "philo_unicode_blocks_note",
171
+ "Pourcentage de caractères correctement restitués par bloc "
172
+ "Unicode rencontré dans la GT (hors Basic Latin).",
173
+ )
174
+ engine_label = labels.get("philo_engine_label", "Moteur")
175
+ global_label = labels.get("philo_global_label", "Global")
176
+
177
+ # Collecte tous les blocs présents (hors Basic Latin déjà filtré
178
+ # par adaptive masking, mais on défilte ici si Basic Latin
179
+ # apparaît malgré tout chez certains moteurs).
180
+ all_blocks: set[str] = set()
181
+ for eng in relevant:
182
+ per_block = eng["aggregated_philological"]["unicode_blocks"].get(
183
+ "per_block", {},
184
+ )
185
+ for block in per_block:
186
+ if block != "Basic Latin":
187
+ all_blocks.add(block)
188
+ blocks = sorted(all_blocks)
189
+ if not blocks:
190
+ return ""
191
+
192
+ parts = [_section_open(title, note), _table_open()]
193
+ parts.append(_table_header([global_label] + blocks, engine_label))
194
+ parts.append("<tbody>")
195
+ for eng in relevant:
196
+ agg = eng["aggregated_philological"]["unicode_blocks"]
197
+ global_acc = agg.get("global_accuracy", 0.0)
198
+ n_chars = agg.get("n_chars_total", 0)
199
+ parts.append("<tr>")
200
+ parts.append(_engine_label_cell(eng["name"]))
201
+ parts.append(_score_cell(global_acc, extra=f"n={n_chars}"))
202
+ per_block = agg.get("per_block", {})
203
+ for block in blocks:
204
+ stats = per_block.get(block)
205
+ if stats and stats.get("total", 0) > 0:
206
+ parts.append(_score_cell(
207
+ stats["accuracy"], extra=f"n={stats['total']}",
208
+ ))
209
+ else:
210
+ parts.append(_score_cell(None))
211
+ parts.append("</tr>")
212
+ parts.append("</tbody>")
213
+ parts.append(_table_close())
214
+ parts.append(_section_close())
215
+ return "".join(parts)
216
+
217
+
218
+ # (sections suivantes définies plus loin)
219
+
220
+
221
+ # ──────────────────────────────────────────────────────────────────────────
222
+ # Sprint 56 — Abréviations Capelli médiévales
223
+ # ──────────────────────────────────────────────────────────────────────────
224
+
225
+
226
+ def build_abbreviations_section(
227
+ engines_summary: list[dict],
228
+ labels: Optional[dict[str, str]] = None,
229
+ ) -> str:
230
+ relevant = _engines_with_module(engines_summary, "abbreviations")
231
+ if not relevant:
232
+ return ""
233
+ labels = labels or {}
234
+ title = labels.get(
235
+ "philo_abbreviations_title",
236
+ "Abréviations médiévales (Capelli)",
237
+ )
238
+ note = labels.get(
239
+ "philo_abbreviations_note",
240
+ "Strict = forme abrégée (ꝑ, ꝓ, ⁊…) préservée telle quelle ; "
241
+ "Expansion = abrégée OU forme développée (per, pro, et…) "
242
+ "présente. Le ratio strict/expansion par moteur indique la "
243
+ "convention adoptée (diplomatique / modernisante).",
244
+ )
245
+ engine_label = labels.get("philo_engine_label", "Moteur")
246
+ strict_label = labels.get("philo_strict_label", "Strict")
247
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
248
+ n_label = labels.get("philo_n_total_label", "n total")
249
+
250
+ parts = [_section_open(title, note), _table_open()]
251
+ parts.append(_table_header(
252
+ [strict_label, expansion_label, n_label], engine_label,
253
+ ))
254
+ parts.append("<tbody>")
255
+ for eng in relevant:
256
+ agg = eng["aggregated_philological"]["abbreviations"]
257
+ parts.append("<tr>")
258
+ parts.append(_engine_label_cell(eng["name"]))
259
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
260
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
261
+ parts.append(
262
+ f'<td style="padding:.3rem .5rem;text-align:center">'
263
+ f'{agg.get("n_abbreviations_in_reference", 0)}</td>'
264
+ )
265
+ parts.append("</tr>")
266
+ parts.append("</tbody>")
267
+ parts.append(_table_close())
268
+ parts.append(_section_close())
269
+ return "".join(parts)
270
+
271
+
272
+ # ──────────────────────────────────────────────────────────────────────────
273
+ # Sprint 57 — Couverture MUFI
274
+ # ──────────────────────────────────────────────────────────────────────────
275
+
276
+
277
+ def build_mufi_section(
278
+ engines_summary: list[dict],
279
+ labels: Optional[dict[str, str]] = None,
280
+ ) -> str:
281
+ relevant = _engines_with_module(engines_summary, "mufi")
282
+ if not relevant:
283
+ return ""
284
+ labels = labels or {}
285
+ title = labels.get(
286
+ "philo_mufi_title",
287
+ "Couverture MUFI (Medieval Unicode Font Initiative)",
288
+ )
289
+ note = labels.get(
290
+ "philo_mufi_note",
291
+ "Taux de caractères MUFI de la GT (þ, ð, ƿ, ſ, æ, lettres "
292
+ "PUA…) correctement restitués dans l'OCR. Critère éditorial "
293
+ "central pour les médiévistes.",
294
+ )
295
+ engine_label = labels.get("philo_engine_label", "Moteur")
296
+ coverage_label = labels.get("philo_mufi_coverage_label", "Couverture")
297
+ n_label = labels.get("philo_n_total_label", "n total")
298
+
299
+ parts = [_section_open(title, note), _table_open()]
300
+ parts.append(_table_header(
301
+ [coverage_label, n_label], engine_label,
302
+ ))
303
+ parts.append("<tbody>")
304
+ for eng in relevant:
305
+ agg = eng["aggregated_philological"]["mufi"]
306
+ parts.append("<tr>")
307
+ parts.append(_engine_label_cell(eng["name"]))
308
+ parts.append(_score_cell(agg.get("coverage", 0.0)))
309
+ parts.append(
310
+ f'<td style="padding:.3rem .5rem;text-align:center">'
311
+ f'{agg.get("n_mufi_chars_reference", 0)}</td>'
312
+ )
313
+ parts.append("</tr>")
314
+ parts.append("</tbody>")
315
+ parts.append(_table_close())
316
+ parts.append(_section_close())
317
+ return "".join(parts)
318
+
319
+
320
+ # ──────────────────────────────────────────────────────────────────────────
321
+ # Sprint 58 — Marqueurs typographiques imprimé ancien (heatmap)
322
+ # ──────────────────────────────────────────────────────────────────────────
323
+
324
+
325
+ def build_early_modern_section(
326
+ engines_summary: list[dict],
327
+ labels: Optional[dict[str, str]] = None,
328
+ ) -> str:
329
+ relevant = _engines_with_module(engines_summary, "early_modern")
330
+ if not relevant:
331
+ return ""
332
+ labels = labels or {}
333
+ title = labels.get(
334
+ "philo_early_modern_title",
335
+ "Marqueurs typographiques imprimé ancien (XVIᵉ-XVIIIᵉ)",
336
+ )
337
+ note = labels.get(
338
+ "philo_early_modern_note",
339
+ "Préservation des ligatures (fi fl ff), s long (ſ), i sans "
340
+ "point (ı), esperluette (&) et tildes nasaux (ã õ ñ). "
341
+ "Une ligne par moteur, une colonne par catégorie.",
342
+ )
343
+ engine_label = labels.get("philo_engine_label", "Moteur")
344
+ global_label = labels.get("philo_global_label", "Global")
345
+
346
+ all_cats: set[str] = set()
347
+ for eng in relevant:
348
+ all_cats.update(
349
+ eng["aggregated_philological"]["early_modern"]
350
+ .get("per_category", {}).keys(),
351
+ )
352
+ cats = sorted(all_cats)
353
+ if not cats:
354
+ return ""
355
+
356
+ parts = [_section_open(title, note), _table_open()]
357
+ parts.append(_table_header([global_label] + cats, engine_label))
358
+ parts.append("<tbody>")
359
+ for eng in relevant:
360
+ agg = eng["aggregated_philological"]["early_modern"]
361
+ n_total = agg.get("n_markers_reference", 0)
362
+ parts.append("<tr>")
363
+ parts.append(_engine_label_cell(eng["name"]))
364
+ parts.append(_score_cell(
365
+ agg.get("global_preservation", 0.0), extra=f"n={n_total}",
366
+ ))
367
+ per_cat = agg.get("per_category", {})
368
+ for cat in cats:
369
+ stats = per_cat.get(cat)
370
+ if stats and stats.get("total", 0) > 0:
371
+ parts.append(_score_cell(
372
+ stats["preservation"], extra=f"n={stats['total']}",
373
+ ))
374
+ else:
375
+ parts.append(_score_cell(None))
376
+ parts.append("</tr>")
377
+ parts.append("</tbody>")
378
+ parts.append(_table_close())
379
+ parts.append(_section_close())
380
+ return "".join(parts)
381
+
382
+
383
+ # ──────────────────────────────────────────────────────────────────────────
384
+ # Sprint 59 — Archives modernes : strict + expansion par catégorie
385
+ # ──────────────────────────────────────────────────────────────────────────
386
+
387
+
388
+ def build_modern_archives_section(
389
+ engines_summary: list[dict],
390
+ labels: Optional[dict[str, str]] = None,
391
+ ) -> str:
392
+ relevant = _engines_with_module(engines_summary, "modern_archives")
393
+ if not relevant:
394
+ return ""
395
+ labels = labels or {}
396
+ title = labels.get(
397
+ "philo_modern_archives_title",
398
+ "Abréviations des archives modernes (XIXᵉ-XXᵉ)",
399
+ )
400
+ note = labels.get(
401
+ "philo_modern_archives_note",
402
+ "Strict = abrégé préservé (Mme, S.A.R., bd, vol., …) ; "
403
+ "Expansion = abrégé OU forme développée. Affiché par "
404
+ "catégorie : civilité, ordinaux, monnaie, administratif, "
405
+ "état civil, ponctuation typo, latin, biblio, adresse.",
406
+ )
407
+ engine_label = labels.get("philo_engine_label", "Moteur")
408
+ global_label = labels.get("philo_global_label", "Global")
409
+ strict_label = labels.get("philo_strict_label", "Strict")
410
+ expansion_label = labels.get("philo_expansion_label", "Expansion")
411
+
412
+ all_cats: set[str] = set()
413
+ for eng in relevant:
414
+ all_cats.update(
415
+ eng["aggregated_philological"]["modern_archives"]
416
+ .get("per_category", {}).keys(),
417
+ )
418
+ cats = sorted(all_cats)
419
+
420
+ parts = [_section_open(title, note)]
421
+ parts.append(
422
+ '<table style="border-collapse:collapse;width:100%;'
423
+ 'font-size:.85rem">'
424
+ )
425
+ parts.append("<thead><tr>")
426
+ parts.append(
427
+ f'<th rowspan="2" style="padding:.3rem .5rem;text-align:left;'
428
+ f'border-bottom:1px solid var(--border);font-weight:600">'
429
+ f'{_e(engine_label)}</th>'
430
+ )
431
+ parts.append(
432
+ f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
433
+ f'border-bottom:1px solid var(--border);font-weight:600">'
434
+ f'{_e(global_label)}</th>'
435
+ )
436
+ for cat in cats:
437
+ parts.append(
438
+ f'<th colspan="2" style="padding:.3rem .5rem;text-align:center;'
439
+ f'border-bottom:1px solid var(--border);font-weight:600">'
440
+ f'{_e(cat)}</th>'
441
+ )
442
+ parts.append("</tr><tr>")
443
+ for _ in range(1 + len(cats)):
444
+ parts.append(
445
+ f'<th style="padding:.2rem .4rem;text-align:center;'
446
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
447
+ f'{_e(strict_label)}</th>'
448
+ )
449
+ parts.append(
450
+ f'<th style="padding:.2rem .4rem;text-align:center;'
451
+ f'font-size:.75rem;font-weight:500;opacity:.7">'
452
+ f'{_e(expansion_label)}</th>'
453
+ )
454
+ parts.append("</tr></thead>")
455
+ parts.append("<tbody>")
456
+ for eng in relevant:
457
+ agg = eng["aggregated_philological"]["modern_archives"]
458
+ parts.append("<tr>")
459
+ parts.append(_engine_label_cell(eng["name"]))
460
+ parts.append(_score_cell(agg.get("global_strict_score", 0.0)))
461
+ parts.append(_score_cell(agg.get("global_expansion_score", 0.0)))
462
+ per_cat = agg.get("per_category", {})
463
+ for cat in cats:
464
+ stats = per_cat.get(cat)
465
+ if stats and stats.get("n_total", 0) > 0:
466
+ parts.append(_score_cell(
467
+ stats["strict_score"],
468
+ extra=f"n={stats['n_total']}",
469
+ ))
470
+ parts.append(_score_cell(stats["expansion_score"]))
471
+ else:
472
+ parts.append(_score_cell(None))
473
+ parts.append(_score_cell(None))
474
+ parts.append("</tr>")
475
+ parts.append("</tbody>")
476
+ parts.append(_table_close())
477
+ parts.append(_section_close())
478
+ return "".join(parts)
479
+
480
+
481
+ # ──────────────────────────────────────────────────────────────────────────
482
+ # Sprint 60 — Numéraux romains : breakdown 5 statuts
483
+ # ──────────────────────────────────────────────────────────────────────────
484
+
485
+
486
+ def build_roman_numerals_section(
487
+ engines_summary: list[dict],
488
+ labels: Optional[dict[str, str]] = None,
489
+ ) -> str:
490
+ relevant = _engines_with_module(engines_summary, "roman_numerals")
491
+ if not relevant:
492
+ return ""
493
+ labels = labels or {}
494
+ title = labels.get(
495
+ "philo_roman_numerals_title",
496
+ "Numéraux romains : restitution par statut",
497
+ )
498
+ note = labels.get(
499
+ "philo_roman_numerals_note",
500
+ "Pour chaque numéral romain de la GT, statut de restitution : "
501
+ "strict (forme exacte), case_changed (casse modifiée), "
502
+ "j_dropped (j médiéval normalisé), converted_to_arabic, lost. "
503
+ "Le breakdown indique la convention : majoritaire strict → "
504
+ "diplomatique ; majoritaire arabic → modernisation profonde.",
505
+ )
506
+ engine_label = labels.get("philo_engine_label", "Moteur")
507
+ n_label = labels.get("philo_n_total_label", "n total")
508
+
509
+ statuses = (
510
+ "strict_preserved", "case_changed", "j_dropped",
511
+ "converted_to_arabic", "lost",
512
+ )
513
+ status_labels = {
514
+ s: labels.get(f"philo_roman_status_{s}", s) for s in statuses
515
+ }
516
+
517
+ parts = [_section_open(title, note), _table_open()]
518
+ parts.append(_table_header(
519
+ [n_label] + [status_labels[s] for s in statuses],
520
+ engine_label,
521
+ ))
522
+ parts.append("<tbody>")
523
+ for eng in relevant:
524
+ agg = eng["aggregated_philological"]["roman_numerals"]
525
+ n_total = agg.get("n_numerals_reference", 0)
526
+ per_status = agg.get("per_status", {})
527
+ parts.append("<tr>")
528
+ parts.append(_engine_label_cell(eng["name"]))
529
+ parts.append(
530
+ f'<td style="padding:.3rem .5rem;text-align:center">'
531
+ f'{n_total}</td>'
532
+ )
533
+ for status in statuses:
534
+ count = per_status.get(status, 0)
535
+ if n_total > 0:
536
+ ratio = count / n_total
537
+ # Pour « lost » on inverse la couleur (un haut taux
538
+ # de perte est mauvais). Pour les autres on garde
539
+ # la sémantique « plus c'est haut, plus l'OCR a
540
+ # adopté ce statut ».
541
+ color = (
542
+ _color_for_score(1.0 - ratio) if status == "lost"
543
+ else _color_for_score(ratio)
544
+ )
545
+ parts.append(
546
+ f'<td style="padding:.3rem .5rem;text-align:center;'
547
+ f'background:{color}">{count} '
548
+ f'<span style="opacity:.6;font-size:.85em">'
549
+ f'({ratio * 100:.0f}%)</span></td>'
550
+ )
551
+ else:
552
+ parts.append(_score_cell(None))
553
+ parts.append("</tr>")
554
+ parts.append("</tbody>")
555
+ parts.append(_table_close())
556
+ parts.append(_section_close())
557
+ return "".join(parts)
558
+
559
+
560
+ # ──────────────────────────────────────────────────────────────────────────
561
+ # Agrégateur principal
562
+ # ──────────────────────────────────────────────────────────────────────────
563
+
564
+
565
+ def build_philological_profile_html(
566
+ engines_summary: list[dict],
567
+ labels: Optional[dict[str, str]] = None,
568
+ ) -> str:
569
+ """Assemble les six sections en un bloc unique.
570
+
571
+ Retourne ``""`` si aucune section n'a de contenu (c.-à-d.
572
+ aucun moteur n'a de signal philologique sur le corpus).
573
+ """
574
+ sections = [
575
+ build_unicode_blocks_section(engines_summary, labels),
576
+ build_abbreviations_section(engines_summary, labels),
577
+ build_mufi_section(engines_summary, labels),
578
+ build_early_modern_section(engines_summary, labels),
579
+ build_modern_archives_section(engines_summary, labels),
580
+ build_roman_numerals_section(engines_summary, labels),
581
+ ]
582
+ non_empty = [s for s in sections if s]
583
+ if not non_empty:
584
+ return ""
585
+ labels = labels or {}
586
+ main_title = labels.get(
587
+ "philo_profile_title", "Profil philologique",
588
+ )
589
+ main_note = labels.get(
590
+ "philo_profile_note",
591
+ "Données brutes par catégorie de marqueur philologique. "
592
+ "L'outil ne classifie pas la convention adoptée par chaque "
593
+ "moteur — c'est au chercheur de lire les chiffres et de "
594
+ "conclure selon ses critères éditoriaux.",
595
+ )
596
+ parts = [
597
+ '<div class="philological-profile">',
598
+ f'<h3 style="margin-top:0">{_e(main_title)}</h3>',
599
+ f'<p style="font-size:.85rem;opacity:.8;margin-bottom:.5rem">'
600
+ f'{_e(main_note)}</p>',
601
+ ]
602
+ parts.extend(non_empty)
603
+ parts.append("</div>")
604
+ return "".join(parts)
605
 
 
 
 
 
 
 
 
 
 
 
 
606
 
607
  __all__ = [
608
+ "build_philological_profile_html",
609
+ "build_unicode_blocks_section",
610
+ "build_abbreviations_section",
611
+ "build_mufi_section",
612
+ "build_early_modern_section",
613
+ "build_modern_archives_section",
614
+ "build_roman_numerals_section",
615
  ]
picarones/report/taxonomy_cooccurrence_render.py CHANGED
@@ -1,26 +1,199 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.taxonomy_cooccurrence_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.taxonomy_cooccurrence_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.taxonomy_cooccurrence_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
 
24
  __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
26
  ]
 
1
+ """Rendu HTML de la heatmap de co-occurrence taxonomique — Sprint 75.
2
 
3
+ A.I.4 chantier 1 du plan d'évolution 2026.
 
 
4
 
5
+ Suite directe ``picarones/core/taxonomy_cooccurrence.py``. Pattern
6
+ identique aux autres rendus (Sprints 41/43/62/67/72/74) :
7
+ **server-side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Sortie typique
10
+ --------------
11
+ - ``build_taxonomy_cooccurrence_html(data, labels)`` produit un
12
+ bloc complet : titre + note d'usage + heatmap SVG + table des
13
+ paires les plus co-occurrentes.
14
+ - ``""`` retourné si ``data is None`` ou si la matrice est vide
15
+ (rapport adaptatif).
16
  """
17
 
18
+ from __future__ import annotations
19
+
20
+ from html import escape as _e
21
+ from typing import Optional
22
+
23
+
24
+ def _color_for_jaccard(j: float) -> str:
25
+ """Gradient blanc → bleu profond pour Jaccard ∈ [0, 1].
26
+
27
+ Interpolation entre #ffffff (j=0) et #1e3a8a (j=1).
28
+ """
29
+ f = max(0.0, min(1.0, j))
30
+ r = int(255 + (30 - 255) * f)
31
+ g = int(255 + (58 - 255) * f)
32
+ b = int(255 + (138 - 255) * f)
33
+ return f"#{r:02x}{g:02x}{b:02x}"
34
+
35
+
36
+ def _text_color_for_bg(j: float) -> str:
37
+ """Texte blanc si fond foncé, noir sinon (lisibilité)."""
38
+ return "#fff" if j > 0.55 else "#222"
39
+
40
+
41
+ def _build_heatmap_svg(
42
+ classes: list[str],
43
+ matrix: dict[str, dict[str, float]],
44
+ *,
45
+ cell_size: int = 36,
46
+ label_left: int = 130,
47
+ label_top: int = 80,
48
+ ) -> str:
49
+ """Construit la heatmap SVG.
50
+
51
+ Cellule = carré coloré ``_color_for_jaccard``, valeur Jaccard
52
+ affichée en chiffres si > 0,05. Étiquettes des classes en
53
+ colonne (haut) et en ligne (gauche).
54
+ """
55
+ n = len(classes)
56
+ if n == 0:
57
+ return ""
58
+ width = label_left + n * cell_size + 10
59
+ height = label_top + n * cell_size + 10
60
+
61
+ parts = [
62
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
63
+ f'width="{width}" height="{height}" '
64
+ f'viewBox="0 0 {width} {height}" '
65
+ f'role="img" aria-label="Heatmap Jaccard co-occurrence taxonomique">',
66
+ ]
67
+ # Étiquettes de colonnes (rotées -45°)
68
+ for j, cls in enumerate(classes):
69
+ cx = label_left + j * cell_size + cell_size // 2
70
+ cy = label_top - 6
71
+ parts.append(
72
+ f'<text x="{cx}" y="{cy}" '
73
+ f'transform="rotate(-45 {cx} {cy})" '
74
+ f'font-size="11" fill="#333" text-anchor="start">'
75
+ f'{_e(cls)}</text>'
76
+ )
77
+ # Étiquettes de lignes
78
+ for i, cls in enumerate(classes):
79
+ rx = label_left - 6
80
+ ry = label_top + i * cell_size + cell_size // 2 + 4
81
+ parts.append(
82
+ f'<text x="{rx}" y="{ry}" '
83
+ f'font-size="11" fill="#333" text-anchor="end">'
84
+ f'{_e(cls)}</text>'
85
+ )
86
+ # Cellules
87
+ for i, ca in enumerate(classes):
88
+ for j, cb in enumerate(classes):
89
+ value = matrix.get(ca, {}).get(cb, 0.0)
90
+ x = label_left + j * cell_size
91
+ y = label_top + i * cell_size
92
+ color = _color_for_jaccard(value)
93
+ text_color = _text_color_for_bg(value)
94
+ parts.append(
95
+ f'<rect x="{x}" y="{y}" '
96
+ f'width="{cell_size}" height="{cell_size}" '
97
+ f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
98
+ )
99
+ if value > 0.05:
100
+ parts.append(
101
+ f'<text x="{x + cell_size // 2}" '
102
+ f'y="{y + cell_size // 2 + 4}" '
103
+ f'font-size="10" fill="{text_color}" '
104
+ f'text-anchor="middle">'
105
+ f'{value:.2f}</text>'
106
+ )
107
+ parts.append("</svg>")
108
+ return "".join(parts)
109
+
110
+
111
+ def _build_top_pairs_table(
112
+ top_pairs: list,
113
+ labels: dict,
114
+ ) -> str:
115
+ """Construit la table HTML des paires les plus co-occurrentes."""
116
+ if not top_pairs:
117
+ return ""
118
+ pair_label = labels.get("taxocooc_pair_label", "Paire")
119
+ jaccard_label = labels.get("taxocooc_jaccard_label", "Jaccard")
120
+
121
+ parts = [
122
+ '<table style="border-collapse:collapse;font-size:.85rem;'
123
+ 'margin-top:.5rem">',
124
+ '<thead><tr>',
125
+ f'<th style="padding:.3rem .5rem;text-align:left;'
126
+ f'border-bottom:1px solid #ccc;font-weight:600">'
127
+ f'{_e(pair_label)}</th>',
128
+ f'<th style="padding:.3rem .5rem;text-align:right;'
129
+ f'border-bottom:1px solid #ccc;font-weight:600">'
130
+ f'{_e(jaccard_label)}</th>',
131
+ '</tr></thead><tbody>',
132
+ ]
133
+ for ca, cb, j in top_pairs:
134
+ parts.append(
135
+ f'<tr>'
136
+ f'<td style="padding:.2rem .5rem">'
137
+ f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
138
+ f'<td style="padding:.2rem .5rem;text-align:right;'
139
+ f'font-family:monospace;background:{_color_for_jaccard(j)};'
140
+ f'color:{_text_color_for_bg(j)}">{j:.2f}</td>'
141
+ f'</tr>'
142
+ )
143
+ parts.append("</tbody></table>")
144
+ return "".join(parts)
145
+
146
+
147
+ def build_taxonomy_cooccurrence_html(
148
+ data: Optional[dict],
149
+ labels: Optional[dict[str, str]] = None,
150
+ ) -> str:
151
+ """Construit le bloc HTML complet de co-occurrence taxonomique.
152
+
153
+ Retourne ``""`` si ``data is None`` ou matrice vide.
154
+ """
155
+ if not data:
156
+ return ""
157
+ classes = data.get("classes") or []
158
+ matrix = data.get("cooccurrence_matrix") or {}
159
+ if not classes or not matrix:
160
+ return ""
161
+ labels = labels or {}
162
+ title = labels.get(
163
+ "taxocooc_title",
164
+ "Co-occurrence des classes d'erreur",
165
+ )
166
+ note = labels.get(
167
+ "taxocooc_note",
168
+ "Indice de Jaccard au niveau document : 1,00 = ces deux classes "
169
+ "apparaissent toujours ensemble ; 0,00 = jamais. Lecture par paires "
170
+ "co-occurrentes ci-dessous.",
171
+ )
172
+ n_docs = data.get("n_documents", 0)
173
+ n_docs_label_template = labels.get(
174
+ "taxocooc_n_docs", "Calculé sur {n_docs} documents.",
175
+ )
176
+ n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
177
+
178
+ svg = _build_heatmap_svg(classes, matrix)
179
+ top_table = _build_top_pairs_table(
180
+ data.get("top_pairs") or [], labels,
181
+ )
182
+
183
+ parts = [
184
+ '<div class="taxocooc" style="margin:1rem 0">',
185
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
186
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
187
+ f'{_e(note)}</div>',
188
+ f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
189
+ f'{_e(n_docs_phrase)}</div>',
190
+ svg,
191
+ top_table,
192
+ "</div>",
193
+ ]
194
+ return "".join(parts)
195
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  __all__ = [
198
+ "build_taxonomy_cooccurrence_html",
199
  ]
picarones/report/taxonomy_intra_doc_render.py CHANGED
@@ -1,26 +1,182 @@
1
- """Alias rétrocompat module déplacé dans :mod:`picarones.extras.render.taxonomy_intra_doc_render`.
2
 
3
- Le contenu vit désormais dans son cercle d'origine. Cet alias permet
4
- aux imports historiques (y compris les noms privés ``_*``) de
5
- continuer à fonctionner sans modification.
6
 
7
- Voir :doc:`docs/architecture-cercles.md` pour la cartographie.
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  """
9
 
10
- from picarones.extras.render.taxonomy_intra_doc_render import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11
 
12
- # Réexport explicite de TOUS les noms (privés inclus) pour la
13
- # rétrocompatibilité des tests Sprints qui importent ``_helper``,
14
- # ``_compute_X``, ``_SCIPY_AVAILABLE``, etc. Sans cette boucle, ``import *``
15
- # ne propage que les noms publics et casse les imports historiques.
16
- import picarones.extras.render.taxonomy_intra_doc_render as _shim_module
17
- for _shim_name in dir(_shim_module):
18
- if _shim_name == "__builtins__":
19
- continue
20
- if _shim_name not in globals():
21
- globals()[_shim_name] = getattr(_shim_module, _shim_name)
22
- del _shim_module, _shim_name
23
 
24
  __all__ = [
25
- _n for _n in dir() if not _n.startswith("__")
26
  ]
 
1
+ """Rendu HTML de la heatmap class × position — Sprint 76.
2
 
3
+ A.I.4 chantier 2 du plan d'évolution 2026.
 
 
4
 
5
+ Suite directe ``picarones/core/taxonomy_intra_doc.py``. Pattern
6
+ identique aux autres rendus (Sprints 41/43/62/67/72/74/75) :
7
+ **server-side**, pas de JavaScript, anti-injection systématique.
8
+
9
+ Sortie typique
10
+ --------------
11
+ Une grille N_classes × N_bins où chaque cellule indique la densité
12
+ d'erreurs de cette classe à cette position dans le document.
13
+ Lecture immédiate : « ligature_error concentré dans la première
14
+ tranche → erreur de marge ; visual_confusion uniformément réparti
15
+ → erreur de scribe ».
16
+
17
+ Adaptive : si ``data is None`` ou si toutes les classes ont 0
18
+ erreur, retourne ``""``.
19
  """
20
 
21
+ from __future__ import annotations
22
+
23
+ from html import escape as _e
24
+ from typing import Optional
25
+
26
+
27
+ def _color_for_density(density: float) -> str:
28
+ """Gradient blanc → orange profond pour densité ∈ [0, 1].
29
+
30
+ Interpolation entre #ffffff (0) et #c2410c (1).
31
+ """
32
+ f = max(0.0, min(1.0, density))
33
+ r = int(255 + (194 - 255) * f)
34
+ g = int(255 + (65 - 255) * f)
35
+ b = int(255 + (12 - 255) * f)
36
+ return f"#{r:02x}{g:02x}{b:02x}"
37
+
38
+
39
+ def _text_color_for_bg(density: float) -> str:
40
+ return "#fff" if density > 0.55 else "#222"
41
+
42
+
43
+ def _build_heatmap_svg(
44
+ classes_with_errors: list[str],
45
+ per_class: dict[str, list[int]],
46
+ n_bins: int,
47
+ *,
48
+ cell_w: int = 36,
49
+ cell_h: int = 26,
50
+ label_left: int = 150,
51
+ label_top: int = 30,
52
+ ) -> str:
53
+ """Construit la heatmap SVG class × position."""
54
+ n_rows = len(classes_with_errors)
55
+ if n_rows == 0:
56
+ return ""
57
+ width = label_left + n_bins * cell_w + 10
58
+ height = label_top + n_rows * cell_h + 30 # +30 pour étiquette X
59
+
60
+ # Normalisation : pour chaque classe, densité relative au max
61
+ # de cette classe (mise en évidence des positions concentrées).
62
+ parts = [
63
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
64
+ f'width="{width}" height="{height}" '
65
+ f'viewBox="0 0 {width} {height}" '
66
+ f'role="img" aria-label="Heatmap class taxonomique × position">',
67
+ ]
68
+ # Étiquettes des colonnes (positions)
69
+ for j in range(n_bins):
70
+ cx = label_left + j * cell_w + cell_w // 2
71
+ cy = label_top - 6
72
+ parts.append(
73
+ f'<text x="{cx}" y="{cy}" '
74
+ f'font-size="10" fill="#666" text-anchor="middle">'
75
+ f'{j + 1}</text>'
76
+ )
77
+ # Cellules
78
+ for i, cls in enumerate(classes_with_errors):
79
+ # Étiquette de ligne (classe)
80
+ rx = label_left - 6
81
+ ry = label_top + i * cell_h + cell_h // 2 + 4
82
+ parts.append(
83
+ f'<text x="{rx}" y="{ry}" '
84
+ f'font-size="11" fill="#333" text-anchor="end">'
85
+ f'{_e(cls)}</text>'
86
+ )
87
+ counts = per_class.get(cls, [0] * n_bins)
88
+ max_count = max(counts) if counts else 0
89
+ for j in range(n_bins):
90
+ x = label_left + j * cell_w
91
+ y = label_top + i * cell_h
92
+ count = counts[j] if j < len(counts) else 0
93
+ density = (count / max_count) if max_count > 0 else 0.0
94
+ color = _color_for_density(density)
95
+ text_color = _text_color_for_bg(density)
96
+ parts.append(
97
+ f'<rect x="{x}" y="{y}" '
98
+ f'width="{cell_w}" height="{cell_h}" '
99
+ f'fill="{color}" stroke="#ddd" stroke-width="0.5"/>'
100
+ )
101
+ if count > 0:
102
+ parts.append(
103
+ f'<text x="{x + cell_w // 2}" '
104
+ f'y="{y + cell_h // 2 + 4}" '
105
+ f'font-size="10" fill="{text_color}" '
106
+ f'text-anchor="middle">{count}</text>'
107
+ )
108
+ # Étiquette axe X en bas
109
+ cx_axis = label_left + (n_bins * cell_w) // 2
110
+ cy_axis = height - 6
111
+ parts.append(
112
+ f'<text x="{cx_axis}" y="{cy_axis}" '
113
+ f'font-size="11" fill="#666" text-anchor="middle" '
114
+ f'font-style="italic">'
115
+ f'Position dans le document (1 = début)</text>'
116
+ )
117
+ parts.append("</svg>")
118
+ return "".join(parts)
119
+
120
+
121
+ def build_taxonomy_intra_doc_html(
122
+ data: Optional[dict],
123
+ labels: Optional[dict[str, str]] = None,
124
+ ) -> str:
125
+ """Construit le bloc HTML complet de la heatmap intra-document.
126
+
127
+ Retourne ``""`` si ``data is None`` ou aucune erreur.
128
+ """
129
+ if not data:
130
+ return ""
131
+ n_bins = data.get("n_bins", 0)
132
+ per_class = data.get("per_class") or {}
133
+ total_errors = data.get("total_errors", 0)
134
+ if total_errors == 0 or n_bins <= 0:
135
+ return ""
136
+ # Filtre : uniquement les classes ayant au moins une erreur
137
+ classes_with_errors = [
138
+ cls for cls, counts in per_class.items()
139
+ if isinstance(counts, list) and sum(counts) > 0
140
+ ]
141
+ if not classes_with_errors:
142
+ return ""
143
+
144
+ labels = labels or {}
145
+ title = labels.get(
146
+ "intradoc_title",
147
+ "Évolution intra-document des classes d'erreur",
148
+ )
149
+ note = labels.get(
150
+ "intradoc_note",
151
+ "Heatmap class × position : densité relative par classe "
152
+ "(plus foncé = concentré). Une classe concentrée dans la "
153
+ "première colonne suggère une erreur de marge ; "
154
+ "une distribution uniforme suggère une erreur de scribe.",
155
+ )
156
+ n_words_gt = data.get("n_words_gt", 0)
157
+ n_words_template = labels.get(
158
+ "intradoc_n_words",
159
+ "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
160
+ )
161
+ n_words_phrase = n_words_template.format(
162
+ n_words_gt=n_words_gt, n_bins=n_bins,
163
+ )
164
+
165
+ svg = _build_heatmap_svg(classes_with_errors, per_class, n_bins)
166
+
167
+ parts = [
168
+ '<div class="intradoc" style="margin:1rem 0">',
169
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
170
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
171
+ f'{_e(note)}</div>',
172
+ f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
173
+ f'{_e(n_words_phrase)}</div>',
174
+ svg,
175
+ "</div>",
176
+ ]
177
+ return "".join(parts)
178
 
 
 
 
 
 
 
 
 
 
 
 
179
 
180
  __all__ = [
181
+ "build_taxonomy_intra_doc_html",
182
  ]