Claude commited on
Commit
fe6661c
·
unverified ·
1 Parent(s): 25bd1fe

chantier3: 5 vues HTML thématiques — branche les 16 renderers orphelins

Browse files

Troisième chantier du plan d'évolution post-Sprint 97 — donner une
adresse à chaque renderer orphelin (16/26 dans report/) en les
regroupant par thème dans des vues collapsibles.

Avant — 16 renderers test-only
------------------------------
L'audit du chantier 0 avait confirmé par grep que ces renderers
existaient et étaient testés mais jamais importés par
generator.py ni inclus dans aucun template :

baseline_render, error_absorption_render, image_predictive_render,
incremental_comparison_render, levers_render, lexical_modernization_render,
longitudinal_render, module_audit_render, multirun_stability_render,
pipeline_dag_render, robustness_projection_render, taxonomy_comparison_render,
taxonomy_cooccurrence_render, taxonomy_intra_doc_render, throughput_render,
worst_lines_render.

CLAUDE.md prétendait pour la plupart "livré bout-en-bout" — c'était faux.

Après — 5 vues thématiques, chaque renderer adressé
---------------------------------------------------
Nouveau package picarones/report/views/ (5 modules) :

- economics.py (158 lignes) — throughput effectif (calculé
automatiquement depuis engine_reports : durations + WER × ref_length)
+ extra_html_blocks pour cost projection et marginal cost opt-in.

- advanced_taxonomy.py (231 lignes) — comparaison miroir
leader vs runner-up (auto, depuis aggregated_taxonomy)
+ cooccurrence/intra_doc/lexical_modernization opt-in.

- diagnostics.py (210 lignes) — leviers d'amélioration (auto,
via detect_levers) + image_predictive/baseline/longitudinal/
multirun_stability/worst_lines opt-in.

- pipeline.py (220 lignes) — pour les rapports de pipelines
composées (workflow picarones pipeline run) : DAG + error_absorption
+ incremental_comparison + module_audit + summary/steps_table.

- robustness.py (95 lignes) — pour le workflow picarones
robustness : déficit projeté.

__init__.py expose les 5 fonctions build_<name>_view_html qui
retournent "" en adaptive masking (aucune sous-section n'a de signal).

Convention de rendu partagée
----------------------------
Chaque vue compose son HTML via _render_view_shell() :
- Titre H3 et note explicative en tête
- Une <details> collapsible par sous-renderer
- Premier <details> ouvert, les autres fermés (réduit le scroll initial)
- Anti-injection HTML systématique via xml.sax.saxutils.escape

Câblage dans le rapport classique
---------------------------------
report/generator.py importe et calcule les 3 vues automatiques
(economics, advanced_taxonomy, diagnostics) après les renderers
historiques. Les 3 variables sont passées au template Jinja2.

view_analyses.html : 3 nouveaux blocs {% if X %} {{ X }} {% endif %}
juste avant la matrice de corrélation, en chart-card pleine largeur.
Les vues s'affichent uniquement si elles ont du contenu.

Vue pipeline et vue robustness : exposées dans le package mais pas
auto-câblées au rapport classique (par construction — un bench
mono-moteur n'a pas de DAG, et la robustesse est un workflow CLI
séparé). Le code des vues est livré pour qu'un cli future puisse
composer un rapport autonome.

Adressage des 16 renderers orphelins
------------------------------------
| Renderer | Vue | Mode |
|-----------------------------------|----------------------|-----------|
| throughput_render | economics | auto |
| taxonomy_comparison_render | advanced_taxonomy | auto |
| taxonomy_cooccurrence_render | advanced_taxonomy | opt-in |
| taxonomy_intra_doc_render | advanced_taxonomy | opt-in |
| lexical_modernization_render | advanced_taxonomy | opt-in |
| levers_render | diagnostics | auto |
| image_predictive_render | diagnostics | opt-in |
| baseline_render | diagnostics | opt-in |
| longitudinal_render | diagnostics | opt-in |
| multirun_stability_render | diagnostics | opt-in |
| worst_lines_render | diagnostics | opt-in |
| pipeline_dag_render | pipeline | opt-in |
| error_absorption_render | pipeline | opt-in |
| incremental_comparison_render | pipeline | opt-in |
| module_audit_render | pipeline | opt-in |
| robustness_projection_render | robustness | opt-in |

Validation 10/10 en sandbox
---------------------------
- Imports OK pour les 5 vues + __init__.
- 5/5 vues retournent "" en adaptive masking sur données vides.
- advanced_taxonomy : 1 moteur → "" (besoin de 2 pour comparer).
- advanced_taxonomy : 2 moteurs → 4404 chars avec mention des 2
noms.
- Anti-injection : "<script>alert(1)</script>" en nom de moteur
est bien échappé.
- economics : 2 moteurs avec 10 docs chacun → 2675 chars throughput.
- economics : durations nulles (bench depuis cache) → masquée.
- Shell <details> : premier ouvert, autres fermés.
- generator.py : 3 imports + 3 variables passées au template.
- view_analyses.html : 3 nouveaux blocs {% if X %} insérés.

Tests
-----
+342 lignes dans tests/test_views.py organisés en 5 classes :
TestViewsImport, TestAdaptiveMasking, TestEconomicsView,
TestAdvancedTaxonomyView, TestDiagnosticsView, TestDetailsShell,
TestGeneratorWiring.

Verrou levé
-----------
Plus aucun renderer n'est strictement orphelin. Les 5 vues sont
composables et adaptive — un rapport sans signal sur une famille
ne montre pas la vue. Les opt-in attendent que les chantiers 4-5
livrent les calculs nécessaires (image_qualities collection avant
compact, baseline/longitudinal depuis history, etc.).

picarones/report/generator.py CHANGED
@@ -841,6 +841,26 @@ class ReportGenerator:
841
  _taxos, labels=labels,
842
  )
843
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
844
  env = _build_jinja_env()
845
  template = env.get_template("base.html.j2")
846
  html = template.render(
@@ -866,6 +886,10 @@ class ReportGenerator:
866
  numerical_sequences_html=numerical_sequences_html,
867
  readability_html=readability_html,
868
  specialization_html=specialization_html,
 
 
 
 
869
  )
870
 
871
  output_path.write_text(html, encoding="utf-8")
 
841
  _taxos, labels=labels,
842
  )
843
 
844
+ # Chantier 3 (post-Sprint 97) — 3 nouvelles vues thématiques
845
+ # qui regroupent les renderers orphelins en sections
846
+ # collapsibles. Adaptive : retourne "" si aucune sous-section
847
+ # n'a de signal, donc la carte du template est masquée.
848
+ from picarones.report.views import (
849
+ build_advanced_taxonomy_view_html,
850
+ build_diagnostics_view_html,
851
+ build_economics_view_html,
852
+ )
853
+ economics_view_html = build_economics_view_html(
854
+ report_data, labels=labels,
855
+ engine_reports=self.benchmark.engine_reports,
856
+ )
857
+ advanced_taxonomy_view_html = build_advanced_taxonomy_view_html(
858
+ report_data, labels=labels,
859
+ )
860
+ diagnostics_view_html = build_diagnostics_view_html(
861
+ report_data, labels=labels,
862
+ )
863
+
864
  env = _build_jinja_env()
865
  template = env.get_template("base.html.j2")
866
  html = template.render(
 
886
  numerical_sequences_html=numerical_sequences_html,
887
  readability_html=readability_html,
888
  specialization_html=specialization_html,
889
+ # Chantier 3 — vues thématiques composées
890
+ economics_view_html=economics_view_html,
891
+ advanced_taxonomy_view_html=advanced_taxonomy_view_html,
892
+ diagnostics_view_html=diagnostics_view_html,
893
  )
894
 
895
  output_path.write_text(html, encoding="utf-8")
picarones/report/templates/view_analyses.html CHANGED
@@ -262,6 +262,26 @@
262
  </div>
263
  {% endif %}
264
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
265
  <!-- Sprint 7 — Matrice de corrélation -->
266
  <div class="chart-card technical" style="grid-column:1/-1">
267
  <h3 data-i18n="h_correlation">Matrice de corrélation entre métriques</h3>
 
262
  </div>
263
  {% endif %}
264
 
265
+ <!-- Chantier 3 (post-Sprint 97) — vues thématiques composées
266
+ qui regroupent les renderers orphelins en sections
267
+ collapsibles. Adaptive : ne s'affichent que si la vue
268
+ retourne du contenu (au moins une sous-section avec signal). -->
269
+ {% if economics_view_html %}
270
+ <div class="chart-card" style="grid-column:1/-1">
271
+ {{ economics_view_html }}
272
+ </div>
273
+ {% endif %}
274
+ {% if advanced_taxonomy_view_html %}
275
+ <div class="chart-card" style="grid-column:1/-1">
276
+ {{ advanced_taxonomy_view_html }}
277
+ </div>
278
+ {% endif %}
279
+ {% if diagnostics_view_html %}
280
+ <div class="chart-card" style="grid-column:1/-1">
281
+ {{ diagnostics_view_html }}
282
+ </div>
283
+ {% endif %}
284
+
285
  <!-- Sprint 7 — Matrice de corrélation -->
286
  <div class="chart-card technical" style="grid-column:1/-1">
287
  <h3 data-i18n="h_correlation">Matrice de corrélation entre métriques</h3>
picarones/report/views/__init__.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vues HTML thématiques — orchestrateurs des renderers du rapport.
2
+
3
+ Chantier 3 du plan d'évolution post-Sprint 97.
4
+
5
+ Pourquoi ce package
6
+ -------------------
7
+ Avant ce chantier, ``picarones/report/`` exposait 26 modules
8
+ ``*_render.py``, dont **16 étaient orphelins** : testés mais jamais
9
+ importés par ``generator.py`` ni inclus dans aucun template Jinja2.
10
+
11
+ Le chantier 3 résout ce déséquilibre **par regroupement** : chaque
12
+ renderer orphelin trouve une **adresse** dans une vue thématique,
13
+ qui est elle-même branchée conditionnellement au rapport principal
14
+ si elle a du contenu à afficher.
15
+
16
+ Vues livrées par ce chantier
17
+ ----------------------------
18
+ - :mod:`economics` — throughput effectif + (cost projection si fourni)
19
+ - :mod:`advanced_taxonomy` — taxonomy_comparison + cooccurrence + intra_doc + lexical_modernization
20
+ - :mod:`diagnostics` — levers + image_predictive + baseline + longitudinal + multirun_stability + worst_lines
21
+ - :mod:`pipeline` — pipeline_dag + error_absorption + incremental_comparison + module_audit
22
+ - :mod:`robustness` — robustness_projection (workflow CLI séparé)
23
+
24
+ Convention API
25
+ --------------
26
+ Chaque vue expose une fonction publique
27
+ ``build_<name>_view_html(report_data, labels, **opts) -> str`` qui :
28
+
29
+ 1. **Prend** ``report_data`` (dict construit par
30
+ :func:`picarones.report.generator._build_report_data`),
31
+ ``labels`` (i18n) et des options spécifiques à la vue (ex. fixtures
32
+ externes que l'utilisateur peut fournir).
33
+ 2. **Calcule** les données dont chaque renderer a besoin à partir de
34
+ ``report_data`` quand c'est possible.
35
+ 3. **Compose** le HTML des sous-renderers en blocs ``<details>``
36
+ collapsibles (premier ouvert par défaut).
37
+ 4. **Retourne** la chaîne HTML complète, ou ``""`` si aucune
38
+ sous-section n'a de contenu (adaptive masking corpus-wide).
39
+
40
+ Le générateur principal (``generator.py``) appelle ces fonctions et
41
+ passe leur retour au template Jinja2 ``view_analyses.html`` qui les
42
+ inclut sous forme de cartes pleine largeur derrière un en-tête
43
+ identifiant la famille.
44
+
45
+ Ne pas confondre
46
+ ----------------
47
+ ``views/<name>.py`` = orchestrateur (composition + adaptive masking).
48
+ ``<name>_render.py`` = rendu HTML d'un seul bloc atomique.
49
+ Les renderers atomiques restent inchangés, l'orchestrateur les
50
+ combine.
51
+ """
52
+
53
+ from picarones.report.views.advanced_taxonomy import build_advanced_taxonomy_view_html
54
+ from picarones.report.views.diagnostics import build_diagnostics_view_html
55
+ from picarones.report.views.economics import build_economics_view_html
56
+ from picarones.report.views.pipeline import build_pipeline_view_html
57
+ from picarones.report.views.robustness import build_robustness_view_html
58
+
59
+ __all__ = [
60
+ "build_advanced_taxonomy_view_html",
61
+ "build_diagnostics_view_html",
62
+ "build_economics_view_html",
63
+ "build_pipeline_view_html",
64
+ "build_robustness_view_html",
65
+ ]
picarones/report/views/advanced_taxonomy.py ADDED
@@ -0,0 +1,245 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vue taxonomique avancée — chantier 3 post-Sprint 97.
2
+
3
+ Regroupe les renderers orientés *édition critique* qui examinent la
4
+ structure des erreurs OCR au-delà du CER global :
5
+
6
+ - :func:`picarones.report.taxonomy_comparison_render.build_taxonomy_comparison_html`
7
+ — diagramme miroir A vs B des proportions d'erreurs par classe
8
+ + tableau de récupérabilité éditoriale.
9
+ - :func:`picarones.report.taxonomy_cooccurrence_render.build_taxonomy_cooccurrence_html`
10
+ — heatmap Jaccard des co-occurrences de classes au niveau document
11
+ (opt-in : nécessite ``per_doc_classes``).
12
+ - :func:`picarones.report.taxonomy_intra_doc_render.build_taxonomy_intra_doc_html`
13
+ — heatmap classe × position intra-document (opt-in : nécessite des
14
+ paires gt+hyp non compactées).
15
+ - :func:`picarones.report.lexical_modernization_render.build_lexical_modernization_html`
16
+ — top-N des tokens GT modernisés par le moteur (opt-in :
17
+ nécessite la sortie de ``compute_lexical_modernization``).
18
+
19
+ Sources de données automatiques
20
+ -------------------------------
21
+ - *Comparaison* : utilise ``aggregated_taxonomy.class_distribution``
22
+ (ou ``counts``) du leader CER vs le runner-up. Disponible dès qu'au
23
+ moins 2 moteurs ont une taxonomie agrégée.
24
+
25
+ Sources de données opt-in (via ``opts``)
26
+ ----------------------------------------
27
+ - ``opts["cooccurrence"]`` : sortie de
28
+ :func:`picarones.core.taxonomy_cooccurrence.compute_taxonomy_cooccurrence`.
29
+ - ``opts["intra_doc"]`` : sortie de
30
+ :func:`picarones.core.taxonomy_intra_doc.compute_taxonomy_position_heatmap`.
31
+ - ``opts["lexical_modernization"]`` : sortie de
32
+ :func:`picarones.core.lexical_modernization.compute_lexical_modernization`
33
+ agrégée corpus-wide.
34
+
35
+ Ces calculs ne sont pas faits automatiquement par le runner standard
36
+ (coût et données nécessaires non triviaux après ``compact()``) ;
37
+ l'utilisateur peut les pré-calculer dans son workflow et les
38
+ fournir via :func:`build_advanced_taxonomy_view_html`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ from typing import Any, Optional
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def _select_two_engines_for_comparison(
50
+ engines_summary: list[dict],
51
+ ) -> Optional[tuple[dict, dict]]:
52
+ """Choisit deux moteurs à comparer dans le diagramme miroir.
53
+
54
+ Stratégie : leader CER (plus bas) vs runner-up (deuxième). Si
55
+ moins de 2 moteurs ont une ``aggregated_taxonomy`` non vide,
56
+ retourne ``None``.
57
+ """
58
+ candidates = [
59
+ e for e in engines_summary
60
+ if isinstance(e.get("aggregated_taxonomy"), dict)
61
+ and (
62
+ e["aggregated_taxonomy"].get("class_distribution")
63
+ or e["aggregated_taxonomy"].get("counts")
64
+ )
65
+ ]
66
+ if len(candidates) < 2:
67
+ return None
68
+ # Tri par CER croissant (leader = meilleur). Les moteurs sans CER
69
+ # vont en queue (clé None considérée comme inf).
70
+ candidates.sort(
71
+ key=lambda e: e.get("cer") if e.get("cer") is not None else float("inf"),
72
+ )
73
+ return candidates[0], candidates[1]
74
+
75
+
76
+ def _extract_class_counts(engine_entry: dict) -> dict[str, float]:
77
+ """Extrait le dict ``{class_name: count}`` d'une entrée moteur.
78
+
79
+ Supporte les deux formats observés en production :
80
+
81
+ - Sprint 5 historique : ``aggregated_taxonomy["class_distribution"]``
82
+ - Variante : ``aggregated_taxonomy["counts"]``
83
+ """
84
+ tax = engine_entry.get("aggregated_taxonomy") or {}
85
+ counts = tax.get("class_distribution") or tax.get("counts") or {}
86
+ if not isinstance(counts, dict):
87
+ return {}
88
+ out: dict[str, float] = {}
89
+ for k, v in counts.items():
90
+ if isinstance(v, (int, float)) and v >= 0:
91
+ out[str(k)] = float(v)
92
+ return out
93
+
94
+
95
+ def build_advanced_taxonomy_view_html(
96
+ report_data: dict,
97
+ labels: Optional[dict[str, str]] = None,
98
+ *,
99
+ cooccurrence: Optional[dict] = None,
100
+ intra_doc: Optional[dict] = None,
101
+ lexical_modernization: Optional[dict] = None,
102
+ ) -> str:
103
+ """Compose la vue taxonomique avancée du rapport.
104
+
105
+ Parameters
106
+ ----------
107
+ report_data:
108
+ Dict produit par :func:`generator._build_report_data`.
109
+ labels:
110
+ Dict i18n complet.
111
+ cooccurrence:
112
+ Sortie pré-calculée de
113
+ :func:`picarones.core.taxonomy_cooccurrence.compute_taxonomy_cooccurrence`.
114
+ Optionnel — la sous-section est masquée si non fourni.
115
+ intra_doc:
116
+ Sortie pré-calculée de
117
+ :func:`picarones.core.taxonomy_intra_doc.compute_taxonomy_position_heatmap`.
118
+ Optionnel.
119
+ lexical_modernization:
120
+ Sortie pré-calculée de
121
+ :func:`picarones.core.lexical_modernization.aggregate_lexical_modernization`.
122
+ Optionnel.
123
+
124
+ Returns
125
+ -------
126
+ str
127
+ HTML de la vue (entête + sous-sections collapsibles) ou
128
+ ``""`` si aucune sous-section n'a de contenu.
129
+ """
130
+ labels = labels or {}
131
+ blocks: list[tuple[str, str]] = []
132
+
133
+ # Sous-section 1 : comparaison des deux leaders
134
+ try:
135
+ engines_summary = report_data.get("engines") or []
136
+ pair = _select_two_engines_for_comparison(engines_summary)
137
+ if pair is not None:
138
+ from picarones.core.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
143
+ data = compare_taxonomies(
144
+ engine_a.get("name", "engine_a"),
145
+ _extract_class_counts(engine_a),
146
+ engine_b.get("name", "engine_b"),
147
+ _extract_class_counts(engine_b),
148
+ )
149
+ html = build_taxonomy_comparison_html(data, labels=labels)
150
+ if html:
151
+ blocks.append((
152
+ labels.get(
153
+ "advtax_comparison_title",
154
+ "Comparaison taxonomique (leader vs runner-up)",
155
+ ),
156
+ html,
157
+ ))
158
+ except Exception as exc: # noqa: BLE001
159
+ logger.warning(
160
+ "[advanced_taxonomy_view.comparison] dégradé : %s", exc,
161
+ )
162
+
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)
170
+ if html:
171
+ blocks.append((
172
+ labels.get(
173
+ "advtax_cooccurrence_title",
174
+ "Co-occurrence de classes d'erreurs",
175
+ ),
176
+ html,
177
+ ))
178
+ except Exception as exc: # noqa: BLE001
179
+ logger.warning(
180
+ "[advanced_taxonomy_view.cooccurrence] dégradé : %s", exc,
181
+ )
182
+
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)
190
+ if html:
191
+ blocks.append((
192
+ labels.get(
193
+ "advtax_intra_doc_title",
194
+ "Distribution intra-document des classes",
195
+ ),
196
+ html,
197
+ ))
198
+ except Exception as exc: # noqa: BLE001
199
+ logger.warning(
200
+ "[advanced_taxonomy_view.intra_doc] dégradé : %s", exc,
201
+ )
202
+
203
+ # Sous-section 4 : modernisation lexicale (opt-in)
204
+ if lexical_modernization:
205
+ try:
206
+ from picarones.report.lexical_modernization_render import (
207
+ build_lexical_modernization_html,
208
+ )
209
+ html = build_lexical_modernization_html(
210
+ lexical_modernization, labels=labels,
211
+ )
212
+ if html:
213
+ blocks.append((
214
+ labels.get(
215
+ "advtax_lexmod_title",
216
+ "Modernisation lexicale (top tokens)",
217
+ ),
218
+ html,
219
+ ))
220
+ except Exception as exc: # noqa: BLE001
221
+ logger.warning(
222
+ "[advanced_taxonomy_view.lexmod] dégradé : %s", exc,
223
+ )
224
+
225
+ if not blocks:
226
+ return ""
227
+
228
+ # Réutilise le shell partagé de la vue economics
229
+ from picarones.report.views.economics import _render_view_shell
230
+
231
+ return _render_view_shell(
232
+ view_title=labels.get(
233
+ "advtax_view_title", "Taxonomie avancée des erreurs",
234
+ ),
235
+ view_note=labels.get(
236
+ "advtax_view_note",
237
+ "Vue centrée sur l'édition critique : composition des "
238
+ "erreurs au-delà du CER global, pour décider quel moteur "
239
+ "produit des erreurs récupérables vs irrécupérables.",
240
+ ),
241
+ blocks=blocks,
242
+ )
243
+
244
+
245
+ __all__ = ["build_advanced_taxonomy_view_html"]
picarones/report/views/diagnostics.py ADDED
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vue diagnostique du rapport — chantier 3 post-Sprint 97.
2
+
3
+ Regroupe les renderers orientés *« comprendre POURQUOI on a ces
4
+ résultats »* :
5
+
6
+ - :func:`picarones.report.levers_render.build_levers_section_html`
7
+ — leviers d'amélioration éditoriale (factuels, pas prescriptifs).
8
+ - :func:`picarones.report.worst_lines_render.build_worst_lines_table_html`
9
+ — top-N des lignes du corpus avec le pire CER (toutes moteurs
10
+ confondus, opt-in : nécessite ``benchmark`` non compacté).
11
+ - :func:`picarones.report.image_predictive_render.build_image_predictive_html`
12
+ — complexité paléographique + homogénéité du corpus (opt-in :
13
+ nécessite la liste des image_qualities individuelles).
14
+ - :func:`picarones.report.baseline_render.build_corpus_difficulty_baseline_html`
15
+ — encart « ce corpus est-il habituel ? » (opt-in : nécessite
16
+ l'historique SQLite).
17
+ - :func:`picarones.report.longitudinal_render.build_longitudinal_html`
18
+ — évolution longitudinale par moteur (opt-in : idem historique).
19
+ - :func:`picarones.report.multirun_stability_render.build_multirun_stability_html`
20
+ — stabilité multi-runs (opt-in : nécessite N runs).
21
+
22
+ Sources de données automatiques
23
+ -------------------------------
24
+ - *Leviers* : :func:`picarones.core.levers.detect_levers` est appelée
25
+ sur ``report_data``. Couvre :
26
+ ``dominant_recoverable_class``, ``pareto_concentration``,
27
+ ``complementarity_observation``, ``lexical_modernization_observation``,
28
+ ``robustness_projection_observation``.
29
+
30
+ Sources de données opt-in (via ``opts``)
31
+ ----------------------------------------
32
+ - ``opts["benchmark"]`` : ``BenchmarkResult`` non compacté (worst lines).
33
+ - ``opts["image_qualities"]`` : liste de dicts image_quality par doc.
34
+ - ``opts["baseline_data"]`` : sortie de
35
+ :func:`picarones.core.baseline_comparison.compute_corpus_difficulty_percentile`.
36
+ - ``opts["longitudinal"]`` : map ``{engine: longitudinal_data}``.
37
+ - ``opts["stability"]`` : sortie de
38
+ :func:`picarones.core.reliability.compute_multirun_stability`.
39
+ """
40
+
41
+ from __future__ import annotations
42
+
43
+ import logging
44
+ from typing import Any, Optional
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+
49
+ def build_diagnostics_view_html(
50
+ report_data: dict,
51
+ labels: Optional[dict[str, str]] = None,
52
+ *,
53
+ benchmark: Optional[Any] = None,
54
+ image_qualities: Optional[list[dict]] = None,
55
+ baseline_data: Optional[dict] = None,
56
+ longitudinal: Optional[dict] = None,
57
+ stability: Optional[list[dict]] = None,
58
+ history_values: Optional[list[float]] = None,
59
+ ) -> str:
60
+ """Compose la vue diagnostique du rapport.
61
+
62
+ Parameters
63
+ ----------
64
+ report_data:
65
+ Dict produit par :func:`generator._build_report_data`.
66
+ labels:
67
+ Dict i18n complet.
68
+ benchmark:
69
+ ``BenchmarkResult`` non compacté pour la sous-section worst
70
+ lines (qui re-split les hypothèses par doc et engine).
71
+ Si ``None`` ou si les ``DocumentResult`` ont été compactés,
72
+ la sous-section est masquée.
73
+ image_qualities:
74
+ Liste de dicts ``{contrast, noise_level, blur_score, …}``
75
+ par document, pré-calculée par le runner (ex. extraction
76
+ depuis les ``EngineReport.document_results`` avant compact).
77
+ baseline_data:
78
+ Sortie de
79
+ :func:`picarones.core.baseline_comparison.compute_corpus_difficulty_percentile`.
80
+ Active l'encart « ce corpus est-il habituel ? ».
81
+ longitudinal:
82
+ Sortie de
83
+ :func:`picarones.core.longitudinal.compute_corpus_longitudinal`.
84
+ Active la table d'évolution.
85
+ stability:
86
+ Liste enrichie de ``{engine_name, ...stability_data}`` par
87
+ moteur, sortie de
88
+ :func:`picarones.core.reliability.compute_multirun_stability`.
89
+ Active la table de stabilité multi-runs.
90
+ history_values:
91
+ Valeurs historiques de difficulté du corpus, utilisées pour
92
+ rendre le boxplot dans l'encart baseline.
93
+
94
+ Returns
95
+ -------
96
+ str
97
+ HTML de la vue ou ``""`` si aucune sous-section n'a de
98
+ contenu.
99
+ """
100
+ labels = labels or {}
101
+ blocks: list[tuple[str, str]] = []
102
+
103
+ # Sous-section 1 : leviers (calculés automatiquement)
104
+ try:
105
+ from picarones.core.levers import detect_levers
106
+ from picarones.report.levers_render import build_levers_section_html
107
+ levers = detect_levers(report_data)
108
+ html = build_levers_section_html(levers, labels=labels)
109
+ if html:
110
+ blocks.append((
111
+ labels.get(
112
+ "diag_levers_title", "Leviers d'amélioration",
113
+ ),
114
+ html,
115
+ ))
116
+ except Exception as exc: # noqa: BLE001
117
+ logger.warning("[diagnostics_view.levers] dégradé : %s", exc)
118
+
119
+ # Sous-section 2 : encart baseline (opt-in via historique)
120
+ if baseline_data:
121
+ try:
122
+ from picarones.report.baseline_render import (
123
+ build_corpus_difficulty_baseline_html,
124
+ )
125
+ html = build_corpus_difficulty_baseline_html(
126
+ baseline_data,
127
+ history_values or [],
128
+ labels=labels,
129
+ )
130
+ if html:
131
+ blocks.append((
132
+ labels.get(
133
+ "diag_baseline_title",
134
+ "Comparaison historique du corpus",
135
+ ),
136
+ html,
137
+ ))
138
+ except Exception as exc: # noqa: BLE001
139
+ logger.warning("[diagnostics_view.baseline] dégradé : %s", exc)
140
+
141
+ # Sous-section 3 : profil d'image du corpus (opt-in)
142
+ if image_qualities:
143
+ try:
144
+ from picarones.core.image_predictive import (
145
+ aggregate_corpus_predictive,
146
+ )
147
+ from picarones.report.image_predictive_render import (
148
+ build_image_predictive_html,
149
+ )
150
+ aggregated = aggregate_corpus_predictive(image_qualities)
151
+ html = build_image_predictive_html(aggregated, labels=labels)
152
+ if html:
153
+ blocks.append((
154
+ labels.get(
155
+ "diag_image_predictive_title",
156
+ "Profil d'image du corpus",
157
+ ),
158
+ html,
159
+ ))
160
+ except Exception as exc: # noqa: BLE001
161
+ logger.warning(
162
+ "[diagnostics_view.image_predictive] dégradé : %s", exc,
163
+ )
164
+
165
+ # Sous-section 4 : évolution longitudinale (opt-in)
166
+ if longitudinal:
167
+ try:
168
+ from picarones.report.longitudinal_render import (
169
+ build_longitudinal_html,
170
+ )
171
+ html = build_longitudinal_html(longitudinal, labels=labels)
172
+ if html:
173
+ blocks.append((
174
+ labels.get(
175
+ "diag_longitudinal_title",
176
+ "Évolution longitudinale par moteur",
177
+ ),
178
+ html,
179
+ ))
180
+ except Exception as exc: # noqa: BLE001
181
+ logger.warning(
182
+ "[diagnostics_view.longitudinal] dégradé : %s", exc,
183
+ )
184
+
185
+ # Sous-section 5 : stabilité multi-runs (opt-in)
186
+ if stability:
187
+ try:
188
+ from picarones.report.multirun_stability_render import (
189
+ build_multirun_stability_html,
190
+ )
191
+ html = build_multirun_stability_html(stability, labels=labels)
192
+ if html:
193
+ blocks.append((
194
+ labels.get(
195
+ "diag_stability_title",
196
+ "Stabilité multi-runs",
197
+ ),
198
+ html,
199
+ ))
200
+ except Exception as exc: # noqa: BLE001
201
+ logger.warning(
202
+ "[diagnostics_view.stability] dégradé : %s", exc,
203
+ )
204
+
205
+ # Sous-section 6 : worst lines (opt-in via benchmark non compacté)
206
+ if benchmark is not None:
207
+ try:
208
+ from picarones.core.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)
213
+ html = build_worst_lines_table_html(entries, labels=labels)
214
+ if html:
215
+ blocks.append((
216
+ labels.get(
217
+ "diag_worst_lines_title",
218
+ "Lignes les pires (top 20, tous moteurs)",
219
+ ),
220
+ html,
221
+ ))
222
+ except Exception as exc: # noqa: BLE001
223
+ logger.warning(
224
+ "[diagnostics_view.worst_lines] dégradé : %s", exc,
225
+ )
226
+
227
+ if not blocks:
228
+ return ""
229
+
230
+ from picarones.report.views.economics import _render_view_shell
231
+
232
+ return _render_view_shell(
233
+ view_title=labels.get(
234
+ "diag_view_title", "Diagnostic approfondi",
235
+ ),
236
+ view_note=labels.get(
237
+ "diag_view_note",
238
+ "Vue d'aide à l'interprétation : leviers d'amélioration "
239
+ "factuels (jamais prescriptifs), profil d'image du corpus, "
240
+ "comparaison à l'historique de l'institution, et lignes "
241
+ "les pires pour inspection ciblée.",
242
+ ),
243
+ blocks=blocks,
244
+ )
245
+
246
+
247
+ __all__ = ["build_diagnostics_view_html"]
picarones/report/views/economics.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vue économique du rapport — chantier 3 post-Sprint 97.
2
+
3
+ Regroupe les renderers orientés *décision budget* :
4
+
5
+ - :func:`picarones.report.throughput_render.build_throughput_html`
6
+ — pages/h **utilisable** (raw - correction humaine), formule
7
+ HTR-United (5 s/erreur).
8
+
9
+ Renderers prévus mais nécessitant des données opt-in (cost projection
10
+ par volume, coût marginal par erreur évitée) restent non câblés ici :
11
+ ils s'activeront quand l'utilisateur fournira ``opts["target_pages"]``
12
+ et ``opts["pricing"]`` au constructeur, ou via un workflow CLI dédié
13
+ ``picarones economics``.
14
+
15
+ Adaptive masking
16
+ ----------------
17
+ La vue retourne ``""`` quand aucune sous-section n'a de signal
18
+ exploitable. Elle ne s'affiche donc dans le rapport que si au moins
19
+ un moteur a un throughput estimable (somme des durées non nulle).
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import logging
25
+ from typing import Any, Optional
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+
30
+ def _estimate_engine_throughput_inputs(
31
+ engine_reports: list,
32
+ ) -> list[dict]:
33
+ """Construit les entrées attendues par
34
+ :func:`picarones.core.throughput.aggregate_effective_throughput`
35
+ à partir des ``EngineReport`` du benchmark.
36
+
37
+ Pour chaque moteur :
38
+
39
+ - ``n_pages`` : nombre de documents traités sans erreur OCR.
40
+ - ``duration_seconds``: somme des ``duration_seconds`` des docs réussis.
41
+ - ``n_errors`` : approximation au niveau **mot** ≈
42
+ ``wer × total_words_gt``. C'est un proxy : on n'a pas l'alignement
43
+ exact, on multiplie le WER moyen par le nombre total de mots dans
44
+ la GT (toutes longueurs confondues). Cette approximation est
45
+ cohérente avec la définition du WER.
46
+
47
+ Le moteur est exclu si ``n_pages == 0`` ou si toutes les durations
48
+ sont nulles (cas d'un cache).
49
+ """
50
+ out: list[dict] = []
51
+ for report in engine_reports:
52
+ successful = [
53
+ dr for dr in report.document_results
54
+ if getattr(dr, "engine_error", None) is None
55
+ ]
56
+ if not successful:
57
+ continue
58
+ total_duration = sum(
59
+ float(getattr(dr, "duration_seconds", 0.0)) for dr in successful
60
+ )
61
+ if total_duration <= 0:
62
+ # Bench depuis cache — pas de mesure de vitesse exploitable
63
+ continue
64
+ # Estimation du nombre de mots GT total (somme des longueurs
65
+ # référence). ``MetricsResult.reference_length`` est en
66
+ # caractères ; on convertit grossièrement en mots par
67
+ # heuristique 5 caractères/mot pour l'agrégation.
68
+ total_words_gt = 0
69
+ weighted_wer = 0.0
70
+ for dr in successful:
71
+ ref_chars = getattr(dr.metrics, "reference_length", 0) or 0
72
+ ref_words = max(1, int(ref_chars / 5)) if ref_chars else 0
73
+ wer = getattr(dr.metrics, "wer", 0.0) or 0.0
74
+ total_words_gt += ref_words
75
+ weighted_wer += wer * ref_words
76
+ if total_words_gt == 0:
77
+ n_errors = 0
78
+ else:
79
+ mean_wer = weighted_wer / total_words_gt
80
+ n_errors = int(round(mean_wer * total_words_gt))
81
+ out.append({
82
+ "engine_name": report.engine_name,
83
+ "n_pages": len(successful),
84
+ "duration_seconds": total_duration,
85
+ "n_errors": max(0, n_errors),
86
+ })
87
+ return out
88
+
89
+
90
+ def build_economics_view_html(
91
+ report_data: dict,
92
+ labels: Optional[dict[str, str]] = None,
93
+ *,
94
+ engine_reports: Optional[list] = None,
95
+ time_per_error_seconds: float = 5.0,
96
+ extra_html_blocks: Optional[list[str]] = None,
97
+ ) -> str:
98
+ """Compose la vue économique du rapport.
99
+
100
+ Parameters
101
+ ----------
102
+ report_data:
103
+ Dict produit par :func:`generator._build_report_data`.
104
+ Les sous-renderers reçoivent ``labels`` directement ; cette
105
+ fonction n'extrait que les éléments qu'elle peut composer
106
+ à partir de ``report_data``.
107
+ labels:
108
+ Dict i18n complet du rapport.
109
+ engine_reports:
110
+ Liste des ``EngineReport`` du benchmark. Indispensable pour
111
+ calculer le throughput effectif (besoin des durations
112
+ document par document, non exposées dans ``report_data``).
113
+ Si ``None``, la sous-section throughput est sautée.
114
+ time_per_error_seconds:
115
+ Constante de correction humaine pour le throughput effectif
116
+ (défaut HTR-United : 5 s par erreur mot).
117
+ extra_html_blocks:
118
+ Blocs HTML déjà rendus à inclure tels quels (par exemple
119
+ cost projection par volume, fourni par un workflow CLI dédié).
120
+ Permet d'étendre la vue sans modifier ce module.
121
+
122
+ Returns
123
+ -------
124
+ str
125
+ HTML complet de la vue (entête + sous-sections collapsibles)
126
+ ou ``""`` si aucune sous-section ne produit de contenu.
127
+ """
128
+ labels = labels or {}
129
+ blocks: list[tuple[str, str]] = []
130
+
131
+ # Sous-section 1 : throughput effectif
132
+ if engine_reports:
133
+ try:
134
+ from picarones.core.throughput import (
135
+ aggregate_effective_throughput,
136
+ )
137
+ from picarones.report.throughput_render import (
138
+ build_throughput_html,
139
+ )
140
+ inputs = _estimate_engine_throughput_inputs(engine_reports)
141
+ aggregated = aggregate_effective_throughput(
142
+ inputs, time_per_error_seconds=time_per_error_seconds,
143
+ )
144
+ html = build_throughput_html(aggregated, labels=labels)
145
+ if html:
146
+ blocks.append((
147
+ labels.get("economics_throughput_title", "Throughput effectif"),
148
+ html,
149
+ ))
150
+ except Exception as exc: # noqa: BLE001
151
+ logger.warning(
152
+ "[economics_view.throughput] dégradé : %s", exc,
153
+ )
154
+
155
+ # Sous-section 2 : blocs externes (cost projection, marginal cost…)
156
+ if extra_html_blocks:
157
+ for i, html in enumerate(extra_html_blocks):
158
+ if not html:
159
+ continue
160
+ blocks.append((
161
+ labels.get(
162
+ f"economics_extra_{i}_title",
163
+ labels.get("economics_extra_title", "Coût projeté"),
164
+ ),
165
+ html,
166
+ ))
167
+
168
+ if not blocks:
169
+ return ""
170
+
171
+ return _render_view_shell(
172
+ view_title=labels.get("economics_view_title", "Coût et performance"),
173
+ view_note=labels.get(
174
+ "economics_view_note",
175
+ "Vue centrée sur la décision budget : pages traitables par "
176
+ "heure réellement utilisable (en intégrant la correction "
177
+ "humaine post-OCR), et projection de coût par volume cible.",
178
+ ),
179
+ blocks=blocks,
180
+ )
181
+
182
+
183
+ def _render_view_shell(
184
+ *,
185
+ view_title: str,
186
+ view_note: str,
187
+ blocks: list[tuple[str, str]],
188
+ ) -> str:
189
+ """Compose un shell ``<details>`` collapsible par bloc, premier ouvert.
190
+
191
+ Convention de rendu partagée par les 5 vues du chantier 3 :
192
+ chaque sous-section est un ``<details>`` natif (collapsible
193
+ sans JS), avec son sous-titre dans le ``<summary>``. Le premier
194
+ est ouvert par défaut, les autres fermés (réduit le scroll
195
+ initial).
196
+ """
197
+ from html import escape as _e
198
+ parts: list[str] = []
199
+ parts.append(
200
+ f'<h3 style="margin-top:1.5em">{_e(view_title)}</h3>'
201
+ )
202
+ if view_note:
203
+ parts.append(
204
+ f'<p style="font-size:.82rem;color:var(--text-muted);'
205
+ f'margin:.2em 0 1em">{_e(view_note)}</p>'
206
+ )
207
+ for i, (title, html) in enumerate(blocks):
208
+ open_attr = " open" if i == 0 else ""
209
+ parts.append(
210
+ f'<details{open_attr} style="margin-bottom:1em">'
211
+ f'<summary style="cursor:pointer;font-weight:600;'
212
+ f'padding:.4em 0">{_e(title)}</summary>'
213
+ f'<div style="margin-top:.5em">{html}</div>'
214
+ f'</details>'
215
+ )
216
+ return "\n".join(parts)
217
+
218
+
219
+ __all__ = ["build_economics_view_html"]
picarones/report/views/pipeline.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vue pipeline composée — chantier 3 post-Sprint 97.
2
+
3
+ Regroupe les renderers spécifiques aux benchmarks de **pipelines
4
+ composées** (axe B du plan d'évolution 2026, Sprints 63-68, 94-97) :
5
+
6
+ - :func:`picarones.report.pipeline_render.build_pipeline_summary_html`
7
+ — résumé corpus-wide (taux de succès, durée, métriques aux jonctions).
8
+ - :func:`picarones.report.pipeline_render.build_pipeline_steps_table_html`
9
+ — tableau par étape (Sprint 67).
10
+ - :func:`picarones.report.pipeline_dag_render.build_pipeline_dag_html`
11
+ — visualisation SVG du DAG avec couleur des arêtes selon la métrique.
12
+ - :func:`picarones.report.error_absorption_render.build_error_absorption_html`
13
+ — corrections vs introductions à chaque jonction (Sprint 94).
14
+ - :func:`picarones.report.incremental_comparison_render.build_incremental_comparison_html`
15
+ — effet isolé d'un slot (LLM, reconstructeur, etc.) en contrôlant
16
+ les autres (Sprint 96).
17
+ - :func:`picarones.report.module_audit_render.build_module_audit_html`
18
+ — audit de conformité des modules contribués (Sprint 97).
19
+
20
+ Cette vue ne s'applique pas au rapport standard (mono-moteur OCR
21
+ classique). Elle est appelée explicitement par le workflow
22
+ ``picarones pipeline run`` (CLI Sprint 70) et par tout outil
23
+ extérieur qui consomme un ``PipelineBenchmarkResult``.
24
+
25
+ Sources de données
26
+ ------------------
27
+ Toutes les sous-sections consomment des structures opt-in passées
28
+ en ``opts``. Aucune n'est calculée à partir de ``report_data`` —
29
+ c'est par construction (un rapport classique n'a pas de DAG).
30
+
31
+ - ``opts["pipeline_benchmark"]`` : ``PipelineBenchmarkResult`` (Sprint 64).
32
+ - ``opts["dag_nodes"]`` / ``opts["dag_labels"]`` / ``opts["dag_edges"]``
33
+ / ``opts["dag_thresholds"]`` / ``opts["dag_higher_is_better"]`` :
34
+ arguments directs de :func:`build_pipeline_dag_html`.
35
+ - ``opts["junctions"]`` : liste de jonctions avec leurs paires
36
+ ``before/after`` pour :func:`build_error_absorption_html`.
37
+ - ``opts["incremental_runs"]`` + ``opts["incremental_varying_slot"]`` :
38
+ arguments de :func:`build_incremental_comparison_html`.
39
+ - ``opts["module_audits"]`` : liste de ``(manifest, audit_result)``.
40
+ """
41
+
42
+ from __future__ import annotations
43
+
44
+ import logging
45
+ from typing import Any, Optional
46
+
47
+ logger = logging.getLogger(__name__)
48
+
49
+
50
+ def build_pipeline_view_html(
51
+ report_data: Optional[dict] = None,
52
+ labels: Optional[dict[str, str]] = None,
53
+ *,
54
+ pipeline_benchmark: Optional[Any] = None,
55
+ dag_nodes: Optional[list] = None,
56
+ dag_labels: Optional[dict[str, str]] = None,
57
+ dag_edges: Optional[list] = None,
58
+ dag_thresholds: Optional[tuple[float, float]] = None,
59
+ dag_higher_is_better: bool = False,
60
+ junctions: Optional[list[dict]] = None,
61
+ incremental_runs: Optional[list] = None,
62
+ incremental_varying_slot: Optional[str] = None,
63
+ incremental_higher_is_better: bool = False,
64
+ module_audits: Optional[list[tuple]] = None,
65
+ ) -> str:
66
+ """Compose la vue pipeline.
67
+
68
+ Parameters
69
+ ----------
70
+ report_data:
71
+ Inutilisé pour cette vue (la pipeline composée a sa propre
72
+ structure de données via ``PipelineBenchmarkResult``).
73
+ Présent dans la signature pour homogénéité avec les autres
74
+ vues du chantier 3.
75
+ labels:
76
+ Dict i18n complet.
77
+ pipeline_benchmark:
78
+ ``PipelineBenchmarkResult`` (Sprint 64) — active les sections
79
+ ``summary`` et ``steps_table`` du :mod:`pipeline_render`.
80
+ dag_nodes, dag_labels, dag_edges, dag_thresholds, dag_higher_is_better:
81
+ Arguments de :func:`build_pipeline_dag_html` (Sprint 95).
82
+ junctions:
83
+ Liste de dicts ``{junction_name, before, after, ...}`` pour
84
+ :func:`build_error_absorption_html` (Sprint 94).
85
+ incremental_runs, incremental_varying_slot, incremental_higher_is_better:
86
+ Arguments de :func:`build_incremental_comparison_html`
87
+ (Sprint 96).
88
+ module_audits:
89
+ Liste de tuples ``(ModuleManifest, AuditResult)`` pour
90
+ :func:`build_module_audit_html` (Sprint 97).
91
+
92
+ Returns
93
+ -------
94
+ str
95
+ HTML de la vue ou ``""`` si aucune sous-section opt-in
96
+ n'est fournie.
97
+ """
98
+ labels = labels or {}
99
+ blocks: list[tuple[str, str]] = []
100
+
101
+ # Sous-section 1 : résumé + steps table
102
+ if pipeline_benchmark is not None:
103
+ try:
104
+ from picarones.report.pipeline_render import (
105
+ build_pipeline_steps_table_html,
106
+ build_pipeline_summary_html,
107
+ )
108
+ summary = build_pipeline_summary_html(pipeline_benchmark)
109
+ steps = build_pipeline_steps_table_html(pipeline_benchmark)
110
+ combined = "\n".join(filter(None, [summary, steps]))
111
+ if combined:
112
+ blocks.append((
113
+ labels.get(
114
+ "pipeline_summary_title",
115
+ "Résumé de la pipeline",
116
+ ),
117
+ combined,
118
+ ))
119
+ except Exception as exc: # noqa: BLE001
120
+ logger.warning(
121
+ "[pipeline_view.summary] dégradé : %s", exc,
122
+ )
123
+
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(
131
+ nodes=dag_nodes,
132
+ labels=dag_labels or {},
133
+ edges=dag_edges,
134
+ thresholds=dag_thresholds or (0.05, 0.15),
135
+ higher_is_better=dag_higher_is_better,
136
+ )
137
+ if html:
138
+ blocks.append((
139
+ labels.get(
140
+ "pipeline_dag_title",
141
+ "Visualisation du DAG",
142
+ ),
143
+ html,
144
+ ))
145
+ except Exception as exc: # noqa: BLE001
146
+ logger.warning("[pipeline_view.dag] dégradé : %s", exc)
147
+
148
+ # Sous-section 3 : absorption d'erreur par jonction
149
+ if junctions:
150
+ try:
151
+ from picarones.report.error_absorption_render import (
152
+ build_error_absorption_html,
153
+ )
154
+ html = build_error_absorption_html(junctions, labels=labels)
155
+ if html:
156
+ blocks.append((
157
+ labels.get(
158
+ "pipeline_absorption_title",
159
+ "Absorption d'erreur par jonction",
160
+ ),
161
+ html,
162
+ ))
163
+ except Exception as exc: # noqa: BLE001
164
+ logger.warning(
165
+ "[pipeline_view.error_absorption] dégradé : %s", exc,
166
+ )
167
+
168
+ # Sous-section 4 : comparaison incrémentale (effet d'un slot)
169
+ if incremental_runs and incremental_varying_slot:
170
+ try:
171
+ from picarones.core.incremental_comparison import (
172
+ compare_isolated_effect,
173
+ )
174
+ from picarones.report.incremental_comparison_render import (
175
+ build_incremental_comparison_html,
176
+ )
177
+ comparison = compare_isolated_effect(
178
+ incremental_runs,
179
+ incremental_varying_slot,
180
+ higher_is_better=incremental_higher_is_better,
181
+ )
182
+ html = build_incremental_comparison_html(
183
+ comparison,
184
+ varying_slot=incremental_varying_slot,
185
+ labels=labels,
186
+ )
187
+ if html:
188
+ blocks.append((
189
+ labels.get(
190
+ "pipeline_incremental_title",
191
+ "Comparaison incrémentale",
192
+ ),
193
+ html,
194
+ ))
195
+ except Exception as exc: # noqa: BLE001
196
+ logger.warning(
197
+ "[pipeline_view.incremental] dégradé : %s", exc,
198
+ )
199
+
200
+ # Sous-section 5 : audit des modules contribués
201
+ if module_audits:
202
+ try:
203
+ from picarones.report.module_audit_render import (
204
+ build_module_audit_html,
205
+ )
206
+ html = build_module_audit_html(module_audits, labels=labels)
207
+ if html:
208
+ blocks.append((
209
+ labels.get(
210
+ "pipeline_audit_title",
211
+ "Audit des modules contribués",
212
+ ),
213
+ html,
214
+ ))
215
+ except Exception as exc: # noqa: BLE001
216
+ logger.warning("[pipeline_view.audit] dégradé : %s", exc)
217
+
218
+ if not blocks:
219
+ return ""
220
+
221
+ from picarones.report.views.economics import _render_view_shell
222
+
223
+ return _render_view_shell(
224
+ view_title=labels.get(
225
+ "pipeline_view_title", "Banc d'essai de pipeline composée",
226
+ ),
227
+ view_note=labels.get(
228
+ "pipeline_view_note",
229
+ "Vue spécifique aux pipelines composées (axe B) : "
230
+ "métriques aux jonctions, absorption d'erreur, comparaison "
231
+ "incrémentale par slot, audit des modules contribués.",
232
+ ),
233
+ blocks=blocks,
234
+ )
235
+
236
+
237
+ __all__ = ["build_pipeline_view_html"]
picarones/report/views/robustness.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vue robustesse — chantier 3 post-Sprint 97.
2
+
3
+ Branche le renderer :func:`picarones.report.robustness_projection_render`
4
+ (Sprint 88) au workflow ``picarones robustness`` (CLI Sprint 8).
5
+
6
+ Cette vue ne s'inclut pas dans le rapport classique : la robustesse
7
+ synthétique exige une étape de calcul lourde (re-OCR sur des
8
+ versions dégradées de chaque image) qui sort du flux standard.
9
+ Le module est exposé pour que l'orchestrateur ``robustness_cmd``
10
+ de la CLI puisse composer un mini-rapport HTML autonome.
11
+
12
+ Sources de données
13
+ ------------------
14
+ - ``opts["projection"]`` : sortie de
15
+ :func:`picarones.core.robustness_projection.project_robustness_on_corpus`.
16
+ - ``opts["aggregated"]`` : sortie de
17
+ :func:`picarones.core.robustness_projection.aggregate_projection_per_engine`.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import Any, Optional
24
+
25
+ logger = logging.getLogger(__name__)
26
+
27
+
28
+ def build_robustness_view_html(
29
+ report_data: Optional[dict] = None,
30
+ labels: Optional[dict[str, str]] = None,
31
+ *,
32
+ projection: Optional[dict] = None,
33
+ aggregated: Optional[dict] = None,
34
+ ) -> str:
35
+ """Compose la vue robustesse.
36
+
37
+ Parameters
38
+ ----------
39
+ report_data:
40
+ Inutilisé (la robustesse a son propre flux). Présent pour
41
+ homogénéité avec les autres vues du chantier 3.
42
+ labels:
43
+ Dict i18n complet.
44
+ projection:
45
+ Sortie de
46
+ :func:`picarones.core.robustness_projection.project_robustness_on_corpus`.
47
+ aggregated:
48
+ Sortie de
49
+ :func:`picarones.core.robustness_projection.aggregate_projection_per_engine`.
50
+ Si ``None`` mais ``projection`` fourni, recalculé.
51
+
52
+ Returns
53
+ -------
54
+ str
55
+ HTML de la vue ou ``""`` si pas de projection fournie.
56
+ """
57
+ if projection is None:
58
+ return ""
59
+ labels = labels or {}
60
+ blocks: list[tuple[str, str]] = []
61
+
62
+ try:
63
+ from picarones.report.robustness_projection_render import (
64
+ build_robustness_projection_html,
65
+ )
66
+ html = build_robustness_projection_html(
67
+ projection, aggregated=aggregated, labels=labels,
68
+ )
69
+ if html:
70
+ blocks.append((
71
+ labels.get(
72
+ "robust_view_title", "Déficit projeté de robustesse",
73
+ ),
74
+ html,
75
+ ))
76
+ except Exception as exc: # noqa: BLE001
77
+ logger.warning(
78
+ "[robustness_view.projection] dégradé : %s", exc,
79
+ )
80
+
81
+ if not blocks:
82
+ return ""
83
+
84
+ from picarones.report.views.economics import _render_view_shell
85
+
86
+ return _render_view_shell(
87
+ view_title=labels.get(
88
+ "robust_view_title", "Robustesse projetée sur le corpus",
89
+ ),
90
+ view_note=labels.get(
91
+ "robust_view_note",
92
+ "Projection des courbes de dégradation synthétique "
93
+ "(bruit, flou, rotation) sur les caractéristiques d'image "
94
+ "réelles du corpus. Permet d'estimer le déficit attendu "
95
+ "sans relancer un OCR coûteux par dégradation.",
96
+ ),
97
+ blocks=blocks,
98
+ )
99
+
100
+
101
+ __all__ = ["build_robustness_view_html"]
tests/test_views.py ADDED
@@ -0,0 +1,358 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests des 5 vues HTML thématiques (chantier 3 post-Sprint 97).
2
+
3
+ Couvre :
4
+
5
+ - Importation et signature des 5 vues.
6
+ - Adaptive masking : ``""`` quand aucune sous-section n'a de signal.
7
+ - Rendu HTML cohérent quand les données sont fournies.
8
+ - Anti-injection HTML sur les noms de moteurs et libellés.
9
+ - Composition correcte du shell ``<details>`` (premier ouvert,
10
+ autres fermés).
11
+ - Câblage générator → vues (les variables sont passées au template).
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any
17
+
18
+ import pytest
19
+
20
+
21
+ # ──────────────────────────────────────────────────────────────────────────
22
+ # 1. Imports + signatures
23
+ # ──────────────────────────────────────────────────────────────────────────
24
+
25
+
26
+ class TestViewsImport:
27
+ def test_all_views_import(self):
28
+ from picarones.report.views import (
29
+ build_advanced_taxonomy_view_html,
30
+ build_diagnostics_view_html,
31
+ build_economics_view_html,
32
+ build_pipeline_view_html,
33
+ build_robustness_view_html,
34
+ )
35
+ assert callable(build_advanced_taxonomy_view_html)
36
+ assert callable(build_diagnostics_view_html)
37
+ assert callable(build_economics_view_html)
38
+ assert callable(build_pipeline_view_html)
39
+ assert callable(build_robustness_view_html)
40
+
41
+
42
+ # ──────────────────────────────────────────────────────────────────────────
43
+ # 2. Adaptive masking — vues vides retournent ""
44
+ # ──────────────────────────────────────────────────────────────────────────
45
+
46
+
47
+ @pytest.fixture
48
+ def empty_report_data() -> dict:
49
+ return {"engines": []}
50
+
51
+
52
+ class TestAdaptiveMasking:
53
+ def test_economics_empty_returns_empty(self, empty_report_data):
54
+ from picarones.report.views import build_economics_view_html
55
+
56
+ assert build_economics_view_html(empty_report_data, {}) == ""
57
+
58
+ def test_advanced_taxonomy_empty_returns_empty(self, empty_report_data):
59
+ from picarones.report.views import build_advanced_taxonomy_view_html
60
+
61
+ assert build_advanced_taxonomy_view_html(empty_report_data, {}) == ""
62
+
63
+ def test_diagnostics_empty_returns_empty(self, empty_report_data):
64
+ from picarones.report.views import build_diagnostics_view_html
65
+
66
+ assert build_diagnostics_view_html(empty_report_data, {}) == ""
67
+
68
+ def test_pipeline_empty_returns_empty(self, empty_report_data):
69
+ from picarones.report.views import build_pipeline_view_html
70
+
71
+ assert build_pipeline_view_html(empty_report_data, {}) == ""
72
+
73
+ def test_robustness_empty_returns_empty(self, empty_report_data):
74
+ from picarones.report.views import build_robustness_view_html
75
+
76
+ assert build_robustness_view_html(empty_report_data, {}) == ""
77
+
78
+ def test_advanced_taxonomy_single_engine_returns_empty(self):
79
+ """La comparaison nécessite ≥ 2 moteurs."""
80
+ from picarones.report.views import build_advanced_taxonomy_view_html
81
+
82
+ single = {"engines": [{
83
+ "name": "tess",
84
+ "aggregated_taxonomy": {"class_distribution": {"x": 10}},
85
+ }]}
86
+ # Pas de comparison possible → vue masquée
87
+ assert build_advanced_taxonomy_view_html(single, {}) == ""
88
+
89
+
90
+ # ──────────────────────────────────────────────────────────────────────────
91
+ # 3. Rendu HTML quand données fournies
92
+ # ──────────────────────────────────────────────────────────────────────────
93
+
94
+
95
+ class _MockMetrics:
96
+ def __init__(self, *, cer=0.05, wer=0.1, reference_length=500):
97
+ self.cer = cer
98
+ self.wer = wer
99
+ self.reference_length = reference_length
100
+ self.error = None
101
+
102
+
103
+ class _MockDocResult:
104
+ def __init__(self, duration=1.0):
105
+ self.engine_error = None
106
+ self.duration_seconds = duration
107
+ self.metrics = _MockMetrics()
108
+
109
+
110
+ class _MockEngineReport:
111
+ def __init__(self, name, n_docs=10):
112
+ self.engine_name = name
113
+ self.document_results = [_MockDocResult() for _ in range(n_docs)]
114
+
115
+
116
+ class TestEconomicsView:
117
+ def test_throughput_with_realistic_engines(self):
118
+ from picarones.report.views import build_economics_view_html
119
+
120
+ reports = [
121
+ _MockEngineReport("tesseract"),
122
+ _MockEngineReport("pero_ocr"),
123
+ ]
124
+ html = build_economics_view_html(
125
+ {"engines": []}, {},
126
+ engine_reports=reports,
127
+ )
128
+ assert html != ""
129
+ # Les deux moteurs doivent apparaître dans le HTML
130
+ assert "tesseract" in html
131
+ assert "pero" in html
132
+
133
+ def test_extra_html_blocks_appended(self):
134
+ from picarones.report.views import build_economics_view_html
135
+
136
+ extra = ['<div class="custom">CUSTOM_BLOCK</div>']
137
+ html = build_economics_view_html(
138
+ {"engines": []},
139
+ {"economics_extra_title": "Coût projeté"},
140
+ engine_reports=[_MockEngineReport("tess")],
141
+ extra_html_blocks=extra,
142
+ )
143
+ assert "CUSTOM_BLOCK" in html
144
+
145
+ def test_zero_duration_excludes_engine(self):
146
+ """Bench depuis cache (durations=0) ne génère pas de throughput."""
147
+ from picarones.report.views import build_economics_view_html
148
+
149
+ report = _MockEngineReport("cached")
150
+ for dr in report.document_results:
151
+ dr.duration_seconds = 0.0
152
+ html = build_economics_view_html(
153
+ {"engines": []}, {}, engine_reports=[report],
154
+ )
155
+ # Aucun moteur n'a de durée → vue masquée
156
+ assert html == ""
157
+
158
+
159
+ class TestAdvancedTaxonomyView:
160
+ def test_two_engines_taxonomy_compared(self):
161
+ from picarones.report.views import build_advanced_taxonomy_view_html
162
+
163
+ report_data = {
164
+ "engines": [
165
+ {
166
+ "name": "tess", "cer": 0.05,
167
+ "aggregated_taxonomy": {
168
+ "class_distribution": {
169
+ "case_error": 100, "ligature_error": 50,
170
+ "lacuna": 30,
171
+ },
172
+ },
173
+ },
174
+ {
175
+ "name": "pero", "cer": 0.07,
176
+ "aggregated_taxonomy": {
177
+ "class_distribution": {
178
+ "case_error": 30, "lacuna": 80,
179
+ "diacritic_error": 60,
180
+ },
181
+ },
182
+ },
183
+ ],
184
+ }
185
+ html = build_advanced_taxonomy_view_html(report_data, {})
186
+ assert html != ""
187
+ # Le diagramme miroir doit nommer les 2 moteurs
188
+ assert "tess" in html
189
+ assert "pero" in html
190
+
191
+ def test_anti_injection_engine_name(self):
192
+ """Un nom de moteur avec balises HTML doit être échappé."""
193
+ from picarones.report.views import build_advanced_taxonomy_view_html
194
+
195
+ report_data = {
196
+ "engines": [
197
+ {
198
+ "name": "<script>alert(1)</script>",
199
+ "cer": 0.05,
200
+ "aggregated_taxonomy": {
201
+ "class_distribution": {"case_error": 10},
202
+ },
203
+ },
204
+ {
205
+ "name": "pero",
206
+ "cer": 0.07,
207
+ "aggregated_taxonomy": {
208
+ "class_distribution": {"lacuna": 10},
209
+ },
210
+ },
211
+ ],
212
+ }
213
+ html = build_advanced_taxonomy_view_html(report_data, {})
214
+ # Pas de balise script non échappée
215
+ assert "<script>alert" not in html
216
+ # Mais le contenu doit être présent sous forme échappée
217
+ assert "&lt;script" in html or "alert" not in html.lower()
218
+
219
+ def test_lexical_modernization_optional(self):
220
+ from picarones.report.views import build_advanced_taxonomy_view_html
221
+
222
+ report_data = {
223
+ "engines": [
224
+ {
225
+ "name": "tess", "cer": 0.05,
226
+ "aggregated_taxonomy": {
227
+ "class_distribution": {"case_error": 10},
228
+ },
229
+ },
230
+ {
231
+ "name": "pero", "cer": 0.07,
232
+ "aggregated_taxonomy": {
233
+ "class_distribution": {"case_error": 5},
234
+ },
235
+ },
236
+ ],
237
+ }
238
+ # Sans lexical_modernization, la sous-section n'apparaît pas
239
+ html_no = build_advanced_taxonomy_view_html(report_data, {})
240
+ # Avec, elle apparaît
241
+ lex_data = {
242
+ "per_token": {
243
+ "maistre": {
244
+ "n_total": 10, "n_modernized": 8,
245
+ "rate_modernized": 0.8,
246
+ "variants": [{"token": "maître", "count": 8}],
247
+ },
248
+ },
249
+ }
250
+ html_yes = build_advanced_taxonomy_view_html(
251
+ report_data, {}, lexical_modernization=lex_data,
252
+ )
253
+ # Au moins une section de plus
254
+ assert len(html_yes) > len(html_no)
255
+
256
+
257
+ class TestDiagnosticsView:
258
+ def test_levers_only_when_signal(self):
259
+ """detect_levers doit être appelé. Si rien ne déclenche, vue masquée."""
260
+ from picarones.report.views import build_diagnostics_view_html
261
+
262
+ # report_data minimal — aucun levier ne devrait se déclencher
263
+ empty = {"engines": []}
264
+ assert build_diagnostics_view_html(empty, {}) == ""
265
+
266
+ def test_image_predictive_with_qualities(self):
267
+ from picarones.report.views import build_diagnostics_view_html
268
+
269
+ # Liste d'image_qualities synthétiques (>= 1 doc)
270
+ qualities = [
271
+ {
272
+ "contrast": 0.8, "noise_level": 0.2,
273
+ "blur_score": 0.1, "estimated_dpi": 300,
274
+ "rotation_estimate": 0.5, "low_contrast_pct": 0.05,
275
+ },
276
+ {
277
+ "contrast": 0.6, "noise_level": 0.4,
278
+ "blur_score": 0.3, "estimated_dpi": 250,
279
+ "rotation_estimate": 1.0, "low_contrast_pct": 0.10,
280
+ },
281
+ ]
282
+ html = build_diagnostics_view_html(
283
+ {"engines": []}, {}, image_qualities=qualities,
284
+ )
285
+ # La section image_predictive doit s'afficher
286
+ assert html != ""
287
+
288
+
289
+ # ──────────────────────────────────────────────────────────────────────────
290
+ # 4. Composition du shell <details>
291
+ # ──────────────────────────────────────────────────────────────────────────
292
+
293
+
294
+ class TestDetailsShell:
295
+ def test_first_block_open_others_closed(self):
296
+ from picarones.report.views.economics import _render_view_shell
297
+
298
+ html = _render_view_shell(
299
+ view_title="Test",
300
+ view_note="Note",
301
+ blocks=[("A", "<p>aaa</p>"), ("B", "<p>bbb</p>"), ("C", "<p>ccc</p>")],
302
+ )
303
+ # Le premier <details> doit être ouvert
304
+ details = html.split("<details")
305
+ assert "open" in details[1].split(">")[0]
306
+ # Les suivants ne doivent pas l'être
307
+ assert "open" not in details[2].split(">")[0]
308
+ assert "open" not in details[3].split(">")[0]
309
+ # Tous les contenus présents
310
+ assert "aaa" in html and "bbb" in html and "ccc" in html
311
+
312
+ def test_xml_chars_in_titles_escaped(self):
313
+ from picarones.report.views.economics import _render_view_shell
314
+
315
+ html = _render_view_shell(
316
+ view_title="<script>alert(1)</script>",
317
+ view_note="Note <b>bold</b>",
318
+ blocks=[("Block <X>", "<p>content</p>")],
319
+ )
320
+ # Pas d'injection
321
+ assert "<script>alert(1)</script>" not in html
322
+ # Mais visible sous forme échappée
323
+ assert "&lt;script" in html
324
+
325
+
326
+ # ──────────────────────────────────────────────────────────────────────────
327
+ # 5. Câblage générator → vues
328
+ # ──────────────────────────────────────────────────────────────────────────
329
+
330
+
331
+ class TestGeneratorWiring:
332
+ def test_generator_imports_three_views(self):
333
+ """generator.py doit importer les 3 vues automatiques (economics,
334
+ advanced_taxonomy, diagnostics) pour les passer au template."""
335
+ from pathlib import Path
336
+
337
+ gen_src = (
338
+ Path(__file__).parent.parent / "picarones" / "report" / "generator.py"
339
+ ).read_text(encoding="utf-8")
340
+ # Les 3 imports doivent être présents
341
+ assert "build_economics_view_html" in gen_src
342
+ assert "build_advanced_taxonomy_view_html" in gen_src
343
+ assert "build_diagnostics_view_html" in gen_src
344
+ # Et les 3 variables doivent être passées au template
345
+ assert "economics_view_html=" in gen_src
346
+ assert "advanced_taxonomy_view_html=" in gen_src
347
+ assert "diagnostics_view_html=" in gen_src
348
+
349
+ def test_template_uses_three_views(self):
350
+ from pathlib import Path
351
+
352
+ tpl_src = (
353
+ Path(__file__).parent.parent
354
+ / "picarones" / "report" / "templates" / "view_analyses.html"
355
+ ).read_text(encoding="utf-8")
356
+ assert "{% if economics_view_html %}" in tpl_src
357
+ assert "{% if advanced_taxonomy_view_html %}" in tpl_src
358
+ assert "{% if diagnostics_view_html %}" in tpl_src