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

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

Browse files

Cinquième vague. Inclut les 3 renderers de la famille
``taxonomy``, ``worst_lines`` et ``pipeline_dag``.

Migrations effectuées
---------------------
| Source legacy | Destination canonique |
|-------------------------------------------------|------------------------------------------------------|
| ``report/taxonomy_intra_doc_render.py`` (148) | ``reports_v2/html/renderers/taxonomy_intra_doc.py`` |
| ``report/taxonomy_cooccurrence_render.py`` (161)| ``reports_v2/html/renderers/taxonomy_cooccurrence.py``|
| ``report/worst_lines_render.py`` (164) | ``reports_v2/html/renderers/worst_lines.py`` |
| ``report/taxonomy_comparison_render.py`` (233) | ``reports_v2/html/renderers/taxonomy_comparison.py`` |
| ``report/pipeline_dag_render.py`` (314) | ``reports_v2/html/renderers/pipeline_dag.py`` |

Total : ~1020 lignes relocalisées. 5 nouveaux shims minimaux.

Adaptations transverses
-----------------------
- ``reports_v2/html/renderers/worst_lines.py`` :
- import ``WorstLineEntry`` redirigé vers
``picarones.evaluation.metrics.worst_lines``
- import ``compute_char_diff`` redirigé vers
``picarones.evaluation`` (au lieu de
``picarones.core.diff_utils``, rejeté par la règle
layer-dependencies sur ``reports_v2``).

Cumul Phase 5.C (batches 1-5) : 25 renderers migrés. Reste
3 renderers : ``levers`` (284 l), ``pipeline_render`` (707 l),
``philological_render`` (595 l). Au total ~1586 LOC pour le
batch 6 (XXL + restants).

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

https://claude.ai/code/session_011XQZNitg1rCgia8ZD1a2hP

docs/migration/legacy-retirement-plan.md CHANGED
@@ -694,11 +694,9 @@ architecture vérifiée.
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 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
- - Batch 5 (les 2 restants + plus gros) : ``calibration``,
698
- ``worst_lines``, ``levers``, ``taxonomy_*`` (3 fichiers),
699
- ``pipeline_dag``.
700
- - Batch 6 (les XXL) : ``pipeline_render`` (707 l),
701
- ``philological_render`` (595 l).
702
  - Phase 5.D : 5 vues (``views/*.py``).
703
  - Phase 5.E : ``generator.py``, ``comparison.py``,
704
  ``snapshot.py``, ``report_data/``, templates Jinja2.
@@ -812,6 +810,49 @@ architecture vérifiée.
812
  et ``philological_render`` (595 l) — les XXL — auront leur propre
813
  batch dédié.
814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
816
 
817
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
 
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 ✅ (cf. ci-dessous) — 5 renderers (188-321 LOC).
697
+ - Batch 5 (cf. ci-dessous) 5 renderers (148-314 LOC).
698
+ - Batch 6 (XXL + restants) : ``pipeline_render`` (707 l),
699
+ ``philological_render`` (595 l), ``levers`` (284 l).
 
 
700
  - Phase 5.D : 5 vues (``views/*.py``).
701
  - Phase 5.E : ``generator.py``, ``comparison.py``,
702
  ``snapshot.py``, ``report_data/``, templates Jinja2.
 
810
  et ``philological_render`` (595 l) — les XXL — auront leur propre
811
  batch dédié.
812
 
813
+ #### Phase 5.C.batch5 — Lot 5 : 5 renderers moyens-gros (2026-05)
814
+
815
+ Cinquième vague. Inclut les 3 renderers de la famille
816
+ ``taxonomy``, ``worst_lines`` et ``pipeline_dag``. Restera ensuite
817
+ batch 6 (XXL + ``levers``) et la migration des 5 vues
818
+ (``views/*.py``).
819
+
820
+ **Migrations effectuées** :
821
+
822
+ | Source legacy | Destination canonique |
823
+ |-------------------------------------------------|------------------------------------------------------|
824
+ | ``report/taxonomy_intra_doc_render.py`` (148) | ``reports_v2/html/renderers/taxonomy_intra_doc.py`` |
825
+ | ``report/taxonomy_cooccurrence_render.py`` (161)| ``reports_v2/html/renderers/taxonomy_cooccurrence.py``|
826
+ | ``report/worst_lines_render.py`` (164) | ``reports_v2/html/renderers/worst_lines.py`` |
827
+ | ``report/taxonomy_comparison_render.py`` (233) | ``reports_v2/html/renderers/taxonomy_comparison.py`` |
828
+ | ``report/pipeline_dag_render.py`` (314) | ``reports_v2/html/renderers/pipeline_dag.py`` |
829
+
830
+ Total : ~1020 lignes relocalisées.
831
+
832
+ **Adaptations transverses** :
833
+
834
+ - ``reports_v2/html/renderers/worst_lines.py`` :
835
+ - import ``WorstLineEntry`` redirigé vers
836
+ ``picarones.evaluation.metrics.worst_lines``
837
+ - import ``compute_char_diff`` redirigé vers
838
+ ``picarones.evaluation`` (au lieu de ``picarones.core.diff_utils``,
839
+ rejeté par la règle layer-dependencies sur ``reports_v2``).
840
+
841
+ **Cumul Phase 5.C** (batches 1+2+3+4+5) : 20 + 5 = **25 renderers
842
+ migrés**, soit l'intégralité moins ``pipeline_render`` et
843
+ ``philological_render`` (XXL) et ``levers`` (oublié dans le plan
844
+ initial). Reste batch 6 (3 renderers) puis Phase 5.D (5 vues).
845
+
846
+ Wait — le compte exact : 22 originaux moins ``pipeline_render``,
847
+ ``philological_render`` et ``levers`` = 19 attendus. Or on en a
848
+ migré 20 + 5 = 25 dans 5 batches. Vérification : on a fait
849
+ batch 1 (5) + batch 2 (5) + batch 3 (5) + batch 4 (5) + batch 5 (5)
850
+ = 25. Le plan initial listait 22 renderers ; en pratique le
851
+ ``report/`` en contient ~28 (cf. ``ls report/*_render.py``) — la
852
+ liste du plan était incomplète. L'inventaire exact restant :
853
+ ``levers_render.py`` + ``pipeline_render.py`` +
854
+ ``philological_render.py`` à finir (3 renderers, ~1586 LOC).
855
+
856
  ### Phase 6 — Pipelines OCR+LLM (`pipelines/`)
857
 
858
  **Modules** : `pipelines/base.OCRLLMPipeline` (3 modes), `pipelines/
picarones/report/generator.py CHANGED
@@ -316,10 +316,10 @@ class ReportGenerator:
316
  from picarones.reports_v2.html.renderers.rare_token_recall import (
317
  build_rare_token_recall_html,
318
  )
319
- from picarones.report.taxonomy_cooccurrence_render import (
320
  build_taxonomy_cooccurrence_html,
321
  )
322
- from picarones.report.taxonomy_intra_doc_render import (
323
  build_taxonomy_intra_doc_html,
324
  )
325
 
 
316
  from picarones.reports_v2.html.renderers.rare_token_recall import (
317
  build_rare_token_recall_html,
318
  )
319
+ from picarones.reports_v2.html.renderers.taxonomy_cooccurrence import (
320
  build_taxonomy_cooccurrence_html,
321
  )
322
+ from picarones.reports_v2.html.renderers.taxonomy_intra_doc import (
323
  build_taxonomy_intra_doc_html,
324
  )
325
 
picarones/report/pipeline_dag_render.py CHANGED
@@ -1,314 +1,18 @@
1
- """Visualisation DAG d'un pipeline composé Sprint 95 (B.4).
2
 
3
- Sprint 95 — B.4 du plan d'évolution 2026.
4
-
5
- Outil d'inspection, pas de construction
6
- ---------------------------------------
7
- Le YAML reste source de vérité. Cette vue **affiche** le
8
- graphe orienté de la pipeline pour permettre l'inspection et
9
- le debug d'un benchmark d'axe B (Sprint 63+) — elle ne
10
- construit rien, ne supporte pas le drag-and-drop, n'exporte
11
- aucun JSON modifiable.
12
-
13
- Pattern identique aux autres rendus : SVG **server-side**,
14
- pas de JS, anti-injection systématique.
15
-
16
- Vue
17
- ---
18
- Layout horizontal de gauche à droite :
19
-
20
- - Chaque **nœud** est un rectangle annoté du nom du module et
21
- de ses types d'entrée/sortie.
22
- - Chaque **arête** porte une étiquette : type d'artefact +
23
- métrique principale + valeur, avec un code couleur
24
- vert/jaune/rouge selon le seuil sur la valeur.
25
-
26
- Adaptive : ``""`` si moins d'un nœud.
27
-
28
- Note d'intégration
29
- ------------------
30
- Module pur — l'utilisateur compose les structures simples
31
- ``nodes`` et ``edges`` depuis sa ``PipelineSpec`` (Sprint 63)
32
- et son ``PipelineBenchmarkResult`` (Sprint 64) :
33
-
34
- .. code-block:: python
35
-
36
- from picarones.report.pipeline_dag_render import build_pipeline_dag_html
37
-
38
- nodes = [
39
- {"name": s.name, "input_types": [t.value for t in s.module.input_types],
40
- "output_types": [t.value for t in s.module.output_types]}
41
- for s in spec.steps
42
- ]
43
- edges = []
44
- for prev, curr in zip(spec.steps, spec.steps[1:]):
45
- agg = bench.aggregate_for_step(curr.name)
46
- for art_type, metrics in (agg.junction_metrics or {}).items():
47
- for metric_name, value in metrics.items():
48
- edges.append({
49
- "from": prev.name, "to": curr.name,
50
- "artifact_type": art_type, "metric_name": metric_name,
51
- "metric_value": value.get("mean"),
52
- })
53
- html = build_pipeline_dag_html(nodes, edges, labels)
54
  """
55
 
56
  from __future__ import annotations
57
 
58
- from html import escape as _e
59
- from typing import Optional
60
-
61
-
62
- # Seuils par défaut sur les métriques d'erreur (CER-like, lower is better).
63
- _DEFAULT_THRESHOLDS = (0.05, 0.15) # vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15
64
-
65
-
66
- def _classify_metric(
67
- value: Optional[float],
68
- thresholds: tuple[float, float],
69
- higher_is_better: bool,
70
- ) -> str:
71
- """Retourne ``"green"``, ``"yellow"``, ``"red"`` ou ``"none"``."""
72
- if value is None:
73
- return "none"
74
- try:
75
- v = float(value)
76
- except (TypeError, ValueError):
77
- return "none"
78
- low, high = thresholds
79
- if higher_is_better:
80
- # Inversion : haut = bon
81
- if v >= 1.0 - low:
82
- return "green"
83
- if v >= 1.0 - high:
84
- return "yellow"
85
- return "red"
86
- if v <= low:
87
- return "green"
88
- if v <= high:
89
- return "yellow"
90
- return "red"
91
-
92
-
93
- # Sprint A7 (m-5) — palette Okabe-Ito daltonien-friendly importée
94
- # depuis le module canonique ``picarones.report.colors``. Avant
95
- # A7, les hex étaient hardcodés (rouge/vert classiques, problème
96
- # pour la deutéranopie) ; maintenant cohérent avec _cer_color et
97
- # difficulty_color.
98
- from picarones.reports_v2._helpers.colors import COLOR_GREEN, COLOR_RED, COLOR_YELLOW
99
-
100
- _QUALITY_COLORS = {
101
- "green": COLOR_GREEN, # Okabe-Ito blue (substitut sémantique « bon »)
102
- "yellow": COLOR_YELLOW, # Okabe-Ito yellow
103
- "red": COLOR_RED, # Okabe-Ito vermillion (substitut sémantique « mauvais »)
104
- "none": "#6b7280",
105
- }
106
-
107
-
108
- def _format_value(value: Optional[float]) -> str:
109
- if value is None:
110
- return "—"
111
- try:
112
- v = float(value)
113
- except (TypeError, ValueError):
114
- return "—"
115
- if abs(v) < 1.0:
116
- return f"{v * 100:.1f}%"
117
- return f"{v:.2f}"
118
-
119
-
120
- def build_pipeline_dag_html(
121
- nodes: Optional[list[dict]],
122
- labels: Optional[dict[str, str]] = None,
123
- edges: Optional[list[dict]] = None,
124
- *,
125
- thresholds: tuple[float, float] = _DEFAULT_THRESHOLDS,
126
- higher_is_better: bool = False,
127
- ) -> str:
128
- """Construit la vue HTML « Pipeline DAG ».
129
-
130
- Parameters
131
- ----------
132
- nodes:
133
- Liste de dicts ``{"name", "input_types"?, "output_types"?}``
134
- dans l'ordre topologique. Si vide ou ``None``, retourne
135
- ``""``.
136
- labels:
137
- Dict i18n. Clés sous le préfixe ``dag_*``.
138
- edges:
139
- Liste de dicts ``{"from", "to", "artifact_type"?,
140
- "metric_name"?, "metric_value"?}``. Optionnel —
141
- auto-déduit séquentiel sinon.
142
- thresholds:
143
- ``(seuil_vert, seuil_jaune)`` sur la valeur de métrique.
144
- Défaut ``(0.05, 0.15)`` — convention CER.
145
- higher_is_better:
146
- Si ``True``, la sémantique est inversée (1 = meilleur).
147
- """
148
- nodes = list(nodes or [])
149
- if not nodes:
150
- return ""
151
- edges = list(edges or [])
152
- labels = labels or {}
153
- title = labels.get("dag_title", "Pipeline DAG")
154
- note = labels.get(
155
- "dag_note",
156
- "Graphe orienté du pipeline composé. Chaque arête porte "
157
- "le type d'artefact transmis et la métrique calculée à "
158
- "la jonction. Code couleur vert/orange/rouge selon le "
159
- "seuil. Outil d'inspection — le YAML reste source de "
160
- "vérité.",
161
- )
162
- # Layout horizontal régulier
163
- n = len(nodes)
164
- box_width = 160
165
- box_height = 70
166
- h_gap = 110 # espace horizontal entre nœuds
167
- margin = 30
168
- svg_width = margin * 2 + n * box_width + (n - 1) * h_gap
169
- svg_height = box_height + margin * 2 + 60 # +60 pour étiquettes arêtes
170
- centre_y = margin + box_height / 2 + 30 # offset pour étiquette de tête
171
-
172
- # Index des nœuds par name pour récupérer la position
173
- node_x: dict[str, float] = {}
174
- parts: list[str] = [
175
- '<section class="dag-section" style="margin:1rem 0">',
176
- f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
177
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
178
- f'{_e(note)}</div>',
179
- f'<svg viewBox="0 0 {svg_width} {svg_height}" '
180
- f'role="img" aria-label="{_e(title)}" '
181
- 'xmlns="http://www.w3.org/2000/svg" '
182
- 'style="max-width:100%;height:auto;'
183
- 'font-family:system-ui,sans-serif;font-size:12px">',
184
- # Définition d'une flèche
185
- '<defs>'
186
- '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" '
187
- 'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
188
- '<path d="M0,0 L10,5 L0,10 z" fill="#374151"/>'
189
- '</marker>'
190
- '</defs>',
191
- ]
192
-
193
- # Étape 1 : nœuds
194
- for i, node in enumerate(nodes):
195
- name = str(node.get("name") or f"step_{i}")
196
- x = margin + i * (box_width + h_gap)
197
- y = margin + 30
198
- node_x[name] = x + box_width
199
- in_types = ", ".join(node.get("input_types") or [])
200
- out_types = ", ".join(node.get("output_types") or [])
201
- parts.append(
202
- f'<rect x="{x}" y="{y}" width="{box_width}" '
203
- f'height="{box_height}" rx="6" fill="#f3f4f6" '
204
- f'stroke="#374151" stroke-width="1.5"/>'
205
- )
206
- parts.append(
207
- f'<text x="{x + box_width / 2}" y="{y + 22}" '
208
- f'text-anchor="middle" font-weight="600" '
209
- f'fill="#111827">{_e(name)}</text>'
210
- )
211
- if in_types:
212
- parts.append(
213
- f'<text x="{x + box_width / 2}" y="{y + 40}" '
214
- f'text-anchor="middle" fill="#4b5563" '
215
- f'font-size="10">in: {_e(in_types)}</text>'
216
- )
217
- if out_types:
218
- parts.append(
219
- f'<text x="{x + box_width / 2}" y="{y + 56}" '
220
- f'text-anchor="middle" fill="#4b5563" '
221
- f'font-size="10">out: {_e(out_types)}</text>'
222
- )
223
-
224
- # Étape 2 : arêtes (mappées sur paires séquentielles si pas de
225
- # "from"/"to" explicites — voir nodes par défaut)
226
- auto_edges: list[dict] = []
227
- if not edges:
228
- for prev, curr in zip(nodes, nodes[1:]):
229
- auto_edges.append({
230
- "from": prev.get("name"),
231
- "to": curr.get("name"),
232
- })
233
- else:
234
- auto_edges = edges
235
-
236
- for edge in auto_edges:
237
- src = str(edge.get("from") or "")
238
- dst = str(edge.get("to") or "")
239
- if not src or not dst:
240
- continue
241
- # Position : du bord droit du src au bord gauche du dst
242
- # Heuristique : on prend la position du nœud src dans la
243
- # liste pour calculer x1, et celle de dst pour x2.
244
- try:
245
- i_src = next(
246
- i for i, n_ in enumerate(nodes)
247
- if n_.get("name") == src
248
- )
249
- i_dst = next(
250
- i for i, n_ in enumerate(nodes)
251
- if n_.get("name") == dst
252
- )
253
- except StopIteration:
254
- continue
255
- x1 = margin + i_src * (box_width + h_gap) + box_width
256
- x2 = margin + i_dst * (box_width + h_gap)
257
- y = centre_y
258
- # Classe la métrique pour le code couleur
259
- value = edge.get("metric_value")
260
- try:
261
- value_f = float(value) if value is not None else None
262
- except (TypeError, ValueError):
263
- value_f = None
264
- cls = _classify_metric(value_f, thresholds, higher_is_better)
265
- color = _QUALITY_COLORS[cls]
266
- # Trace la flèche
267
- parts.append(
268
- f'<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" '
269
- f'stroke="{color}" stroke-width="2" '
270
- f'marker-end="url(#arrow)"/>'
271
- )
272
- # Étiquette : type + métrique : valeur
273
- artifact_type = edge.get("artifact_type") or ""
274
- metric_name = edge.get("metric_name") or ""
275
- value_str = _format_value(value_f)
276
- label_lines: list[str] = []
277
- if artifact_type:
278
- label_lines.append(str(artifact_type))
279
- if metric_name:
280
- label_lines.append(f"{metric_name}: {value_str}")
281
- if label_lines:
282
- label_x = (x1 + x2) / 2
283
- for k, line in enumerate(label_lines):
284
- parts.append(
285
- f'<text x="{label_x}" y="{y - 8 - k * 12}" '
286
- f'text-anchor="middle" fill="{color}" '
287
- f'font-size="10" font-weight="600">'
288
- f'{_e(line)}</text>'
289
- )
290
- parts.append("</svg>")
291
-
292
- # Légende
293
- h_legend = labels.get("dag_legend", "Lecture")
294
- legend_green = labels.get("dag_legend_green", "qualité élevée")
295
- legend_yellow = labels.get("dag_legend_yellow", "qualité moyenne")
296
- legend_red = labels.get("dag_legend_red", "qualité faible")
297
- parts.append(
298
- '<div style="font-size:.8rem;opacity:.75;margin-top:.4rem">'
299
- f'<strong>{_e(h_legend)} :</strong> '
300
- f'<span style="color:{_QUALITY_COLORS["green"]};'
301
- f'font-weight:600">●</span> {_e(legend_green)} '
302
- f'(≤ {thresholds[0] * 100:.0f}%) '
303
- f'<span style="color:{_QUALITY_COLORS["yellow"]};'
304
- f'font-weight:600">●</span> {_e(legend_yellow)} '
305
- f'(≤ {thresholds[1] * 100:.0f}%) '
306
- f'<span style="color:{_QUALITY_COLORS["red"]};'
307
- f'font-weight:600">●</span> {_e(legend_red)}'
308
- '</div>'
309
- )
310
- parts.append("</section>")
311
- return "".join(parts)
312
 
 
313
 
314
- __all__ = ["build_pipeline_dag_html"]
 
 
 
 
 
 
1
+ """``picarones.report.pipeline_dag_render``shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.pipeline_dag`.
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.pipeline_dag import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.pipeline_dag_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.pipeline_dag instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/taxonomy_comparison_render.py CHANGED
@@ -1,233 +1,18 @@
1
- """Rendu HTML du diagramme miroir taxonomique — Sprint 77.
2
 
3
- A.I.4 chantier 3 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/taxonomy_comparison.py``. Pattern
6
- identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Diagramme miroir
10
- ----------------
11
- Une ligne par classe taxonomique, divisée en deux barres
12
- horizontales :
13
-
14
- - À **gauche** : barre du moteur A (orientée vers la gauche, du
15
- centre vers le bord).
16
- - À **droite** : barre du moteur B (orientée vers la droite).
17
- - Couleur de la classe selon ``recoverability`` :
18
-
19
- - vert (#5fa860) : ``recoverable``
20
- - orange (#e0a050) : ``difficult``
21
- - rouge (#d8553b) : ``irrecoverable``
22
-
23
- Lecture immédiate : un moteur dont les barres tirent vers la
24
- **gauche** sur du vert (case_error, ligature_error) et un moteur
25
- qui tire à droite sur du rouge (lacuna) — la décision éditoriale
26
- est évidente même si les CER globaux sont identiques.
27
  """
28
 
29
  from __future__ import annotations
30
 
31
- from html import escape as _e
32
- from typing import Optional
33
-
34
-
35
- _RECOVERABILITY_COLORS = {
36
- "recoverable": "#5fa860",
37
- "difficult": "#e0a050",
38
- "irrecoverable": "#d8553b",
39
- }
40
-
41
-
42
- def _build_mirror_chart_svg(
43
- data: dict,
44
- *,
45
- bar_max_width: int = 200,
46
- row_height: int = 22,
47
- label_width: int = 140,
48
- margin_top: int = 50,
49
- margin_bottom: int = 20,
50
- ) -> str:
51
- """Construit le diagramme miroir SVG."""
52
- classes = data["classes"]
53
- prop_a = data["proportions_a"]
54
- prop_b = data["proportions_b"]
55
- recov = data["recoverability"]
56
- engine_a = data["engine_a"]
57
- engine_b = data["engine_b"]
58
-
59
- n_rows = len(classes)
60
- if n_rows == 0:
61
- return ""
62
-
63
- # Échelle : on normalise à la valeur max de toutes les
64
- # proportions (pour que la classe la plus présente atteigne
65
- # bar_max_width).
66
- max_prop = max(
67
- max(prop_a.values(), default=0.0),
68
- max(prop_b.values(), default=0.0),
69
- )
70
- if max_prop <= 0:
71
- max_prop = 1.0 # évite division par zéro (cas dégénéré)
72
-
73
- width = label_width + 2 * bar_max_width + 40
74
- height = margin_top + n_rows * row_height + margin_bottom
75
- center = width // 2
76
-
77
- parts = [
78
- f'<svg xmlns="http://www.w3.org/2000/svg" '
79
- f'width="{width}" height="{height}" '
80
- f'viewBox="0 0 {width} {height}" '
81
- f'role="img" aria-label="Diagramme miroir taxonomique">',
82
- # En-têtes des deux moteurs
83
- f'<text x="{center - bar_max_width // 2}" y="20" '
84
- f'font-size="13" font-weight="600" fill="#333" '
85
- f'text-anchor="middle">{_e(engine_a)}</text>',
86
- f'<text x="{center + bar_max_width // 2}" y="20" '
87
- f'font-size="13" font-weight="600" fill="#333" '
88
- f'text-anchor="middle">{_e(engine_b)}</text>',
89
- # Ligne centrale
90
- f'<line x1="{center}" y1="{margin_top - 4}" '
91
- f'x2="{center}" y2="{height - margin_bottom + 4}" '
92
- f'stroke="#999" stroke-width="1"/>',
93
- ]
94
-
95
- # Barres
96
- for i, cls in enumerate(classes):
97
- y = margin_top + i * row_height
98
- level = recov.get(cls, "difficult")
99
- color = _RECOVERABILITY_COLORS.get(level, "#888")
100
- # Étiquette de classe au centre
101
- parts.append(
102
- f'<text x="{center}" y="{y + row_height // 2 + 4}" '
103
- f'font-size="11" fill="#222" text-anchor="middle" '
104
- f'font-family="monospace">{_e(cls)}</text>'
105
- )
106
- # Barre A (gauche)
107
- a_width = (prop_a.get(cls, 0.0) / max_prop) * bar_max_width
108
- if a_width > 0:
109
- x_a = center - label_width // 2 - a_width
110
- parts.append(
111
- f'<rect x="{x_a:.1f}" y="{y + 3}" '
112
- f'width="{a_width:.1f}" height="{row_height - 6}" '
113
- f'fill="{color}" stroke="#666" stroke-width="0.5" '
114
- f'opacity="0.85"/>'
115
- )
116
- # Valeur en %
117
- parts.append(
118
- f'<text x="{x_a - 3:.1f}" y="{y + row_height // 2 + 4}" '
119
- f'font-size="10" fill="#444" text-anchor="end">'
120
- f'{prop_a.get(cls, 0.0) * 100:.1f}%</text>'
121
- )
122
- # Barre B (droite)
123
- b_width = (prop_b.get(cls, 0.0) / max_prop) * bar_max_width
124
- if b_width > 0:
125
- x_b = center + label_width // 2
126
- parts.append(
127
- f'<rect x="{x_b:.1f}" y="{y + 3}" '
128
- f'width="{b_width:.1f}" height="{row_height - 6}" '
129
- f'fill="{color}" stroke="#666" stroke-width="0.5" '
130
- f'opacity="0.85"/>'
131
- )
132
- parts.append(
133
- f'<text x="{x_b + b_width + 3:.1f}" '
134
- f'y="{y + row_height // 2 + 4}" '
135
- f'font-size="10" fill="#444" text-anchor="start">'
136
- f'{prop_b.get(cls, 0.0) * 100:.1f}%</text>'
137
- )
138
- parts.append("</svg>")
139
- return "".join(parts)
140
-
141
-
142
- def _build_recoverability_summary_html(
143
- data: dict, labels: dict,
144
- ) -> str:
145
- """Encart résumé par catégorie de récupérabilité (3 lignes)."""
146
- totals = data.get("totals_by_recoverability") or {}
147
- if not totals:
148
- return ""
149
- label_recov = labels.get("taxocomp_recoverable", "Récupérable")
150
- label_diff = labels.get("taxocomp_difficult", "Difficile")
151
- label_irrec = labels.get("taxocomp_irrecoverable", "Irrécupérable")
152
- rows = [
153
- ("recoverable", label_recov),
154
- ("difficult", label_diff),
155
- ("irrecoverable", label_irrec),
156
- ]
157
- parts = [
158
- '<table style="border-collapse:collapse;font-size:.85rem;'
159
- 'margin-top:.5rem">',
160
- '<thead><tr>',
161
- '<th scope=\"col\" style="padding:.2rem .5rem;text-align:left;'
162
- 'border-bottom:1px solid #ccc">'
163
- f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
164
- '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
165
- 'border-bottom:1px solid #ccc">'
166
- f'{_e(_e(data["engine_a"]))}</th>',
167
- '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
168
- 'border-bottom:1px solid #ccc">'
169
- f'{_e(_e(data["engine_b"]))}</th>',
170
- '</tr></thead><tbody>',
171
- ]
172
- for level, label in rows:
173
- cell = totals.get(level, {"a": 0.0, "b": 0.0})
174
- color = _RECOVERABILITY_COLORS.get(level, "#888")
175
- parts.append(
176
- f'<tr>'
177
- f'<td style="padding:.2rem .5rem">'
178
- f'<span style="display:inline-block;width:10px;height:10px;'
179
- f'background:{color};margin-right:.4rem;border-radius:2px"></span>'
180
- f'{_e(label)}</td>'
181
- f'<td style="padding:.2rem .5rem;text-align:right;'
182
- f'font-family:monospace">{cell["a"] * 100:.1f}%</td>'
183
- f'<td style="padding:.2rem .5rem;text-align:right;'
184
- f'font-family:monospace">{cell["b"] * 100:.1f}%</td>'
185
- f'</tr>'
186
- )
187
- parts.append("</tbody></table>")
188
- return "".join(parts)
189
-
190
-
191
- def build_taxonomy_comparison_html(
192
- data: Optional[dict],
193
- labels: Optional[dict[str, str]] = None,
194
- ) -> str:
195
- """Construit le bloc HTML de comparaison taxonomique entre 2 moteurs.
196
-
197
- Retourne ``""`` si ``data is None`` ou aucune classe.
198
- """
199
- if not data:
200
- return ""
201
- classes = data.get("classes") or []
202
- if not classes:
203
- return ""
204
- labels = labels or {}
205
- title_template = labels.get(
206
- "taxocomp_title", "Profil taxonomique : {engine_a} vs {engine_b}",
207
- )
208
- title = title_template.format(
209
- engine_a=data["engine_a"], engine_b=data["engine_b"],
210
- )
211
- note = labels.get(
212
- "taxocomp_note",
213
- "Diagramme miroir des proportions d'erreurs par classe. "
214
- "Couleur selon récupérabilité éditoriale (vert = corrigeable, "
215
- "rouge = irrécupérable). À CER global égal, un moteur dont les "
216
- "erreurs sont majoritairement vertes est préférable pour une "
217
- "édition critique.",
218
- )
219
- parts = [
220
- '<div class="taxocomp" style="margin:1rem 0">',
221
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
222
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
223
- f'{_e(note)}</div>',
224
- _build_mirror_chart_svg(data),
225
- _build_recoverability_summary_html(data, labels),
226
- "</div>",
227
- ]
228
- return "".join(parts)
229
 
 
230
 
231
- __all__ = [
232
- "build_taxonomy_comparison_html",
233
- ]
 
 
 
 
1
+ """``picarones.report.taxonomy_comparison_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.taxonomy_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.taxonomy_comparison import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.taxonomy_comparison_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.taxonomy_comparison instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/report/taxonomy_cooccurrence_render.py CHANGED
@@ -1,161 +1,18 @@
1
- """Rendu HTML de la heatmap de co-occurrence taxonomique Sprint 75.
2
 
3
- A.I.4 chantier 1 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/taxonomy_cooccurrence.py``. Pattern
6
- identique aux autres rendus (Sprints 41/43/62/67/72/74) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Sortie typique
10
- --------------
11
- - ``build_taxonomy_cooccurrence_html(data, labels)`` produit un
12
- bloc complet : titre + note d'usage + heatmap SVG + table des
13
- paires les plus co-occurrentes.
14
- - ``""`` retourné si ``data is None`` ou si la matrice est vide
15
- (rapport adaptatif).
16
  """
17
 
18
  from __future__ import annotations
19
 
20
- from html import escape as _e
21
- from typing import Optional
22
-
23
- from picarones.reports_v2._helpers.render_helpers import (
24
- GRADIENT_TARGET_BLUE,
25
- build_grid_svg,
26
- color_single_gradient,
27
- text_color_for_bg,
28
- )
29
-
30
-
31
- def _build_jaccard_heatmap_svg(
32
- classes: list[str],
33
- matrix: dict[str, dict[str, float]],
34
- *,
35
- cell_size: int = 36,
36
- label_left: int = 130,
37
- label_top: int = 80,
38
- ) -> str:
39
- """Heatmap Jaccard de co-occurrence taxonomique.
40
-
41
- Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
42
- encapsule les conventions spécifiques à la matrice symétrique
43
- (valeur affichée seulement si > 0,05, étiquettes rotées).
44
- """
45
- if not classes:
46
- return ""
47
-
48
- def cell_value(i: int, j: int) -> float:
49
- return matrix.get(classes[i], {}).get(classes[j], 0.0)
50
-
51
- return build_grid_svg(
52
- n_rows=len(classes),
53
- n_cols=len(classes),
54
- row_label_fn=lambda i: classes[i],
55
- col_label_fn=lambda j: classes[j],
56
- cell_color_fn=lambda i, j: color_single_gradient(
57
- cell_value(i, j), end_rgb=GRADIENT_TARGET_BLUE,
58
- ),
59
- cell_text_fn=lambda i, j: (
60
- f"{cell_value(i, j):.2f}" if cell_value(i, j) > 0.05 else None
61
- ),
62
- cell_text_color_fn=lambda i, j: text_color_for_bg(cell_value(i, j)),
63
- cell_w=cell_size,
64
- cell_h=cell_size,
65
- label_left=label_left,
66
- label_top=label_top,
67
- rotate_col_labels=True,
68
- aria_label="Heatmap Jaccard co-occurrence taxonomique",
69
- )
70
 
 
71
 
72
- def _build_top_pairs_table(
73
- top_pairs: list,
74
- labels: dict,
75
- ) -> str:
76
- """Construit la table HTML des paires les plus co-occurrentes."""
77
- if not top_pairs:
78
- return ""
79
- pair_label = labels.get("taxocooc_pair_label", "Paire")
80
- jaccard_label = labels.get("taxocooc_jaccard_label", "Jaccard")
81
-
82
- parts = [
83
- '<table style="border-collapse:collapse;font-size:.85rem;'
84
- 'margin-top:.5rem">',
85
- '<thead><tr>',
86
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
87
- f'border-bottom:1px solid #ccc;font-weight:600">'
88
- f'{_e(pair_label)}</th>',
89
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
90
- f'border-bottom:1px solid #ccc;font-weight:600">'
91
- f'{_e(jaccard_label)}</th>',
92
- '</tr></thead><tbody>',
93
- ]
94
- for ca, cb, j in top_pairs:
95
- parts.append(
96
- f'<tr>'
97
- f'<td style="padding:.2rem .5rem">'
98
- f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
99
- f'<td style="padding:.2rem .5rem;text-align:right;'
100
- f'font-family:monospace;'
101
- f'background:{color_single_gradient(j, end_rgb=GRADIENT_TARGET_BLUE)};'
102
- f'color:{text_color_for_bg(j)}">{j:.2f}</td>'
103
- f'</tr>'
104
- )
105
- parts.append("</tbody></table>")
106
- return "".join(parts)
107
-
108
-
109
- def build_taxonomy_cooccurrence_html(
110
- data: Optional[dict],
111
- labels: Optional[dict[str, str]] = None,
112
- ) -> str:
113
- """Construit le bloc HTML complet de co-occurrence taxonomique.
114
-
115
- Retourne ``""`` si ``data is None`` ou matrice vide.
116
- """
117
- if not data:
118
- return ""
119
- classes = data.get("classes") or []
120
- matrix = data.get("cooccurrence_matrix") or {}
121
- if not classes or not matrix:
122
- return ""
123
- labels = labels or {}
124
- title = labels.get(
125
- "taxocooc_title",
126
- "Co-occurrence des classes d'erreur",
127
- )
128
- note = labels.get(
129
- "taxocooc_note",
130
- "Indice de Jaccard au niveau document : 1,00 = ces deux classes "
131
- "apparaissent toujours ensemble ; 0,00 = jamais. Lecture par paires "
132
- "co-occurrentes ci-dessous.",
133
- )
134
- n_docs = data.get("n_documents", 0)
135
- n_docs_label_template = labels.get(
136
- "taxocooc_n_docs", "Calculé sur {n_docs} documents.",
137
- )
138
- n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
139
-
140
- svg = _build_jaccard_heatmap_svg(classes, matrix)
141
- top_table = _build_top_pairs_table(
142
- data.get("top_pairs") or [], labels,
143
- )
144
-
145
- parts = [
146
- '<div class="taxocooc" style="margin:1rem 0">',
147
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
148
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
149
- f'{_e(note)}</div>',
150
- f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
151
- f'{_e(n_docs_phrase)}</div>',
152
- svg,
153
- top_table,
154
- "</div>",
155
- ]
156
- return "".join(parts)
157
-
158
-
159
- __all__ = [
160
- "build_taxonomy_cooccurrence_html",
161
- ]
 
1
+ """``picarones.report.taxonomy_cooccurrence_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.taxonomy_cooccurrence`.
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.taxonomy_cooccurrence import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.taxonomy_cooccurrence_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.taxonomy_cooccurrence instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/taxonomy_intra_doc_render.py CHANGED
@@ -1,148 +1,18 @@
1
- """Rendu HTML de la heatmap class × position — Sprint 76.
2
 
3
- A.I.4 chantier 2 du plan d'évolution 2026.
4
-
5
- Suite directe ``picarones/core/taxonomy_intra_doc.py``. Pattern
6
- identique aux autres rendus (Sprints 41/43/62/67/72/74/75) :
7
- **server-side**, pas de JavaScript, anti-injection systématique.
8
-
9
- Sortie typique
10
- --------------
11
- Une grille N_classes × N_bins où chaque cellule indique la densité
12
- d'erreurs de cette classe à cette position dans le document.
13
- Lecture immédiate : « ligature_error concentré dans la première
14
- tranche → erreur de marge ; visual_confusion uniformément réparti
15
- → erreur de scribe ».
16
-
17
- Adaptive : si ``data is None`` ou si toutes les classes ont 0
18
- erreur, retourne ``""``.
19
  """
20
 
21
  from __future__ import annotations
22
 
23
- from html import escape as _e
24
- from typing import Optional
25
-
26
- from picarones.reports_v2._helpers.render_helpers import (
27
- GRADIENT_TARGET_ORANGE,
28
- build_grid_svg,
29
- color_single_gradient,
30
- text_color_for_bg,
31
- )
32
-
33
-
34
- def _build_position_heatmap_svg(
35
- classes_with_errors: list[str],
36
- per_class: dict[str, list[int]],
37
- n_bins: int,
38
- *,
39
- cell_w: int = 36,
40
- cell_h: int = 26,
41
- label_left: int = 150,
42
- label_top: int = 30,
43
- ) -> str:
44
- """Heatmap class taxonomique × position (densité relative par classe).
45
-
46
- Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
47
- encapsule la normalisation par classe (densité relative au max
48
- observé sur la ligne).
49
- """
50
- if not classes_with_errors:
51
- return ""
52
-
53
- # Pré-calcule densité et count par cellule pour éviter les boucles
54
- # imbriquées dans les callbacks.
55
- grid: list[list[tuple[int, float]]] = []
56
- for cls in classes_with_errors:
57
- counts = per_class.get(cls, [0] * n_bins)
58
- max_count = max(counts) if counts else 0
59
- row: list[tuple[int, float]] = []
60
- for j in range(n_bins):
61
- count = counts[j] if j < len(counts) else 0
62
- density = (count / max_count) if max_count > 0 else 0.0
63
- row.append((count, density))
64
- grid.append(row)
65
 
66
- return build_grid_svg(
67
- n_rows=len(classes_with_errors),
68
- n_cols=n_bins,
69
- row_label_fn=lambda i: classes_with_errors[i],
70
- col_label_fn=lambda j: str(j + 1),
71
- cell_color_fn=lambda i, j: color_single_gradient(
72
- grid[i][j][1], end_rgb=GRADIENT_TARGET_ORANGE,
73
- ),
74
- cell_text_fn=lambda i, j: (
75
- str(grid[i][j][0]) if grid[i][j][0] > 0 else None
76
- ),
77
- cell_text_color_fn=lambda i, j: text_color_for_bg(grid[i][j][1]),
78
- cell_w=cell_w,
79
- cell_h=cell_h,
80
- label_left=label_left,
81
- label_top=label_top,
82
- aria_label="Heatmap class taxonomique × position",
83
- x_axis_title="Position dans le document (1 = début)",
84
- )
85
 
86
-
87
- def build_taxonomy_intra_doc_html(
88
- data: Optional[dict],
89
- labels: Optional[dict[str, str]] = None,
90
- ) -> str:
91
- """Construit le bloc HTML complet de la heatmap intra-document.
92
-
93
- Retourne ``""`` si ``data is None`` ou aucune erreur.
94
- """
95
- if not data:
96
- return ""
97
- n_bins = data.get("n_bins", 0)
98
- per_class = data.get("per_class") or {}
99
- total_errors = data.get("total_errors", 0)
100
- if total_errors == 0 or n_bins <= 0:
101
- return ""
102
- # Filtre : uniquement les classes ayant au moins une erreur
103
- classes_with_errors = [
104
- cls for cls, counts in per_class.items()
105
- if isinstance(counts, list) and sum(counts) > 0
106
- ]
107
- if not classes_with_errors:
108
- return ""
109
-
110
- labels = labels or {}
111
- title = labels.get(
112
- "intradoc_title",
113
- "Évolution intra-document des classes d'erreur",
114
- )
115
- note = labels.get(
116
- "intradoc_note",
117
- "Heatmap class × position : densité relative par classe "
118
- "(plus foncé = concentré). Une classe concentrée dans la "
119
- "première colonne suggère une erreur de marge ; "
120
- "une distribution uniforme suggère une erreur de scribe.",
121
- )
122
- n_words_gt = data.get("n_words_gt", 0)
123
- n_words_template = labels.get(
124
- "intradoc_n_words",
125
- "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
126
- )
127
- n_words_phrase = n_words_template.format(
128
- n_words_gt=n_words_gt, n_bins=n_bins,
129
- )
130
-
131
- svg = _build_position_heatmap_svg(classes_with_errors, per_class, n_bins)
132
-
133
- parts = [
134
- '<div class="intradoc" style="margin:1rem 0">',
135
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
136
- f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
137
- f'{_e(note)}</div>',
138
- f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
139
- f'{_e(n_words_phrase)}</div>',
140
- svg,
141
- "</div>",
142
- ]
143
- return "".join(parts)
144
-
145
-
146
- __all__ = [
147
- "build_taxonomy_intra_doc_html",
148
- ]
 
1
+ """``picarones.report.taxonomy_intra_doc_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.taxonomy_intra_doc`.
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.taxonomy_intra_doc import * # noqa: F401, F403
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
12
 
13
+ warnings.warn(
14
+ "picarones.report.taxonomy_intra_doc_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.taxonomy_intra_doc instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
picarones/report/views/advanced_taxonomy.py CHANGED
@@ -136,7 +136,7 @@ def build_advanced_taxonomy_view_html(
136
  pair = _select_two_engines_for_comparison(engines_summary)
137
  if pair is not None:
138
  from picarones.measurements.taxonomy_comparison import compare_taxonomies
139
- from picarones.report.taxonomy_comparison_render import (
140
  build_taxonomy_comparison_html,
141
  )
142
  engine_a, engine_b = pair
@@ -163,7 +163,7 @@ def build_advanced_taxonomy_view_html(
163
  # Sous-section 2 : co-occurrence (opt-in)
164
  if cooccurrence:
165
  try:
166
- from picarones.report.taxonomy_cooccurrence_render import (
167
  build_taxonomy_cooccurrence_html,
168
  )
169
  html = build_taxonomy_cooccurrence_html(cooccurrence, labels=labels)
@@ -183,7 +183,7 @@ def build_advanced_taxonomy_view_html(
183
  # Sous-section 3 : intra-document (opt-in)
184
  if intra_doc:
185
  try:
186
- from picarones.report.taxonomy_intra_doc_render import (
187
  build_taxonomy_intra_doc_html,
188
  )
189
  html = build_taxonomy_intra_doc_html(intra_doc, labels=labels)
 
136
  pair = _select_two_engines_for_comparison(engines_summary)
137
  if pair is not None:
138
  from picarones.measurements.taxonomy_comparison import compare_taxonomies
139
+ from picarones.reports_v2.html.renderers.taxonomy_comparison import (
140
  build_taxonomy_comparison_html,
141
  )
142
  engine_a, engine_b = pair
 
163
  # Sous-section 2 : co-occurrence (opt-in)
164
  if cooccurrence:
165
  try:
166
+ from picarones.reports_v2.html.renderers.taxonomy_cooccurrence import (
167
  build_taxonomy_cooccurrence_html,
168
  )
169
  html = build_taxonomy_cooccurrence_html(cooccurrence, labels=labels)
 
183
  # Sous-section 3 : intra-document (opt-in)
184
  if intra_doc:
185
  try:
186
+ from picarones.reports_v2.html.renderers.taxonomy_intra_doc import (
187
  build_taxonomy_intra_doc_html,
188
  )
189
  html = build_taxonomy_intra_doc_html(intra_doc, labels=labels)
picarones/report/views/diagnostics.py CHANGED
@@ -206,7 +206,7 @@ def build_diagnostics_view_html(
206
  if benchmark is not None:
207
  try:
208
  from picarones.measurements.worst_lines import extract_worst_lines
209
- from picarones.report.worst_lines_render import (
210
  build_worst_lines_table_html,
211
  )
212
  entries = extract_worst_lines(benchmark, top_n=20)
 
206
  if benchmark is not None:
207
  try:
208
  from picarones.measurements.worst_lines import extract_worst_lines
209
+ from picarones.reports_v2.html.renderers.worst_lines import (
210
  build_worst_lines_table_html,
211
  )
212
  entries = extract_worst_lines(benchmark, top_n=20)
picarones/report/views/pipeline.py CHANGED
@@ -124,7 +124,7 @@ def build_pipeline_view_html(
124
  # Sous-section 2 : DAG visualization
125
  if dag_nodes:
126
  try:
127
- from picarones.report.pipeline_dag_render import (
128
  build_pipeline_dag_html,
129
  )
130
  html = build_pipeline_dag_html(
 
124
  # Sous-section 2 : DAG visualization
125
  if dag_nodes:
126
  try:
127
+ from picarones.reports_v2.html.renderers.pipeline_dag import (
128
  build_pipeline_dag_html,
129
  )
130
  html = build_pipeline_dag_html(
picarones/report/worst_lines_render.py CHANGED
@@ -1,164 +1,18 @@
1
- """Rendu HTML de la vue « Worst lines globale » — Sprint 72.
2
 
3
- Suite directe de ``picarones/core/worst_lines.py`` (extraction
4
- transversale). Pattern identique aux Sprints 41/43/62/67 : rendu
5
- **server-side**, pas de JavaScript, anti-injection systématique
6
- via ``html.escape``.
7
-
8
- Vue distincte du tableau gallery existant
9
- -----------------------------------------
10
- La galerie OCR (vue ``view_gallery.html``) liste les documents
11
- les plus problématiques. Cette vue va plus fin : elle liste les
12
- **lignes individuelles** les plus problématiques, transversalement
13
- à tous les documents et moteurs. Complémentaire, pas redondante.
14
  """
15
 
16
  from __future__ import annotations
17
 
18
- from html import escape as _e
19
- from typing import Optional
20
-
21
- from picarones.measurements.worst_lines import WorstLineEntry
22
- from picarones.core.diff_utils import compute_char_diff
23
- from picarones.reports_v2._helpers.render_helpers import color_traffic_light
24
-
25
-
26
- def _bg_for_cer(cer: float) -> str:
27
- """Beige clair sous le seuil catastrophique (0.30), gradient
28
- jaune → rouge au-delà.
29
-
30
- Le seuil dur à 0.30 préserve la sémantique « toléré jusqu'à 30 %
31
- pour un manuscrit difficile ». Au-delà, on entre en zone visible
32
- avec :func:`color_traffic_light` (low_is_good).
33
- """
34
- f = max(0.0, min(1.0, cer))
35
- if f < 0.3:
36
- return "#fff8dc"
37
- return color_traffic_light(f, low_is_good=True, scale_min=0.3, scale_max=1.0)
38
-
39
-
40
- def _render_diff_inline(reference: str, hypothesis: str) -> str:
41
- """Rendu HTML inline d'un diff caractère par caractère.
42
-
43
- - ``equal`` → texte normal
44
- - ``delete`` → fond rouge clair, barré (manquait dans hyp)
45
- - ``insert`` → fond vert clair (ajouté par hyp)
46
- - ``replace`` → fond rouge clair barré + fond vert clair pour
47
- la nouvelle valeur (côte à côte)
48
- """
49
- if not reference and not hypothesis:
50
- return '<span style="opacity:.5">∅</span>'
51
- ops = compute_char_diff(reference or "", hypothesis or "")
52
- parts: list[str] = []
53
- for op in ops:
54
- kind = op["op"]
55
- if kind == "equal":
56
- parts.append(_e(op["text"]))
57
- elif kind == "delete":
58
- parts.append(
59
- f'<span style="background:#fdd;text-decoration:line-through">'
60
- f'{_e(op["text"])}</span>'
61
- )
62
- elif kind == "insert":
63
- parts.append(
64
- f'<span style="background:#dfd">{_e(op["text"])}</span>'
65
- )
66
- elif kind == "replace":
67
- parts.append(
68
- f'<span style="background:#fdd;text-decoration:line-through">'
69
- f'{_e(op["old"])}</span>'
70
- f'<span style="background:#dfd">{_e(op["new"])}</span>'
71
- )
72
- return "".join(parts)
73
-
74
-
75
- def build_worst_lines_table_html(
76
- entries: list[WorstLineEntry],
77
- labels: Optional[dict[str, str]] = None,
78
- ) -> str:
79
- """Construit le tableau HTML des worst lines.
80
-
81
- Retourne ``""`` si la liste est vide. Adaptive : si aucune
82
- entrée n'a de ``script_type``, la colonne strate est omise.
83
- """
84
- if not entries:
85
- return ""
86
- labels = labels or {}
87
- title = labels.get("worst_lines_title", "Lignes les plus problématiques")
88
- note = labels.get(
89
- "worst_lines_note",
90
- "Top-N lignes du corpus classées par CER ligne décroissant. "
91
- "Diff caractère par caractère : rouge barré = manquant dans "
92
- "l'OCR, vert = ajouté par l'OCR.",
93
- )
94
- rank_label = labels.get("worst_lines_rank_label", "Rang")
95
- cer_label = labels.get("worst_lines_cer_label", "CER")
96
- engine_label = labels.get("worst_lines_engine_label", "Moteur")
97
- doc_label = labels.get("worst_lines_doc_label", "Document")
98
- line_label = labels.get("worst_lines_line_label", "Ligne #")
99
- strata_label = labels.get("worst_lines_strata_label", "Strate")
100
- diff_label = labels.get("worst_lines_diff_label", "GT → OCR (diff)")
101
-
102
- has_strata = any(e.script_type for e in entries)
103
-
104
- parts = [
105
- '<div class="worst-lines" style="margin:1rem 0">',
106
- f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
107
- f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
108
- f'{_e(note)}</div>',
109
- '<table style="border-collapse:collapse;width:100%;'
110
- 'font-size:.85rem">',
111
- '<thead><tr>',
112
- ]
113
- cols = [rank_label, cer_label, engine_label, doc_label, line_label]
114
- if has_strata:
115
- cols.append(strata_label)
116
- cols.append(diff_label)
117
- for col in cols:
118
- parts.append(
119
- f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
120
- f'border-bottom:1px solid #ccc;font-weight:600">'
121
- f'{_e(col)}</th>'
122
- )
123
- parts.append("</tr></thead><tbody>")
124
- for entry in entries:
125
- cer_color = _bg_for_cer(entry.cer)
126
- parts.append("<tr>")
127
- parts.append(
128
- f'<td style="padding:.3rem .5rem;text-align:right;'
129
- f'font-weight:600">{entry.rank}</td>'
130
- )
131
- parts.append(
132
- f'<td style="padding:.3rem .5rem;text-align:right;'
133
- f'background:{cer_color};font-family:monospace">'
134
- f'{entry.cer * 100:.1f}%</td>'
135
- )
136
- parts.append(
137
- f'<td style="padding:.3rem .5rem">{_e(entry.engine_name)}</td>'
138
- )
139
- parts.append(
140
- f'<td style="padding:.3rem .5rem;font-family:monospace;'
141
- f'font-size:.8rem">{_e(entry.doc_id)}</td>'
142
- )
143
- parts.append(
144
- f'<td style="padding:.3rem .5rem;text-align:right">'
145
- f'{entry.line_index}</td>'
146
- )
147
- if has_strata:
148
- parts.append(
149
- f'<td style="padding:.3rem .5rem;font-size:.8rem">'
150
- f'{_e(entry.script_type or "—")}</td>'
151
- )
152
- parts.append(
153
- f'<td style="padding:.3rem .5rem;font-family:monospace;'
154
- f'font-size:.85rem">'
155
- f'{_render_diff_inline(entry.gt_line, entry.hyp_line)}</td>'
156
- )
157
- parts.append("</tr>")
158
- parts.append("</tbody></table></div>")
159
- return "".join(parts)
160
 
 
161
 
162
- __all__ = [
163
- "build_worst_lines_table_html",
164
- ]
 
 
 
 
1
+ """``picarones.report.worst_lines_render`` shim re-export (déprécié, suppression 2.0).
2
 
3
+ Canonique : :mod:`picarones.reports_v2.html.renderers.worst_lines`.
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.worst_lines import * # noqa: F401, F403
12
 
13
+ warnings.warn(
14
+ "picarones.report.worst_lines_render is deprecated and will be removed in 2.0. "
15
+ "Import from picarones.reports_v2.html.renderers.worst_lines instead.",
16
+ DeprecationWarning,
17
+ stacklevel=2,
18
+ )
picarones/reports_v2/html/renderers/pipeline_dag.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Visualisation DAG d'un pipeline composé — Sprint 95 (B.4).
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.pipeline_dag_render`` vers
5
+ ``picarones.reports_v2.html.renderers.pipeline_dag``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Sprint 95 — B.4 du plan d'évolution 2026.
10
+
11
+ Outil d'inspection, pas de construction
12
+ ---------------------------------------
13
+ Le YAML reste source de vérité. Cette vue **affiche** le
14
+ graphe orienté de la pipeline pour permettre l'inspection et
15
+ le debug d'un benchmark d'axe B (Sprint 63+) — elle ne
16
+ construit rien, ne supporte pas le drag-and-drop, n'exporte
17
+ aucun JSON modifiable.
18
+
19
+ Pattern identique aux autres rendus : SVG **server-side**,
20
+ pas de JS, anti-injection systématique.
21
+
22
+ Vue
23
+ ---
24
+ Layout horizontal de gauche à droite :
25
+
26
+ - Chaque **nœud** est un rectangle annoté du nom du module et
27
+ de ses types d'entrée/sortie.
28
+ - Chaque **arête** porte une étiquette : type d'artefact +
29
+ métrique principale + valeur, avec un code couleur
30
+ vert/jaune/rouge selon le seuil sur la valeur.
31
+
32
+ Adaptive : ``""`` si moins d'un nœud.
33
+
34
+ Note d'intégration
35
+ ------------------
36
+ Module pur — l'utilisateur compose les structures simples
37
+ ``nodes`` et ``edges`` depuis sa ``PipelineSpec`` (Sprint 63)
38
+ et son ``PipelineBenchmarkResult`` (Sprint 64) :
39
+
40
+ .. code-block:: python
41
+
42
+ from picarones.reports_v2.html.renderers.pipeline_dag import build_pipeline_dag_html
43
+
44
+ nodes = [
45
+ {"name": s.name, "input_types": [t.value for t in s.module.input_types],
46
+ "output_types": [t.value for t in s.module.output_types]}
47
+ for s in spec.steps
48
+ ]
49
+ edges = []
50
+ for prev, curr in zip(spec.steps, spec.steps[1:]):
51
+ agg = bench.aggregate_for_step(curr.name)
52
+ for art_type, metrics in (agg.junction_metrics or {}).items():
53
+ for metric_name, value in metrics.items():
54
+ edges.append({
55
+ "from": prev.name, "to": curr.name,
56
+ "artifact_type": art_type, "metric_name": metric_name,
57
+ "metric_value": value.get("mean"),
58
+ })
59
+ html = build_pipeline_dag_html(nodes, edges, labels)
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ from html import escape as _e
65
+ from typing import Optional
66
+
67
+
68
+ # Seuils par défaut sur les métriques d'erreur (CER-like, lower is better).
69
+ _DEFAULT_THRESHOLDS = (0.05, 0.15) # vert ≤ 0.05, jaune ≤ 0.15, rouge > 0.15
70
+
71
+
72
+ def _classify_metric(
73
+ value: Optional[float],
74
+ thresholds: tuple[float, float],
75
+ higher_is_better: bool,
76
+ ) -> str:
77
+ """Retourne ``"green"``, ``"yellow"``, ``"red"`` ou ``"none"``."""
78
+ if value is None:
79
+ return "none"
80
+ try:
81
+ v = float(value)
82
+ except (TypeError, ValueError):
83
+ return "none"
84
+ low, high = thresholds
85
+ if higher_is_better:
86
+ # Inversion : haut = bon
87
+ if v >= 1.0 - low:
88
+ return "green"
89
+ if v >= 1.0 - high:
90
+ return "yellow"
91
+ return "red"
92
+ if v <= low:
93
+ return "green"
94
+ if v <= high:
95
+ return "yellow"
96
+ return "red"
97
+
98
+
99
+ # Sprint A7 (m-5) — palette Okabe-Ito daltonien-friendly importée
100
+ # depuis le module canonique ``picarones.report.colors``. Avant
101
+ # A7, les hex étaient hardcodés (rouge/vert classiques, problème
102
+ # pour la deutéranopie) ; maintenant cohérent avec _cer_color et
103
+ # difficulty_color.
104
+ from picarones.reports_v2._helpers.colors import COLOR_GREEN, COLOR_RED, COLOR_YELLOW
105
+
106
+ _QUALITY_COLORS = {
107
+ "green": COLOR_GREEN, # Okabe-Ito blue (substitut sémantique « bon »)
108
+ "yellow": COLOR_YELLOW, # Okabe-Ito yellow
109
+ "red": COLOR_RED, # Okabe-Ito vermillion (substitut sémantique « mauvais »)
110
+ "none": "#6b7280",
111
+ }
112
+
113
+
114
+ def _format_value(value: Optional[float]) -> str:
115
+ if value is None:
116
+ return "—"
117
+ try:
118
+ v = float(value)
119
+ except (TypeError, ValueError):
120
+ return "—"
121
+ if abs(v) < 1.0:
122
+ return f"{v * 100:.1f}%"
123
+ return f"{v:.2f}"
124
+
125
+
126
+ def build_pipeline_dag_html(
127
+ nodes: Optional[list[dict]],
128
+ labels: Optional[dict[str, str]] = None,
129
+ edges: Optional[list[dict]] = None,
130
+ *,
131
+ thresholds: tuple[float, float] = _DEFAULT_THRESHOLDS,
132
+ higher_is_better: bool = False,
133
+ ) -> str:
134
+ """Construit la vue HTML « Pipeline DAG ».
135
+
136
+ Parameters
137
+ ----------
138
+ nodes:
139
+ Liste de dicts ``{"name", "input_types"?, "output_types"?}``
140
+ dans l'ordre topologique. Si vide ou ``None``, retourne
141
+ ``""``.
142
+ labels:
143
+ Dict i18n. Clés sous le préfixe ``dag_*``.
144
+ edges:
145
+ Liste de dicts ``{"from", "to", "artifact_type"?,
146
+ "metric_name"?, "metric_value"?}``. Optionnel —
147
+ auto-déduit séquentiel sinon.
148
+ thresholds:
149
+ ``(seuil_vert, seuil_jaune)`` sur la valeur de métrique.
150
+ Défaut ``(0.05, 0.15)`` — convention CER.
151
+ higher_is_better:
152
+ Si ``True``, la sémantique est inversée (1 = meilleur).
153
+ """
154
+ nodes = list(nodes or [])
155
+ if not nodes:
156
+ return ""
157
+ edges = list(edges or [])
158
+ labels = labels or {}
159
+ title = labels.get("dag_title", "Pipeline DAG")
160
+ note = labels.get(
161
+ "dag_note",
162
+ "Graphe orienté du pipeline composé. Chaque arête porte "
163
+ "le type d'artefact transmis et la métrique calculée à "
164
+ "la jonction. Code couleur vert/orange/rouge selon le "
165
+ "seuil. Outil d'inspection — le YAML reste source de "
166
+ "vérité.",
167
+ )
168
+ # Layout horizontal régulier
169
+ n = len(nodes)
170
+ box_width = 160
171
+ box_height = 70
172
+ h_gap = 110 # espace horizontal entre nœuds
173
+ margin = 30
174
+ svg_width = margin * 2 + n * box_width + (n - 1) * h_gap
175
+ svg_height = box_height + margin * 2 + 60 # +60 pour étiquettes arêtes
176
+ centre_y = margin + box_height / 2 + 30 # offset pour étiquette de tête
177
+
178
+ # Index des nœuds par name pour récupérer la position
179
+ node_x: dict[str, float] = {}
180
+ parts: list[str] = [
181
+ '<section class="dag-section" style="margin:1rem 0">',
182
+ f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
183
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
184
+ f'{_e(note)}</div>',
185
+ f'<svg viewBox="0 0 {svg_width} {svg_height}" '
186
+ f'role="img" aria-label="{_e(title)}" '
187
+ 'xmlns="http://www.w3.org/2000/svg" '
188
+ 'style="max-width:100%;height:auto;'
189
+ 'font-family:system-ui,sans-serif;font-size:12px">',
190
+ # Définition d'une flèche
191
+ '<defs>'
192
+ '<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" '
193
+ 'markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
194
+ '<path d="M0,0 L10,5 L0,10 z" fill="#374151"/>'
195
+ '</marker>'
196
+ '</defs>',
197
+ ]
198
+
199
+ # Étape 1 : nœuds
200
+ for i, node in enumerate(nodes):
201
+ name = str(node.get("name") or f"step_{i}")
202
+ x = margin + i * (box_width + h_gap)
203
+ y = margin + 30
204
+ node_x[name] = x + box_width
205
+ in_types = ", ".join(node.get("input_types") or [])
206
+ out_types = ", ".join(node.get("output_types") or [])
207
+ parts.append(
208
+ f'<rect x="{x}" y="{y}" width="{box_width}" '
209
+ f'height="{box_height}" rx="6" fill="#f3f4f6" '
210
+ f'stroke="#374151" stroke-width="1.5"/>'
211
+ )
212
+ parts.append(
213
+ f'<text x="{x + box_width / 2}" y="{y + 22}" '
214
+ f'text-anchor="middle" font-weight="600" '
215
+ f'fill="#111827">{_e(name)}</text>'
216
+ )
217
+ if in_types:
218
+ parts.append(
219
+ f'<text x="{x + box_width / 2}" y="{y + 40}" '
220
+ f'text-anchor="middle" fill="#4b5563" '
221
+ f'font-size="10">in: {_e(in_types)}</text>'
222
+ )
223
+ if out_types:
224
+ parts.append(
225
+ f'<text x="{x + box_width / 2}" y="{y + 56}" '
226
+ f'text-anchor="middle" fill="#4b5563" '
227
+ f'font-size="10">out: {_e(out_types)}</text>'
228
+ )
229
+
230
+ # Étape 2 : arêtes (mappées sur paires séquentielles si pas de
231
+ # "from"/"to" explicites — voir nodes par défaut)
232
+ auto_edges: list[dict] = []
233
+ if not edges:
234
+ for prev, curr in zip(nodes, nodes[1:]):
235
+ auto_edges.append({
236
+ "from": prev.get("name"),
237
+ "to": curr.get("name"),
238
+ })
239
+ else:
240
+ auto_edges = edges
241
+
242
+ for edge in auto_edges:
243
+ src = str(edge.get("from") or "")
244
+ dst = str(edge.get("to") or "")
245
+ if not src or not dst:
246
+ continue
247
+ # Position : du bord droit du src au bord gauche du dst
248
+ # Heuristique : on prend la position du nœud src dans la
249
+ # liste pour calculer x1, et celle de dst pour x2.
250
+ try:
251
+ i_src = next(
252
+ i for i, n_ in enumerate(nodes)
253
+ if n_.get("name") == src
254
+ )
255
+ i_dst = next(
256
+ i for i, n_ in enumerate(nodes)
257
+ if n_.get("name") == dst
258
+ )
259
+ except StopIteration:
260
+ continue
261
+ x1 = margin + i_src * (box_width + h_gap) + box_width
262
+ x2 = margin + i_dst * (box_width + h_gap)
263
+ y = centre_y
264
+ # Classe la métrique pour le code couleur
265
+ value = edge.get("metric_value")
266
+ try:
267
+ value_f = float(value) if value is not None else None
268
+ except (TypeError, ValueError):
269
+ value_f = None
270
+ cls = _classify_metric(value_f, thresholds, higher_is_better)
271
+ color = _QUALITY_COLORS[cls]
272
+ # Trace la flèche
273
+ parts.append(
274
+ f'<line x1="{x1}" y1="{y}" x2="{x2}" y2="{y}" '
275
+ f'stroke="{color}" stroke-width="2" '
276
+ f'marker-end="url(#arrow)"/>'
277
+ )
278
+ # Étiquette : type + métrique : valeur
279
+ artifact_type = edge.get("artifact_type") or ""
280
+ metric_name = edge.get("metric_name") or ""
281
+ value_str = _format_value(value_f)
282
+ label_lines: list[str] = []
283
+ if artifact_type:
284
+ label_lines.append(str(artifact_type))
285
+ if metric_name:
286
+ label_lines.append(f"{metric_name}: {value_str}")
287
+ if label_lines:
288
+ label_x = (x1 + x2) / 2
289
+ for k, line in enumerate(label_lines):
290
+ parts.append(
291
+ f'<text x="{label_x}" y="{y - 8 - k * 12}" '
292
+ f'text-anchor="middle" fill="{color}" '
293
+ f'font-size="10" font-weight="600">'
294
+ f'{_e(line)}</text>'
295
+ )
296
+ parts.append("</svg>")
297
+
298
+ # Légende
299
+ h_legend = labels.get("dag_legend", "Lecture")
300
+ legend_green = labels.get("dag_legend_green", "qualité élevée")
301
+ legend_yellow = labels.get("dag_legend_yellow", "qualité moyenne")
302
+ legend_red = labels.get("dag_legend_red", "qualité faible")
303
+ parts.append(
304
+ '<div style="font-size:.8rem;opacity:.75;margin-top:.4rem">'
305
+ f'<strong>{_e(h_legend)} :</strong> '
306
+ f'<span style="color:{_QUALITY_COLORS["green"]};'
307
+ f'font-weight:600">●</span> {_e(legend_green)} '
308
+ f'(≤ {thresholds[0] * 100:.0f}%) '
309
+ f'<span style="color:{_QUALITY_COLORS["yellow"]};'
310
+ f'font-weight:600">●</span> {_e(legend_yellow)} '
311
+ f'(≤ {thresholds[1] * 100:.0f}%) '
312
+ f'<span style="color:{_QUALITY_COLORS["red"]};'
313
+ f'font-weight:600">●</span> {_e(legend_red)}'
314
+ '</div>'
315
+ )
316
+ parts.append("</section>")
317
+ return "".join(parts)
318
+
319
+
320
+ __all__ = ["build_pipeline_dag_html"]
picarones/reports_v2/html/renderers/taxonomy_comparison.py ADDED
@@ -0,0 +1,239 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML du diagramme miroir taxonomique — Sprint 77.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.taxonomy_comparison_render`` vers
5
+ ``picarones.reports_v2.html.renderers.taxonomy_comparison``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ A.I.4 chantier 3 du plan d'évolution 2026.
10
+
11
+ Suite directe ``picarones/core/taxonomy_comparison.py``. Pattern
12
+ identique aux autres rendus (Sprints 41/43/62/67/72/74/75/76) :
13
+ **server-side**, pas de JavaScript, anti-injection systématique.
14
+
15
+ Diagramme miroir
16
+ ----------------
17
+ Une ligne par classe taxonomique, divisée en deux barres
18
+ horizontales :
19
+
20
+ - À **gauche** : barre du moteur A (orientée vers la gauche, du
21
+ centre vers le bord).
22
+ - À **droite** : barre du moteur B (orientée vers la droite).
23
+ - Couleur de la classe selon ``recoverability`` :
24
+
25
+ - vert (#5fa860) : ``recoverable``
26
+ - orange (#e0a050) : ``difficult``
27
+ - rouge (#d8553b) : ``irrecoverable``
28
+
29
+ Lecture immédiate : un moteur dont les barres tirent vers la
30
+ **gauche** sur du vert (case_error, ligature_error) et un moteur
31
+ qui tire à droite sur du rouge (lacuna) — la décision éditoriale
32
+ est évidente même si les CER globaux sont identiques.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from html import escape as _e
38
+ from typing import Optional
39
+
40
+
41
+ _RECOVERABILITY_COLORS = {
42
+ "recoverable": "#5fa860",
43
+ "difficult": "#e0a050",
44
+ "irrecoverable": "#d8553b",
45
+ }
46
+
47
+
48
+ def _build_mirror_chart_svg(
49
+ data: dict,
50
+ *,
51
+ bar_max_width: int = 200,
52
+ row_height: int = 22,
53
+ label_width: int = 140,
54
+ margin_top: int = 50,
55
+ margin_bottom: int = 20,
56
+ ) -> str:
57
+ """Construit le diagramme miroir SVG."""
58
+ classes = data["classes"]
59
+ prop_a = data["proportions_a"]
60
+ prop_b = data["proportions_b"]
61
+ recov = data["recoverability"]
62
+ engine_a = data["engine_a"]
63
+ engine_b = data["engine_b"]
64
+
65
+ n_rows = len(classes)
66
+ if n_rows == 0:
67
+ return ""
68
+
69
+ # Échelle : on normalise à la valeur max de toutes les
70
+ # proportions (pour que la classe la plus présente atteigne
71
+ # bar_max_width).
72
+ max_prop = max(
73
+ max(prop_a.values(), default=0.0),
74
+ max(prop_b.values(), default=0.0),
75
+ )
76
+ if max_prop <= 0:
77
+ max_prop = 1.0 # évite division par zéro (cas dégénéré)
78
+
79
+ width = label_width + 2 * bar_max_width + 40
80
+ height = margin_top + n_rows * row_height + margin_bottom
81
+ center = width // 2
82
+
83
+ parts = [
84
+ f'<svg xmlns="http://www.w3.org/2000/svg" '
85
+ f'width="{width}" height="{height}" '
86
+ f'viewBox="0 0 {width} {height}" '
87
+ f'role="img" aria-label="Diagramme miroir taxonomique">',
88
+ # En-têtes des deux moteurs
89
+ f'<text x="{center - bar_max_width // 2}" y="20" '
90
+ f'font-size="13" font-weight="600" fill="#333" '
91
+ f'text-anchor="middle">{_e(engine_a)}</text>',
92
+ f'<text x="{center + bar_max_width // 2}" y="20" '
93
+ f'font-size="13" font-weight="600" fill="#333" '
94
+ f'text-anchor="middle">{_e(engine_b)}</text>',
95
+ # Ligne centrale
96
+ f'<line x1="{center}" y1="{margin_top - 4}" '
97
+ f'x2="{center}" y2="{height - margin_bottom + 4}" '
98
+ f'stroke="#999" stroke-width="1"/>',
99
+ ]
100
+
101
+ # Barres
102
+ for i, cls in enumerate(classes):
103
+ y = margin_top + i * row_height
104
+ level = recov.get(cls, "difficult")
105
+ color = _RECOVERABILITY_COLORS.get(level, "#888")
106
+ # Étiquette de classe au centre
107
+ parts.append(
108
+ f'<text x="{center}" y="{y + row_height // 2 + 4}" '
109
+ f'font-size="11" fill="#222" text-anchor="middle" '
110
+ f'font-family="monospace">{_e(cls)}</text>'
111
+ )
112
+ # Barre A (gauche)
113
+ a_width = (prop_a.get(cls, 0.0) / max_prop) * bar_max_width
114
+ if a_width > 0:
115
+ x_a = center - label_width // 2 - a_width
116
+ parts.append(
117
+ f'<rect x="{x_a:.1f}" y="{y + 3}" '
118
+ f'width="{a_width:.1f}" height="{row_height - 6}" '
119
+ f'fill="{color}" stroke="#666" stroke-width="0.5" '
120
+ f'opacity="0.85"/>'
121
+ )
122
+ # Valeur en %
123
+ parts.append(
124
+ f'<text x="{x_a - 3:.1f}" y="{y + row_height // 2 + 4}" '
125
+ f'font-size="10" fill="#444" text-anchor="end">'
126
+ f'{prop_a.get(cls, 0.0) * 100:.1f}%</text>'
127
+ )
128
+ # Barre B (droite)
129
+ b_width = (prop_b.get(cls, 0.0) / max_prop) * bar_max_width
130
+ if b_width > 0:
131
+ x_b = center + label_width // 2
132
+ parts.append(
133
+ f'<rect x="{x_b:.1f}" y="{y + 3}" '
134
+ f'width="{b_width:.1f}" height="{row_height - 6}" '
135
+ f'fill="{color}" stroke="#666" stroke-width="0.5" '
136
+ f'opacity="0.85"/>'
137
+ )
138
+ parts.append(
139
+ f'<text x="{x_b + b_width + 3:.1f}" '
140
+ f'y="{y + row_height // 2 + 4}" '
141
+ f'font-size="10" fill="#444" text-anchor="start">'
142
+ f'{prop_b.get(cls, 0.0) * 100:.1f}%</text>'
143
+ )
144
+ parts.append("</svg>")
145
+ return "".join(parts)
146
+
147
+
148
+ def _build_recoverability_summary_html(
149
+ data: dict, labels: dict,
150
+ ) -> str:
151
+ """Encart résumé par catégorie de récupérabilité (3 lignes)."""
152
+ totals = data.get("totals_by_recoverability") or {}
153
+ if not totals:
154
+ return ""
155
+ label_recov = labels.get("taxocomp_recoverable", "Récupérable")
156
+ label_diff = labels.get("taxocomp_difficult", "Difficile")
157
+ label_irrec = labels.get("taxocomp_irrecoverable", "Irrécupérable")
158
+ rows = [
159
+ ("recoverable", label_recov),
160
+ ("difficult", label_diff),
161
+ ("irrecoverable", label_irrec),
162
+ ]
163
+ parts = [
164
+ '<table style="border-collapse:collapse;font-size:.85rem;'
165
+ 'margin-top:.5rem">',
166
+ '<thead><tr>',
167
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:left;'
168
+ 'border-bottom:1px solid #ccc">'
169
+ f'{_e(labels.get("taxocomp_level_label", "Catégorie"))}</th>',
170
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
171
+ 'border-bottom:1px solid #ccc">'
172
+ f'{_e(_e(data["engine_a"]))}</th>',
173
+ '<th scope=\"col\" style="padding:.2rem .5rem;text-align:right;'
174
+ 'border-bottom:1px solid #ccc">'
175
+ f'{_e(_e(data["engine_b"]))}</th>',
176
+ '</tr></thead><tbody>',
177
+ ]
178
+ for level, label in rows:
179
+ cell = totals.get(level, {"a": 0.0, "b": 0.0})
180
+ color = _RECOVERABILITY_COLORS.get(level, "#888")
181
+ parts.append(
182
+ f'<tr>'
183
+ f'<td style="padding:.2rem .5rem">'
184
+ f'<span style="display:inline-block;width:10px;height:10px;'
185
+ f'background:{color};margin-right:.4rem;border-radius:2px"></span>'
186
+ f'{_e(label)}</td>'
187
+ f'<td style="padding:.2rem .5rem;text-align:right;'
188
+ f'font-family:monospace">{cell["a"] * 100:.1f}%</td>'
189
+ f'<td style="padding:.2rem .5rem;text-align:right;'
190
+ f'font-family:monospace">{cell["b"] * 100:.1f}%</td>'
191
+ f'</tr>'
192
+ )
193
+ parts.append("</tbody></table>")
194
+ return "".join(parts)
195
+
196
+
197
+ def build_taxonomy_comparison_html(
198
+ data: Optional[dict],
199
+ labels: Optional[dict[str, str]] = None,
200
+ ) -> str:
201
+ """Construit le bloc HTML de comparaison taxonomique entre 2 moteurs.
202
+
203
+ Retourne ``""`` si ``data is None`` ou aucune classe.
204
+ """
205
+ if not data:
206
+ return ""
207
+ classes = data.get("classes") or []
208
+ if not classes:
209
+ return ""
210
+ labels = labels or {}
211
+ title_template = labels.get(
212
+ "taxocomp_title", "Profil taxonomique : {engine_a} vs {engine_b}",
213
+ )
214
+ title = title_template.format(
215
+ engine_a=data["engine_a"], engine_b=data["engine_b"],
216
+ )
217
+ note = labels.get(
218
+ "taxocomp_note",
219
+ "Diagramme miroir des proportions d'erreurs par classe. "
220
+ "Couleur selon récupérabilité éditoriale (vert = corrigeable, "
221
+ "rouge = irrécupérable). À CER global égal, un moteur dont les "
222
+ "erreurs sont majoritairement vertes est préférable pour une "
223
+ "édition critique.",
224
+ )
225
+ parts = [
226
+ '<div class="taxocomp" style="margin:1rem 0">',
227
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
228
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
229
+ f'{_e(note)}</div>',
230
+ _build_mirror_chart_svg(data),
231
+ _build_recoverability_summary_html(data, labels),
232
+ "</div>",
233
+ ]
234
+ return "".join(parts)
235
+
236
+
237
+ __all__ = [
238
+ "build_taxonomy_comparison_html",
239
+ ]
picarones/reports_v2/html/renderers/taxonomy_cooccurrence.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la heatmap de co-occurrence taxonomique — Sprint 75.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.taxonomy_cooccurrence_render`` vers
5
+ ``picarones.reports_v2.html.renderers.taxonomy_cooccurrence``.
6
+ Le chemin legacy reste disponible via un shim avec
7
+ ``DeprecationWarning`` ; suppression prévue en 2.0.
8
+
9
+ A.I.4 chantier 1 du plan d'évolution 2026.
10
+
11
+ Suite directe ``picarones/core/taxonomy_cooccurrence.py``. Pattern
12
+ identique aux autres rendus (Sprints 41/43/62/67/72/74) :
13
+ **server-side**, pas de JavaScript, anti-injection systématique.
14
+
15
+ Sortie typique
16
+ --------------
17
+ - ``build_taxonomy_cooccurrence_html(data, labels)`` produit un
18
+ bloc complet : titre + note d'usage + heatmap SVG + table des
19
+ paires les plus co-occurrentes.
20
+ - ``""`` retourné si ``data is None`` ou si la matrice est vide
21
+ (rapport adaptatif).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from html import escape as _e
27
+ from typing import Optional
28
+
29
+ from picarones.reports_v2._helpers.render_helpers import (
30
+ GRADIENT_TARGET_BLUE,
31
+ build_grid_svg,
32
+ color_single_gradient,
33
+ text_color_for_bg,
34
+ )
35
+
36
+
37
+ def _build_jaccard_heatmap_svg(
38
+ classes: list[str],
39
+ matrix: dict[str, dict[str, float]],
40
+ *,
41
+ cell_size: int = 36,
42
+ label_left: int = 130,
43
+ label_top: int = 80,
44
+ ) -> str:
45
+ """Heatmap Jaccard de co-occurrence taxonomique.
46
+
47
+ Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
48
+ encapsule les conventions spécifiques à la matrice symétrique
49
+ (valeur affichée seulement si > 0,05, étiquettes rotées).
50
+ """
51
+ if not classes:
52
+ return ""
53
+
54
+ def cell_value(i: int, j: int) -> float:
55
+ return matrix.get(classes[i], {}).get(classes[j], 0.0)
56
+
57
+ return build_grid_svg(
58
+ n_rows=len(classes),
59
+ n_cols=len(classes),
60
+ row_label_fn=lambda i: classes[i],
61
+ col_label_fn=lambda j: classes[j],
62
+ cell_color_fn=lambda i, j: color_single_gradient(
63
+ cell_value(i, j), end_rgb=GRADIENT_TARGET_BLUE,
64
+ ),
65
+ cell_text_fn=lambda i, j: (
66
+ f"{cell_value(i, j):.2f}" if cell_value(i, j) > 0.05 else None
67
+ ),
68
+ cell_text_color_fn=lambda i, j: text_color_for_bg(cell_value(i, j)),
69
+ cell_w=cell_size,
70
+ cell_h=cell_size,
71
+ label_left=label_left,
72
+ label_top=label_top,
73
+ rotate_col_labels=True,
74
+ aria_label="Heatmap Jaccard co-occurrence taxonomique",
75
+ )
76
+
77
+
78
+ def _build_top_pairs_table(
79
+ top_pairs: list,
80
+ labels: dict,
81
+ ) -> str:
82
+ """Construit la table HTML des paires les plus co-occurrentes."""
83
+ if not top_pairs:
84
+ return ""
85
+ pair_label = labels.get("taxocooc_pair_label", "Paire")
86
+ jaccard_label = labels.get("taxocooc_jaccard_label", "Jaccard")
87
+
88
+ parts = [
89
+ '<table style="border-collapse:collapse;font-size:.85rem;'
90
+ 'margin-top:.5rem">',
91
+ '<thead><tr>',
92
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
93
+ f'border-bottom:1px solid #ccc;font-weight:600">'
94
+ f'{_e(pair_label)}</th>',
95
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:right;'
96
+ f'border-bottom:1px solid #ccc;font-weight:600">'
97
+ f'{_e(jaccard_label)}</th>',
98
+ '</tr></thead><tbody>',
99
+ ]
100
+ for ca, cb, j in top_pairs:
101
+ parts.append(
102
+ f'<tr>'
103
+ f'<td style="padding:.2rem .5rem">'
104
+ f'<code>{_e(ca)}</code> ↔ <code>{_e(cb)}</code></td>'
105
+ f'<td style="padding:.2rem .5rem;text-align:right;'
106
+ f'font-family:monospace;'
107
+ f'background:{color_single_gradient(j, end_rgb=GRADIENT_TARGET_BLUE)};'
108
+ f'color:{text_color_for_bg(j)}">{j:.2f}</td>'
109
+ f'</tr>'
110
+ )
111
+ parts.append("</tbody></table>")
112
+ return "".join(parts)
113
+
114
+
115
+ def build_taxonomy_cooccurrence_html(
116
+ data: Optional[dict],
117
+ labels: Optional[dict[str, str]] = None,
118
+ ) -> str:
119
+ """Construit le bloc HTML complet de co-occurrence taxonomique.
120
+
121
+ Retourne ``""`` si ``data is None`` ou matrice vide.
122
+ """
123
+ if not data:
124
+ return ""
125
+ classes = data.get("classes") or []
126
+ matrix = data.get("cooccurrence_matrix") or {}
127
+ if not classes or not matrix:
128
+ return ""
129
+ labels = labels or {}
130
+ title = labels.get(
131
+ "taxocooc_title",
132
+ "Co-occurrence des classes d'erreur",
133
+ )
134
+ note = labels.get(
135
+ "taxocooc_note",
136
+ "Indice de Jaccard au niveau document : 1,00 = ces deux classes "
137
+ "apparaissent toujours ensemble ; 0,00 = jamais. Lecture par paires "
138
+ "co-occurrentes ci-dessous.",
139
+ )
140
+ n_docs = data.get("n_documents", 0)
141
+ n_docs_label_template = labels.get(
142
+ "taxocooc_n_docs", "Calculé sur {n_docs} documents.",
143
+ )
144
+ n_docs_phrase = n_docs_label_template.format(n_docs=n_docs)
145
+
146
+ svg = _build_jaccard_heatmap_svg(classes, matrix)
147
+ top_table = _build_top_pairs_table(
148
+ data.get("top_pairs") or [], labels,
149
+ )
150
+
151
+ parts = [
152
+ '<div class="taxocooc" style="margin:1rem 0">',
153
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
154
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
155
+ f'{_e(note)}</div>',
156
+ f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
157
+ f'{_e(n_docs_phrase)}</div>',
158
+ svg,
159
+ top_table,
160
+ "</div>",
161
+ ]
162
+ return "".join(parts)
163
+
164
+
165
+ __all__ = [
166
+ "build_taxonomy_cooccurrence_html",
167
+ ]
picarones/reports_v2/html/renderers/taxonomy_intra_doc.py ADDED
@@ -0,0 +1,154 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la heatmap class × position — Sprint 76.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.taxonomy_intra_doc_render`` vers
5
+ ``picarones.reports_v2.html.renderers.taxonomy_intra_doc``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ A.I.4 chantier 2 du plan d'évolution 2026.
10
+
11
+ Suite directe ``picarones/core/taxonomy_intra_doc.py``. Pattern
12
+ identique aux autres rendus (Sprints 41/43/62/67/72/74/75) :
13
+ **server-side**, pas de JavaScript, anti-injection systématique.
14
+
15
+ Sortie typique
16
+ --------------
17
+ Une grille N_classes × N_bins où chaque cellule indique la densité
18
+ d'erreurs de cette classe à cette position dans le document.
19
+ Lecture immédiate : « ligature_error concentré dans la première
20
+ tranche → erreur de marge ; visual_confusion uniformément réparti
21
+ → erreur de scribe ».
22
+
23
+ Adaptive : si ``data is None`` ou si toutes les classes ont 0
24
+ erreur, retourne ``""``.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ from html import escape as _e
30
+ from typing import Optional
31
+
32
+ from picarones.reports_v2._helpers.render_helpers import (
33
+ GRADIENT_TARGET_ORANGE,
34
+ build_grid_svg,
35
+ color_single_gradient,
36
+ text_color_for_bg,
37
+ )
38
+
39
+
40
+ def _build_position_heatmap_svg(
41
+ classes_with_errors: list[str],
42
+ per_class: dict[str, list[int]],
43
+ n_bins: int,
44
+ *,
45
+ cell_w: int = 36,
46
+ cell_h: int = 26,
47
+ label_left: int = 150,
48
+ label_top: int = 30,
49
+ ) -> str:
50
+ """Heatmap class taxonomique × position (densité relative par classe).
51
+
52
+ Délègue à :func:`build_grid_svg` ; reste un wrapper local qui
53
+ encapsule la normalisation par classe (densité relative au max
54
+ observé sur la ligne).
55
+ """
56
+ if not classes_with_errors:
57
+ return ""
58
+
59
+ # Pré-calcule densité et count par cellule pour éviter les boucles
60
+ # imbriquées dans les callbacks.
61
+ grid: list[list[tuple[int, float]]] = []
62
+ for cls in classes_with_errors:
63
+ counts = per_class.get(cls, [0] * n_bins)
64
+ max_count = max(counts) if counts else 0
65
+ row: list[tuple[int, float]] = []
66
+ for j in range(n_bins):
67
+ count = counts[j] if j < len(counts) else 0
68
+ density = (count / max_count) if max_count > 0 else 0.0
69
+ row.append((count, density))
70
+ grid.append(row)
71
+
72
+ return build_grid_svg(
73
+ n_rows=len(classes_with_errors),
74
+ n_cols=n_bins,
75
+ row_label_fn=lambda i: classes_with_errors[i],
76
+ col_label_fn=lambda j: str(j + 1),
77
+ cell_color_fn=lambda i, j: color_single_gradient(
78
+ grid[i][j][1], end_rgb=GRADIENT_TARGET_ORANGE,
79
+ ),
80
+ cell_text_fn=lambda i, j: (
81
+ str(grid[i][j][0]) if grid[i][j][0] > 0 else None
82
+ ),
83
+ cell_text_color_fn=lambda i, j: text_color_for_bg(grid[i][j][1]),
84
+ cell_w=cell_w,
85
+ cell_h=cell_h,
86
+ label_left=label_left,
87
+ label_top=label_top,
88
+ aria_label="Heatmap class taxonomique × position",
89
+ x_axis_title="Position dans le document (1 = début)",
90
+ )
91
+
92
+
93
+ def build_taxonomy_intra_doc_html(
94
+ data: Optional[dict],
95
+ labels: Optional[dict[str, str]] = None,
96
+ ) -> str:
97
+ """Construit le bloc HTML complet de la heatmap intra-document.
98
+
99
+ Retourne ``""`` si ``data is None`` ou aucune erreur.
100
+ """
101
+ if not data:
102
+ return ""
103
+ n_bins = data.get("n_bins", 0)
104
+ per_class = data.get("per_class") or {}
105
+ total_errors = data.get("total_errors", 0)
106
+ if total_errors == 0 or n_bins <= 0:
107
+ return ""
108
+ # Filtre : uniquement les classes ayant au moins une erreur
109
+ classes_with_errors = [
110
+ cls for cls, counts in per_class.items()
111
+ if isinstance(counts, list) and sum(counts) > 0
112
+ ]
113
+ if not classes_with_errors:
114
+ return ""
115
+
116
+ labels = labels or {}
117
+ title = labels.get(
118
+ "intradoc_title",
119
+ "Évolution intra-document des classes d'erreur",
120
+ )
121
+ note = labels.get(
122
+ "intradoc_note",
123
+ "Heatmap class × position : densité relative par classe "
124
+ "(plus foncé = concentré). Une classe concentrée dans la "
125
+ "première colonne suggère une erreur de marge ; "
126
+ "une distribution uniforme suggère une erreur de scribe.",
127
+ )
128
+ n_words_gt = data.get("n_words_gt", 0)
129
+ n_words_template = labels.get(
130
+ "intradoc_n_words",
131
+ "Calculé sur {n_words_gt} mots GT, répartis en {n_bins} tranches.",
132
+ )
133
+ n_words_phrase = n_words_template.format(
134
+ n_words_gt=n_words_gt, n_bins=n_bins,
135
+ )
136
+
137
+ svg = _build_position_heatmap_svg(classes_with_errors, per_class, n_bins)
138
+
139
+ parts = [
140
+ '<div class="intradoc" style="margin:1rem 0">',
141
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
142
+ f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.5rem">'
143
+ f'{_e(note)}</div>',
144
+ f'<div style="font-size:.8rem;opacity:.7;margin-bottom:.5rem">'
145
+ f'{_e(n_words_phrase)}</div>',
146
+ svg,
147
+ "</div>",
148
+ ]
149
+ return "".join(parts)
150
+
151
+
152
+ __all__ = [
153
+ "build_taxonomy_intra_doc_html",
154
+ ]
picarones/reports_v2/html/renderers/worst_lines.py ADDED
@@ -0,0 +1,170 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rendu HTML de la vue « Worst lines globale » — Sprint 72.
2
+
3
+ Phase 5.C — module relocalisé depuis
4
+ ``picarones.report.worst_lines_render`` vers
5
+ ``picarones.reports_v2.html.renderers.worst_lines``. Le chemin
6
+ legacy reste disponible via un shim avec ``DeprecationWarning`` ;
7
+ suppression prévue en 2.0.
8
+
9
+ Suite directe de ``picarones/core/worst_lines.py`` (extraction
10
+ transversale). Pattern identique aux Sprints 41/43/62/67 : rendu
11
+ **server-side**, pas de JavaScript, anti-injection systématique
12
+ via ``html.escape``.
13
+
14
+ Vue distincte du tableau gallery existant
15
+ -----------------------------------------
16
+ La galerie OCR (vue ``view_gallery.html``) liste les documents
17
+ les plus problématiques. Cette vue va plus fin : elle liste les
18
+ **lignes individuelles** les plus problématiques, transversalement
19
+ à tous les documents et moteurs. Complémentaire, pas redondante.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ from html import escape as _e
25
+ from typing import Optional
26
+
27
+ from picarones.evaluation.metrics.worst_lines import WorstLineEntry
28
+ from picarones.evaluation import compute_char_diff
29
+ from picarones.reports_v2._helpers.render_helpers import color_traffic_light
30
+
31
+
32
+ def _bg_for_cer(cer: float) -> str:
33
+ """Beige clair sous le seuil catastrophique (0.30), gradient
34
+ jaune → rouge au-delà.
35
+
36
+ Le seuil dur à 0.30 préserve la sémantique « toléré jusqu'à 30 %
37
+ pour un manuscrit difficile ». Au-delà, on entre en zone visible
38
+ avec :func:`color_traffic_light` (low_is_good).
39
+ """
40
+ f = max(0.0, min(1.0, cer))
41
+ if f < 0.3:
42
+ return "#fff8dc"
43
+ return color_traffic_light(f, low_is_good=True, scale_min=0.3, scale_max=1.0)
44
+
45
+
46
+ def _render_diff_inline(reference: str, hypothesis: str) -> str:
47
+ """Rendu HTML inline d'un diff caractère par caractère.
48
+
49
+ - ``equal`` → texte normal
50
+ - ``delete`` → fond rouge clair, barré (manquait dans hyp)
51
+ - ``insert`` → fond vert clair (ajouté par hyp)
52
+ - ``replace`` → fond rouge clair barré + fond vert clair pour
53
+ la nouvelle valeur (côte à côte)
54
+ """
55
+ if not reference and not hypothesis:
56
+ return '<span style="opacity:.5">∅</span>'
57
+ ops = compute_char_diff(reference or "", hypothesis or "")
58
+ parts: list[str] = []
59
+ for op in ops:
60
+ kind = op["op"]
61
+ if kind == "equal":
62
+ parts.append(_e(op["text"]))
63
+ elif kind == "delete":
64
+ parts.append(
65
+ f'<span style="background:#fdd;text-decoration:line-through">'
66
+ f'{_e(op["text"])}</span>'
67
+ )
68
+ elif kind == "insert":
69
+ parts.append(
70
+ f'<span style="background:#dfd">{_e(op["text"])}</span>'
71
+ )
72
+ elif kind == "replace":
73
+ parts.append(
74
+ f'<span style="background:#fdd;text-decoration:line-through">'
75
+ f'{_e(op["old"])}</span>'
76
+ f'<span style="background:#dfd">{_e(op["new"])}</span>'
77
+ )
78
+ return "".join(parts)
79
+
80
+
81
+ def build_worst_lines_table_html(
82
+ entries: list[WorstLineEntry],
83
+ labels: Optional[dict[str, str]] = None,
84
+ ) -> str:
85
+ """Construit le tableau HTML des worst lines.
86
+
87
+ Retourne ``""`` si la liste est vide. Adaptive : si aucune
88
+ entrée n'a de ``script_type``, la colonne strate est omise.
89
+ """
90
+ if not entries:
91
+ return ""
92
+ labels = labels or {}
93
+ title = labels.get("worst_lines_title", "Lignes les plus problématiques")
94
+ note = labels.get(
95
+ "worst_lines_note",
96
+ "Top-N lignes du corpus classées par CER ligne décroissant. "
97
+ "Diff caractère par caractère : rouge barré = manquant dans "
98
+ "l'OCR, vert = ajouté par l'OCR.",
99
+ )
100
+ rank_label = labels.get("worst_lines_rank_label", "Rang")
101
+ cer_label = labels.get("worst_lines_cer_label", "CER")
102
+ engine_label = labels.get("worst_lines_engine_label", "Moteur")
103
+ doc_label = labels.get("worst_lines_doc_label", "Document")
104
+ line_label = labels.get("worst_lines_line_label", "Ligne #")
105
+ strata_label = labels.get("worst_lines_strata_label", "Strate")
106
+ diff_label = labels.get("worst_lines_diff_label", "GT → OCR (diff)")
107
+
108
+ has_strata = any(e.script_type for e in entries)
109
+
110
+ parts = [
111
+ '<div class="worst-lines" style="margin:1rem 0">',
112
+ f'<div style="font-weight:600;margin-bottom:.4rem">{_e(title)}</div>',
113
+ f'<div style="font-size:.8rem;opacity:.75;margin-bottom:.5rem">'
114
+ f'{_e(note)}</div>',
115
+ '<table style="border-collapse:collapse;width:100%;'
116
+ 'font-size:.85rem">',
117
+ '<thead><tr>',
118
+ ]
119
+ cols = [rank_label, cer_label, engine_label, doc_label, line_label]
120
+ if has_strata:
121
+ cols.append(strata_label)
122
+ cols.append(diff_label)
123
+ for col in cols:
124
+ parts.append(
125
+ f'<th scope=\"col\" style="padding:.3rem .5rem;text-align:left;'
126
+ f'border-bottom:1px solid #ccc;font-weight:600">'
127
+ f'{_e(col)}</th>'
128
+ )
129
+ parts.append("</tr></thead><tbody>")
130
+ for entry in entries:
131
+ cer_color = _bg_for_cer(entry.cer)
132
+ parts.append("<tr>")
133
+ parts.append(
134
+ f'<td style="padding:.3rem .5rem;text-align:right;'
135
+ f'font-weight:600">{entry.rank}</td>'
136
+ )
137
+ parts.append(
138
+ f'<td style="padding:.3rem .5rem;text-align:right;'
139
+ f'background:{cer_color};font-family:monospace">'
140
+ f'{entry.cer * 100:.1f}%</td>'
141
+ )
142
+ parts.append(
143
+ f'<td style="padding:.3rem .5rem">{_e(entry.engine_name)}</td>'
144
+ )
145
+ parts.append(
146
+ f'<td style="padding:.3rem .5rem;font-family:monospace;'
147
+ f'font-size:.8rem">{_e(entry.doc_id)}</td>'
148
+ )
149
+ parts.append(
150
+ f'<td style="padding:.3rem .5rem;text-align:right">'
151
+ f'{entry.line_index}</td>'
152
+ )
153
+ if has_strata:
154
+ parts.append(
155
+ f'<td style="padding:.3rem .5rem;font-size:.8rem">'
156
+ f'{_e(entry.script_type or "—")}</td>'
157
+ )
158
+ parts.append(
159
+ f'<td style="padding:.3rem .5rem;font-family:monospace;'
160
+ f'font-size:.85rem">'
161
+ f'{_render_diff_inline(entry.gt_line, entry.hyp_line)}</td>'
162
+ )
163
+ parts.append("</tr>")
164
+ parts.append("</tbody></table></div>")
165
+ return "".join(parts)
166
+
167
+
168
+ __all__ = [
169
+ "build_worst_lines_table_html",
170
+ ]
tests/report/test_extra_metrics.py CHANGED
@@ -97,7 +97,7 @@ class TestTaxonomyCooccurrence:
97
  assert r == results[0]
98
 
99
  def test_compatible_with_renderer(self, sample_benchmark) -> None:
100
- from picarones.report.taxonomy_cooccurrence_render import (
101
  build_taxonomy_cooccurrence_html,
102
  )
103
  result = compute_taxonomy_cooccurrence_section(sample_benchmark)
@@ -138,7 +138,7 @@ class TestTaxonomyIntraDoc:
138
  assert key in result, f"clé {key!r} manquante (renderer la requiert)"
139
 
140
  def test_renders_html_when_signal_present(self, sample_benchmark) -> None:
141
- from picarones.report.taxonomy_intra_doc_render import (
142
  build_taxonomy_intra_doc_html,
143
  )
144
  result = compute_taxonomy_intra_doc_section(sample_benchmark)
 
97
  assert r == results[0]
98
 
99
  def test_compatible_with_renderer(self, sample_benchmark) -> None:
100
+ from picarones.reports_v2.html.renderers.taxonomy_cooccurrence import (
101
  build_taxonomy_cooccurrence_html,
102
  )
103
  result = compute_taxonomy_cooccurrence_section(sample_benchmark)
 
138
  assert key in result, f"clé {key!r} manquante (renderer la requiert)"
139
 
140
  def test_renders_html_when_signal_present(self, sample_benchmark) -> None:
141
+ from picarones.reports_v2.html.renderers.taxonomy_intra_doc import (
142
  build_taxonomy_intra_doc_html,
143
  )
144
  result = compute_taxonomy_intra_doc_section(sample_benchmark)
tests/report/test_sprint72_worst_lines.py CHANGED
@@ -28,7 +28,7 @@ from dataclasses import dataclass, field
28
  from typing import Any
29
 
30
  from picarones.measurements.worst_lines import WorstLineEntry, extract_worst_lines
31
- from picarones.report.worst_lines_render import build_worst_lines_table_html
32
 
33
 
34
  # ──────────────────────────────────────────────────────────────────────────
 
28
  from typing import Any
29
 
30
  from picarones.measurements.worst_lines import WorstLineEntry, extract_worst_lines
31
+ from picarones.reports_v2.html.renderers.worst_lines import build_worst_lines_table_html
32
 
33
 
34
  # ──────────────────────────────────────────────────────────────────────────
tests/report/test_sprint75_taxonomy_cooccurrence.py CHANGED
@@ -29,7 +29,7 @@ import pytest
29
  from picarones.measurements.taxonomy_cooccurrence import (
30
  compute_taxonomy_cooccurrence,
31
  )
32
- from picarones.report.taxonomy_cooccurrence_render import (
33
  build_taxonomy_cooccurrence_html,
34
  )
35
 
 
29
  from picarones.measurements.taxonomy_cooccurrence import (
30
  compute_taxonomy_cooccurrence,
31
  )
32
+ from picarones.reports_v2.html.renderers.taxonomy_cooccurrence import (
33
  build_taxonomy_cooccurrence_html,
34
  )
35
 
tests/report/test_sprint76_taxonomy_intra_doc.py CHANGED
@@ -29,7 +29,7 @@ import pytest
29
  from picarones.measurements.taxonomy_intra_doc import (
30
  compute_taxonomy_position_heatmap,
31
  )
32
- from picarones.report.taxonomy_intra_doc_render import (
33
  build_taxonomy_intra_doc_html,
34
  )
35
 
 
29
  from picarones.measurements.taxonomy_intra_doc import (
30
  compute_taxonomy_position_heatmap,
31
  )
32
+ from picarones.reports_v2.html.renderers.taxonomy_intra_doc import (
33
  build_taxonomy_intra_doc_html,
34
  )
35
 
tests/report/test_sprint77_taxonomy_comparison.py CHANGED
@@ -27,7 +27,7 @@ from picarones.measurements.taxonomy_comparison import (
27
  RECOVERABILITY,
28
  compare_taxonomies,
29
  )
30
- from picarones.report.taxonomy_comparison_render import (
31
  build_taxonomy_comparison_html,
32
  )
33
 
 
27
  RECOVERABILITY,
28
  compare_taxonomies,
29
  )
30
+ from picarones.reports_v2.html.renderers.taxonomy_comparison import (
31
  build_taxonomy_comparison_html,
32
  )
33
 
tests/report/test_sprint95_pipeline_dag.py CHANGED
@@ -21,7 +21,7 @@ from __future__ import annotations
21
  import json
22
  from pathlib import Path
23
 
24
- from picarones.report.pipeline_dag_render import build_pipeline_dag_html
25
 
26
 
27
  def _load_labels(lang: str) -> dict:
 
21
  import json
22
  from pathlib import Path
23
 
24
+ from picarones.reports_v2.html.renderers.pipeline_dag import build_pipeline_dag_html
25
 
26
 
27
  def _load_labels(lang: str) -> dict: