Picarones / picarones /report /longitudinal_render.py
Claude
refactor(report): consolidate 27 render helpers into render_helpers.py
2d6c41d unverified
Raw
History Blame
6.02 kB
"""Rendu HTML « Évolution dans le temps » — Sprint 92 (A.II.9).
Suite directe ``picarones/core/longitudinal.py``. Pattern
identique aux autres rendus : server-side, pas de JS, anti-
injection systématique.
Vue
---
Tableau résumé moteur × {n_runs, premier CER, dernier CER,
variation cumulée colorée, pente annualisée, R², point de
rupture si détecté}.
Adaptive : ``""`` si la liste est vide.
Note d'intégration
------------------
Module pur — l'utilisateur compose :
.. code-block:: python
from picarones.measurements.history import BenchmarkHistory
from picarones.measurements.longitudinal import compute_corpus_longitudinal
from picarones.report.longitudinal_render import build_longitudinal_html
hist = BenchmarkHistory(db_path)
entries = hist.list_entries()
trends = compute_corpus_longitudinal(entries, corpus_name)
html = build_longitudinal_html(trends, labels)
"""
from __future__ import annotations
from html import escape as _e
from typing import Optional
from picarones.report.render_helpers import color_diverging
def _bg_for_cer_delta(delta_pct: float) -> str:
"""Cellule colorée pour un delta de CER en points de pourcentage :
vert si delta ≈ 0, orange/rouge en régression, bleu en amélioration.
Saturation à ±5 points.
"""
if abs(delta_pct) < 1.0:
return "#a7f0a7"
return color_diverging(
delta_pct,
max_abs=5.0,
neutral_rgb=(167, 240, 167),
positive_rgb=(220, 50, 50),
negative_rgb=(90, 160, 210),
)
def build_longitudinal_html(
trends: Optional[list],
labels: Optional[dict[str, str]] = None,
) -> str:
"""Construit la vue HTML longitudinale.
Parameters
----------
trends:
Sortie de ``compute_corpus_longitudinal`` (liste de
dicts). Si ``None`` ou vide, retourne ``""``.
labels:
Dict i18n. Clés sous le préfixe ``longitudinal_*``.
"""
if not trends:
return ""
rows = [t for t in trends if isinstance(t, dict) and t.get("engine_name")]
if not rows:
return ""
labels = labels or {}
title = labels.get(
"longitudinal_title", "Évolution dans le temps",
)
note = labels.get(
"longitudinal_note",
"Tendance et points de rupture sur l'historique SQLite "
"des runs précédents. Une variation positive signale "
"une dégradation cumulée — utile pour relier une "
"régression à un changement de pipeline ou de modèle.",
)
h_engine = labels.get("longitudinal_engine", "Moteur")
h_n_runs = labels.get("longitudinal_n_runs", "Runs")
h_first = labels.get("longitudinal_first", "Premier CER")
h_last = labels.get("longitudinal_last", "Dernier CER")
h_delta = labels.get("longitudinal_delta", "Δ cumulé (pts)")
h_slope = labels.get("longitudinal_slope", "Pente annuelle (pts/an)")
h_r2 = labels.get("longitudinal_r2", "R²")
h_change = labels.get("longitudinal_change", "Rupture")
parts = [
'<section class="longitudinal-section" style="margin:1rem 0">',
f'<h3 style="margin:0 0 .3rem 0">{_e(title)}</h3>',
f'<div style="font-size:.85rem;opacity:.75;margin-bottom:.6rem">'
f'{_e(note)}</div>',
'<table style="border-collapse:collapse;width:100%;'
'font-size:.9rem">',
'<thead><tr>',
]
for col in (h_engine, h_n_runs, h_first, h_last, h_delta,
h_slope, h_r2, h_change):
parts.append(
f'<th scope=\"col\" style="padding:.4rem .6rem;text-align:left;'
f'border-bottom:1px solid #ccc;font-weight:600">'
f'{_e(col)}</th>'
)
parts.append("</tr></thead><tbody>")
for entry in sorted(
rows,
key=lambda r: -float(r.get("absolute_delta") or 0.0),
):
engine = str(entry.get("engine_name") or "?")
n_runs = int(entry.get("n_runs") or 0)
first_cer = float(entry.get("first_cer") or 0.0)
last_cer = float(entry.get("last_cer") or 0.0)
delta_pct = float(entry.get("absolute_delta_pct") or 0.0)
delta_color = _bg_for_cer_delta(delta_pct)
trend = entry.get("trend") or {}
slope = trend.get("slope")
r2 = trend.get("r_squared")
slope_str = (
f"{float(slope) * 365 * 100:+.2f}"
if isinstance(slope, (int, float)) else "—"
)
r2_str = (
f"{float(r2):.2f}"
if isinstance(r2, (int, float)) else "—"
)
cp = entry.get("change_point")
if isinstance(cp, dict) and cp.get("timestamp"):
cp_delta = float(cp.get("delta") or 0.0)
cp_str = (
f'{_e(str(cp["timestamp"]))} '
f'<span style="opacity:.75">'
f'({cp_delta * 100:+.2f} pts)</span>'
)
else:
cp_str = "—"
parts.append(
f'<tr>'
f'<td style="padding:.4rem .6rem">{_e(engine)}</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'font-family:monospace">{n_runs}</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'font-family:monospace">{first_cer * 100:.2f}%</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'font-family:monospace">{last_cer * 100:.2f}%</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'background:{delta_color};font-family:monospace;'
f'font-weight:600">{delta_pct:+.2f}</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'font-family:monospace">{slope_str}</td>'
f'<td style="padding:.4rem .6rem;text-align:right;'
f'font-family:monospace">{r2_str}</td>'
f'<td style="padding:.4rem .6rem">{cp_str}</td>'
f'</tr>'
)
parts.append("</tbody></table></section>")
return "".join(parts)
__all__ = ["build_longitudinal_html"]