Spaces:
Sleeping
Sleeping
File size: 5,702 Bytes
3020394 | 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 | """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())
|