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"]