Claude commited on
Commit
254ec06
·
unverified ·
1 Parent(s): 0605de8

feat(migration): Phase 5.C batch 3 — 5 renderers moyens vers reports_v2/html/

Browse files

Troisième vague de migration des 22 renderers thématiques. Tous
les renderers sélectionnés sont purs sur le contrat : import
depuis ``_helpers/`` uniquement, pas de dépendance sur des modules
legacy non-migrés.

Migrations effectuées
---------------------
| Source legacy | Destination canonique |
|------------------------------------------------|--------------------------------------------------------|
| ``report/module_audit_render.py`` (173) | ``reports_v2/html/renderers/module_audit.py`` |
| ``report/incremental_comparison_render.py`` (201)| ``reports_v2/html/renderers/incremental_comparison.py``|
| ``report/image_predictive_render.py`` (207) | ``reports_v2/html/renderers/image_predictive.py`` |
| ``report/error_absorption_render.py`` (210) | ``reports_v2/html/renderers/error_absorption.py`` |
| ``report/ner_render.py`` (222) | ``reports_v2/html/renderers/ner.py`` |

Total : ~1013 lignes relocalisées. 5 nouveaux shims minimaux
(< 20 lignes) avec ``DeprecationWarning``.

Cumul Phase 5.C (batches 1+2+3) : 15 / 22 renderers migrés
(~2211 lignes). 7 renderers restants.

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

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

docs/migration/legacy-retirement-plan.md CHANGED
@@ -692,12 +692,7 @@ architecture vérifiée.
692
  **Reporté aux batches suivants** :
693
 
694
  - Batch 2 ✅ (cf. ci-dessous) — 5 renderers (45-165 LOC).
695
- - Batch 3 (~5 renderers moyens) : ``module_audit``,
696
- ``ner``, ``image_predictive``,
697
- ``incremental_comparison``, ``numerical_sequences``
698
- (ce dernier exige d'abord la migration du module
699
- ``measurements/numerical_sequences.py`` qui dépend de
700
- ``measurements/roman_numerals.py``).
701
  - Batch 4 (~5 renderers gros) : ``error_absorption``,
702
  ``baseline``, ``inter_engine``, ``robustness_projection``,
703
  ``stratification``.
@@ -752,6 +747,36 @@ architecture vérifiée.
752
  **Cumul Phase 5.C** (batches 1+2) : 10 / 22 renderers migrés
753
  (~1198 lignes). 12 renderers restants.
754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
755
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
756
 
757
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
 
692
  **Reporté aux batches suivants** :
693
 
694
  - Batch 2 ✅ (cf. ci-dessous) — 5 renderers (45-165 LOC).
695
+ - Batch 3 (cf. ci-dessous) — 5 renderers (173-222 LOC).
 
 
 
 
 
696
  - Batch 4 (~5 renderers gros) : ``error_absorption``,
697
  ``baseline``, ``inter_engine``, ``robustness_projection``,
698
  ``stratification``.
 
747
  **Cumul Phase 5.C** (batches 1+2) : 10 / 22 renderers migrés
748
  (~1198 lignes). 12 renderers restants.
749
 
750
+ #### Phase 5.C.batch3 — Lot 3 : 5 renderers moyens (2026-05)
751
+
752
+ Troisième vague. Tous les renderers sélectionnés sont
753
+ **purs sur le contrat** : import depuis ``_helpers/`` uniquement,
754
+ pas de dépendance sur des modules legacy non-migrés.
755
+
756
+ **Migrations effectuées** :
757
+
758
+ | Source legacy | Destination canonique |
759
+ |------------------------------------------------|--------------------------------------------------------|
760
+ | ``report/module_audit_render.py`` (173) | ``reports_v2/html/renderers/module_audit.py`` |
761
+ | ``report/incremental_comparison_render.py`` (201)| ``reports_v2/html/renderers/incremental_comparison.py``|
762
+ | ``report/image_predictive_render.py`` (207) | ``reports_v2/html/renderers/image_predictive.py`` |
763
+ | ``report/error_absorption_render.py`` (210) | ``reports_v2/html/renderers/error_absorption.py`` |
764
+ | ``report/ner_render.py`` (222) | ``reports_v2/html/renderers/ner.py`` |
765
+
766
+ Total : ~1013 lignes relocalisées. 5 nouveaux shims minimaux
767
+ (< 20 lignes) avec ``DeprecationWarning``.
768
+
769
+ **Adaptations transverses** :
770
+
771
+ - Tests + ``picarones/report/generator.py`` mis à jour pour les
772
+ 5 chemins canoniques.
773
+
774
+ **Acceptance batch 3** : 5019 tests passent, lint vert,
775
+ architecture vérifiée.
776
+
777
+ **Cumul Phase 5.C** (batches 1+2+3) : 15 / 22 renderers migrés
778
+ (~2211 lignes). 7 renderers restants.
779
+
780
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
781
 
782
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
picarones/report/error_absorption_render.py CHANGED
@@ -1,210 +1,18 @@
1
- """Rendu HTML « Absorption d'erreur » Sprint 94 (B.3).
2
 
3
- Suite directe ``picarones/core/error_absorption.py``. Pattern
4
- identique aux autres rendus : server-side, pas de JS, anti-
5
- injection systématique.
6
-
7
- Vue
8
- ---
9
- Tableau résumé des jonctions du pipeline ; chaque ligne décrit
10
- un module post-correction et présente :
11
-
12
- - erreurs en entrée vs en sortie ;
13
- - nb corrigées (gradient vert), nb introduites (gradient rouge) ;
14
- - taux de correction (gradient vert), taux d'introduction
15
- (gradient rouge) ;
16
- - amélioration nette (n_corrected - n_introduced) — coloré.
17
- - éventuellement un échantillon de tokens corrigés/introduits.
18
-
19
- Adaptive : ``""`` si la liste est vide.
20
-
21
- Note d'intégration
22
- ------------------
23
- Module pur — la liste ``junctions`` est composée par
24
- l'utilisateur depuis son benchmark de pipeline composée :
25
-
26
- .. code-block:: python
27
-
28
- from picarones.measurements.error_absorption import (
29
- compute_error_absorption, aggregate_error_absorption,
30
- )
31
- from picarones.report.error_absorption_render import (
32
- build_error_absorption_html,
33
- )
34
-
35
- junctions = []
36
- for step in pipeline.steps_with_text_output:
37
- per_doc = [
38
- compute_error_absorption(doc.gt_text, doc.before_text,
39
- doc.after_text)
40
- for doc in benchmark.docs
41
- ]
42
- agg = aggregate_error_absorption(per_doc)
43
- if agg is not None:
44
- agg["junction_name"] = step.name
45
- junctions.append(agg)
46
- html = build_error_absorption_html(junctions, labels)
47
  """
48
 
49
  from __future__ import annotations
50
 
51
- from html import escape as _e
52
- from typing import Optional
53
-
54
- from picarones.reports_v2._helpers.render_helpers import color_diverging, color_traffic_light
55
-
56
-
57
- # Palette « net improvement » : vert clair au centre, vert profond
58
- # si favorable (net > 0), rouge si défavorable (net < 0). Centrée
59
- # sur le vert clair car un delta nul est déjà « pas de régression ».
60
- _NET_NEUTRAL_RGB = (167, 240, 167)
61
- _NET_POSITIVE_RGB = (90, 200, 90)
62
- _NET_NEGATIVE_RGB = (220, 50, 50)
63
-
64
-
65
- def build_error_absorption_html(
66
- junctions: Optional[list],
67
- labels: Optional[dict[str, str]] = None,
68
- *,
69
- sample_max: int = 8,
70
- ) -> str:
71
- """Construit la vue HTML « Absorption d'erreur ».
72
-
73
- Parameters
74
- ----------
75
- junctions:
76
- Liste de dicts (un par jonction de pipeline), enrichis
77
- d'un ``junction_name``. Si vide ou ``None``, retourne
78
- ``""``.
79
- labels:
80
- Dict i18n. Clés sous le préfixe ``absorption_*``.
81
- sample_max:
82
- Nombre maximal de tokens corrigés/introduits affichés
83
- en cellule échantillon.
84
- """
85
- if not junctions:
86
- return ""
87
- rows = [
88
- j for j in junctions
89
- if isinstance(j, dict) and j.get("junction_name")
90
- ]
91
- if not rows:
92
- return ""
93
- labels = labels or {}
94
- title = labels.get(
95
- "absorption_title", "Absorption d'erreur par jonction",
96
- )
97
- note = labels.get(
98
- "absorption_note",
99
- "À chaque jonction du pipeline, deux flux sont mesurés "
100
- "indépendamment : combien d'erreurs sont corrigées et "
101
- "combien sont introduites. Une jonction qui corrige "
102
- "beaucoup mais introduit aussi beaucoup absorbe les "
103
- "différences amont au lieu de les améliorer.",
104
- )
105
- h_junction = labels.get("absorption_junction", "Jonction")
106
- h_errors_before = labels.get("absorption_errors_before", "Erreurs avant")
107
- h_errors_after = labels.get("absorption_errors_after", "Erreurs après")
108
- h_corrected = labels.get("absorption_corrected", "Corrigées")
109
- h_introduced = labels.get("absorption_introduced", "Introduites")
110
- h_corr_rate = labels.get("absorption_corr_rate", "% corrigées")
111
- h_intro_rate = labels.get("absorption_intro_rate", "% introduites")
112
- h_net = labels.get("absorption_net", "Amélioration nette")
113
- h_sample = labels.get("absorption_sample", "Échantillon (intro)")
114
-
115
- # Saturation pour le gradient « net »
116
- max_abs_net = max(
117
- (abs(int(r.get("net_improvement") or 0)) for r in rows), default=1,
118
- ) or 1
119
-
120
- parts = [
121
- '<section class="absorption-section" style="margin:1rem 0">',
122
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
123
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
124
- f'{_e(note)}</div>',
125
- '<table style="border-collapse:collapse;width:100%;'
126
- 'font-size:.9rem">',
127
- '<thead><tr>',
128
- ]
129
- for col in (h_junction, h_errors_before, h_errors_after,
130
- h_corrected, h_introduced, h_corr_rate,
131
- h_intro_rate, h_net, h_sample):
132
- parts.append(
133
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
134
- f'border-bottom:1px solid #ccc;font-weight:600">'
135
- f'{_e(col)}</th>'
136
- )
137
- parts.append("</tr></thead><tbody>")
138
- for entry in rows:
139
- name = str(entry.get("junction_name") or "?")
140
- n_eb = int(entry.get("n_errors_before") or 0)
141
- n_ea = int(entry.get("n_errors_after") or 0)
142
- n_corr = int(entry.get("n_corrected") or 0)
143
- n_intro = int(entry.get("n_introduced") or 0)
144
- net = int(entry.get("net_improvement") or 0)
145
- corr_rate = entry.get("correction_rate")
146
- intro_rate = entry.get("introduction_rate")
147
- if isinstance(corr_rate, (int, float)):
148
- corr_rate_str = f"{corr_rate * 100:.1f}%"
149
- corr_color = color_traffic_light(float(corr_rate))
150
- corr_cell = (
151
- f'<td style="padding:.4rem .6rem;text-align:right;'
152
- f'background:{corr_color};font-family:monospace;'
153
- f'font-weight:600">{corr_rate_str}</td>'
154
- )
155
- else:
156
- corr_cell = (
157
- '<td style="padding:.4rem .6rem;text-align:right;'
158
- 'opacity:.4">—</td>'
159
- )
160
- if isinstance(intro_rate, (int, float)):
161
- intro_rate_str = f"{intro_rate * 100:.1f}%"
162
- intro_color = color_traffic_light(float(intro_rate), low_is_good=True)
163
- intro_cell = (
164
- f'<td style="padding:.4rem .6rem;text-align:right;'
165
- f'background:{intro_color};font-family:monospace;'
166
- f'font-weight:600">{intro_rate_str}</td>'
167
- )
168
- else:
169
- intro_cell = (
170
- '<td style="padding:.4rem .6rem;text-align:right;'
171
- 'opacity:.4">—</td>'
172
- )
173
- net_color = color_diverging(
174
- float(net),
175
- max_abs=float(max_abs_net) if max_abs_net else 1.0,
176
- neutral_rgb=_NET_NEUTRAL_RGB,
177
- positive_rgb=_NET_POSITIVE_RGB,
178
- negative_rgb=_NET_NEGATIVE_RGB,
179
- )
180
- intro_sample = entry.get("introduced_tokens_sample") or []
181
- sample_cell_text = ", ".join(
182
- _e(str(t)) for t in intro_sample[:sample_max]
183
- ) or "—"
184
- if len(intro_sample) > sample_max:
185
- sample_cell_text += " …"
186
- parts.append(
187
- f'<tr>'
188
- f'<td style="padding:.4rem .6rem">{_e(name)}</td>'
189
- f'<td style="padding:.4rem .6rem;text-align:right;'
190
- f'font-family:monospace">{n_eb}</td>'
191
- f'<td style="padding:.4rem .6rem;text-align:right;'
192
- f'font-family:monospace">{n_ea}</td>'
193
- f'<td style="padding:.4rem .6rem;text-align:right;'
194
- f'font-family:monospace">{n_corr}</td>'
195
- f'<td style="padding:.4rem .6rem;text-align:right;'
196
- f'font-family:monospace">{n_intro}</td>'
197
- f'{corr_cell}'
198
- f'{intro_cell}'
199
- f'<td style="padding:.4rem .6rem;text-align:right;'
200
- f'background:{net_color};font-family:monospace;'
201
- f'font-weight:600">{net:+d}</td>'
202
- f'<td style="padding:.4rem .6rem;font-family:monospace;'
203
- f'font-size:.8rem">{sample_cell_text}</td>'
204
- f'</tr>'
205
- )
206
- parts.append("</tbody></table></section>")
207
- return "".join(parts)
208
 
 
209
 
210
- __all__ = ["build_error_absorption_html"]
 
 
 
 
 
 
1
+ """``picarones.report.error_absorption_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.error_absorption`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.error_absorption import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.error_absorption_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.error_absorption instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/generator.py CHANGED
@@ -268,7 +268,7 @@ class ReportGenerator:
268
  build_oracle_gap_html,
269
  )
270
  # Sprint 41 — section NER (résumé F1 par moteur + heatmap par catégorie).
271
- from picarones.report.ner_render import (
272
  build_ner_per_category_html,
273
  build_ner_summary_html,
274
  )
 
268
  build_oracle_gap_html,
269
  )
270
  # Sprint 41 — section NER (résumé F1 par moteur + heatmap par catégorie).
271
+ from picarones.reports_v2.html.renderers.ner import (
272
  build_ner_per_category_html,
273
  build_ner_summary_html,
274
  )
picarones/report/image_predictive_render.py CHANGED
@@ -1,207 +1,18 @@
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
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
40
-
41
-
42
- _FEATURE_LABEL_KEYS = {
43
- "noise_level": "imgpred_feat_noise",
44
- "sharpness_score": "imgpred_feat_sharpness",
45
- "contrast_score": "imgpred_feat_contrast",
46
- "rotation_degrees": "imgpred_feat_rotation",
47
- }
48
-
49
-
50
- def _render_complexity_block(
51
- aggregated: dict, labels: dict[str, str],
52
- ) -> str:
53
- h_complex = labels.get(
54
- "imgpred_complexity", "Complexité paléographique",
55
- )
56
- h_mean = labels.get("imgpred_mean", "Moyenne")
57
- h_median = labels.get("imgpred_median", "Médiane")
58
- h_min = labels.get("imgpred_min", "Min")
59
- h_max = labels.get("imgpred_max", "Max")
60
- h_stdev = labels.get("imgpred_stdev", "Écart-type")
61
- h_docs = labels.get("imgpred_docs", "Docs")
62
- mean = float(aggregated.get("complexity_mean") or 0.0)
63
- median = float(aggregated.get("complexity_median") or 0.0)
64
- mn = float(aggregated.get("complexity_min") or 0.0)
65
- mx = float(aggregated.get("complexity_max") or 0.0)
66
- sd = float(aggregated.get("complexity_stdev") or 0.0)
67
- n_docs = int(aggregated.get("n_docs") or 0)
68
- color_mean = color_traffic_light(mean, low_is_good=True)
69
- return (
70
- f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
71
- f'{_e(h_complex)}</div>'
72
- '<table style="border-collapse:collapse;width:100%;'
73
- 'font-size:.9rem;margin-bottom:.8rem">'
74
- f'<thead><tr>'
75
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
76
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
77
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
78
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
79
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
80
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
81
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
82
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
83
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
84
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
85
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
86
- f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
87
- f'</tr></thead>'
88
- f'<tbody><tr>'
89
- f'<td style="padding:.4rem .6rem;text-align:right;'
90
- f'background:{color_mean};font-family:monospace;font-weight:600">'
91
- f'{mean:.3f}</td>'
92
- f'<td style="padding:.4rem .6rem;text-align:right;'
93
- f'font-family:monospace">{median:.3f}</td>'
94
- f'<td style="padding:.4rem .6rem;text-align:right;'
95
- f'font-family:monospace">{mn:.3f}</td>'
96
- f'<td style="padding:.4rem .6rem;text-align:right;'
97
- f'font-family:monospace">{mx:.3f}</td>'
98
- f'<td style="padding:.4rem .6rem;text-align:right;'
99
- f'font-family:monospace">{sd:.3f}</td>'
100
- f'<td style="padding:.4rem .6rem;text-align:right;'
101
- f'font-family:monospace">{n_docs}</td>'
102
- f'</tr></tbody></table>'
103
- )
104
-
105
-
106
- def _render_homogeneity_block(
107
- homogeneity: dict, labels: dict[str, str],
108
- ) -> str:
109
- h_homo = labels.get(
110
- "imgpred_homogeneity", "Homogénéité du corpus",
111
- )
112
- h_feat = labels.get("imgpred_feature", "Feature")
113
- h_mean = labels.get("imgpred_feat_mean", "Moyenne")
114
- h_stdev = labels.get("imgpred_feat_stdev", "Écart-type")
115
- h_norm = labels.get(
116
- "imgpred_feat_norm", "Contribution normalisée",
117
- )
118
- score = float(homogeneity.get("score") or 0.0)
119
- color = color_traffic_light(score, low_is_good=True)
120
- parts = [
121
- f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
122
- f'{_e(h_homo)} : '
123
- f'<span style="background:{color};padding:.1rem .4rem;'
124
- f'border-radius:.3rem;font-family:monospace">{score:.3f}</span>'
125
- f'</div>',
126
- '<table style="border-collapse:collapse;width:100%;'
127
- 'font-size:.9rem">',
128
- '<thead><tr>',
129
- ]
130
- for col in (h_feat, h_mean, h_stdev, h_norm):
131
- parts.append(
132
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
133
- f'border-bottom:1px solid #ccc;font-weight:600">'
134
- f'{_e(col)}</th>'
135
- )
136
- parts.append("</tr></thead><tbody>")
137
- per_feat = homogeneity.get("per_feature") or {}
138
- for key, label_key in _FEATURE_LABEL_KEYS.items():
139
- if key not in per_feat:
140
- continue
141
- slot = per_feat[key]
142
- feat_label = labels.get(label_key, key)
143
- feat_mean = float(slot.get("mean") or 0.0)
144
- feat_stdev = float(slot.get("stdev") or 0.0)
145
- feat_norm = float(slot.get("normalised") or 0.0)
146
- norm_color = color_traffic_light(feat_norm, low_is_good=True)
147
- parts.append(
148
- f'<tr>'
149
- f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
150
- f'<td style="padding:.4rem .6rem;text-align:right;'
151
- f'font-family:monospace">{feat_mean:.3f}</td>'
152
- f'<td style="padding:.4rem .6rem;text-align:right;'
153
- f'font-family:monospace">{feat_stdev:.3f}</td>'
154
- f'<td style="padding:.4rem .6rem;text-align:right;'
155
- f'background:{norm_color};font-family:monospace">'
156
- f'{feat_norm:.3f}</td>'
157
- f'</tr>'
158
- )
159
- parts.append("</tbody></table>")
160
- return "".join(parts)
161
-
162
-
163
- def build_image_predictive_html(
164
- aggregated: Optional[dict],
165
- labels: Optional[dict[str, str]] = None,
166
- ) -> str:
167
- """Construit la vue HTML « Profil d'image du corpus ».
168
-
169
- Parameters
170
- ----------
171
- aggregated:
172
- Sortie de ``aggregate_corpus_predictive``. Si ``None``
173
- ou ``n_docs == 0``, retourne ``""``.
174
- labels:
175
- Dict i18n. Clés sous le préfixe ``imgpred_*``.
176
- """
177
- if not aggregated:
178
- return ""
179
- if not aggregated.get("n_docs"):
180
- return ""
181
- labels = labels or {}
182
- title = labels.get(
183
- "imgpred_title", "Profil d'image du corpus",
184
- )
185
- note = labels.get(
186
- "imgpred_note",
187
- "Score de complexité paléographique combinant bruit, "
188
- "flou, faible contraste et rotation. Le score "
189
- "d'homogénéité signale si la moyenne globale est fiable "
190
- "(corpus uniforme) ou trompeuse (corpus hétérogène — "
191
- "voir alors la vue stratifiée).",
192
- )
193
- parts = [
194
- '<section class="imgpred-section" style="margin:1rem 0">',
195
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
196
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
197
- f'{_e(note)}</div>',
198
- ]
199
- parts.append(_render_complexity_block(aggregated, labels))
200
- homo = aggregated.get("homogeneity")
201
- if isinstance(homo, dict):
202
- parts.append(_render_homogeneity_block(homo, labels))
203
- parts.append("</section>")
204
- return "".join(parts)
205
 
 
206
 
207
- __all__ = ["build_image_predictive_html"]
 
 
 
 
 
 
1
+ """``picarones.report.image_predictive_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.image_predictive`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.image_predictive import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.image_predictive_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.image_predictive instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/incremental_comparison_render.py CHANGED
@@ -1,201 +1,18 @@
1
- """Rendu HTML « Comparaison contrôlée » Sprint 96 (B.5).
2
 
3
- Suite directe ``picarones/core/incremental_comparison.py``.
4
- Pattern identique aux autres rendus : server-side, pas de JS,
5
- anti-injection systématique.
6
-
7
- Vue
8
- ---
9
- Tableau ANOVA-like : pour chaque valeur du slot variant, mean
10
- ± stdev, rang moyen, n_observations. Mean colorée en
11
- gradient vert (meilleur) → rouge (pire), normalisée sur la
12
- plage des moyennes observées.
13
-
14
- Adaptive : ``""`` si ``analysis`` est ``None``.
15
-
16
- Note d'intégration
17
- ------------------
18
- Module pur — l'utilisateur compose :
19
-
20
- .. code-block:: python
21
-
22
- from picarones.measurements.incremental_comparison import (
23
- PipelineRun, compare_isolated_effect,
24
- )
25
- from picarones.report.incremental_comparison_render import (
26
- build_incremental_comparison_html,
27
- )
28
-
29
- runs = [
30
- PipelineRun(name=p.name,
31
- slots={"ocr": p.ocr, "llm": p.llm},
32
- score=p.cer_mean)
33
- for p in benchmark.pipelines
34
- ]
35
- analysis = compare_isolated_effect(runs, "llm")
36
- html = build_incremental_comparison_html(analysis, labels)
37
  """
38
 
39
  from __future__ import annotations
40
 
41
- from html import escape as _e
42
- from typing import Optional
43
-
44
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
45
-
46
-
47
- def _bg_for_relative_score(
48
- score: float, low: float, high: float, higher_is_better: bool,
49
- ) -> str:
50
- """Mappe ``score`` sur une plage [low, high] et retourne une cellule
51
- colorée traffic-light.
52
-
53
- Si ``higher_is_better=True``, ``score=high`` est vert ; sinon
54
- ``score=low`` est vert.
55
- """
56
- if high == low:
57
- return color_traffic_light(1.0) # neutre vert clair
58
- return color_traffic_light(
59
- score,
60
- low_is_good=not higher_is_better,
61
- scale_min=low,
62
- scale_max=high,
63
- )
64
-
65
-
66
- def _format_score(value: Optional[float]) -> str:
67
- if value is None:
68
- return "—"
69
- if abs(value) < 1.0:
70
- return f"{value * 100:.2f}%"
71
- return f"{value:.3f}"
72
-
73
-
74
- def build_incremental_comparison_html(
75
- analysis: Optional[dict],
76
- labels: Optional[dict[str, str]] = None,
77
- ) -> str:
78
- """Construit la vue HTML « Comparaison contrôlée ».
79
-
80
- Parameters
81
- ----------
82
- analysis:
83
- Sortie de ``compare_isolated_effect``. ``None`` ou
84
- ``per_value`` vide → retourne ``""``.
85
- labels:
86
- Dict i18n. Clés sous le préfixe ``incr_*``.
87
- """
88
- if not analysis:
89
- return ""
90
- per_value = analysis.get("per_value") or {}
91
- if not per_value:
92
- return ""
93
- labels = labels or {}
94
- title = labels.get(
95
- "incr_title", "Comparaison contrôlée par slot",
96
- )
97
- note = labels.get(
98
- "incr_note",
99
- "Effet isolé du module variant sur les pipelines en "
100
- "contrôlant les autres slots. Pour chaque valeur du "
101
- "slot, moyenne ± écart-type, rang moyen sur les groupes "
102
- "fixes, et nombre d'observations. Type design "
103
- "d'expérience pour des comparaisons honnêtes.",
104
- )
105
- slot_label = labels.get("incr_slot_label", "Slot variant")
106
- h_value = labels.get("incr_value", "Valeur")
107
- h_mean = labels.get("incr_mean", "Score moyen")
108
- h_stdev = labels.get("incr_stdev", "± σ")
109
- h_rank = labels.get("incr_rank", "Rang moyen")
110
- h_n_obs = labels.get("incr_n_obs", "Observations")
111
- h_groups = labels.get("incr_groups", "Groupes fixes")
112
- higher_is_better = bool(analysis.get("higher_is_better", False))
113
-
114
- # Plage de moyennes pour le code couleur
115
- means = [
116
- d["mean"] for d in per_value.values() if d.get("mean") is not None
117
- ]
118
- low = min(means) if means else 0.0
119
- high = max(means) if means else 0.0
120
-
121
- varying_slot = str(analysis.get("varying_slot") or "?")
122
- n_groups = int(analysis.get("n_groups") or 0)
123
- n_runs = int(analysis.get("n_runs") or 0)
124
- best = analysis.get("best_value")
125
- worst = analysis.get("worst_value")
126
-
127
- parts = [
128
- '<section class="incr-section" style="margin:1rem 0">',
129
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
130
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
131
- f'{_e(note)}</div>',
132
- f'<div style="font-size:.85rem;margin-bottom:.5rem">'
133
- f'<strong>{_e(slot_label)} :</strong> '
134
- f'<code>{_e(varying_slot)}</code> &nbsp; '
135
- f'<span style="opacity:.75">'
136
- f'{n_runs} runs, {n_groups} {_e(h_groups.lower())}'
137
- f'</span></div>',
138
- '<table style="border-collapse:collapse;width:100%;'
139
- 'font-size:.9rem">',
140
- '<thead><tr>',
141
- ]
142
- for col in (h_value, h_mean, h_stdev, h_rank, h_n_obs):
143
- parts.append(
144
- f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
145
- f'border-bottom:1px solid #ccc;font-weight:600">'
146
- f'{_e(col)}</th>'
147
- )
148
- parts.append("</tr></thead><tbody>")
149
-
150
- # Tri par rang moyen ascendant
151
- rows = sorted(
152
- per_value.items(),
153
- key=lambda kv: (kv[1].get("mean_rank") or float("inf")),
154
- )
155
- for value, d in rows:
156
- mean = d.get("mean")
157
- stdev = d.get("stdev")
158
- rank = d.get("mean_rank")
159
- n_obs = int(d.get("n_observations") or 0)
160
- if isinstance(mean, (int, float)):
161
- color = _bg_for_relative_score(
162
- float(mean), low, high, higher_is_better,
163
- )
164
- mean_cell = (
165
- f'<td style="padding:.4rem .6rem;text-align:right;'
166
- f'background:{color};font-family:monospace;'
167
- f'font-weight:600">{_format_score(mean)}</td>'
168
- )
169
- else:
170
- mean_cell = (
171
- '<td style="padding:.4rem .6rem;text-align:right;'
172
- 'opacity:.4">—</td>'
173
- )
174
- stdev_str = (
175
- f"± {_format_score(stdev)}"
176
- if isinstance(stdev, (int, float)) else "—"
177
- )
178
- rank_str = f"{rank:.2f}" if isinstance(rank, (int, float)) else "—"
179
- marker = ""
180
- if value == best:
181
- marker = ' <span style="color:#16a34a">★</span>'
182
- elif value == worst:
183
- marker = ' <span style="color:#dc2626">▼</span>'
184
- parts.append(
185
- f'<tr>'
186
- f'<td style="padding:.4rem .6rem;font-family:monospace">'
187
- f'{_e(str(value))}{marker}</td>'
188
- f'{mean_cell}'
189
- f'<td style="padding:.4rem .6rem;text-align:right;'
190
- f'font-family:monospace">{stdev_str}</td>'
191
- f'<td style="padding:.4rem .6rem;text-align:right;'
192
- f'font-family:monospace">{rank_str}</td>'
193
- f'<td style="padding:.4rem .6rem;text-align:right;'
194
- f'font-family:monospace">{n_obs}</td>'
195
- f'</tr>'
196
- )
197
- parts.append("</tbody></table></section>")
198
- return "".join(parts)
199
 
 
200
 
201
- __all__ = ["build_incremental_comparison_html"]
 
 
 
 
 
 
1
+ """``picarones.report.incremental_comparison_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.incremental_comparison`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.incremental_comparison import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.incremental_comparison_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.incremental_comparison instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/module_audit_render.py CHANGED
@@ -1,173 +1,18 @@
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 scope=\"col\" 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"]
 
 
 
 
 
 
1
+ """``picarones.report.module_audit_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.module_audit`.
4
+ Phase 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.module_audit import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.module_audit_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.module_audit instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/ner_render.py CHANGED
@@ -1,222 +1,18 @@
1
- """Rendu HTML server-side de la section NER (Sprint 41).
2
 
3
- Suite directe des Sprints 38-40 : la couche de calcul, le backend
4
- extracteur et le câblage runner sont en place ; ce module produit les
5
- blocs HTML qui remontent ces données dans le rapport.
6
-
7
- - ``build_ner_summary_html`` — encart factuel par moteur : F1 global,
8
- precision/recall, total entités, hallucinations, missed.
9
- - ``build_ner_per_category_html`` — table heatmap moteur × catégorie,
10
- cellules colorées par F1 (rouge → vert).
11
-
12
- Principe — cohérent avec ``inter_engine_render`` (Sprint 37) : rendu
13
- server-side, pas de JavaScript, déterministe. Si aucun moteur n'a de
14
- ``aggregated_ner``, les fonctions retournent une chaîne vide — la vue
15
- est silencieusement omise (rapport adaptatif).
16
-
17
- Anti-injection : tous les noms de moteurs et catégories sont passés à
18
- ``html.escape`` avant insertion.
19
  """
20
 
21
  from __future__ import annotations
22
 
23
- from html import escape as _e
24
- from typing import Optional
25
-
26
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
27
-
28
-
29
- def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
30
- """Filtre les moteurs qui ont une analyse NER agrégée."""
31
- return [e for e in engines_summary if e.get("aggregated_ner")]
32
-
33
-
34
- def build_ner_summary_html(
35
- engines_summary: list[dict],
36
- labels: Optional[dict[str, str]] = None,
37
- ) -> str:
38
- """Construit l'encart résumé NER : F1 global par moteur + totaux.
39
-
40
- Parameters
41
- ----------
42
- engines_summary:
43
- Liste de dicts moteur (au moins ``name`` et ``aggregated_ner``).
44
- labels:
45
- Dict d'étiquettes i18n.
46
-
47
- Returns
48
- -------
49
- str
50
- HTML ``<div>...</div>`` ou ``""`` si aucun moteur n'a de NER.
51
- """
52
- relevant = _engines_with_ner(engines_summary)
53
- if not relevant:
54
- return ""
55
-
56
- labels = labels or {}
57
- caption = labels.get("ner_summary_caption", "Précision sur entités nommées")
58
- engine_label = labels.get("ner_engine_label", "Moteur")
59
- f1_label = labels.get("ner_f1_label", "F1 global")
60
- p_label = labels.get("ner_precision_label", "Précision")
61
- r_label = labels.get("ner_recall_label", "Rappel")
62
- docs_label = labels.get("ner_doc_count_label", "Docs évalués")
63
- halluc_label = labels.get("ner_hallucinated_label", "Hallucinations")
64
- missed_label = labels.get("ner_missed_label", "Entités manquées")
65
-
66
- parts: list[str] = []
67
- parts.append('<div class="ner-summary">')
68
- parts.append(
69
- f'<div class="ner-summary-caption" style="font-weight:600;'
70
- f'margin-bottom:.4rem">{_e(caption)}</div>'
71
- )
72
- parts.append(
73
- '<table class="ner-summary-table" '
74
- 'style="border-collapse:collapse;font-size:.85rem;width:100%">'
75
- )
76
- parts.append("<thead><tr>")
77
- for hdr in (engine_label, f1_label, p_label, r_label,
78
- docs_label, halluc_label, missed_label):
79
- parts.append(
80
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
81
- f'border-bottom:1px solid var(--border);font-weight:600">'
82
- f'{_e(hdr)}</th>'
83
- )
84
- parts.append("</tr></thead><tbody>")
85
- for engine in relevant:
86
- agg = engine["aggregated_ner"]
87
- global_stats = agg.get("global", {}) or {}
88
- f1 = float(global_stats.get("f1") or 0.0)
89
- precision = float(global_stats.get("precision") or 0.0)
90
- recall = float(global_stats.get("recall") or 0.0)
91
- doc_count = int(agg.get("doc_count") or 0)
92
- hallucinated = int(agg.get("hallucinated_total") or 0)
93
- missed = int(agg.get("missed_total") or 0)
94
- bg = color_traffic_light(f1)
95
- parts.append("<tr>")
96
- parts.append(
97
- f'<td style="padding:.3rem .5rem;font-weight:600">'
98
- f'{_e(engine.get("name", ""))}</td>'
99
- )
100
- parts.append(
101
- f'<td style="padding:.3rem .5rem;background:{bg};'
102
- f'font-variant-numeric:tabular-nums">{f1 * 100:.1f} %</td>'
103
- )
104
- parts.append(
105
- f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
106
- f'{precision * 100:.1f} %</td>'
107
- )
108
- parts.append(
109
- f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
110
- f'{recall * 100:.1f} %</td>'
111
- )
112
- parts.append(
113
- f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
114
- f'{doc_count}</td>'
115
- )
116
- parts.append(
117
- f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
118
- f'{hallucinated}</td>'
119
- )
120
- parts.append(
121
- f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
122
- f'{missed}</td>'
123
- )
124
- parts.append("</tr>")
125
- parts.append("</tbody></table></div>")
126
- return "".join(parts)
127
-
128
-
129
- def build_ner_per_category_html(
130
- engines_summary: list[dict],
131
- labels: Optional[dict[str, str]] = None,
132
- ) -> str:
133
- """Construit la heatmap NER moteur × catégorie d'entité.
134
-
135
- Lignes = moteurs, colonnes = catégories (PER, LOC, ORG, DATE,
136
- MISC…). Cellules colorées par F1 (rouge → vert). La cellule
137
- affiche le F1 en pourcentage. Cellules vides quand la catégorie
138
- n'a pas été observée pour le moteur.
139
-
140
- Returns
141
- -------
142
- str
143
- HTML ``<div>...</div>`` ou ``""`` si pas de données.
144
- """
145
- relevant = _engines_with_ner(engines_summary)
146
- if not relevant:
147
- return ""
148
-
149
- # Catégories : union sur tous les moteurs, ordre alphabétique
150
- all_categories: set[str] = set()
151
- for engine in relevant:
152
- per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
153
- all_categories.update(per_cat.keys())
154
- if not all_categories:
155
- return ""
156
- categories = sorted(all_categories)
157
-
158
- labels = labels or {}
159
- caption = labels.get(
160
- "ner_per_category_caption",
161
- "F1 par catégorie d'entité (heatmap)",
162
- )
163
- engine_label = labels.get("ner_engine_label", "Moteur")
164
- no_data = labels.get("ner_no_data_label", "—")
165
-
166
- parts: list[str] = []
167
- parts.append('<div class="ner-per-category">')
168
- parts.append(
169
- f'<div class="ner-per-category-caption" '
170
- f'style="font-weight:600;margin-bottom:.4rem">{_e(caption)}</div>'
171
- )
172
- parts.append(
173
- '<table class="ner-per-category-table" '
174
- 'style="border-collapse:collapse;font-size:.8rem">'
175
- )
176
- parts.append("<thead><tr>")
177
- parts.append(
178
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
179
- f'border-bottom:1px solid var(--border)">{_e(engine_label)}</th>'
180
- )
181
- for cat in categories:
182
- parts.append(
183
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
184
- f'border-bottom:1px solid var(--border)">{_e(cat)}</th>'
185
- )
186
- parts.append("</tr></thead><tbody>")
187
- for engine in relevant:
188
- per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
189
- parts.append("<tr>")
190
- parts.append(
191
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
192
- f'border-right:1px solid var(--border);font-weight:600">'
193
- f'{_e(engine.get("name", ""))}</th>'
194
- )
195
- for cat in categories:
196
- stats = per_cat.get(cat)
197
- if not stats or int(stats.get("support", 0)) == 0:
198
- parts.append(
199
- f'<td style="padding:.3rem .5rem;text-align:center;'
200
- f'background:#f4f4f4;color:var(--text-muted);'
201
- f'font-style:italic">{_e(no_data)}</td>'
202
- )
203
- else:
204
- f1 = float(stats.get("f1") or 0.0)
205
- support = int(stats.get("support", 0))
206
- bg = color_traffic_light(f1)
207
- parts.append(
208
- f'<td style="padding:.3rem .5rem;text-align:center;'
209
- f'background:{bg};color:#222;'
210
- f'font-variant-numeric:tabular-nums" '
211
- f'title="support={support}">'
212
- f'{f1 * 100:.1f} %</td>'
213
- )
214
- parts.append("</tr>")
215
- parts.append("</tbody></table></div>")
216
- return "".join(parts)
217
 
 
218
 
219
- __all__ = [
220
- "build_ner_summary_html",
221
- "build_ner_per_category_html",
222
- ]
 
 
 
1
+ """``picarones.report.ner_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.ner`. Phase
4
+ 5.C du retrait du legacy.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  """
6
 
7
  from __future__ import annotations
8
 
9
+ import warnings
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
+ from picarones.reports_v2.html.renderers.ner import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.ner_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.ner instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/views/diagnostics.py CHANGED
@@ -144,7 +144,7 @@ def build_diagnostics_view_html(
144
  from picarones.measurements.image_predictive import (
145
  aggregate_corpus_predictive,
146
  )
147
- from picarones.report.image_predictive_render import (
148
  build_image_predictive_html,
149
  )
150
  aggregated = aggregate_corpus_predictive(image_qualities)
 
144
  from picarones.measurements.image_predictive import (
145
  aggregate_corpus_predictive,
146
  )
147
+ from picarones.reports_v2.html.renderers.image_predictive import (
148
  build_image_predictive_html,
149
  )
150
  aggregated = aggregate_corpus_predictive(image_qualities)
picarones/report/views/pipeline.py CHANGED
@@ -148,7 +148,7 @@ def build_pipeline_view_html(
148
  # Sous-section 3 : absorption d'erreur par jonction
149
  if junctions:
150
  try:
151
- from picarones.report.error_absorption_render import (
152
  build_error_absorption_html,
153
  )
154
  html = build_error_absorption_html(junctions, labels=labels)
@@ -171,7 +171,7 @@ def build_pipeline_view_html(
171
  from picarones.measurements.incremental_comparison import (
172
  compare_isolated_effect,
173
  )
174
- from picarones.report.incremental_comparison_render import (
175
  build_incremental_comparison_html,
176
  )
177
  comparison = compare_isolated_effect(
@@ -200,7 +200,7 @@ def build_pipeline_view_html(
200
  # Sous-section 5 : audit des modules contribués
201
  if module_audits:
202
  try:
203
- from picarones.report.module_audit_render import (
204
  build_module_audit_html,
205
  )
206
  html = build_module_audit_html(module_audits, labels=labels)
 
148
  # Sous-section 3 : absorption d'erreur par jonction
149
  if junctions:
150
  try:
151
+ from picarones.reports_v2.html.renderers.error_absorption import (
152
  build_error_absorption_html,
153
  )
154
  html = build_error_absorption_html(junctions, labels=labels)
 
171
  from picarones.measurements.incremental_comparison import (
172
  compare_isolated_effect,
173
  )
174
+ from picarones.reports_v2.html.renderers.incremental_comparison import (
175
  build_incremental_comparison_html,
176
  )
177
  comparison = compare_isolated_effect(
 
200
  # Sous-section 5 : audit des modules contribués
201
  if module_audits:
202
  try:
203
+ from picarones.reports_v2.html.renderers.module_audit import (
204
  build_module_audit_html,
205
  )
206
  html = build_module_audit_html(module_audits, labels=labels)
picarones/reports_v2/html/renderers/error_absorption.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Absorption d'erreur » — Sprint 94 (B.3).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.error_absorption_render`` vers
5
+ ``picarones.reports_v2.html.renderers.error_absorption``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/error_absorption.py``. Pattern
10
+ identique aux autres rendus : server-side, pas de JS, anti-
11
+ injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Tableau résumé des jonctions du pipeline ; chaque ligne décrit
16
+ un module post-correction et présente :
17
+
18
+ - erreurs en entrée vs en sortie ;
19
+ - nb corrigées (gradient vert), nb introduites (gradient rouge) ;
20
+ - taux de correction (gradient vert), taux d'introduction
21
+ (gradient rouge) ;
22
+ - amélioration nette (n_corrected - n_introduced) — coloré.
23
+ - éventuellement un échantillon de tokens corrigés/introduits.
24
+
25
+ Adaptive : ``""`` si la liste est vide.
26
+
27
+ Note d'intégration
28
+ ------------------
29
+ Module pur — la liste ``junctions`` est composée par
30
+ l'utilisateur depuis son benchmark de pipeline composée :
31
+
32
+ .. code-block:: python
33
+
34
+ from picarones.measurements.error_absorption import (
35
+ compute_error_absorption, aggregate_error_absorption,
36
+ )
37
+ from picarones.reports_v2.html.renderers.error_absorption import (
38
+ build_error_absorption_html,
39
+ )
40
+
41
+ junctions = []
42
+ for step in pipeline.steps_with_text_output:
43
+ per_doc = [
44
+ compute_error_absorption(doc.gt_text, doc.before_text,
45
+ doc.after_text)
46
+ for doc in benchmark.docs
47
+ ]
48
+ agg = aggregate_error_absorption(per_doc)
49
+ if agg is not None:
50
+ agg["junction_name"] = step.name
51
+ junctions.append(agg)
52
+ html = build_error_absorption_html(junctions, labels)
53
+ """
54
+
55
+ from __future__ import annotations
56
+
57
+ from html import escape as _e
58
+ from typing import Optional
59
+
60
+ from picarones.reports_v2._helpers.render_helpers import color_diverging, color_traffic_light
61
+
62
+
63
+ # Palette « net improvement » : vert clair au centre, vert profond
64
+ # si favorable (net > 0), rouge si défavorable (net < 0). Centrée
65
+ # sur le vert clair car un delta nul est déjà « pas de régression ».
66
+ _NET_NEUTRAL_RGB = (167, 240, 167)
67
+ _NET_POSITIVE_RGB = (90, 200, 90)
68
+ _NET_NEGATIVE_RGB = (220, 50, 50)
69
+
70
+
71
+ def build_error_absorption_html(
72
+ junctions: Optional[list],
73
+ labels: Optional[dict[str, str]] = None,
74
+ *,
75
+ sample_max: int = 8,
76
+ ) -> str:
77
+ """Construit la vue HTML « Absorption d'erreur ».
78
+
79
+ Parameters
80
+ ----------
81
+ junctions:
82
+ Liste de dicts (un par jonction de pipeline), enrichis
83
+ d'un ``junction_name``. Si vide ou ``None``, retourne
84
+ ``""``.
85
+ labels:
86
+ Dict i18n. Clés sous le préfixe ``absorption_*``.
87
+ sample_max:
88
+ Nombre maximal de tokens corrigés/introduits affichés
89
+ en cellule échantillon.
90
+ """
91
+ if not junctions:
92
+ return ""
93
+ rows = [
94
+ j for j in junctions
95
+ if isinstance(j, dict) and j.get("junction_name")
96
+ ]
97
+ if not rows:
98
+ return ""
99
+ labels = labels or {}
100
+ title = labels.get(
101
+ "absorption_title", "Absorption d'erreur par jonction",
102
+ )
103
+ note = labels.get(
104
+ "absorption_note",
105
+ "À chaque jonction du pipeline, deux flux sont mesurés "
106
+ "indépendamment : combien d'erreurs sont corrigées et "
107
+ "combien sont introduites. Une jonction qui corrige "
108
+ "beaucoup mais introduit aussi beaucoup absorbe les "
109
+ "différences amont au lieu de les améliorer.",
110
+ )
111
+ h_junction = labels.get("absorption_junction", "Jonction")
112
+ h_errors_before = labels.get("absorption_errors_before", "Erreurs avant")
113
+ h_errors_after = labels.get("absorption_errors_after", "Erreurs après")
114
+ h_corrected = labels.get("absorption_corrected", "Corrigées")
115
+ h_introduced = labels.get("absorption_introduced", "Introduites")
116
+ h_corr_rate = labels.get("absorption_corr_rate", "% corrigées")
117
+ h_intro_rate = labels.get("absorption_intro_rate", "% introduites")
118
+ h_net = labels.get("absorption_net", "Amélioration nette")
119
+ h_sample = labels.get("absorption_sample", "Échantillon (intro)")
120
+
121
+ # Saturation pour le gradient « net »
122
+ max_abs_net = max(
123
+ (abs(int(r.get("net_improvement") or 0)) for r in rows), default=1,
124
+ ) or 1
125
+
126
+ parts = [
127
+ '<section class="absorption-section" style="margin:1rem 0">',
128
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
129
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
130
+ f'{_e(note)}</div>',
131
+ '<table style="border-collapse:collapse;width:100%;'
132
+ 'font-size:.9rem">',
133
+ '<thead><tr>',
134
+ ]
135
+ for col in (h_junction, h_errors_before, h_errors_after,
136
+ h_corrected, h_introduced, h_corr_rate,
137
+ h_intro_rate, h_net, h_sample):
138
+ parts.append(
139
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
140
+ f'border-bottom:1px solid #ccc;font-weight:600">'
141
+ f'{_e(col)}</th>'
142
+ )
143
+ parts.append("</tr></thead><tbody>")
144
+ for entry in rows:
145
+ name = str(entry.get("junction_name") or "?")
146
+ n_eb = int(entry.get("n_errors_before") or 0)
147
+ n_ea = int(entry.get("n_errors_after") or 0)
148
+ n_corr = int(entry.get("n_corrected") or 0)
149
+ n_intro = int(entry.get("n_introduced") or 0)
150
+ net = int(entry.get("net_improvement") or 0)
151
+ corr_rate = entry.get("correction_rate")
152
+ intro_rate = entry.get("introduction_rate")
153
+ if isinstance(corr_rate, (int, float)):
154
+ corr_rate_str = f"{corr_rate * 100:.1f}%"
155
+ corr_color = color_traffic_light(float(corr_rate))
156
+ corr_cell = (
157
+ f'<td style="padding:.4rem .6rem;text-align:right;'
158
+ f'background:{corr_color};font-family:monospace;'
159
+ f'font-weight:600">{corr_rate_str}</td>'
160
+ )
161
+ else:
162
+ corr_cell = (
163
+ '<td style="padding:.4rem .6rem;text-align:right;'
164
+ 'opacity:.4">—</td>'
165
+ )
166
+ if isinstance(intro_rate, (int, float)):
167
+ intro_rate_str = f"{intro_rate * 100:.1f}%"
168
+ intro_color = color_traffic_light(float(intro_rate), low_is_good=True)
169
+ intro_cell = (
170
+ f'<td style="padding:.4rem .6rem;text-align:right;'
171
+ f'background:{intro_color};font-family:monospace;'
172
+ f'font-weight:600">{intro_rate_str}</td>'
173
+ )
174
+ else:
175
+ intro_cell = (
176
+ '<td style="padding:.4rem .6rem;text-align:right;'
177
+ 'opacity:.4">—</td>'
178
+ )
179
+ net_color = color_diverging(
180
+ float(net),
181
+ max_abs=float(max_abs_net) if max_abs_net else 1.0,
182
+ neutral_rgb=_NET_NEUTRAL_RGB,
183
+ positive_rgb=_NET_POSITIVE_RGB,
184
+ negative_rgb=_NET_NEGATIVE_RGB,
185
+ )
186
+ intro_sample = entry.get("introduced_tokens_sample") or []
187
+ sample_cell_text = ", ".join(
188
+ _e(str(t)) for t in intro_sample[:sample_max]
189
+ ) or "—"
190
+ if len(intro_sample) > sample_max:
191
+ sample_cell_text += " …"
192
+ parts.append(
193
+ f'<tr>'
194
+ f'<td style="padding:.4rem .6rem">{_e(name)}</td>'
195
+ f'<td style="padding:.4rem .6rem;text-align:right;'
196
+ f'font-family:monospace">{n_eb}</td>'
197
+ f'<td style="padding:.4rem .6rem;text-align:right;'
198
+ f'font-family:monospace">{n_ea}</td>'
199
+ f'<td style="padding:.4rem .6rem;text-align:right;'
200
+ f'font-family:monospace">{n_corr}</td>'
201
+ f'<td style="padding:.4rem .6rem;text-align:right;'
202
+ f'font-family:monospace">{n_intro}</td>'
203
+ f'{corr_cell}'
204
+ f'{intro_cell}'
205
+ f'<td style="padding:.4rem .6rem;text-align:right;'
206
+ f'background:{net_color};font-family:monospace;'
207
+ f'font-weight:600">{net:+d}</td>'
208
+ f'<td style="padding:.4rem .6rem;font-family:monospace;'
209
+ f'font-size:.8rem">{sample_cell_text}</td>'
210
+ f'</tr>'
211
+ )
212
+ parts.append("</tbody></table></section>")
213
+ return "".join(parts)
214
+
215
+
216
+ __all__ = ["build_error_absorption_html"]
picarones/reports_v2/html/renderers/image_predictive.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Profil d'image du corpus » — Sprint 93 (A.II.7).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.image_predictive_render`` vers
5
+ ``picarones.reports_v2.html.renderers.image_predictive``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/image_predictive.py``. Pattern
10
+ identique aux autres rendus : server-side, pas de JS, anti-
11
+ injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Deux blocs dans une section unique :
16
+
17
+ 1. **Complexité paléographique** : moyenne, médiane, min, max,
18
+ écart-type sur l'ensemble du corpus.
19
+ 2. **Homogénéité du corpus** : score combiné + détail par
20
+ feature (mean, stdev, contribution normalisée).
21
+
22
+ Adaptive : ``""`` si pas de données.
23
+
24
+ Note d'intégration
25
+ ------------------
26
+ Module pur — l'utilisateur compose :
27
+
28
+ .. code-block:: python
29
+
30
+ from picarones.measurements.image_predictive import aggregate_corpus_predictive
31
+ from picarones.reports_v2.html.renderers.image_predictive import (
32
+ build_image_predictive_html,
33
+ )
34
+
35
+ qualities = [doc.image_quality.as_dict() for doc in benchmark.docs]
36
+ agg = aggregate_corpus_predictive(qualities)
37
+ html = build_image_predictive_html(agg, labels)
38
+ """
39
+
40
+ from __future__ import annotations
41
+
42
+ from html import escape as _e
43
+ from typing import Optional
44
+
45
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
46
+
47
+
48
+ _FEATURE_LABEL_KEYS = {
49
+ "noise_level": "imgpred_feat_noise",
50
+ "sharpness_score": "imgpred_feat_sharpness",
51
+ "contrast_score": "imgpred_feat_contrast",
52
+ "rotation_degrees": "imgpred_feat_rotation",
53
+ }
54
+
55
+
56
+ def _render_complexity_block(
57
+ aggregated: dict, labels: dict[str, str],
58
+ ) -> str:
59
+ h_complex = labels.get(
60
+ "imgpred_complexity", "Complexité paléographique",
61
+ )
62
+ h_mean = labels.get("imgpred_mean", "Moyenne")
63
+ h_median = labels.get("imgpred_median", "Médiane")
64
+ h_min = labels.get("imgpred_min", "Min")
65
+ h_max = labels.get("imgpred_max", "Max")
66
+ h_stdev = labels.get("imgpred_stdev", "Écart-type")
67
+ h_docs = labels.get("imgpred_docs", "Docs")
68
+ mean = float(aggregated.get("complexity_mean") or 0.0)
69
+ median = float(aggregated.get("complexity_median") or 0.0)
70
+ mn = float(aggregated.get("complexity_min") or 0.0)
71
+ mx = float(aggregated.get("complexity_max") or 0.0)
72
+ sd = float(aggregated.get("complexity_stdev") or 0.0)
73
+ n_docs = int(aggregated.get("n_docs") or 0)
74
+ color_mean = color_traffic_light(mean, low_is_good=True)
75
+ return (
76
+ f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
77
+ f'{_e(h_complex)}</div>'
78
+ '<table style="border-collapse:collapse;width:100%;'
79
+ 'font-size:.9rem;margin-bottom:.8rem">'
80
+ f'<thead><tr>'
81
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
82
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_mean)}</th>'
83
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
84
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_median)}</th>'
85
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
86
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_min)}</th>'
87
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
88
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_max)}</th>'
89
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
90
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_stdev)}</th>'
91
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:right;'
92
+ f'border-bottom:1px solid #ccc;font-weight:600">{_e(h_docs)}</th>'
93
+ f'</tr></thead>'
94
+ f'<tbody><tr>'
95
+ f'<td style="padding:.4rem .6rem;text-align:right;'
96
+ f'background:{color_mean};font-family:monospace;font-weight:600">'
97
+ f'{mean:.3f}</td>'
98
+ f'<td style="padding:.4rem .6rem;text-align:right;'
99
+ f'font-family:monospace">{median:.3f}</td>'
100
+ f'<td style="padding:.4rem .6rem;text-align:right;'
101
+ f'font-family:monospace">{mn:.3f}</td>'
102
+ f'<td style="padding:.4rem .6rem;text-align:right;'
103
+ f'font-family:monospace">{mx:.3f}</td>'
104
+ f'<td style="padding:.4rem .6rem;text-align:right;'
105
+ f'font-family:monospace">{sd:.3f}</td>'
106
+ f'<td style="padding:.4rem .6rem;text-align:right;'
107
+ f'font-family:monospace">{n_docs}</td>'
108
+ f'</tr></tbody></table>'
109
+ )
110
+
111
+
112
+ def _render_homogeneity_block(
113
+ homogeneity: dict, labels: dict[str, str],
114
+ ) -> str:
115
+ h_homo = labels.get(
116
+ "imgpred_homogeneity", "Homogénéité du corpus",
117
+ )
118
+ h_feat = labels.get("imgpred_feature", "Feature")
119
+ h_mean = labels.get("imgpred_feat_mean", "Moyenne")
120
+ h_stdev = labels.get("imgpred_feat_stdev", "Écart-type")
121
+ h_norm = labels.get(
122
+ "imgpred_feat_norm", "Contribution normalisée",
123
+ )
124
+ score = float(homogeneity.get("score") or 0.0)
125
+ color = color_traffic_light(score, low_is_good=True)
126
+ parts = [
127
+ f'<div style="font-weight:600;margin:.4rem 0 .3rem 0">'
128
+ f'{_e(h_homo)} : '
129
+ f'<span style="background:{color};padding:.1rem .4rem;'
130
+ f'border-radius:.3rem;font-family:monospace">{score:.3f}</span>'
131
+ f'</div>',
132
+ '<table style="border-collapse:collapse;width:100%;'
133
+ 'font-size:.9rem">',
134
+ '<thead><tr>',
135
+ ]
136
+ for col in (h_feat, h_mean, h_stdev, h_norm):
137
+ parts.append(
138
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
139
+ f'border-bottom:1px solid #ccc;font-weight:600">'
140
+ f'{_e(col)}</th>'
141
+ )
142
+ parts.append("</tr></thead><tbody>")
143
+ per_feat = homogeneity.get("per_feature") or {}
144
+ for key, label_key in _FEATURE_LABEL_KEYS.items():
145
+ if key not in per_feat:
146
+ continue
147
+ slot = per_feat[key]
148
+ feat_label = labels.get(label_key, key)
149
+ feat_mean = float(slot.get("mean") or 0.0)
150
+ feat_stdev = float(slot.get("stdev") or 0.0)
151
+ feat_norm = float(slot.get("normalised") or 0.0)
152
+ norm_color = color_traffic_light(feat_norm, low_is_good=True)
153
+ parts.append(
154
+ f'<tr>'
155
+ f'<td style="padding:.4rem .6rem">{_e(feat_label)}</td>'
156
+ f'<td style="padding:.4rem .6rem;text-align:right;'
157
+ f'font-family:monospace">{feat_mean:.3f}</td>'
158
+ f'<td style="padding:.4rem .6rem;text-align:right;'
159
+ f'font-family:monospace">{feat_stdev:.3f}</td>'
160
+ f'<td style="padding:.4rem .6rem;text-align:right;'
161
+ f'background:{norm_color};font-family:monospace">'
162
+ f'{feat_norm:.3f}</td>'
163
+ f'</tr>'
164
+ )
165
+ parts.append("</tbody></table>")
166
+ return "".join(parts)
167
+
168
+
169
+ def build_image_predictive_html(
170
+ aggregated: Optional[dict],
171
+ labels: Optional[dict[str, str]] = None,
172
+ ) -> str:
173
+ """Construit la vue HTML « Profil d'image du corpus ».
174
+
175
+ Parameters
176
+ ----------
177
+ aggregated:
178
+ Sortie de ``aggregate_corpus_predictive``. Si ``None``
179
+ ou ``n_docs == 0``, retourne ``""``.
180
+ labels:
181
+ Dict i18n. Clés sous le préfixe ``imgpred_*``.
182
+ """
183
+ if not aggregated:
184
+ return ""
185
+ if not aggregated.get("n_docs"):
186
+ return ""
187
+ labels = labels or {}
188
+ title = labels.get(
189
+ "imgpred_title", "Profil d'image du corpus",
190
+ )
191
+ note = labels.get(
192
+ "imgpred_note",
193
+ "Score de complexité paléographique combinant bruit, "
194
+ "flou, faible contraste et rotation. Le score "
195
+ "d'homogénéité signale si la moyenne globale est fiable "
196
+ "(corpus uniforme) ou trompeuse (corpus hétérogène — "
197
+ "voir alors la vue stratifiée).",
198
+ )
199
+ parts = [
200
+ '<section class="imgpred-section" style="margin:1rem 0">',
201
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
202
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
203
+ f'{_e(note)}</div>',
204
+ ]
205
+ parts.append(_render_complexity_block(aggregated, labels))
206
+ homo = aggregated.get("homogeneity")
207
+ if isinstance(homo, dict):
208
+ parts.append(_render_homogeneity_block(homo, labels))
209
+ parts.append("</section>")
210
+ return "".join(parts)
211
+
212
+
213
+ __all__ = ["build_image_predictive_html"]
picarones/reports_v2/html/renderers/incremental_comparison.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Comparaison contrôlée » — Sprint 96 (B.5).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.incremental_comparison_render`` vers
5
+ ``picarones.reports_v2.html.renderers.incremental_comparison``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/incremental_comparison.py``.
10
+ Pattern identique aux autres rendus : server-side, pas de JS,
11
+ anti-injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Tableau ANOVA-like : pour chaque valeur du slot variant, mean
16
+ ± stdev, rang moyen, n_observations. Mean colorée en
17
+ gradient vert (meilleur) → rouge (pire), normalisée sur la
18
+ plage des moyennes observées.
19
+
20
+ Adaptive : ``""`` si ``analysis`` est ``None``.
21
+
22
+ Note d'intégration
23
+ ------------------
24
+ Module pur — l'utilisateur compose :
25
+
26
+ .. code-block:: python
27
+
28
+ from picarones.measurements.incremental_comparison import (
29
+ PipelineRun, compare_isolated_effect,
30
+ )
31
+ from picarones.reports_v2.html.renderers.incremental_comparison import (
32
+ build_incremental_comparison_html,
33
+ )
34
+
35
+ runs = [
36
+ PipelineRun(name=p.name,
37
+ slots={"ocr": p.ocr, "llm": p.llm},
38
+ score=p.cer_mean)
39
+ for p in benchmark.pipelines
40
+ ]
41
+ analysis = compare_isolated_effect(runs, "llm")
42
+ html = build_incremental_comparison_html(analysis, labels)
43
+ """
44
+
45
+ from __future__ import annotations
46
+
47
+ from html import escape as _e
48
+ from typing import Optional
49
+
50
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
51
+
52
+
53
+ def _bg_for_relative_score(
54
+ score: float, low: float, high: float, higher_is_better: bool,
55
+ ) -> str:
56
+ """Mappe ``score`` sur une plage [low, high] et retourne une cellule
57
+ colorée traffic-light.
58
+
59
+ Si ``higher_is_better=True``, ``score=high`` est vert ; sinon
60
+ ``score=low`` est vert.
61
+ """
62
+ if high == low:
63
+ return color_traffic_light(1.0) # neutre vert clair
64
+ return color_traffic_light(
65
+ score,
66
+ low_is_good=not higher_is_better,
67
+ scale_min=low,
68
+ scale_max=high,
69
+ )
70
+
71
+
72
+ def _format_score(value: Optional[float]) -> str:
73
+ if value is None:
74
+ return "—"
75
+ if abs(value) < 1.0:
76
+ return f"{value * 100:.2f}%"
77
+ return f"{value:.3f}"
78
+
79
+
80
+ def build_incremental_comparison_html(
81
+ analysis: Optional[dict],
82
+ labels: Optional[dict[str, str]] = None,
83
+ ) -> str:
84
+ """Construit la vue HTML « Comparaison contrôlée ».
85
+
86
+ Parameters
87
+ ----------
88
+ analysis:
89
+ Sortie de ``compare_isolated_effect``. ``None`` ou
90
+ ``per_value`` vide → retourne ``""``.
91
+ labels:
92
+ Dict i18n. Clés sous le préfixe ``incr_*``.
93
+ """
94
+ if not analysis:
95
+ return ""
96
+ per_value = analysis.get("per_value") or {}
97
+ if not per_value:
98
+ return ""
99
+ labels = labels or {}
100
+ title = labels.get(
101
+ "incr_title", "Comparaison contrôlée par slot",
102
+ )
103
+ note = labels.get(
104
+ "incr_note",
105
+ "Effet isolé du module variant sur les pipelines en "
106
+ "contrôlant les autres slots. Pour chaque valeur du "
107
+ "slot, moyenne ± écart-type, rang moyen sur les groupes "
108
+ "fixes, et nombre d'observations. Type design "
109
+ "d'expérience pour des comparaisons honnêtes.",
110
+ )
111
+ slot_label = labels.get("incr_slot_label", "Slot variant")
112
+ h_value = labels.get("incr_value", "Valeur")
113
+ h_mean = labels.get("incr_mean", "Score moyen")
114
+ h_stdev = labels.get("incr_stdev", "± σ")
115
+ h_rank = labels.get("incr_rank", "Rang moyen")
116
+ h_n_obs = labels.get("incr_n_obs", "Observations")
117
+ h_groups = labels.get("incr_groups", "Groupes fixes")
118
+ higher_is_better = bool(analysis.get("higher_is_better", False))
119
+
120
+ # Plage de moyennes pour le code couleur
121
+ means = [
122
+ d["mean"] for d in per_value.values() if d.get("mean") is not None
123
+ ]
124
+ low = min(means) if means else 0.0
125
+ high = max(means) if means else 0.0
126
+
127
+ varying_slot = str(analysis.get("varying_slot") or "?")
128
+ n_groups = int(analysis.get("n_groups") or 0)
129
+ n_runs = int(analysis.get("n_runs") or 0)
130
+ best = analysis.get("best_value")
131
+ worst = analysis.get("worst_value")
132
+
133
+ parts = [
134
+ '<section class="incr-section" style="margin:1rem 0">',
135
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
136
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
137
+ f'{_e(note)}</div>',
138
+ f'<div style="font-size:.85rem;margin-bottom:.5rem">'
139
+ f'<strong>{_e(slot_label)} :</strong> '
140
+ f'<code>{_e(varying_slot)}</code> &nbsp; '
141
+ f'<span style="opacity:.75">'
142
+ f'{n_runs} runs, {n_groups} {_e(h_groups.lower())}'
143
+ f'</span></div>',
144
+ '<table style="border-collapse:collapse;width:100%;'
145
+ 'font-size:.9rem">',
146
+ '<thead><tr>',
147
+ ]
148
+ for col in (h_value, h_mean, h_stdev, h_rank, h_n_obs):
149
+ parts.append(
150
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
151
+ f'border-bottom:1px solid #ccc;font-weight:600">'
152
+ f'{_e(col)}</th>'
153
+ )
154
+ parts.append("</tr></thead><tbody>")
155
+
156
+ # Tri par rang moyen ascendant
157
+ rows = sorted(
158
+ per_value.items(),
159
+ key=lambda kv: (kv[1].get("mean_rank") or float("inf")),
160
+ )
161
+ for value, d in rows:
162
+ mean = d.get("mean")
163
+ stdev = d.get("stdev")
164
+ rank = d.get("mean_rank")
165
+ n_obs = int(d.get("n_observations") or 0)
166
+ if isinstance(mean, (int, float)):
167
+ color = _bg_for_relative_score(
168
+ float(mean), low, high, higher_is_better,
169
+ )
170
+ mean_cell = (
171
+ f'<td style="padding:.4rem .6rem;text-align:right;'
172
+ f'background:{color};font-family:monospace;'
173
+ f'font-weight:600">{_format_score(mean)}</td>'
174
+ )
175
+ else:
176
+ mean_cell = (
177
+ '<td style="padding:.4rem .6rem;text-align:right;'
178
+ 'opacity:.4">—</td>'
179
+ )
180
+ stdev_str = (
181
+ f"± {_format_score(stdev)}"
182
+ if isinstance(stdev, (int, float)) else "—"
183
+ )
184
+ rank_str = f"{rank:.2f}" if isinstance(rank, (int, float)) else "—"
185
+ marker = ""
186
+ if value == best:
187
+ marker = ' <span style="color:#16a34a">★</span>'
188
+ elif value == worst:
189
+ marker = ' <span style="color:#dc2626">▼</span>'
190
+ parts.append(
191
+ f'<tr>'
192
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
193
+ f'{_e(str(value))}{marker}</td>'
194
+ f'{mean_cell}'
195
+ f'<td style="padding:.4rem .6rem;text-align:right;'
196
+ f'font-family:monospace">{stdev_str}</td>'
197
+ f'<td style="padding:.4rem .6rem;text-align:right;'
198
+ f'font-family:monospace">{rank_str}</td>'
199
+ f'<td style="padding:.4rem .6rem;text-align:right;'
200
+ f'font-family:monospace">{n_obs}</td>'
201
+ f'</tr>'
202
+ )
203
+ parts.append("</tbody></table></section>")
204
+ return "".join(parts)
205
+
206
+
207
+ __all__ = ["build_incremental_comparison_html"]
picarones/reports_v2/html/renderers/module_audit.py ADDED
@@ -0,0 +1,179 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML « Modules audités » — Sprint 97 (B.6).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.module_audit_render`` vers
5
+ ``picarones.reports_v2.html.renderers.module_audit``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe ``picarones/core/module_policy.py``. Pattern
10
+ identique aux autres rendus : server-side, pas de JS, anti-
11
+ injection systématique.
12
+
13
+ Vue
14
+ ---
15
+ Tableau récapitulatif des modules utilisés dans une pipeline
16
+ composée, chacun avec :
17
+
18
+ - Statut d'audit (✓ vert si tous les checks passent, ✗ rouge
19
+ sinon, avec compte des échecs) ;
20
+ - Métadonnées : version, auteur, licence ;
21
+ - Citation académique si fournie ;
22
+ - Lien vers la homepage si fourni.
23
+
24
+ Adaptive : ``""`` si la liste est vide.
25
+
26
+ Note d'intégration
27
+ ------------------
28
+ Module pur — l'utilisateur compose la liste depuis sa
29
+ ``PipelineSpec`` augmentée des ``ModuleManifest`` :
30
+
31
+ .. code-block:: python
32
+
33
+ from picarones.measurements.module_policy import audit_module
34
+ from picarones.reports_v2.html.renderers.module_audit import build_module_audit_html
35
+
36
+ audits = []
37
+ for step in pipeline.steps:
38
+ manifest = step.module.manifest # convention applicative
39
+ result = audit_module(step.module, manifest)
40
+ audits.append({
41
+ "manifest": manifest.as_dict(),
42
+ "audit": result.as_dict(),
43
+ })
44
+ html = build_module_audit_html(audits, labels)
45
+ """
46
+
47
+ from __future__ import annotations
48
+
49
+ from html import escape as _e
50
+ from typing import Optional
51
+
52
+
53
+ def _passed_badge(passed: bool, n_failed: int, label_pass: str,
54
+ label_fail: str) -> str:
55
+ if passed:
56
+ return (
57
+ f'<span style="color:#16a34a;font-weight:700">'
58
+ f'✓ {_e(label_pass)}</span>'
59
+ )
60
+ return (
61
+ f'<span style="color:#dc2626;font-weight:700">'
62
+ f'✗ {_e(label_fail)} ({n_failed})</span>'
63
+ )
64
+
65
+
66
+ def build_module_audit_html(
67
+ audits: Optional[list],
68
+ labels: Optional[dict[str, str]] = None,
69
+ ) -> str:
70
+ """Construit la vue HTML « Modules audités ».
71
+
72
+ Parameters
73
+ ----------
74
+ audits:
75
+ Liste de dicts ``{"manifest": ManifestDict, "audit":
76
+ AuditResultDict}``. Si vide ou ``None``, retourne ``""``.
77
+ labels:
78
+ Dict i18n. Clés sous le préfixe ``audit_*``.
79
+ """
80
+ if not audits:
81
+ return ""
82
+ rows = [
83
+ a for a in audits
84
+ if isinstance(a, dict)
85
+ and isinstance(a.get("manifest"), dict)
86
+ and isinstance(a.get("audit"), dict)
87
+ ]
88
+ if not rows:
89
+ return ""
90
+ labels = labels or {}
91
+ title = labels.get("audit_title", "Modules audités")
92
+ note = labels.get(
93
+ "audit_note",
94
+ "Récapitulatif des modules utilisés dans la pipeline "
95
+ "composée. Un module qui ne passe pas l'audit n'est "
96
+ "pas exécutable. Métadonnées issues du manifest fourni "
97
+ "par le contributeur (auteur, licence, citation).",
98
+ )
99
+ label_pass = labels.get("audit_pass", "audit OK")
100
+ label_fail = labels.get("audit_fail", "checks échoués")
101
+ h_module = labels.get("audit_module", "Module")
102
+ h_status = labels.get("audit_status", "Audit")
103
+ h_version = labels.get("audit_version", "Version")
104
+ h_author = labels.get("audit_author", "Auteur")
105
+ h_license = labels.get("audit_license", "Licence")
106
+ h_io = labels.get("audit_io", "Entrée → sortie")
107
+ h_citation = labels.get("audit_citation", "Citation")
108
+ h_homepage = labels.get("audit_homepage", "Page projet")
109
+
110
+ parts = [
111
+ '<section class="audit-section" style="margin:1rem 0">',
112
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
113
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
114
+ f'{_e(note)}</div>',
115
+ '<table style="border-collapse:collapse;width:100%;'
116
+ 'font-size:.9rem">',
117
+ '<thead><tr>',
118
+ ]
119
+ for col in (h_module, h_status, h_version, h_author,
120
+ h_license, h_io, h_citation, h_homepage):
121
+ parts.append(
122
+ f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
123
+ f'border-bottom:1px solid #ccc;font-weight:600">'
124
+ f'{_e(col)}</th>'
125
+ )
126
+ parts.append("</tr></thead><tbody>")
127
+
128
+ for entry in rows:
129
+ manifest = entry["manifest"]
130
+ audit = entry["audit"]
131
+ name = str(manifest.get("name") or "?")
132
+ version = str(manifest.get("version") or "—")
133
+ author = str(manifest.get("author") or "—")
134
+ license_ = str(manifest.get("license") or "—")
135
+ in_types = ", ".join(manifest.get("input_types") or []) or "—"
136
+ out_types = ", ".join(manifest.get("output_types") or []) or "—"
137
+ citation = manifest.get("citation") or ""
138
+ homepage = manifest.get("homepage") or ""
139
+ passed = bool(audit.get("passed"))
140
+ n_failed = int(audit.get("n_failed") or 0)
141
+ status_cell = _passed_badge(
142
+ passed, n_failed, label_pass, label_fail,
143
+ )
144
+ # Citation : tronqué si trop long
145
+ citation_str = str(citation)[:120]
146
+ if len(str(citation)) > 120:
147
+ citation_str += "…"
148
+ citation_cell = (
149
+ _e(citation_str) if citation_str.strip() else "—"
150
+ )
151
+ # Homepage : on n'auto-link **pas** (anti-injection +
152
+ # honnêteté : l'URL peut pointer ailleurs). On affiche
153
+ # le texte échappé tel quel.
154
+ homepage_cell = (
155
+ _e(str(homepage))[:80] + ("…" if len(str(homepage)) > 80 else "")
156
+ ) if str(homepage).strip() else "—"
157
+ parts.append(
158
+ f'<tr>'
159
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
160
+ f'{_e(name)}</td>'
161
+ f'<td style="padding:.4rem .6rem">{status_cell}</td>'
162
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
163
+ f'{_e(version)}</td>'
164
+ f'<td style="padding:.4rem .6rem">{_e(author)}</td>'
165
+ f'<td style="padding:.4rem .6rem;font-family:monospace">'
166
+ f'{_e(license_)}</td>'
167
+ f'<td style="padding:.4rem .6rem;font-family:monospace;'
168
+ f'font-size:.8rem">{_e(in_types)} → {_e(out_types)}</td>'
169
+ f'<td style="padding:.4rem .6rem;font-size:.8rem;'
170
+ f'opacity:.85">{citation_cell}</td>'
171
+ f'<td style="padding:.4rem .6rem;font-family:monospace;'
172
+ f'font-size:.8rem">{homepage_cell}</td>'
173
+ f'</tr>'
174
+ )
175
+ parts.append("</tbody></table></section>")
176
+ return "".join(parts)
177
+
178
+
179
+ __all__ = ["build_module_audit_html"]
picarones/reports_v2/html/renderers/ner.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML server-side de la section NER (Sprint 41).
2
+
3
+ Phase 5.C — module relocalisé depuis ``picarones.report.ner_render``
4
+ vers ``picarones.reports_v2.html.renderers.ner``. Le chemin legacy
5
+ reste disponible via un shim avec ``DeprecationWarning`` ;
6
+ suppression prévue en 2.0.
7
+
8
+ Suite directe des Sprints 38-40 : la couche de calcul, le backend
9
+ extracteur et le câblage runner sont en place ; ce module produit les
10
+ blocs HTML qui remontent ces données dans le rapport.
11
+
12
+ - ``build_ner_summary_html`` — encart factuel par moteur : F1 global,
13
+ precision/recall, total entités, hallucinations, missed.
14
+ - ``build_ner_per_category_html`` — table heatmap moteur × catégorie,
15
+ cellules colorées par F1 (rouge → vert).
16
+
17
+ Principe — cohérent avec ``inter_engine_render`` (Sprint 37) : rendu
18
+ server-side, pas de JavaScript, déterministe. Si aucun moteur n'a de
19
+ ``aggregated_ner``, les fonctions retournent une chaîne vide — la vue
20
+ est silencieusement omise (rapport adaptatif).
21
+
22
+ Anti-injection : tous les noms de moteurs et catégories sont passés à
23
+ ``html.escape`` avant insertion.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ from html import escape as _e
29
+ from typing import Optional
30
+
31
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
32
+
33
+
34
+ def _engines_with_ner(engines_summary: list[dict]) -> list[dict]:
35
+ """Filtre les moteurs qui ont une analyse NER agrégée."""
36
+ return [e for e in engines_summary if e.get("aggregated_ner")]
37
+
38
+
39
+ def build_ner_summary_html(
40
+ engines_summary: list[dict],
41
+ labels: Optional[dict[str, str]] = None,
42
+ ) -> str:
43
+ """Construit l'encart résumé NER : F1 global par moteur + totaux.
44
+
45
+ Parameters
46
+ ----------
47
+ engines_summary:
48
+ Liste de dicts moteur (au moins ``name`` et ``aggregated_ner``).
49
+ labels:
50
+ Dict d'étiquettes i18n.
51
+
52
+ Returns
53
+ -------
54
+ str
55
+ HTML ``<div>...</div>`` ou ``""`` si aucun moteur n'a de NER.
56
+ """
57
+ relevant = _engines_with_ner(engines_summary)
58
+ if not relevant:
59
+ return ""
60
+
61
+ labels = labels or {}
62
+ caption = labels.get("ner_summary_caption", "Précision sur entités nommées")
63
+ engine_label = labels.get("ner_engine_label", "Moteur")
64
+ f1_label = labels.get("ner_f1_label", "F1 global")
65
+ p_label = labels.get("ner_precision_label", "Précision")
66
+ r_label = labels.get("ner_recall_label", "Rappel")
67
+ docs_label = labels.get("ner_doc_count_label", "Docs évalués")
68
+ halluc_label = labels.get("ner_hallucinated_label", "Hallucinations")
69
+ missed_label = labels.get("ner_missed_label", "Entités manquées")
70
+
71
+ parts: list[str] = []
72
+ parts.append('<div class="ner-summary">')
73
+ parts.append(
74
+ f'<div class="ner-summary-caption" style="font-weight:600;'
75
+ f'margin-bottom:.4rem">{_e(caption)}</div>'
76
+ )
77
+ parts.append(
78
+ '<table class="ner-summary-table" '
79
+ 'style="border-collapse:collapse;font-size:.85rem;width:100%">'
80
+ )
81
+ parts.append("<thead><tr>")
82
+ for hdr in (engine_label, f1_label, p_label, r_label,
83
+ docs_label, halluc_label, missed_label):
84
+ parts.append(
85
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
86
+ f'border-bottom:1px solid var(--border);font-weight:600">'
87
+ f'{_e(hdr)}</th>'
88
+ )
89
+ parts.append("</tr></thead><tbody>")
90
+ for engine in relevant:
91
+ agg = engine["aggregated_ner"]
92
+ global_stats = agg.get("global", {}) or {}
93
+ f1 = float(global_stats.get("f1") or 0.0)
94
+ precision = float(global_stats.get("precision") or 0.0)
95
+ recall = float(global_stats.get("recall") or 0.0)
96
+ doc_count = int(agg.get("doc_count") or 0)
97
+ hallucinated = int(agg.get("hallucinated_total") or 0)
98
+ missed = int(agg.get("missed_total") or 0)
99
+ bg = color_traffic_light(f1)
100
+ parts.append("<tr>")
101
+ parts.append(
102
+ f'<td style="padding:.3rem .5rem;font-weight:600">'
103
+ f'{_e(engine.get("name", ""))}</td>'
104
+ )
105
+ parts.append(
106
+ f'<td style="padding:.3rem .5rem;background:{bg};'
107
+ f'font-variant-numeric:tabular-nums">{f1 * 100:.1f} %</td>'
108
+ )
109
+ parts.append(
110
+ f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
111
+ f'{precision * 100:.1f} %</td>'
112
+ )
113
+ parts.append(
114
+ f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
115
+ f'{recall * 100:.1f} %</td>'
116
+ )
117
+ parts.append(
118
+ f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
119
+ f'{doc_count}</td>'
120
+ )
121
+ parts.append(
122
+ f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
123
+ f'{hallucinated}</td>'
124
+ )
125
+ parts.append(
126
+ f'<td style="padding:.3rem .5rem;font-variant-numeric:tabular-nums">'
127
+ f'{missed}</td>'
128
+ )
129
+ parts.append("</tr>")
130
+ parts.append("</tbody></table></div>")
131
+ return "".join(parts)
132
+
133
+
134
+ def build_ner_per_category_html(
135
+ engines_summary: list[dict],
136
+ labels: Optional[dict[str, str]] = None,
137
+ ) -> str:
138
+ """Construit la heatmap NER moteur × catégorie d'entité.
139
+
140
+ Lignes = moteurs, colonnes = catégories (PER, LOC, ORG, DATE,
141
+ MISC…). Cellules colorées par F1 (rouge → vert). La cellule
142
+ affiche le F1 en pourcentage. Cellules vides quand la catégorie
143
+ n'a pas été observée pour le moteur.
144
+
145
+ Returns
146
+ -------
147
+ str
148
+ HTML ``<div>...</div>`` ou ``""`` si pas de données.
149
+ """
150
+ relevant = _engines_with_ner(engines_summary)
151
+ if not relevant:
152
+ return ""
153
+
154
+ # Catégories : union sur tous les moteurs, ordre alphabétique
155
+ all_categories: set[str] = set()
156
+ for engine in relevant:
157
+ per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
158
+ all_categories.update(per_cat.keys())
159
+ if not all_categories:
160
+ return ""
161
+ categories = sorted(all_categories)
162
+
163
+ labels = labels or {}
164
+ caption = labels.get(
165
+ "ner_per_category_caption",
166
+ "F1 par catégorie d'entité (heatmap)",
167
+ )
168
+ engine_label = labels.get("ner_engine_label", "Moteur")
169
+ no_data = labels.get("ner_no_data_label", "—")
170
+
171
+ parts: list[str] = []
172
+ parts.append('<div class="ner-per-category">')
173
+ parts.append(
174
+ f'<div class="ner-per-category-caption" '
175
+ f'style="font-weight:600;margin-bottom:.4rem">{_e(caption)}</div>'
176
+ )
177
+ parts.append(
178
+ '<table class="ner-per-category-table" '
179
+ 'style="border-collapse:collapse;font-size:.8rem">'
180
+ )
181
+ parts.append("<thead><tr>")
182
+ parts.append(
183
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
184
+ f'border-bottom:1px solid var(--border)">{_e(engine_label)}</th>'
185
+ )
186
+ for cat in categories:
187
+ parts.append(
188
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:center;'
189
+ f'border-bottom:1px solid var(--border)">{_e(cat)}</th>'
190
+ )
191
+ parts.append("</tr></thead><tbody>")
192
+ for engine in relevant:
193
+ per_cat = (engine["aggregated_ner"] or {}).get("per_category") or {}
194
+ parts.append("<tr>")
195
+ parts.append(
196
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
197
+ f'border-right:1px solid var(--border);font-weight:600">'
198
+ f'{_e(engine.get("name", ""))}</th>'
199
+ )
200
+ for cat in categories:
201
+ stats = per_cat.get(cat)
202
+ if not stats or int(stats.get("support", 0)) == 0:
203
+ parts.append(
204
+ f'<td style="padding:.3rem .5rem;text-align:center;'
205
+ f'background:#f4f4f4;color:var(--text-muted);'
206
+ f'font-style:italic">{_e(no_data)}</td>'
207
+ )
208
+ else:
209
+ f1 = float(stats.get("f1") or 0.0)
210
+ support = int(stats.get("support", 0))
211
+ bg = color_traffic_light(f1)
212
+ parts.append(
213
+ f'<td style="padding:.3rem .5rem;text-align:center;'
214
+ f'background:{bg};color:#222;'
215
+ f'font-variant-numeric:tabular-nums" '
216
+ f'title="support={support}">'
217
+ f'{f1 * 100:.1f} %</td>'
218
+ )
219
+ parts.append("</tr>")
220
+ parts.append("</tbody></table></div>")
221
+ return "".join(parts)
222
+
223
+
224
+ __all__ = [
225
+ "build_ner_summary_html",
226
+ "build_ner_per_category_html",
227
+ ]
tests/integration/test_sprint94_error_absorption.py CHANGED
@@ -29,7 +29,7 @@ from picarones.measurements.error_absorption import (
29
  aggregate_error_absorption,
30
  compute_error_absorption,
31
  )
32
- from picarones.report.error_absorption_render import (
33
  build_error_absorption_html,
34
  )
35
 
 
29
  aggregate_error_absorption,
30
  compute_error_absorption,
31
  )
32
+ from picarones.reports_v2.html.renderers.error_absorption import (
33
  build_error_absorption_html,
34
  )
35
 
tests/measurements/test_sprint93_image_predictive.py CHANGED
@@ -35,7 +35,7 @@ from picarones.measurements.image_predictive import (
35
  compute_corpus_homogeneity,
36
  compute_paleographic_complexity,
37
  )
38
- from picarones.report.image_predictive_render import (
39
  build_image_predictive_html,
40
  )
41
 
 
35
  compute_corpus_homogeneity,
36
  compute_paleographic_complexity,
37
  )
38
+ from picarones.reports_v2.html.renderers.image_predictive import (
39
  build_image_predictive_html,
40
  )
41
 
tests/measurements/test_sprint96_incremental_comparison.py CHANGED
@@ -31,7 +31,7 @@ from picarones.measurements.incremental_comparison import (
31
  PipelineRun,
32
  compare_isolated_effect,
33
  )
34
- from picarones.report.incremental_comparison_render import (
35
  build_incremental_comparison_html,
36
  )
37
 
 
31
  PipelineRun,
32
  compare_isolated_effect,
33
  )
34
+ from picarones.reports_v2.html.renderers.incremental_comparison import (
35
  build_incremental_comparison_html,
36
  )
37
 
tests/measurements/test_sprint97_module_policy.py CHANGED
@@ -36,7 +36,7 @@ from picarones.measurements.module_policy import (
36
  validate_manifest,
37
  )
38
  from picarones.core.modules import ArtifactType, BaseModule
39
- from picarones.report.module_audit_render import (
40
  build_module_audit_html,
41
  )
42
 
 
36
  validate_manifest,
37
  )
38
  from picarones.core.modules import ArtifactType, BaseModule
39
+ from picarones.reports_v2.html.renderers.module_audit import (
40
  build_module_audit_html,
41
  )
42
 
tests/report/test_sprint41_ner_html.py CHANGED
@@ -23,7 +23,7 @@ import pytest
23
 
24
  from picarones.fixtures import generate_sample_benchmark
25
  from picarones.report.generator import ReportGenerator
26
- from picarones.report.ner_render import (
27
  build_ner_per_category_html,
28
  build_ner_summary_html,
29
  )
 
23
 
24
  from picarones.fixtures import generate_sample_benchmark
25
  from picarones.report.generator import ReportGenerator
26
+ from picarones.reports_v2.html.renderers.ner import (
27
  build_ner_per_category_html,
28
  build_ner_summary_html,
29
  )