YELY_AI_Module / monitoring /metrics.py
danielxdata's picture
Ajoute le suivi des echecs et la boucle de correction pompiste
3020394
Raw
History Blame Contribute Delete
5.7 kB
"""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 `"<timestamp> | <niveau> | <message>"` (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())