Spaces:
Running
Running
Claude
refactor(core): faire de core/ un cercle 1 strict, déplacer cercle 2 vers measurements/
979f3c3 unverified | """Projection de coût en volume cible — Sprint 79 (A.I.6). | |
| Sprint 79 — A.I.6 du plan d'évolution 2026. | |
| Pourquoi ce module | |
| ------------------ | |
| La vue Pareto (Sprint 20) trace CER vs coût mais le coût est par | |
| unité (1 000 pages). Pour décider business-side, il faut projeter | |
| ce coût sur le **volume cible** que l'utilisateur prévoit de | |
| traiter — payer 50 € de plus sur 50 pages est trivial, sur | |
| 5 millions ça change tout. | |
| Sortie typique | |
| -------------- | |
| *« Pour vos 80 000 pages BMS — Tesseract = 3 €, Pero = 0 € (local | |
| amorti), Mistral OCR = 280 €, GPT-4o post-correction = 600 €. »* | |
| Aucun seuil arbitraire imposé : le module fournit les chiffres, | |
| le chercheur arbitre selon son budget. | |
| Dépendance | |
| ---------- | |
| S'appuie sur ``picarones.measurements.pricing`` (Sprint 20) qui expose | |
| ``EngineCost.cost_per_1k_pages_eur`` et | |
| ``co2_per_1k_pages_g``. | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from dataclasses import dataclass | |
| from typing import Optional | |
| from picarones.measurements.pricing import EngineCost | |
| logger = logging.getLogger(__name__) | |
| class ProjectedCost: | |
| """Coût total projeté d'un moteur pour un volume cible.""" | |
| engine_key: str | |
| target_pages: int | |
| cost_total_eur: Optional[float] | |
| co2_total_g: Optional[float] | |
| cost_per_1k_pages_eur: Optional[float] | |
| co2_per_1k_pages_g: Optional[float] | |
| type: str # "local" / "cloud_api" / "unknown" | |
| def as_dict(self) -> dict: | |
| return { | |
| "engine_key": self.engine_key, | |
| "target_pages": self.target_pages, | |
| "cost_total_eur": self.cost_total_eur, | |
| "co2_total_g": self.co2_total_g, | |
| "cost_per_1k_pages_eur": self.cost_per_1k_pages_eur, | |
| "co2_per_1k_pages_g": self.co2_per_1k_pages_g, | |
| "type": self.type, | |
| } | |
| def project_cost_total( | |
| engine_cost: EngineCost, target_pages: int, | |
| ) -> Optional[float]: | |
| """Coût total projeté en euros pour ``target_pages`` pages. | |
| Retourne ``None`` si ``cost_per_1k_pages_eur`` est ``None`` | |
| (données insuffisantes) ou si ``target_pages`` est négatif. | |
| """ | |
| if target_pages < 0: | |
| return None | |
| if engine_cost.cost_per_1k_pages_eur is None: | |
| return None | |
| return engine_cost.cost_per_1k_pages_eur * target_pages / 1000.0 | |
| def project_co2_total( | |
| engine_cost: EngineCost, target_pages: int, | |
| ) -> Optional[float]: | |
| """Empreinte CO₂ totale en grammes pour ``target_pages`` pages.""" | |
| if target_pages < 0: | |
| return None | |
| if engine_cost.co2_per_1k_pages_g is None: | |
| return None | |
| return engine_cost.co2_per_1k_pages_g * target_pages / 1000.0 | |
| def project_engine( | |
| engine_cost: EngineCost, target_pages: int, | |
| ) -> ProjectedCost: | |
| """Retourne le ``ProjectedCost`` complet pour un moteur.""" | |
| return ProjectedCost( | |
| engine_key=engine_cost.engine_key, | |
| target_pages=int(target_pages), | |
| cost_total_eur=project_cost_total(engine_cost, target_pages), | |
| co2_total_g=project_co2_total(engine_cost, target_pages), | |
| cost_per_1k_pages_eur=engine_cost.cost_per_1k_pages_eur, | |
| co2_per_1k_pages_g=engine_cost.co2_per_1k_pages_g, | |
| type=engine_cost.type, | |
| ) | |
| def project_all_engines( | |
| engine_costs: dict[str, EngineCost], | |
| target_pages: int, | |
| ) -> dict[str, ProjectedCost]: | |
| """Projette les coûts de plusieurs moteurs sur le volume cible. | |
| Retourne un dict ``{engine_name: ProjectedCost}`` avec entrée | |
| pour chaque moteur, y compris ceux sans données de coût (où | |
| ``cost_total_eur`` sera ``None``). | |
| """ | |
| if target_pages < 0: | |
| raise ValueError("target_pages doit être ≥ 0") | |
| return { | |
| name: project_engine(cost, target_pages) | |
| for name, cost in engine_costs.items() | |
| } | |
| def cost_gap_table( | |
| projections: dict[str, ProjectedCost], | |
| baseline_engine: str, | |
| ) -> dict[str, dict[str, Optional[float]]]: | |
| """Pour chaque moteur, écart de coût total vs baseline. | |
| Retourne ``{engine: {"total": float, "delta_abs": float, | |
| "delta_rel": float}}`` où : | |
| - ``delta_abs`` = ``cost - cost_baseline`` (None si l'un des | |
| deux est None) | |
| - ``delta_rel`` = ``delta_abs / cost_baseline`` (None si | |
| baseline = 0 ou None) | |
| Lève ``KeyError`` si la baseline est inconnue. | |
| """ | |
| if baseline_engine not in projections: | |
| raise KeyError( | |
| f"baseline {baseline_engine!r} absente des projections", | |
| ) | |
| baseline_total = projections[baseline_engine].cost_total_eur | |
| out: dict[str, dict[str, Optional[float]]] = {} | |
| for name, proj in projections.items(): | |
| total = proj.cost_total_eur | |
| if total is None or baseline_total is None: | |
| delta_abs: Optional[float] = None | |
| delta_rel: Optional[float] = None | |
| else: | |
| delta_abs = total - baseline_total | |
| if baseline_total != 0: | |
| delta_rel = delta_abs / baseline_total | |
| else: | |
| delta_rel = None | |
| out[name] = { | |
| "total": total, | |
| "delta_abs": delta_abs, | |
| "delta_rel": delta_rel, | |
| } | |
| return out | |
| __all__ = [ | |
| "ProjectedCost", | |
| "project_cost_total", | |
| "project_co2_total", | |
| "project_engine", | |
| "project_all_engines", | |
| "cost_gap_table", | |
| ] | |