File size: 11,596 Bytes
052fb51
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
"""Modélisation des coûts — APIs cloud et temps d'inférence local.

Sert uniquement à la vue Pareto coût/qualité du rapport (Sprint 5).
Les prix sont indicatifs et vieillissent vite : voir ``picarones/data/pricing.yaml``
pour les hypothèses, dates et URLs de référence.

Conventions
-----------
- Unité monétaire : EUR (conversion indicative depuis USD quand applicable).
- Coût exprimé par **1 000 pages** traitées.
- Coût local = temps moyen d'inférence × taux horaire (paramétrable).
- Empreinte carbone optionnelle : kWh × intensité g CO₂/kWh du réseau
  d'exécution (mix France bas carbone par défaut pour le local,
  moyenne cloud hyperscaler pour les APIs).
"""

from __future__ import annotations

import logging
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional

import yaml

logger = logging.getLogger(__name__)

# Sprint A14-S10 — chemin ajusté après déplacement de
# ``picarones/measurements/pricing.py`` vers
# ``picarones/evaluation/metrics/pricing.py``.  Le YAML reste dans
# ``picarones/data/``, donc on remonte de 3 niveaux au lieu de 2.
_DEFAULT_PRICING_PATH = Path(__file__).parent.parent.parent / "data" / "pricing.yaml"


@dataclass(frozen=True)
class PricingDefaults:
    """Valeurs par défaut du fichier de prix (section ``meta``)."""

    last_updated: Optional[str] = None
    currency: str = "EUR"
    hourly_rate_local_cpu_eur: float = 0.08
    hourly_rate_local_gpu_eur: float = 1.20
    grid_intensity_local: float = 58.0
    grid_intensity_cloud: float = 380.0


@dataclass
class EngineCost:
    """Coût estimé d'un moteur sur 1 000 pages, avec traçabilité des hypothèses.

    La représentation est immuable après construction : une fois que l'utilisateur
    a choisi un taux horaire local, toutes les instances partagent cette
    hypothèse par injection explicite dans ``build_costs_for_benchmark``.
    """

    engine_key: str
    """Nom ou modèle servant de clé dans la table (ex. ``"gpt-4o"``, ``"tesseract"``)."""

    type: str  # "local" | "cloud_api" | "unknown"

    cost_per_1k_pages_eur: Optional[float] = None
    """Coût par 1 000 pages en euros. ``None`` si les données sont insuffisantes."""

    currency: str = "EUR"

    # Source / date
    pricing_source_url: Optional[str] = None
    pricing_date: Optional[str] = None

    # Pour les APIs cloud : prix brut
    api_price_per_1k_pages: Optional[float] = None

    # Pour le local : temps d'inférence et taux horaire utilisés
    local_mean_seconds_per_page: Optional[float] = None
    hourly_rate_eur: Optional[float] = None

    # Empreinte carbone (estimation — étiquetée "expérimentale" dans le rapport)
    kwh_per_1k_pages: Optional[float] = None
    grid_intensity_g_co2_per_kwh: Optional[float] = None
    co2_per_1k_pages_g: Optional[float] = None

    notes: Optional[str] = None

    assumptions: list[str] = field(default_factory=list)
    """Liste d'hypothèses textuelles à afficher sous le graphique."""

    def as_dict(self) -> dict:
        return {
            "engine_key": self.engine_key,
            "type": self.type,
            "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur,
            "currency": self.currency,
            "pricing_source_url": self.pricing_source_url,
            "pricing_date": self.pricing_date,
            "api_price_per_1k_pages": self.api_price_per_1k_pages,
            "local_mean_seconds_per_page": self.local_mean_seconds_per_page,
            "hourly_rate_eur": self.hourly_rate_eur,
            "kwh_per_1k_pages": self.kwh_per_1k_pages,
            "grid_intensity_g_co2_per_kwh": self.grid_intensity_g_co2_per_kwh,
            "co2_per_1k_pages_g": self.co2_per_1k_pages_g,
            "notes": self.notes,
            "assumptions": list(self.assumptions),
        }


def load_pricing_database(path: Optional[Path] = None) -> tuple[PricingDefaults, dict]:
    """Charge la table de prix YAML.

    Retourne ``(defaults, engines_table)`` où ``engines_table`` est un dict
    ``{engine_key: raw_entry}``.
    """
    path = Path(path) if path else _DEFAULT_PRICING_PATH
    if not path.exists():
        logger.warning("[pricing] fichier %s introuvable", path)
        return PricingDefaults(), {}
    try:
        with path.open(encoding="utf-8") as fh:
            data = yaml.safe_load(fh) or {}
    except yaml.YAMLError as e:
        logger.warning("[pricing] échec parsing %s : %s", path, e)
        return PricingDefaults(), {}

    meta = data.get("meta", {}) or {}
    defaults = PricingDefaults(
        last_updated=meta.get("last_updated"),
        currency=meta.get("currency", "EUR"),
        hourly_rate_local_cpu_eur=float(meta.get("default_hourly_rate_local_cpu_eur", 0.08)),
        hourly_rate_local_gpu_eur=float(meta.get("default_hourly_rate_local_gpu_eur", 1.20)),
        grid_intensity_local=float(meta.get("default_grid_intensity_g_co2_per_kwh", 58.0)),
        grid_intensity_cloud=float(meta.get("cloud_grid_intensity_g_co2_per_kwh", 380.0)),
    )
    engines_table = data.get("engines", {}) or {}
    return defaults, engines_table


def _match_key(engine_name: str, llm_model: Optional[str], table: dict) -> Optional[str]:
    """Cherche la meilleure clé pour ce moteur dans la table.

    Stratégie : d'abord le nom du modèle LLM (pour les pipelines), puis le
    nom OCR, puis un match partiel (substring) comme filet de sécurité.
    """
    candidates = [llm_model, engine_name]
    for c in candidates:
        if c and c in table:
            return c
    # Matching partiel — utile pour "tesseract → gpt-4o" ou "gpt-4o-vision"
    for c in candidates:
        if not c:
            continue
        for key in table:
            if key in c:
                return key
    return None


def estimate_cost(
    engine_name: str,
    *,
    llm_model: Optional[str] = None,
    is_pipeline: bool = False,
    measured_seconds_per_page: Optional[float] = None,
    table: Optional[dict] = None,
    defaults: Optional[PricingDefaults] = None,
    hourly_rate_override_eur: Optional[float] = None,
) -> EngineCost:
    """Calcule le ``EngineCost`` pour un moteur donné.

    Parameters
    ----------
    engine_name:
        Nom public du moteur (ex. ``"tesseract"``, ``"tesseract → gpt-4o"``).
    llm_model:
        Si pipeline OCR+LLM, le modèle LLM utilisé — prioritaire pour la
        lookup car c'est lui qui domine le coût.
    is_pipeline:
        Indique un pipeline OCR+LLM (change la sémantique de lookup).
    measured_seconds_per_page:
        Temps moyen observé sur le benchmark courant. Remplace la valeur
        indicative de la table si fournie (plus fiable).
    table, defaults:
        Overrides pour tests ou usage institutionnel.
    hourly_rate_override_eur:
        Taux horaire à utiliser pour le calcul local (sinon valeur table
        ou défaut).
    """
    if table is None or defaults is None:
        _defaults, _table = load_pricing_database()
        defaults = defaults or _defaults
        table = table or _table

    key = _match_key(engine_name, llm_model if is_pipeline else None, table)
    if key is None:
        return EngineCost(
            engine_key=engine_name,
            type="unknown",
            assumptions=["Aucune entrée dans la table de prix pour ce moteur."],
        )

    entry = table[key]
    etype = str(entry.get("type", "unknown"))
    notes = entry.get("notes")
    assumptions: list[str] = []
    currency = defaults.currency

    cost_eur: Optional[float] = None
    api_price: Optional[float] = None
    local_seconds = measured_seconds_per_page
    hourly_rate = None

    if etype == "cloud_api":
        api_price = entry.get("api_price_per_1k_pages")
        if api_price is not None:
            cost_eur = float(api_price)
            assumptions.append(
                f"Prix API indicatif : {cost_eur:.2f} €/1000 pages "
                f"(source : {entry.get('pricing_source_url', '—')}, {entry.get('pricing_date', 'date inconnue')})."
            )
    elif etype == "local":
        indicative_seconds = entry.get("local_mean_seconds_per_page")
        if local_seconds is None and indicative_seconds is not None:
            local_seconds = float(indicative_seconds)
            assumptions.append(
                f"Temps d'inférence indicatif : {local_seconds:.1f} s/page (non mesuré sur ce benchmark)."
            )
        elif local_seconds is not None:
            assumptions.append(
                f"Temps d'inférence mesuré : {local_seconds:.1f} s/page (moyenne sur le corpus)."
            )

        hourly_rate = (
            hourly_rate_override_eur
            if hourly_rate_override_eur is not None
            else entry.get("hourly_rate_override_eur")
        )
        if hourly_rate is None:
            # Heuristique : si l'entrée précise un override GPU, sinon CPU
            hourly_rate = (
                defaults.hourly_rate_local_gpu_eur
                if "gpu" in str(notes or "").lower()
                else defaults.hourly_rate_local_cpu_eur
            )
        hourly_rate = float(hourly_rate)

        if local_seconds is not None and hourly_rate is not None:
            cost_eur = (local_seconds / 3600.0) * hourly_rate * 1000.0
            assumptions.append(
                f"Taux horaire appliqué : {hourly_rate:.2f} €/h "
                f"(défaut {'GPU' if hourly_rate >= 0.5 else 'CPU'})."
            )

    # Empreinte carbone optionnelle
    kwh_1k = entry.get("kwh_per_1k_pages")
    grid = (
        entry.get("grid_intensity_g_co2_per_kwh")
        or (defaults.grid_intensity_cloud if etype == "cloud_api" else defaults.grid_intensity_local)
    )
    co2_g = None
    if kwh_1k is not None and grid is not None:
        co2_g = float(kwh_1k) * float(grid)

    return EngineCost(
        engine_key=key,
        type=etype,
        cost_per_1k_pages_eur=cost_eur,
        currency=currency,
        pricing_source_url=entry.get("pricing_source_url"),
        pricing_date=entry.get("pricing_date"),
        api_price_per_1k_pages=api_price,
        local_mean_seconds_per_page=local_seconds,
        hourly_rate_eur=hourly_rate,
        kwh_per_1k_pages=float(kwh_1k) if kwh_1k is not None else None,
        grid_intensity_g_co2_per_kwh=float(grid) if grid is not None else None,
        co2_per_1k_pages_g=co2_g,
        notes=notes,
        assumptions=assumptions,
    )


def build_costs_for_benchmark(
    engines_summary: list[dict],
    durations_by_engine: dict[str, float],
    *,
    hourly_rate_local_eur: Optional[float] = None,
    pricing_path: Optional[Path] = None,
) -> dict[str, dict]:
    """Calcule le coût de chaque moteur d'un benchmark.

    Returns
    -------
    dict ``{engine_name: EngineCost.as_dict()}``.
    """
    defaults, table = load_pricing_database(pricing_path)
    out: dict[str, dict] = {}
    for e in engines_summary:
        name = e.get("name")
        if not name:
            continue
        measured = durations_by_engine.get(name)
        llm_model = None
        pipeline_info = e.get("pipeline_info") or {}
        if pipeline_info:
            llm_model = pipeline_info.get("llm_model")
        cost = estimate_cost(
            engine_name=name,
            llm_model=llm_model,
            is_pipeline=bool(e.get("is_pipeline")),
            measured_seconds_per_page=measured,
            table=table,
            defaults=defaults,
            hourly_rate_override_eur=hourly_rate_local_eur,
        )
        out[name] = cost.as_dict()
    return out