Spaces:
Sleeping
Sleeping
| """API YELY — module IA de vérification pompiste (modèle CRNN fine-tuné). | |
| Endpoint unique : POST /analyze | |
| - image (fichier, requis) | |
| - fuel_price (float, optionnel) : prix du litre configuré côté YELY | |
| - driver_id, pompiste_id, station_id (str, optionnels) : traçabilité | |
| Réponse conforme au §9/§13 du cahier des charges YELY. | |
| """ | |
| import json | |
| import logging | |
| import os | |
| import shutil | |
| import tempfile | |
| import uuid | |
| from datetime import datetime, timezone | |
| from pathlib import Path | |
| import cv2 | |
| from fastapi import FastAPI, File, UploadFile, Form, HTTPException | |
| from fastapi.middleware.cors import CORSMiddleware | |
| from fastapi.responses import JSONResponse, FileResponse | |
| from .config import load_config | |
| from .preprocessing import detect_screen_region | |
| from .quality import estimate_image_quality | |
| from .recognizer import get_device, load_model, recognize_screen | |
| from .postprocess import process as postprocess_results | |
| from .rules import evaluate as evaluate_rules | |
| from monitoring.feedback import record_feedback, CORRECTABLE_FIELDS | |
| from monitoring.metrics import compute_metrics, list_failed_requests | |
| MODULE_ROOT = Path(__file__).resolve().parent.parent | |
| # YELY_DATA_DIR : répertoire persistant optionnel (ex. stockage persistant | |
| # d'un Hugging Face Space, monté sur /data). Sans stockage persistant, un | |
| # Space Docker gratuit repart de zéro à chaque redémarrage/veille — photos, | |
| # logs et feedback.jsonl sont alors perdus. Voir docs/MONITORING.md pour la | |
| # marche à suivre côté Space. Doit rester identique à `DATA_DIR` dans | |
| # `monitoring/metrics.py` et `monitoring/feedback.py`. | |
| DATA_DIR = Path(os.environ.get("YELY_DATA_DIR", str(MODULE_ROOT))) | |
| LOG_DIR = DATA_DIR / "logs" | |
| LOG_DIR.mkdir(parents=True, exist_ok=True) | |
| PHOTOS_DIR = DATA_DIR / "photos" | |
| PHOTOS_DIR.mkdir(parents=True, exist_ok=True) | |
| # Identifiant du modèle actif, à faire varier entre deux versions ré-entraînées | |
| # (voir docs/MONITORING.md) — permet de comparer les métriques d'une version | |
| # à l'autre a posteriori dans les logs, sans changer le contrat de réponse. | |
| MODEL_VERSION = os.environ.get("MODEL_VERSION", "crnn_v2_70.2pct") | |
| logger = logging.getLogger("yely_ai_module") | |
| logger.setLevel(logging.INFO) | |
| _file_handler = logging.FileHandler(LOG_DIR / "api.log", encoding="utf-8") | |
| _file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s")) | |
| logger.addHandler(_file_handler) | |
| app = FastAPI(title="YELY — Module IA pompiste (CRNN)") | |
| # Le frontend (Netlify) et l'API (Hugging Face Spaces) sont sur des domaines | |
| # différents : sans CORS, le navigateur bloquerait la lecture de la réponse | |
| # même si la requête aboutit côté serveur. ALLOWED_ORIGINS est une liste | |
| # d'origines séparées par des virgules (ex. "https://yely-demo.netlify.app"). | |
| _allowed_origins = os.environ.get("ALLOWED_ORIGINS", "*") | |
| app.add_middleware( | |
| CORSMiddleware, | |
| allow_origins=["*"] if _allowed_origins == "*" else _allowed_origins.split(","), | |
| allow_methods=["GET", "POST"], | |
| allow_headers=["*"], | |
| ) | |
| SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/bmp", "image/webp", "image/tiff"} | |
| cfg = load_config() | |
| _model = None | |
| _device = None | |
| def _safe_photo_path(photo_reference: str) -> Path | None: | |
| """Résout `photo_reference` sous `PHOTOS_DIR`, sans jamais sortir de ce | |
| dossier (`Path.name` élimine tout `../`/séparateur). Retourne `None` si | |
| le fichier n'existe pas — les appelants renvoient alors un 404. | |
| """ | |
| candidate = PHOTOS_DIR / Path(photo_reference).name | |
| if candidate.exists() and candidate.parent == PHOTOS_DIR: | |
| return candidate | |
| return None | |
| def _get_model(): | |
| global _model, _device | |
| if _model is None: | |
| _device = get_device() | |
| _model = load_model(device=_device) | |
| logger.info(f"Modèle CRNN chargé sur {_device}.") | |
| return _model, _device | |
| async def analyze( | |
| image: UploadFile = File(...), | |
| fuel_price: float = Form(None), | |
| driver_id: str = Form(None), | |
| pompiste_id: str = Form(None), | |
| station_id: str = Form(None), | |
| ): | |
| if image.content_type not in SUPPORTED_IMAGE_TYPES: | |
| raise HTTPException(status_code=400, detail="Type d'image non supporté") | |
| transaction_id = str(uuid.uuid4()) | |
| transaction_datetime = datetime.now(timezone.utc).isoformat() | |
| with tempfile.NamedTemporaryFile(delete=False, suffix=Path(image.filename).suffix) as tmp: | |
| tmp.write(await image.read()) | |
| tmp_path = tmp.name | |
| try: | |
| img_bgr = cv2.imread(tmp_path) | |
| if img_bgr is None: | |
| raise HTTPException(status_code=400, detail="Impossible de lire l'image") | |
| try: | |
| crop, (sx, sy, sw, sh) = detect_screen_region(img_bgr) | |
| img_to_process = crop if sw < img_bgr.shape[1] * 0.95 else img_bgr | |
| model, device = _get_model() | |
| recognized = recognize_screen(model, img_to_process, device) | |
| parsed = postprocess_results(recognized, fuel_price=fuel_price, cfg=cfg) | |
| quality = estimate_image_quality(img_to_process, ocr_results=recognized, cfg=cfg) | |
| gate = evaluate_rules(parsed, quality, fuel_price, cfg) | |
| except HTTPException: | |
| raise | |
| except Exception: | |
| logger.exception("Erreur interne lors du traitement de l'image") | |
| return JSONResponse(status_code=500, content={ | |
| "success": False, | |
| "message": "Erreur interne de traitement", | |
| "transaction_datetime": transaction_datetime, | |
| }) | |
| photo_reference = None | |
| try: | |
| photo_reference = f"{transaction_id}{Path(image.filename).suffix}" | |
| shutil.copyfile(tmp_path, PHOTOS_DIR / photo_reference) | |
| except Exception: | |
| logger.warning(f"Impossible de sauvegarder la photo pour la transaction {transaction_id}", exc_info=True) | |
| photo_reference = None | |
| response = { | |
| "success": gate["success"], | |
| "image_quality": quality["image_quality"], | |
| "detected_liters": parsed["detected_liters"], | |
| "detected_amount": parsed["detected_amount"], | |
| "fuel_price": parsed["fuel_price"], | |
| "calculated_amount": parsed["calculated_amount"], | |
| "calculated_liters": parsed["calculated_liters"], | |
| "is_consistent": parsed["is_consistent"], | |
| "confidence_score": gate["confidence_score"], | |
| "message": gate["message"], | |
| "driver_id": driver_id, | |
| "pompiste_id": pompiste_id, | |
| "station_id": station_id, | |
| "transaction_datetime": transaction_datetime, | |
| "photo_reference": photo_reference, | |
| } | |
| logger.info(json.dumps({ | |
| "model_version": MODEL_VERSION, | |
| "request": { | |
| "filename": image.filename, | |
| "fuel_price": fuel_price, | |
| "driver_id": driver_id, | |
| "pompiste_id": pompiste_id, | |
| "station_id": station_id, | |
| }, | |
| "response": response, | |
| }, ensure_ascii=False)) | |
| return JSONResponse(content=response) | |
| finally: | |
| try: | |
| os.remove(tmp_path) | |
| except Exception: | |
| pass | |
| async def feedback( | |
| photo_reference: str = Form(...), | |
| corrected_prix: float = Form(None), | |
| corrected_volume: float = Form(None), | |
| corrected_prix_litre: float = Form(None), | |
| corrected_by: str = Form(None), | |
| ): | |
| """Corrections a posteriori (pompiste/station) sur une transaction déjà | |
| traitée par `/analyze` — matière première de l'apprentissage continu. | |
| Voir docs/MONITORING.md pour le fonctionnement complet de la boucle. | |
| """ | |
| if _safe_photo_path(photo_reference) is None: | |
| raise HTTPException(status_code=404, detail="photo_reference inconnue (transaction introuvable)") | |
| corrected_fields = {} | |
| if corrected_prix is not None: | |
| corrected_fields["prix"] = corrected_prix | |
| if corrected_volume is not None: | |
| corrected_fields["volume"] = corrected_volume | |
| if corrected_prix_litre is not None: | |
| corrected_fields["prix_litre"] = corrected_prix_litre | |
| if not corrected_fields: | |
| raise HTTPException(status_code=400, detail="Aucune correction fournie (corrected_prix/corrected_volume/corrected_prix_litre)") | |
| entry = record_feedback(photo_reference, corrected_fields, corrected_by=corrected_by) | |
| logger.info(json.dumps({"feedback": entry}, ensure_ascii=False)) | |
| return JSONResponse(content={"success": True, "feedback_id": entry["feedback_id"]}) | |
| async def metrics(): | |
| """Aperçu agrégé des performances observées en production (§3 de | |
| docs/WORKFLOW.md) : taux de succès, confiance moyenne, causes de blocage. | |
| Calculé à la volée à partir de `logs/api.log` — pas d'état en mémoire. | |
| """ | |
| return JSONResponse(content=compute_metrics()) | |
| async def failures(limit: int = 20): | |
| """Dernières transactions bloquées (`success=false`), pour la section | |
| « Requêtes échouées » de `web/stats.html` — chaque entrée référence sa | |
| photo via `photo_reference`, servie par `GET /photos/{photo_reference}`. | |
| """ | |
| return JSONResponse(content=list_failed_requests(limit=limit)) | |
| async def get_photo(photo_reference: str): | |
| """Ressert une photo déjà reçue par `/analyze`, pour l'affichage dans | |
| le tableau de bord et le formulaire de correction (`web/feedback.html`). | |
| """ | |
| photo_path = _safe_photo_path(photo_reference) | |
| if photo_path is None: | |
| raise HTTPException(status_code=404, detail="Photo introuvable") | |
| return FileResponse(photo_path) | |