"""Suivi des performances en production : parse `logs/api.log` (une ligne JSON par appel à `/analyze`, écrite par `app/main.py`) et calcule des métriques agrégées (taux de succès, confiance moyenne, causes de blocage). Ne dépend d'aucun état en mémoire : peut tourner en script séparé pendant que l'API tourne (lecture seule du fichier de log), ou être appelé depuis `GET /metrics` pour un aperçu rapide en démo. """ import json import os from pathlib import Path from typing import Any, Dict, List, Optional from collections import Counter MODULE_ROOT = Path(__file__).resolve().parent.parent # YELY_DATA_DIR pointe vers un répertoire persistant (ex. le stockage # persistant d'un Hugging Face Space, monté sur /data) quand il est défini — # sinon on retombe sur le dossier du module (éphémère en conteneur Docker # sans stockage persistant). Doit rester cohérent avec DATA_DIR dans # `app/main.py` et `monitoring/feedback.py`, sinon /metrics et /failures # liraient un fichier différent de celui où l'API écrit réellement. DATA_DIR = Path(os.environ.get("YELY_DATA_DIR", str(MODULE_ROOT))) DEFAULT_LOG_PATH = DATA_DIR / "logs" / "api.log" def parse_log_entries(log_path: Optional[Path] = None) -> List[Dict[str, Any]]: """Extrait les entrées `{"request": ..., "response": ...}` de `api.log`. Le format de ligne est `" | | "` (voir le `Formatter` dans `app/main.py`) ; seules les lignes INFO dont le message est le JSON structuré loggé après chaque `/analyze` sont retenues — les autres lignes (ex. "Modèle CRNN chargé sur cpu.") sont ignorées. """ log_path = log_path or DEFAULT_LOG_PATH if not log_path.exists(): return [] entries = [] with open(log_path, "r", encoding="utf-8") as f: for line in f: parts = line.rstrip("\n").split(" | ", 2) if len(parts) != 3: continue _, _, message = parts try: data = json.loads(message) except json.JSONDecodeError: continue if isinstance(data, dict) and "response" in data: entries.append(data) return entries def compute_metrics(log_path: Optional[Path] = None) -> Dict[str, Any]: """Calcule les métriques agrégées sur l'ensemble des entrées loggées. Retourne `total_requests=0` et des métriques à `None` si aucun appel n'a encore été journalisé (démo pas encore lancée), plutôt qu'une erreur. """ entries = parse_log_entries(log_path) total = len(entries) if total == 0: return { "total_requests": 0, "success_rate": None, "avg_confidence_score": None, "blocking_causes": {}, "image_quality_distribution": {}, } responses = [e["response"] for e in entries] success_count = sum(1 for r in responses if r.get("success")) confidences = [r["confidence_score"] for r in responses if r.get("confidence_score") is not None] avg_confidence = sum(confidences) / len(confidences) if confidences else None blocking_causes = Counter(r["message"] for r in responses if not r.get("success")) quality_distribution = Counter(r.get("image_quality") for r in responses) return { "total_requests": total, "success_rate": round(success_count / total, 3), "avg_confidence_score": round(avg_confidence, 3) if avg_confidence is not None else None, "blocking_causes": dict(blocking_causes.most_common()), "image_quality_distribution": dict(quality_distribution.most_common()), } def list_failed_requests(log_path: Optional[Path] = None, limit: int = 20) -> List[Dict[str, Any]]: """Retourne les `limit` dernières réponses bloquées (`success=false`), les plus récentes en premier — matière première de la revue manuelle (voir `web/stats.html` -> `web/feedback.html` et docs/MONITORING.md). Ne renvoie que les champs utiles à l'affichage/la correction, pas l'entrée de log complète (pas de `request.filename`, `model_version`...). """ entries = parse_log_entries(log_path) failed = [e["response"] for e in entries if not e["response"].get("success")] failed.reverse() out = [] for r in failed[:limit]: out.append({ "photo_reference": r.get("photo_reference"), "message": r.get("message"), "image_quality": r.get("image_quality"), "detected_liters": r.get("detected_liters"), "detected_amount": r.get("detected_amount"), "fuel_price": r.get("fuel_price"), "confidence_score": r.get("confidence_score"), "transaction_datetime": r.get("transaction_datetime"), }) return out def _print_report(metrics: Dict[str, Any]) -> None: print("=== Suivi des performances YELY (logs/api.log) ===") print(f"Requêtes totales : {metrics['total_requests']}") if metrics["total_requests"] == 0: print("Aucun appel journalisé pour l'instant.") return print(f"Taux de succès : {metrics['success_rate'] * 100:.1f}%") conf = metrics["avg_confidence_score"] print(f"Confiance moyenne : {conf if conf is None else round(conf * 100, 1)}%") print("Répartition qualité image :") for label, count in metrics["image_quality_distribution"].items(): print(f" - {label}: {count}") print("Causes de blocage (hors succès) :") for message, count in metrics["blocking_causes"].items(): print(f" - {message}: {count}") if __name__ == "__main__": _print_report(compute_metrics())