Spaces:
Sleeping
Sleeping
File size: 7,983 Bytes
fe6661c c0f7ba9 fe6661c 979f3c3 fe6661c 979f3c3 fe6661c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 | """Vue économique du rapport — chantier 3 post-Sprint 97.
Regroupe les renderers orientés *décision budget* :
- :func:`picarones.report.throughput_render.build_throughput_html`
— pages/h **utilisable** (raw - correction humaine), formule
HTR-United (5 s/erreur).
Renderers prévus mais nécessitant des données opt-in (cost projection
par volume, coût marginal par erreur évitée) restent non câblés ici :
ils s'activeront quand l'utilisateur fournira ``opts["target_pages"]``
et ``opts["pricing"]`` au constructeur, ou via un workflow CLI dédié
``picarones economics``.
Adaptive masking
----------------
La vue retourne ``""`` quand aucune sous-section n'a de signal
exploitable. Elle ne s'affiche donc dans le rapport que si au moins
un moteur a un throughput estimable (somme des durées non nulle).
"""
from __future__ import annotations
import logging
from typing import Optional
logger = logging.getLogger(__name__)
def _estimate_engine_throughput_inputs(
engine_reports: list,
) -> list[dict]:
"""Construit les entrées attendues par
:func:`picarones.measurements.throughput.aggregate_effective_throughput`
à partir des ``EngineReport`` du benchmark.
Pour chaque moteur :
- ``n_pages`` : nombre de documents traités sans erreur OCR.
- ``duration_seconds``: somme des ``duration_seconds`` des docs réussis.
- ``n_errors`` : approximation au niveau **mot** ≈
``wer × total_words_gt``. C'est un proxy : on n'a pas l'alignement
exact, on multiplie le WER moyen par le nombre total de mots dans
la GT (toutes longueurs confondues). Cette approximation est
cohérente avec la définition du WER.
Le moteur est exclu si ``n_pages == 0`` ou si toutes les durations
sont nulles (cas d'un cache).
"""
out: list[dict] = []
for report in engine_reports:
successful = [
dr for dr in report.document_results
if getattr(dr, "engine_error", None) is None
]
if not successful:
continue
total_duration = sum(
float(getattr(dr, "duration_seconds", 0.0)) for dr in successful
)
if total_duration <= 0:
# Bench depuis cache — pas de mesure de vitesse exploitable
continue
# Estimation du nombre de mots GT total (somme des longueurs
# référence). ``MetricsResult.reference_length`` est en
# caractères ; on convertit grossièrement en mots par
# heuristique 5 caractères/mot pour l'agrégation.
total_words_gt = 0
weighted_wer = 0.0
for dr in successful:
ref_chars = getattr(dr.metrics, "reference_length", 0) or 0
ref_words = max(1, int(ref_chars / 5)) if ref_chars else 0
wer = getattr(dr.metrics, "wer", 0.0) or 0.0
total_words_gt += ref_words
weighted_wer += wer * ref_words
if total_words_gt == 0:
n_errors = 0
else:
mean_wer = weighted_wer / total_words_gt
n_errors = int(round(mean_wer * total_words_gt))
out.append({
"engine_name": report.engine_name,
"n_pages": len(successful),
"duration_seconds": total_duration,
"n_errors": max(0, n_errors),
})
return out
def build_economics_view_html(
report_data: dict,
labels: Optional[dict[str, str]] = None,
*,
engine_reports: Optional[list] = None,
time_per_error_seconds: float = 5.0,
extra_html_blocks: Optional[list[str]] = None,
) -> str:
"""Compose la vue économique du rapport.
Parameters
----------
report_data:
Dict produit par :func:`generator._build_report_data`.
Les sous-renderers reçoivent ``labels`` directement ; cette
fonction n'extrait que les éléments qu'elle peut composer
à partir de ``report_data``.
labels:
Dict i18n complet du rapport.
engine_reports:
Liste des ``EngineReport`` du benchmark. Indispensable pour
calculer le throughput effectif (besoin des durations
document par document, non exposées dans ``report_data``).
Si ``None``, la sous-section throughput est sautée.
time_per_error_seconds:
Constante de correction humaine pour le throughput effectif
(défaut HTR-United : 5 s par erreur mot).
extra_html_blocks:
Blocs HTML déjà rendus à inclure tels quels (par exemple
cost projection par volume, fourni par un workflow CLI dédié).
Permet d'étendre la vue sans modifier ce module.
Returns
-------
str
HTML complet de la vue (entête + sous-sections collapsibles)
ou ``""`` si aucune sous-section ne produit de contenu.
"""
labels = labels or {}
blocks: list[tuple[str, str]] = []
# Sous-section 1 : throughput effectif
if engine_reports:
try:
from picarones.measurements.throughput import (
aggregate_effective_throughput,
)
from picarones.report.throughput_render import (
build_throughput_html,
)
inputs = _estimate_engine_throughput_inputs(engine_reports)
aggregated = aggregate_effective_throughput(
inputs, time_per_error_seconds=time_per_error_seconds,
)
html = build_throughput_html(aggregated, labels=labels)
if html:
blocks.append((
labels.get("economics_throughput_title", "Throughput effectif"),
html,
))
except Exception as exc: # noqa: BLE001
logger.warning(
"[economics_view.throughput] dégradé : %s", exc,
)
# Sous-section 2 : blocs externes (cost projection, marginal cost…)
if extra_html_blocks:
for i, html in enumerate(extra_html_blocks):
if not html:
continue
blocks.append((
labels.get(
f"economics_extra_{i}_title",
labels.get("economics_extra_title", "Coût projeté"),
),
html,
))
if not blocks:
return ""
return _render_view_shell(
view_title=labels.get("economics_view_title", "Coût et performance"),
view_note=labels.get(
"economics_view_note",
"Vue centrée sur la décision budget : pages traitables par "
"heure réellement utilisable (en intégrant la correction "
"humaine post-OCR), et projection de coût par volume cible.",
),
blocks=blocks,
)
def _render_view_shell(
*,
view_title: str,
view_note: str,
blocks: list[tuple[str, str]],
) -> str:
"""Compose un shell ``<details>`` collapsible par bloc, premier ouvert.
Convention de rendu partagée par les 5 vues du chantier 3 :
chaque sous-section est un ``<details>`` natif (collapsible
sans JS), avec son sous-titre dans le ``<summary>``. Le premier
est ouvert par défaut, les autres fermés (réduit le scroll
initial).
"""
from html import escape as _e
parts: list[str] = []
parts.append(
f'<h3 style="margin-top:1.5em">{_e(view_title)}</h3>'
)
if view_note:
parts.append(
f'<p style="font-size:.82rem;color:var(--text-muted);'
f'margin:.2em 0 1em">{_e(view_note)}</p>'
)
for i, (title, html) in enumerate(blocks):
open_attr = " open" if i == 0 else ""
parts.append(
f'<details{open_attr} style="margin-bottom:1em">'
f'<summary style="cursor:pointer;font-weight:600;'
f'padding:.4em 0">{_e(title)}</summary>'
f'<div style="margin-top:.5em">{html}</div>'
f'</details>'
)
return "\n".join(parts)
__all__ = ["build_economics_view_html"]
|