Spaces:
Sleeping
Sleeping
Commit ·
3020394
1
Parent(s): 69db25a
Ajoute le suivi des echecs et la boucle de correction pompiste
Browse files- quality.py : verifie luminosite avant flou (evite les faux "floue" sur
photos sombres mais nettes)
- monitoring/ : feedback.py (collecte + conversion vers annotations.json)
et metrics.py (agregation logs/api.log, dont liste des echecs)
- API : GET /failures, GET /photos/{ref}, stockage optionnel sur
YELY_DATA_DIR (stockage persistant HF Spaces)
- web/stats.html : section requetes echouees avec photo + lien de
correction ; web/feedback.html : formulaire de correction premployi
- tests/test_monitoring.py (19 tests)
- .gitignore +3 -1
- app/main.py +91 -5
- app/quality.py +12 -7
- docs/API.md +43 -0
- docs/LIMITATIONS.md +13 -0
- docs/MONITORING.md +263 -0
- docs/WORKFLOW.md +23 -15
- monitoring/__init__.py +0 -0
- monitoring/feedback.py +165 -0
- monitoring/metrics.py +134 -0
- tests/test_monitoring.py +258 -0
- web/feedback.html +228 -0
- web/stats.html +277 -0
- web/vercel.json +0 -3
.gitignore
CHANGED
|
@@ -3,4 +3,6 @@ photos/
|
|
| 3 |
logs/
|
| 4 |
__pycache__/
|
| 5 |
*.pyc
|
| 6 |
-
*.tmp
|
|
|
|
|
|
|
|
|
| 3 |
logs/
|
| 4 |
__pycache__/
|
| 5 |
*.pyc
|
| 6 |
+
*.tmp
|
| 7 |
+
monitoring/feedback.jsonl
|
| 8 |
+
monitoring/feedback_crops/
|
app/main.py
CHANGED
|
@@ -19,7 +19,7 @@ from pathlib import Path
|
|
| 19 |
import cv2
|
| 20 |
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
| 21 |
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
-
from fastapi.responses import JSONResponse
|
| 23 |
|
| 24 |
from .config import load_config
|
| 25 |
from .preprocessing import detect_screen_region
|
|
@@ -27,13 +27,28 @@ from .quality import estimate_image_quality
|
|
| 27 |
from .recognizer import get_device, load_model, recognize_screen
|
| 28 |
from .postprocess import process as postprocess_results
|
| 29 |
from .rules import evaluate as evaluate_rules
|
|
|
|
|
|
|
| 30 |
|
| 31 |
MODULE_ROOT = Path(__file__).resolve().parent.parent
|
| 32 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 34 |
-
PHOTOS_DIR =
|
| 35 |
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
logger = logging.getLogger("yely_ai_module")
|
| 38 |
logger.setLevel(logging.INFO)
|
| 39 |
_file_handler = logging.FileHandler(LOG_DIR / "api.log", encoding="utf-8")
|
|
@@ -46,12 +61,12 @@ app = FastAPI(title="YELY — Module IA pompiste (CRNN)")
|
|
| 46 |
# différents : sans CORS, le navigateur bloquerait la lecture de la réponse
|
| 47 |
# même si la requête aboutit côté serveur. ALLOWED_ORIGINS est une liste
|
| 48 |
# d'origines séparées par des virgules (ex. "https://yely-demo.vercel.app").
|
| 49 |
-
_allowed_origins = os.environ.get("ALLOWED_ORIGINS", "
|
| 50 |
|
| 51 |
app.add_middleware(
|
| 52 |
CORSMiddleware,
|
| 53 |
allow_origins=["*"] if _allowed_origins == "*" else _allowed_origins.split(","),
|
| 54 |
-
allow_methods=["POST"],
|
| 55 |
allow_headers=["*"],
|
| 56 |
)
|
| 57 |
|
|
@@ -63,6 +78,17 @@ _model = None
|
|
| 63 |
_device = None
|
| 64 |
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
def _get_model():
|
| 67 |
global _model, _device
|
| 68 |
if _model is None:
|
|
@@ -142,6 +168,7 @@ async def analyze(
|
|
| 142 |
}
|
| 143 |
|
| 144 |
logger.info(json.dumps({
|
|
|
|
| 145 |
"request": {
|
| 146 |
"filename": image.filename,
|
| 147 |
"fuel_price": fuel_price,
|
|
@@ -158,3 +185,62 @@ async def analyze(
|
|
| 158 |
os.remove(tmp_path)
|
| 159 |
except Exception:
|
| 160 |
pass
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
import cv2
|
| 20 |
from fastapi import FastAPI, File, UploadFile, Form, HTTPException
|
| 21 |
from fastapi.middleware.cors import CORSMiddleware
|
| 22 |
+
from fastapi.responses import JSONResponse, FileResponse
|
| 23 |
|
| 24 |
from .config import load_config
|
| 25 |
from .preprocessing import detect_screen_region
|
|
|
|
| 27 |
from .recognizer import get_device, load_model, recognize_screen
|
| 28 |
from .postprocess import process as postprocess_results
|
| 29 |
from .rules import evaluate as evaluate_rules
|
| 30 |
+
from monitoring.feedback import record_feedback, CORRECTABLE_FIELDS
|
| 31 |
+
from monitoring.metrics import compute_metrics, list_failed_requests
|
| 32 |
|
| 33 |
MODULE_ROOT = Path(__file__).resolve().parent.parent
|
| 34 |
+
|
| 35 |
+
# YELY_DATA_DIR : répertoire persistant optionnel (ex. stockage persistant
|
| 36 |
+
# d'un Hugging Face Space, monté sur /data). Sans stockage persistant, un
|
| 37 |
+
# Space Docker gratuit repart de zéro à chaque redémarrage/veille — photos,
|
| 38 |
+
# logs et feedback.jsonl sont alors perdus. Voir docs/MONITORING.md pour la
|
| 39 |
+
# marche à suivre côté Space. Doit rester identique à `DATA_DIR` dans
|
| 40 |
+
# `monitoring/metrics.py` et `monitoring/feedback.py`.
|
| 41 |
+
DATA_DIR = Path(os.environ.get("YELY_DATA_DIR", str(MODULE_ROOT)))
|
| 42 |
+
LOG_DIR = DATA_DIR / "logs"
|
| 43 |
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
| 44 |
+
PHOTOS_DIR = DATA_DIR / "photos"
|
| 45 |
PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
|
| 46 |
|
| 47 |
+
# Identifiant du modèle actif, à faire varier entre deux versions ré-entraînées
|
| 48 |
+
# (voir docs/MONITORING.md) — permet de comparer les métriques d'une version
|
| 49 |
+
# à l'autre a posteriori dans les logs, sans changer le contrat de réponse.
|
| 50 |
+
MODEL_VERSION = os.environ.get("MODEL_VERSION", "crnn_v2_70.2pct")
|
| 51 |
+
|
| 52 |
logger = logging.getLogger("yely_ai_module")
|
| 53 |
logger.setLevel(logging.INFO)
|
| 54 |
_file_handler = logging.FileHandler(LOG_DIR / "api.log", encoding="utf-8")
|
|
|
|
| 61 |
# différents : sans CORS, le navigateur bloquerait la lecture de la réponse
|
| 62 |
# même si la requête aboutit côté serveur. ALLOWED_ORIGINS est une liste
|
| 63 |
# d'origines séparées par des virgules (ex. "https://yely-demo.vercel.app").
|
| 64 |
+
_allowed_origins = os.environ.get("ALLOWED_ORIGINS", "*")
|
| 65 |
|
| 66 |
app.add_middleware(
|
| 67 |
CORSMiddleware,
|
| 68 |
allow_origins=["*"] if _allowed_origins == "*" else _allowed_origins.split(","),
|
| 69 |
+
allow_methods=["GET", "POST"],
|
| 70 |
allow_headers=["*"],
|
| 71 |
)
|
| 72 |
|
|
|
|
| 78 |
_device = None
|
| 79 |
|
| 80 |
|
| 81 |
+
def _safe_photo_path(photo_reference: str) -> Path | None:
|
| 82 |
+
"""Résout `photo_reference` sous `PHOTOS_DIR`, sans jamais sortir de ce
|
| 83 |
+
dossier (`Path.name` élimine tout `../`/séparateur). Retourne `None` si
|
| 84 |
+
le fichier n'existe pas — les appelants renvoient alors un 404.
|
| 85 |
+
"""
|
| 86 |
+
candidate = PHOTOS_DIR / Path(photo_reference).name
|
| 87 |
+
if candidate.exists() and candidate.parent == PHOTOS_DIR:
|
| 88 |
+
return candidate
|
| 89 |
+
return None
|
| 90 |
+
|
| 91 |
+
|
| 92 |
def _get_model():
|
| 93 |
global _model, _device
|
| 94 |
if _model is None:
|
|
|
|
| 168 |
}
|
| 169 |
|
| 170 |
logger.info(json.dumps({
|
| 171 |
+
"model_version": MODEL_VERSION,
|
| 172 |
"request": {
|
| 173 |
"filename": image.filename,
|
| 174 |
"fuel_price": fuel_price,
|
|
|
|
| 185 |
os.remove(tmp_path)
|
| 186 |
except Exception:
|
| 187 |
pass
|
| 188 |
+
|
| 189 |
+
|
| 190 |
+
@app.post("/feedback")
|
| 191 |
+
async def feedback(
|
| 192 |
+
photo_reference: str = Form(...),
|
| 193 |
+
corrected_prix: float = Form(None),
|
| 194 |
+
corrected_volume: float = Form(None),
|
| 195 |
+
corrected_prix_litre: float = Form(None),
|
| 196 |
+
corrected_by: str = Form(None),
|
| 197 |
+
):
|
| 198 |
+
"""Corrections a posteriori (pompiste/station) sur une transaction déjà
|
| 199 |
+
traitée par `/analyze` — matière première de l'apprentissage continu.
|
| 200 |
+
Voir docs/MONITORING.md pour le fonctionnement complet de la boucle.
|
| 201 |
+
"""
|
| 202 |
+
if _safe_photo_path(photo_reference) is None:
|
| 203 |
+
raise HTTPException(status_code=404, detail="photo_reference inconnue (transaction introuvable)")
|
| 204 |
+
|
| 205 |
+
corrected_fields = {}
|
| 206 |
+
if corrected_prix is not None:
|
| 207 |
+
corrected_fields["prix"] = corrected_prix
|
| 208 |
+
if corrected_volume is not None:
|
| 209 |
+
corrected_fields["volume"] = corrected_volume
|
| 210 |
+
if corrected_prix_litre is not None:
|
| 211 |
+
corrected_fields["prix_litre"] = corrected_prix_litre
|
| 212 |
+
if not corrected_fields:
|
| 213 |
+
raise HTTPException(status_code=400, detail="Aucune correction fournie (corrected_prix/corrected_volume/corrected_prix_litre)")
|
| 214 |
+
|
| 215 |
+
entry = record_feedback(photo_reference, corrected_fields, corrected_by=corrected_by)
|
| 216 |
+
logger.info(json.dumps({"feedback": entry}, ensure_ascii=False))
|
| 217 |
+
return JSONResponse(content={"success": True, "feedback_id": entry["feedback_id"]})
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@app.get("/metrics")
|
| 221 |
+
async def metrics():
|
| 222 |
+
"""Aperçu agrégé des performances observées en production (§3 de
|
| 223 |
+
docs/WORKFLOW.md) : taux de succès, confiance moyenne, causes de blocage.
|
| 224 |
+
Calculé à la volée à partir de `logs/api.log` — pas d'état en mémoire.
|
| 225 |
+
"""
|
| 226 |
+
return JSONResponse(content=compute_metrics())
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@app.get("/failures")
|
| 230 |
+
async def failures(limit: int = 20):
|
| 231 |
+
"""Dernières transactions bloquées (`success=false`), pour la section
|
| 232 |
+
« Requêtes échouées » de `web/stats.html` — chaque entrée référence sa
|
| 233 |
+
photo via `photo_reference`, servie par `GET /photos/{photo_reference}`.
|
| 234 |
+
"""
|
| 235 |
+
return JSONResponse(content=list_failed_requests(limit=limit))
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
@app.get("/photos/{photo_reference}")
|
| 239 |
+
async def get_photo(photo_reference: str):
|
| 240 |
+
"""Ressert une photo déjà reçue par `/analyze`, pour l'affichage dans
|
| 241 |
+
le tableau de bord et le formulaire de correction (`web/feedback.html`).
|
| 242 |
+
"""
|
| 243 |
+
photo_path = _safe_photo_path(photo_reference)
|
| 244 |
+
if photo_path is None:
|
| 245 |
+
raise HTTPException(status_code=404, detail="Photo introuvable")
|
| 246 |
+
return FileResponse(photo_path)
|
app/quality.py
CHANGED
|
@@ -25,16 +25,21 @@ def estimate_image_quality(img: np.ndarray,
|
|
| 25 |
blur = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 26 |
mean_brightness = float(np.mean(gray))
|
| 27 |
|
| 28 |
-
#
|
| 29 |
-
#
|
| 30 |
-
#
|
| 31 |
-
#
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
|
|
|
|
|
|
|
|
|
| 35 |
label = "dark"
|
| 36 |
elif mean_brightness > cfg.bright_threshold:
|
| 37 |
label = "bright"
|
|
|
|
|
|
|
| 38 |
elif ocr_results is not None and len(ocr_results) < 2:
|
| 39 |
label = "unclear"
|
| 40 |
else:
|
|
|
|
| 25 |
blur = cv2.Laplacian(gray, cv2.CV_64F).var()
|
| 26 |
mean_brightness = float(np.mean(gray))
|
| 27 |
|
| 28 |
+
# La luminosité est vérifiée AVANT le flou. Raison (constatée sur des
|
| 29 |
+
# photos réelles sombres mais nettes) : la variance du Laplacien dépend
|
| 30 |
+
# elle-même du contraste, qui s'effondre dans une image sombre même
|
| 31 |
+
# quand les contours sont parfaitement nets. Tester le flou en premier
|
| 32 |
+
# classait donc à tort des photos sombres comme "blurry" au lieu de
|
| 33 |
+
# "dark" — deux causes qui appellent des messages/corrections différents
|
| 34 |
+
# côté utilisateur (reprendre la photo avec plus de lumière n'est pas le
|
| 35 |
+
# même geste que la stabiliser). Le flou n'est un diagnostic fiable que
|
| 36 |
+
# sur une image dont la luminosité est déjà normale.
|
| 37 |
+
if mean_brightness < cfg.dark_threshold:
|
| 38 |
label = "dark"
|
| 39 |
elif mean_brightness > cfg.bright_threshold:
|
| 40 |
label = "bright"
|
| 41 |
+
elif blur < cfg.blur_threshold:
|
| 42 |
+
label = "blurry"
|
| 43 |
elif ocr_results is not None and len(ocr_results) < 2:
|
| 44 |
label = "unclear"
|
| 45 |
else:
|
docs/API.md
CHANGED
|
@@ -59,3 +59,46 @@ et référencée dans la réponse (`photo_reference`) — nécessaire pour
|
|
| 59 |
qu'un contrôle a posteriori (station, YELY) puisse revérifier une
|
| 60 |
transaction contestée, et c'est aussi la matière première de la boucle
|
| 61 |
d'apprentissage continu (`docs/MONITORING.md`).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
qu'un contrôle a posteriori (station, YELY) puisse revérifier une
|
| 60 |
transaction contestée, et c'est aussi la matière première de la boucle
|
| 61 |
d'apprentissage continu (`docs/MONITORING.md`).
|
| 62 |
+
|
| 63 |
+
## `POST /feedback`
|
| 64 |
+
|
| 65 |
+
Correction a posteriori d'une transaction déjà traitée par `/analyze` —
|
| 66 |
+
voir `docs/MONITORING.md` pour le fonctionnement complet de la boucle
|
| 67 |
+
d'apprentissage continu.
|
| 68 |
+
|
| 69 |
+
| Champ | Type | Requis | Description |
|
| 70 |
+
|---|---|---|---|
|
| 71 |
+
| `photo_reference` | string | oui | valeur renvoyée par `/analyze` (doit exister dans `photos/`) |
|
| 72 |
+
| `corrected_prix` | float | non* | montant réellement correct |
|
| 73 |
+
| `corrected_volume` | float | non* | volume réellement correct |
|
| 74 |
+
| `corrected_prix_litre` | float | non* | prix du litre réellement correct |
|
| 75 |
+
| `corrected_by` | string | non | identifiant de qui corrige (pompiste/station) |
|
| 76 |
+
|
| 77 |
+
\* au moins un des trois champs `corrected_*` est requis.
|
| 78 |
+
|
| 79 |
+
Réponses : `200` (`{"success": true, "feedback_id": "..."}`), `404` si
|
| 80 |
+
`photo_reference` ne correspond à aucune photo connue, `400` si aucune
|
| 81 |
+
correction n'est fournie.
|
| 82 |
+
|
| 83 |
+
## `GET /metrics`
|
| 84 |
+
|
| 85 |
+
Aperçu agrégé des performances observées en production, calculé à la volée
|
| 86 |
+
à partir de `logs/api.log` (pas de paramètre, pas d'état en mémoire) :
|
| 87 |
+
`total_requests`, `success_rate`, `avg_confidence_score`, `blocking_causes`,
|
| 88 |
+
`image_quality_distribution`. Voir `docs/MONITORING.md`.
|
| 89 |
+
|
| 90 |
+
## `GET /failures`
|
| 91 |
+
|
| 92 |
+
Les `limit` (défaut 20) dernières transactions bloquées (`success=false`),
|
| 93 |
+
les plus récentes en premier — alimente la section « Requêtes échouées » de
|
| 94 |
+
`web/stats.html`. Chaque élément : `photo_reference`, `message`,
|
| 95 |
+
`image_quality`, `detected_liters`, `detected_amount`, `fuel_price`,
|
| 96 |
+
`confidence_score`, `transaction_datetime`.
|
| 97 |
+
|
| 98 |
+
## `GET /photos/{photo_reference}`
|
| 99 |
+
|
| 100 |
+
Ressert l'image sauvegardée par `/analyze` sous ce `photo_reference` (sert
|
| 101 |
+
l'affichage dans le tableau de bord et le formulaire de correction). `404`
|
| 102 |
+
si la référence est inconnue. `photo_reference` est assaini côté serveur
|
| 103 |
+
(`Path(...).name`) avant résolution sous `PHOTOS_DIR` — aucune traversée de
|
| 104 |
+
répertoire possible même si la valeur contient `../`.
|
docs/LIMITATIONS.md
CHANGED
|
@@ -70,6 +70,19 @@ qui restent d'actualité.
|
|
| 70 |
- Envisager de la **data augmentation** (légères rotations, variations de
|
| 71 |
luminosité/contraste) pour compenser partiellement le manque de diversité.
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
## Ce qui reste fiable indépendamment du modèle
|
| 74 |
|
| 75 |
La détection d'écran, le contrôle qualité (flou/luminosité), le moteur de
|
|
|
|
| 70 |
- Envisager de la **data augmentation** (légères rotations, variations de
|
| 71 |
luminosité/contraste) pour compenser partiellement le manque de diversité.
|
| 72 |
|
| 73 |
+
## Bug de classification qualité corrigé (flou vs sombre)
|
| 74 |
+
|
| 75 |
+
`app/quality.py` vérifiait le flou (variance du Laplacien) avant la
|
| 76 |
+
luminosité. Or la variance du Laplacien dépend elle-même du contraste, qui
|
| 77 |
+
s'effondre dans une image sombre même quand les contours sont nets : une
|
| 78 |
+
photo prise dans un environnement peu éclairé mais parfaitement stable
|
| 79 |
+
pouvait donc être classée à tort `"blurry"` ("reprendre la photo, image
|
| 80 |
+
floue") au lieu de `"dark"` ("reprendre la photo avec plus de lumière") —
|
| 81 |
+
deux diagnostics qui appellent un geste correctif différent côté
|
| 82 |
+
utilisateur. **Corrigé** : la luminosité (`dark`/`bright`) est maintenant
|
| 83 |
+
vérifiée avant le flou, qui n'est un diagnostic fiable que sur une image
|
| 84 |
+
déjà correctement exposée.
|
| 85 |
+
|
| 86 |
## Ce qui reste fiable indépendamment du modèle
|
| 87 |
|
| 88 |
La détection d'écran, le contrôle qualité (flou/luminosité), le moteur de
|
docs/MONITORING.md
ADDED
|
@@ -0,0 +1,263 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Monitoring et apprentissage continu (`monitoring/`)
|
| 2 |
+
|
| 3 |
+
Deux besoins distincts couverts ici : **savoir comment le modèle se comporte
|
| 4 |
+
en production** (suivi des performances), et **transformer les erreurs
|
| 5 |
+
observées en nouvelles données d'entraînement** (apprentissage continu).
|
| 6 |
+
Aucun des deux ne modifie le comportement de `/analyze` — ce sont des
|
| 7 |
+
composants qui lisent/complètent les traces déjà produites par l'API.
|
| 8 |
+
|
| 9 |
+
## 1. Suivi des performances
|
| 10 |
+
|
| 11 |
+
Chaque appel à `POST /analyze` écrit déjà une ligne JSON dans `logs/api.log`
|
| 12 |
+
(requête sans l'image + réponse complète + `model_version`). `monitoring/metrics.py`
|
| 13 |
+
parse ce fichier et calcule, sans état en mémoire ni base de données :
|
| 14 |
+
|
| 15 |
+
- `total_requests` : nombre d'appels journalisés.
|
| 16 |
+
- `success_rate` : proportion de réponses `success=true`.
|
| 17 |
+
- `avg_confidence_score` : moyenne des `confidence_score` renvoyés.
|
| 18 |
+
- `blocking_causes` : histogramme des `message` pour les réponses bloquées
|
| 19 |
+
(utile pour voir si le facteur limitant en démo est le flou, l'incohérence,
|
| 20 |
+
ou la confiance — voir `docs/LIMITATIONS.md`).
|
| 21 |
+
- `image_quality_distribution` : histogramme des `image_quality` observés.
|
| 22 |
+
|
| 23 |
+
Deux façons de le lire :
|
| 24 |
+
|
| 25 |
+
```bash
|
| 26 |
+
# en ligne de commande, pour un rapport texte
|
| 27 |
+
cd yely_ai_module
|
| 28 |
+
python -m monitoring.metrics
|
| 29 |
+
|
| 30 |
+
# via l'API elle-même, pendant la démo
|
| 31 |
+
curl http://127.0.0.1:8000/metrics
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
`GET /metrics` renvoie le même dictionnaire que `compute_metrics()`, sans
|
| 35 |
+
paramètre — il relit `logs/api.log` à chaque appel, donc reflète l'état
|
| 36 |
+
courant sans redémarrer le serveur.
|
| 37 |
+
|
| 38 |
+
`web/stats.html` est un tableau de bord minimal pour la soutenance : il
|
| 39 |
+
appelle `GET /metrics` (URL de l'API dérivée de celle configurée dans
|
| 40 |
+
`web/index.html`, stockée dans le même `localStorage`) et affiche requêtes
|
| 41 |
+
totales, taux de succès, confiance moyenne, répartition qualité image et
|
| 42 |
+
causes de blocage, avec auto-rafraîchissement toutes les 15s. Nécessite que
|
| 43 |
+
`allow_methods` inclue `GET` dans le middleware CORS de `app/main.py`
|
| 44 |
+
(sinon le navigateur bloque la lecture de la réponse, même si l'appel
|
| 45 |
+
aboutit côté serveur — piège déjà rencontré avec `/analyze` en `POST`).
|
| 46 |
+
|
| 47 |
+
`model_version` (variable d'environnement `MODEL_VERSION`, valeur par défaut
|
| 48 |
+
`crnn_v2_70.2pct`) est ajouté à chaque entrée de log — pas à la réponse
|
| 49 |
+
publique de `/analyze`, pour ne pas polluer le contrat API §13 avec un champ
|
| 50 |
+
technique. Il permet de comparer `logs/api.log` avant/après un
|
| 51 |
+
ré-entraînement en filtrant par version, une fois plusieurs modèles utilisés
|
| 52 |
+
en séquence (voir §3).
|
| 53 |
+
|
| 54 |
+
## 2. Boucle d'apprentissage continu (`monitoring/feedback.py`)
|
| 55 |
+
|
| 56 |
+
### Principe
|
| 57 |
+
|
| 58 |
+
Chaque photo reçue par `/analyze` est déjà sauvegardée dans
|
| 59 |
+
`photos/<transaction_id>.jpg` et référencée dans la réponse
|
| 60 |
+
(`photo_reference`). Cette boucle réutilise cette photo pour transformer une
|
| 61 |
+
lecture erronée, corrigée a posteriori par un pompiste ou un contrôle YELY,
|
| 62 |
+
en nouvelle donnée d'entraînement — sans jamais toucher au dataset pendant
|
| 63 |
+
qu'un entraînement est en cours (leçon retenue d'un crash réel, voir
|
| 64 |
+
`docs/TRAINING.md`).
|
| 65 |
+
|
| 66 |
+
### Étape 1 — collecte : `POST /feedback`
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
curl -X POST http://127.0.0.1:8000/feedback \
|
| 70 |
+
-F "photo_reference=3f2a1c9e-....jpg" \
|
| 71 |
+
-F "corrected_prix=10000" \
|
| 72 |
+
-F "corrected_volume=14.28" \
|
| 73 |
+
-F "corrected_prix_litre=700" \
|
| 74 |
+
-F "corrected_by=pompiste-7"
|
| 75 |
+
```
|
| 76 |
+
|
| 77 |
+
- `photo_reference` (requis) : valeur renvoyée par `/analyze` dans la
|
| 78 |
+
réponse d'origine — l'API vérifie que la photo existe encore dans
|
| 79 |
+
`photos/` avant d'accepter la correction (`404` sinon).
|
| 80 |
+
- Au moins un des trois champs `corrected_prix` / `corrected_volume` /
|
| 81 |
+
`corrected_prix_litre` est requis ; les champs non fournis restent
|
| 82 |
+
inconnus (pas déduits automatiquement).
|
| 83 |
+
- Chaque correction est ajoutée en une ligne à `monitoring/feedback.jsonl`
|
| 84 |
+
(append-only, jamais réécrit sur cet endpoint) via
|
| 85 |
+
`monitoring.feedback.record_feedback`.
|
| 86 |
+
|
| 87 |
+
**En pratique (démo/soutenance), pas besoin de `curl`** : `web/stats.html`
|
| 88 |
+
liste les transactions bloquées (`GET /failures`, 20 dernières, avec la
|
| 89 |
+
photo servie par `GET /photos/{photo_reference}`) avec un bouton
|
| 90 |
+
« Corriger » par entrée, qui ouvre `web/feedback.html?ref=<photo_reference>`
|
| 91 |
+
— la référence est déjà pré-remplie, la photo s'affiche automatiquement, il
|
| 92 |
+
ne reste qu'à saisir les valeurs correctes et valider. Cette page reste
|
| 93 |
+
aussi utilisable seule (référence saisie à la main) si besoin.
|
| 94 |
+
|
| 95 |
+
### Étape 2 — conversion : `monitoring/feedback.py`
|
| 96 |
+
|
| 97 |
+
```bash
|
| 98 |
+
cd yely_ai_module
|
| 99 |
+
python -m monitoring.feedback
|
| 100 |
+
```
|
| 101 |
+
|
| 102 |
+
Pour chaque entrée de `feedback.jsonl` pas encore convertie :
|
| 103 |
+
|
| 104 |
+
1. Relit la photo dans `photos/`.
|
| 105 |
+
2. Relance `detect_screen_region` (même fonction que l'API) pour localiser
|
| 106 |
+
l'écran et produire un crop, sauvegardé dans `monitoring/feedback_crops/`.
|
| 107 |
+
3. Ajoute une entrée à `annotator/annotations/annotations.json`, **au même
|
| 108 |
+
format** que l'annotation manuelle (`lcd_bbox`, `lcd_crop`, `fields`), en
|
| 109 |
+
utilisant les valeurs corrigées comme champs.
|
| 110 |
+
4. Marque l'entrée `converted=true` dans `feedback.jsonl` pour ne pas la
|
| 111 |
+
reconvertir au prochain passage.
|
| 112 |
+
|
| 113 |
+
**Point important** : la détection d'écran automatique n'est pas fiable à
|
| 114 |
+
100% (voir `docs/LIMITATIONS.md`, point 2 — 86% des images d'origine sont
|
| 115 |
+
tombées sur un repli approximatif). Les entrées générées ici sont donc
|
| 116 |
+
marquées `"status": "pending_review"` (pas `"annotated"`) : une relecture
|
| 117 |
+
humaine rapide via l'outil `annotator/` (vérifier que `lcd_bbox` cadre bien
|
| 118 |
+
l'écran) reste nécessaire avant de les inclure dans un ré-entraînement. Ce
|
| 119 |
+
choix est délibéré — préférer une étape manuelle courte à l'injection
|
| 120 |
+
silencieuse de crops mal cadrés dans le dataset, qui dégraderait la
|
| 121 |
+
précision plutôt que de l'améliorer (cf. la cause n°2 de la limitation à
|
| 122 |
+
70.2%).
|
| 123 |
+
|
| 124 |
+
#### Relire les entrées `pending_review` (`annotator/annotate.py --review-pending`)
|
| 125 |
+
|
| 126 |
+
`annotate.py` a un mode dédié qui ne parcourt pas un dossier d'images mais
|
| 127 |
+
relit directement les entrées `status="pending_review"` d'`annotations.json`
|
| 128 |
+
— une par une, avec le rectangle et les valeurs déjà pré-remplis (à
|
| 129 |
+
confirmer ou corriger) au lieu de repartir de zéro :
|
| 130 |
+
|
| 131 |
+
```bash
|
| 132 |
+
cd annotator
|
| 133 |
+
python annotate.py --review-pending
|
| 134 |
+
```
|
| 135 |
+
|
| 136 |
+
Marche à suivre pour chaque image affichée :
|
| 137 |
+
|
| 138 |
+
1. Le rectangle vert affiché est le `lcd_bbox` détecté automatiquement au
|
| 139 |
+
moment de la conversion — regarde s'il cadre bien l'écran LCD.
|
| 140 |
+
2. **S'il est correct** : appuie sur `S` ou `Entrée` directement, pas besoin
|
| 141 |
+
de redessiner.
|
| 142 |
+
3. **S'il est mal cadré** (coupe un chiffre, déborde sur le boîtier...) :
|
| 143 |
+
appuie sur `R` pour l'effacer, puis dessine un nouveau rectangle
|
| 144 |
+
(clic + glisser) avant `S`/`Entrée`.
|
| 145 |
+
4. Le terminal demande ensuite de confirmer les valeurs (prix, volume,
|
| 146 |
+
prix/litre...) — elles sont **déjà pré-remplies** avec la correction
|
| 147 |
+
envoyée par le pompiste (visibles entre crochets `[...]`) : appuie sur
|
| 148 |
+
`Entrée` pour chaque champ correct, ou retape la valeur si besoin de
|
| 149 |
+
l'ajuster.
|
| 150 |
+
5. `N` pour ignorer une entrée douteuse (photo illisible, correction qui ne
|
| 151 |
+
semble pas fiable) — elle passe en `status="skipped"` et ne sera plus
|
| 152 |
+
proposée ni utilisée à l'entraînement.
|
| 153 |
+
6. `Q`/`Esc` pour arrêter la session en cours ; la progression déjà validée
|
| 154 |
+
est sauvegardée, il suffit de relancer `--review-pending` plus tard pour
|
| 155 |
+
reprendre (seules les entrées encore `pending_review` sont reproposées).
|
| 156 |
+
|
| 157 |
+
Chaque entrée confirmée passe automatiquement en `status="annotated"` — la
|
| 158 |
+
suite (§3) la traite alors exactement comme une annotation manuelle
|
| 159 |
+
classique. `python annotate.py --review` (sans `--review-pending`) affiche
|
| 160 |
+
à tout moment un résumé indiquant combien d'entrées restent `[à relire]`.
|
| 161 |
+
|
| 162 |
+
### Étape 3 — ré-entraînement périodique
|
| 163 |
+
|
| 164 |
+
Une fois un nombre suffisant d'entrées `pending_review` relues et passées à
|
| 165 |
+
`"annotated"` (ex. tous les 30-50 nouvelles corrections, à ajuster selon le
|
| 166 |
+
volume réel observé en production) :
|
| 167 |
+
|
| 168 |
+
```bash
|
| 169 |
+
cd annotator
|
| 170 |
+
python split_lines.py # nettoie et régénère train/val (fix leak inclus)
|
| 171 |
+
cd ../train
|
| 172 |
+
python prepare_doctr_dataset.py
|
| 173 |
+
python finetune_doctr.py --epochs 60
|
| 174 |
+
```
|
| 175 |
+
|
| 176 |
+
Voir `docs/TRAINING.md` pour le détail de chaque étape et les pièges déjà
|
| 177 |
+
rencontrés (ne pas modifier le dataset pendant l'entraînement, toujours
|
| 178 |
+
repartir d'un dossier de sortie nettoyé).
|
| 179 |
+
|
| 180 |
+
### Étape 4 — versioning du modèle actif
|
| 181 |
+
|
| 182 |
+
Chaque nouveau modèle entraîné est écrit dans son propre dossier
|
| 183 |
+
(`train/models/v<N>/`), jamais en écrasant le précédent — l'incident de ce
|
| 184 |
+
projet où un entraînement de test a écrasé le meilleur checkpoint (55.3%)
|
| 185 |
+
avec un résultat plus faible (44.7%) a motivé ce choix. Pour déployer une
|
| 186 |
+
nouvelle version :
|
| 187 |
+
|
| 188 |
+
1. Copier le `.pt` retenu vers `yely_ai_module/models/crnn_fuel_pump_best.pt`
|
| 189 |
+
(écrase le fichier servi par l'API, mais l'ancien reste dans `train/models/v<N>/`
|
| 190 |
+
si un retour arrière est nécessaire).
|
| 191 |
+
2. Mettre à jour `MODEL_VERSION` (variable d'environnement du déploiement,
|
| 192 |
+
ex. Hugging Face Spaces → Settings → Variables) pour que les nouvelles
|
| 193 |
+
entrées de `logs/api.log` soient distinguables des précédentes dans
|
| 194 |
+
`monitoring/metrics.py`.
|
| 195 |
+
3. Redémarrer le Space (ou le processus `uvicorn`) — le modèle est chargé une
|
| 196 |
+
seule fois au démarrage (singleton `_get_model()`), un nouveau fichier sur
|
| 197 |
+
disque n'est pas repris à chaud.
|
| 198 |
+
|
| 199 |
+
## 3. Stockage persistant (obligatoire pour que tout ceci survive en production)
|
| 200 |
+
|
| 201 |
+
Par défaut, un Hugging Face Space Docker écrit sur le disque **éphémère**
|
| 202 |
+
du conteneur : `photos/`, `logs/api.log` et `monitoring/feedback.jsonl`
|
| 203 |
+
disparaissent à chaque redémarrage/veille du Space (les Spaces gratuits se
|
| 204 |
+
mettent en veille après une période d'inactivité). Pour une démo tenue en
|
| 205 |
+
une seule session continue ce n'est pas gênant, mais **la boucle de
|
| 206 |
+
feedback et l'historique `/metrics`/`/failures` n'ont de sens que si ces
|
| 207 |
+
fichiers survivent** �� c'est là qu'intervient le stockage persistant.
|
| 208 |
+
|
| 209 |
+
### Ce que fait le code
|
| 210 |
+
|
| 211 |
+
`app/main.py`, `monitoring/metrics.py` et `monitoring/feedback.py` lisent
|
| 212 |
+
tous la même variable d'environnement `YELY_DATA_DIR` : si elle est
|
| 213 |
+
définie, `photos/`, `logs/` et `monitoring/feedback.jsonl` sont placés sous
|
| 214 |
+
ce répertoire au lieu du dossier de l'application. Par défaut (variable
|
| 215 |
+
absente), tout reste sous `yely_ai_module/` comme avant — donc rien ne
|
| 216 |
+
casse en local ou sur un Space sans stockage persistant.
|
| 217 |
+
|
| 218 |
+
### Activer le stockage persistant sur le Space
|
| 219 |
+
|
| 220 |
+
1. Ouvre ton Space → **Settings** → section **Persistent storage**. C'est
|
| 221 |
+
une option payante (facturée au mois, plusieurs tailles proposées) —
|
| 222 |
+
vérifie le tarif affiché à cet instant sur la page, il peut avoir changé.
|
| 223 |
+
Pour une démo, la plus petite taille suffit largement (quelques photos +
|
| 224 |
+
logs texte).
|
| 225 |
+
2. Active-la et choisis une taille. Hugging Face monte un disque dans le
|
| 226 |
+
conteneur — le chemin de montage est indiqué dans l'interface au moment
|
| 227 |
+
de l'activation (généralement `/data`).
|
| 228 |
+
3. Toujours dans **Settings** → **Variables and secrets** → **New variable**
|
| 229 |
+
(pas *secret*, cette valeur n'a rien de sensible) :
|
| 230 |
+
- Nom : `YELY_DATA_DIR`
|
| 231 |
+
- Valeur : le chemin de montage indiqué à l'étape 2 (ex. `/data`)
|
| 232 |
+
4. Redémarre le Space (**Settings** → **Restart this Space**, ou un simple
|
| 233 |
+
push suffit à le redéployer) pour que la variable soit prise en compte.
|
| 234 |
+
5. **Vérifier que ça persiste vraiment** : envoie une image via `/analyze`
|
| 235 |
+
(ou le formulaire `web/index.html`), consulte `GET /metrics`
|
| 236 |
+
(`total_requests` ≥ 1), puis redémarre le Space depuis Settings et
|
| 237 |
+
rappelle `GET /metrics` — si `total_requests` n'est pas retombé à 0,
|
| 238 |
+
c'est branché correctement.
|
| 239 |
+
|
| 240 |
+
### Si tu ne veux pas payer pour la démo
|
| 241 |
+
|
| 242 |
+
Pas bloquant : sans stockage persistant, tout fonctionne normalement tant
|
| 243 |
+
que le Space ne redémarre pas pendant la session de démonstration
|
| 244 |
+
(soutenance en continu). C'est seulement l'historique qui ne survit pas
|
| 245 |
+
d'une session à l'autre — acceptable pour une démo ponctuelle, à mentionner
|
| 246 |
+
comme limitation connue si le sujet est posé (`docs/LIMITATIONS.md`).
|
| 247 |
+
|
| 248 |
+
## 4. Ce qui est réellement implémenté vs conçu
|
| 249 |
+
|
| 250 |
+
Fonctionnel et testé (`tests/test_monitoring.py`) :
|
| 251 |
+
`POST /feedback`, `GET /metrics`, `GET /failures`, `GET /photos/{photo_reference}`,
|
| 252 |
+
`monitoring/metrics.py`,
|
| 253 |
+
`monitoring/feedback.py::record_feedback` et `::convert_feedback_to_annotations`.
|
| 254 |
+
Le mode `annotate.py --review-pending` n'a pas de test automatisé (outil
|
| 255 |
+
interactif OpenCV, nécessite un affichage) — vérifié manuellement.
|
| 256 |
+
|
| 257 |
+
Conçu mais volontairement manuel (pas automatisé, par choix — voir §2 ci-dessus) :
|
| 258 |
+
la relecture des entrées `pending_review` et le déclenchement du
|
| 259 |
+
ré-entraînement lui-même, qui restent des actions humaines délibérées plutôt
|
| 260 |
+
qu'un cron — le volume de données actuel (quelques centaines d'images) ne
|
| 261 |
+
justifie pas encore une automatisation complète, et une relecture humaine
|
| 262 |
+
reste la meilleure garde-fou contre la dégradation du dataset tant que le
|
| 263 |
+
découpage en lignes n'est pas plus robuste.
|
docs/WORKFLOW.md
CHANGED
|
@@ -15,25 +15,27 @@ yely_ai_module/
|
|
| 15 |
│ ├── test_postprocess.py # ✅ fait
|
| 16 |
│ ├── test_rules.py # ✅ fait
|
| 17 |
│ ├── test_api.py # ✅ fait (mocké)
|
| 18 |
-
│
|
|
|
|
| 19 |
├── tools/
|
| 20 |
│ └── check_seen_image.py # ✅ fait
|
| 21 |
-
├── monitoring/ #
|
| 22 |
-
│ ├──
|
| 23 |
-
│
|
| 24 |
-
|
| 25 |
-
├── docs/ # à créer — §4
|
| 26 |
│ ├── WORKFLOW.md # ce fichier
|
| 27 |
│ ├── LIMITATIONS.md # ✅ fait
|
| 28 |
-
│ ├── ARCHITECTURE.md
|
| 29 |
-
│ ├── PREPROCESSING.md
|
| 30 |
-
│ ├── RECOGNIZER.md
|
| 31 |
-
│ ├── POSTPROCESS_RULES.md
|
| 32 |
-
│ ├── API.md
|
| 33 |
-
│ └── MONITORING.md
|
| 34 |
-
├── web/ #
|
| 35 |
-
│ ├── index.html
|
| 36 |
-
│
|
|
|
|
|
|
|
| 37 |
└── requirements.txt
|
| 38 |
```
|
| 39 |
|
|
@@ -164,6 +166,12 @@ d'exécution comme Vercel, `Dockerfile` déjà préparé (`yely_ai_module/Docker
|
|
| 164 |
l'URL que le frontend Vercel appellera pour `/analyze`.
|
| 165 |
5. Ajouter `CORSMiddleware` dans `app/main.py` pour autoriser le domaine
|
| 166 |
Vercel du frontend (sinon le navigateur bloquera les réponses).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 167 |
|
| 168 |
## 7. Ordre d'exécution recommandé (vu le délai serré)
|
| 169 |
|
|
|
|
| 15 |
│ ├── test_postprocess.py # ✅ fait
|
| 16 |
│ ├── test_rules.py # ✅ fait
|
| 17 |
│ ├── test_api.py # ✅ fait (mocké)
|
| 18 |
+
│ ├── test_monitoring.py # ✅ fait (feedback + métriques + endpoints)
|
| 19 |
+
│ └── test_integration.py # à lancer : vrai modèle, vraies images
|
| 20 |
├── tools/
|
| 21 |
│ └── check_seen_image.py # ✅ fait
|
| 22 |
+
├── monitoring/ # ✅ fait — §2
|
| 23 |
+
│ ├── metrics.py # ✅ agrégation logs/api.log (succès, confiance, blocages)
|
| 24 |
+
│ └── feedback.py # ✅ collecte + conversion des corrections pompiste
|
| 25 |
+
├── docs/ # ✅ fait — §4
|
|
|
|
| 26 |
│ ├── WORKFLOW.md # ce fichier
|
| 27 |
│ ├── LIMITATIONS.md # ✅ fait
|
| 28 |
+
│ ├── ARCHITECTURE.md # ✅ fait
|
| 29 |
+
│ ├── PREPROCESSING.md # ✅ fait
|
| 30 |
+
│ ├── RECOGNIZER.md # ✅ fait
|
| 31 |
+
│ ├── POSTPROCESS_RULES.md # ✅ fait
|
| 32 |
+
│ ├── API.md # ✅ fait
|
| 33 |
+
│ └── MONITORING.md # ✅ fait
|
| 34 |
+
├── web/ # ✅ fait — §5, interface de démo
|
| 35 |
+
│ ├── index.html # démo /analyze
|
| 36 |
+
│ ├── stats.html # tableau de bord /metrics + /failures (soutenance)
|
| 37 |
+
│ ├── feedback.html # correction d'une lecture (préremplie depuis stats.html)
|
| 38 |
+
│ └── config.js
|
| 39 |
└── requirements.txt
|
| 40 |
```
|
| 41 |
|
|
|
|
| 166 |
l'URL que le frontend Vercel appellera pour `/analyze`.
|
| 167 |
5. Ajouter `CORSMiddleware` dans `app/main.py` pour autoriser le domaine
|
| 168 |
Vercel du frontend (sinon le navigateur bloquera les réponses).
|
| 169 |
+
6. (Optionnel, payant) Activer le **stockage persistant** du Space et
|
| 170 |
+
définir la variable d'environnement `YELY_DATA_DIR` sur son point de
|
| 171 |
+
montage — sans quoi `photos/`, `logs/` et `monitoring/feedback.jsonl`
|
| 172 |
+
sont perdus à chaque redémarrage/veille du Space. Voir
|
| 173 |
+
`docs/MONITORING.md` §3 pour la marche à suivre complète et l'impact si
|
| 174 |
+
on s'en passe pour la démo.
|
| 175 |
|
| 176 |
## 7. Ordre d'exécution recommandé (vu le délai serré)
|
| 177 |
|
monitoring/__init__.py
ADDED
|
File without changes
|
monitoring/feedback.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Boucle de feedback : collecte des corrections pompiste et conversion en
|
| 2 |
+
données d'entraînement (voir docs/MONITORING.md).
|
| 3 |
+
|
| 4 |
+
Principe : chaque photo traitée par `/analyze` est déjà sauvegardée dans
|
| 5 |
+
`photos/<transaction_id>.jpg` (voir `app/main.py`). Une correction envoyée
|
| 6 |
+
via `POST /feedback` référence cette photo par son `photo_reference` et
|
| 7 |
+
fournit les valeurs réellement correctes. `convert_feedback_to_annotations`
|
| 8 |
+
transforme ensuite ces corrections en entrées au même format que
|
| 9 |
+
`annotator/annotations/annotations.json`, réutilisables par le pipeline
|
| 10 |
+
d'entraînement existant (`split_lines.py` -> `prepare_doctr_dataset.py` ->
|
| 11 |
+
`finetune_doctr.py`).
|
| 12 |
+
"""
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import uuid
|
| 16 |
+
from datetime import datetime, timezone
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from typing import Any, Dict, List, Optional
|
| 19 |
+
|
| 20 |
+
from app.preprocessing import detect_screen_region
|
| 21 |
+
|
| 22 |
+
MODULE_ROOT = Path(__file__).resolve().parent.parent
|
| 23 |
+
|
| 24 |
+
# Même variable YELY_DATA_DIR que `app/main.py`/`monitoring/metrics.py` : si
|
| 25 |
+
# un stockage persistant est monté (ex. /data sur un Hugging Face Space),
|
| 26 |
+
# feedback.jsonl et les crops doivent survivre aux redémarrages du Space
|
| 27 |
+
# comme les photos et les logs, sinon la boucle de feedback perd tout à
|
| 28 |
+
# chaque redéploiement. `DEFAULT_ANNOTATIONS_PATH` reste dans le dépôt
|
| 29 |
+
# (`annotator/`) volontairement : la conversion en données d'entraînement
|
| 30 |
+
# est un script lancé en local par un développeur, pas sur le Space (voir
|
| 31 |
+
# docs/MONITORING.md — l'annotateur n'est de toute façon pas poussé sur HF).
|
| 32 |
+
DATA_DIR = Path(os.environ.get("YELY_DATA_DIR", str(MODULE_ROOT)))
|
| 33 |
+
DEFAULT_FEEDBACK_PATH = DATA_DIR / "monitoring" / "feedback.jsonl"
|
| 34 |
+
DEFAULT_PHOTOS_DIR = DATA_DIR / "photos"
|
| 35 |
+
DEFAULT_ANNOTATIONS_PATH = MODULE_ROOT.parent / "annotator" / "annotations" / "annotations.json"
|
| 36 |
+
DEFAULT_CROPS_DIR = DATA_DIR / "monitoring" / "feedback_crops"
|
| 37 |
+
|
| 38 |
+
CORRECTABLE_FIELDS = ("prix", "volume", "prix_litre")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def record_feedback(photo_reference: str,
|
| 42 |
+
corrected_fields: Dict[str, Any],
|
| 43 |
+
corrected_by: Optional[str] = None,
|
| 44 |
+
feedback_path: Optional[Path] = None) -> Dict[str, Any]:
|
| 45 |
+
"""Ajoute une correction pompiste au journal `feedback.jsonl` (append-only).
|
| 46 |
+
|
| 47 |
+
`corrected_fields` : sous-ensemble de {"prix", "volume", "prix_litre"} ->
|
| 48 |
+
valeur correcte (les champs non fournis restent inconnus, pas déduits).
|
| 49 |
+
"""
|
| 50 |
+
unknown = set(corrected_fields) - set(CORRECTABLE_FIELDS)
|
| 51 |
+
if unknown:
|
| 52 |
+
raise ValueError(f"Champs de correction inconnus : {sorted(unknown)}")
|
| 53 |
+
if not corrected_fields:
|
| 54 |
+
raise ValueError("Aucune correction fournie.")
|
| 55 |
+
|
| 56 |
+
feedback_path = feedback_path or DEFAULT_FEEDBACK_PATH
|
| 57 |
+
feedback_path.parent.mkdir(parents=True, exist_ok=True)
|
| 58 |
+
|
| 59 |
+
entry = {
|
| 60 |
+
"feedback_id": str(uuid.uuid4()),
|
| 61 |
+
"photo_reference": photo_reference,
|
| 62 |
+
"corrected_fields": corrected_fields,
|
| 63 |
+
"corrected_by": corrected_by,
|
| 64 |
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
| 65 |
+
"converted": False,
|
| 66 |
+
}
|
| 67 |
+
with open(feedback_path, "a", encoding="utf-8") as f:
|
| 68 |
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
| 69 |
+
return entry
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def load_feedback_entries(feedback_path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
| 73 |
+
feedback_path = feedback_path or DEFAULT_FEEDBACK_PATH
|
| 74 |
+
if not feedback_path.exists():
|
| 75 |
+
return []
|
| 76 |
+
entries = []
|
| 77 |
+
with open(feedback_path, "r", encoding="utf-8") as f:
|
| 78 |
+
for line in f:
|
| 79 |
+
line = line.strip()
|
| 80 |
+
if not line:
|
| 81 |
+
continue
|
| 82 |
+
try:
|
| 83 |
+
entries.append(json.loads(line))
|
| 84 |
+
except json.JSONDecodeError:
|
| 85 |
+
continue
|
| 86 |
+
return entries
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def convert_feedback_to_annotations(photos_dir: Optional[Path] = None,
|
| 90 |
+
feedback_path: Optional[Path] = None,
|
| 91 |
+
annotations_path: Optional[Path] = None,
|
| 92 |
+
crops_dir: Optional[Path] = None) -> int:
|
| 93 |
+
"""Convertit les corrections non encore traitées en entrées `annotations.json`.
|
| 94 |
+
|
| 95 |
+
La détection d'écran (`detect_screen_region`) est automatique, donc pas
|
| 96 |
+
fiable à 100% (voir docs/LIMITATIONS.md, point 2) : les entrées générées
|
| 97 |
+
sont marquées `status="pending_review"` plutôt que `"annotated"`, pour
|
| 98 |
+
qu'une relecture humaine (même rapide, via le visualiseur de l'annotateur)
|
| 99 |
+
précède leur utilisation dans un ré-entraînement.
|
| 100 |
+
|
| 101 |
+
Retourne le nombre d'entrées converties.
|
| 102 |
+
"""
|
| 103 |
+
import cv2
|
| 104 |
+
|
| 105 |
+
photos_dir = photos_dir or DEFAULT_PHOTOS_DIR
|
| 106 |
+
feedback_path = feedback_path or DEFAULT_FEEDBACK_PATH
|
| 107 |
+
annotations_path = annotations_path or DEFAULT_ANNOTATIONS_PATH
|
| 108 |
+
crops_dir = crops_dir or DEFAULT_CROPS_DIR
|
| 109 |
+
|
| 110 |
+
entries = load_feedback_entries(feedback_path)
|
| 111 |
+
pending = [e for e in entries if not e.get("converted")]
|
| 112 |
+
if not pending:
|
| 113 |
+
return 0
|
| 114 |
+
|
| 115 |
+
annotations_path.parent.mkdir(parents=True, exist_ok=True)
|
| 116 |
+
if annotations_path.exists():
|
| 117 |
+
with open(annotations_path, "r", encoding="utf-8") as f:
|
| 118 |
+
annotations = json.load(f)
|
| 119 |
+
else:
|
| 120 |
+
annotations = {}
|
| 121 |
+
|
| 122 |
+
crops_dir.mkdir(parents=True, exist_ok=True)
|
| 123 |
+
|
| 124 |
+
converted_count = 0
|
| 125 |
+
for entry in pending:
|
| 126 |
+
photo_path = photos_dir / entry["photo_reference"]
|
| 127 |
+
if not photo_path.exists():
|
| 128 |
+
continue
|
| 129 |
+
|
| 130 |
+
img = cv2.imread(str(photo_path))
|
| 131 |
+
if img is None:
|
| 132 |
+
continue
|
| 133 |
+
crop, bbox = detect_screen_region(img)
|
| 134 |
+
crop_name = f"feedback_{entry['feedback_id']}_lcd.jpg"
|
| 135 |
+
cv2.imwrite(str(crops_dir / crop_name), crop)
|
| 136 |
+
|
| 137 |
+
fields = {name: "" for name in CORRECTABLE_FIELDS}
|
| 138 |
+
fields.update({"unite_prix": "", "unite_vol": "", "notes": "issu du feedback pompiste"})
|
| 139 |
+
for name, value in entry["corrected_fields"].items():
|
| 140 |
+
fields[name] = str(value)
|
| 141 |
+
|
| 142 |
+
annotations[entry["photo_reference"]] = {
|
| 143 |
+
"status": "pending_review",
|
| 144 |
+
"annotated_at": entry["created_at"],
|
| 145 |
+
"image_path": str(photo_path),
|
| 146 |
+
"lcd_bbox": list(bbox),
|
| 147 |
+
"lcd_crop": str(crops_dir / crop_name),
|
| 148 |
+
"fields": fields,
|
| 149 |
+
}
|
| 150 |
+
entry["converted"] = True
|
| 151 |
+
converted_count += 1
|
| 152 |
+
|
| 153 |
+
with open(annotations_path, "w", encoding="utf-8") as f:
|
| 154 |
+
json.dump(annotations, f, ensure_ascii=False, indent=2)
|
| 155 |
+
|
| 156 |
+
with open(feedback_path, "w", encoding="utf-8") as f:
|
| 157 |
+
for entry in entries:
|
| 158 |
+
f.write(json.dumps(entry, ensure_ascii=False) + "\n")
|
| 159 |
+
|
| 160 |
+
return converted_count
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
if __name__ == "__main__":
|
| 164 |
+
n = convert_feedback_to_annotations()
|
| 165 |
+
print(f"{n} correction(s) converties en entrées à relire dans annotations.json")
|
monitoring/metrics.py
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Suivi des performances en production : parse `logs/api.log` (une ligne
|
| 2 |
+
JSON par appel à `/analyze`, écrite par `app/main.py`) et calcule des
|
| 3 |
+
métriques agrégées (taux de succès, confiance moyenne, causes de blocage).
|
| 4 |
+
|
| 5 |
+
Ne dépend d'aucun état en mémoire : peut tourner en script séparé pendant
|
| 6 |
+
que l'API tourne (lecture seule du fichier de log), ou être appelé depuis
|
| 7 |
+
`GET /metrics` pour un aperçu rapide en démo.
|
| 8 |
+
"""
|
| 9 |
+
import json
|
| 10 |
+
import os
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
from typing import Any, Dict, List, Optional
|
| 13 |
+
from collections import Counter
|
| 14 |
+
|
| 15 |
+
MODULE_ROOT = Path(__file__).resolve().parent.parent
|
| 16 |
+
|
| 17 |
+
# YELY_DATA_DIR pointe vers un répertoire persistant (ex. le stockage
|
| 18 |
+
# persistant d'un Hugging Face Space, monté sur /data) quand il est défini —
|
| 19 |
+
# sinon on retombe sur le dossier du module (éphémère en conteneur Docker
|
| 20 |
+
# sans stockage persistant). Doit rester cohérent avec DATA_DIR dans
|
| 21 |
+
# `app/main.py` et `monitoring/feedback.py`, sinon /metrics et /failures
|
| 22 |
+
# liraient un fichier différent de celui où l'API écrit réellement.
|
| 23 |
+
DATA_DIR = Path(os.environ.get("YELY_DATA_DIR", str(MODULE_ROOT)))
|
| 24 |
+
DEFAULT_LOG_PATH = DATA_DIR / "logs" / "api.log"
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def parse_log_entries(log_path: Optional[Path] = None) -> List[Dict[str, Any]]:
|
| 28 |
+
"""Extrait les entrées `{"request": ..., "response": ...}` de `api.log`.
|
| 29 |
+
|
| 30 |
+
Le format de ligne est `"<timestamp> | <niveau> | <message>"` (voir le
|
| 31 |
+
`Formatter` dans `app/main.py`) ; seules les lignes INFO dont le message
|
| 32 |
+
est le JSON structuré loggé après chaque `/analyze` sont retenues — les
|
| 33 |
+
autres lignes (ex. "Modèle CRNN chargé sur cpu.") sont ignorées.
|
| 34 |
+
"""
|
| 35 |
+
log_path = log_path or DEFAULT_LOG_PATH
|
| 36 |
+
if not log_path.exists():
|
| 37 |
+
return []
|
| 38 |
+
|
| 39 |
+
entries = []
|
| 40 |
+
with open(log_path, "r", encoding="utf-8") as f:
|
| 41 |
+
for line in f:
|
| 42 |
+
parts = line.rstrip("\n").split(" | ", 2)
|
| 43 |
+
if len(parts) != 3:
|
| 44 |
+
continue
|
| 45 |
+
_, _, message = parts
|
| 46 |
+
try:
|
| 47 |
+
data = json.loads(message)
|
| 48 |
+
except json.JSONDecodeError:
|
| 49 |
+
continue
|
| 50 |
+
if isinstance(data, dict) and "response" in data:
|
| 51 |
+
entries.append(data)
|
| 52 |
+
return entries
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def compute_metrics(log_path: Optional[Path] = None) -> Dict[str, Any]:
|
| 56 |
+
"""Calcule les métriques agrégées sur l'ensemble des entrées loggées.
|
| 57 |
+
|
| 58 |
+
Retourne `total_requests=0` et des métriques à `None` si aucun appel n'a
|
| 59 |
+
encore été journalisé (démo pas encore lancée), plutôt qu'une erreur.
|
| 60 |
+
"""
|
| 61 |
+
entries = parse_log_entries(log_path)
|
| 62 |
+
total = len(entries)
|
| 63 |
+
if total == 0:
|
| 64 |
+
return {
|
| 65 |
+
"total_requests": 0,
|
| 66 |
+
"success_rate": None,
|
| 67 |
+
"avg_confidence_score": None,
|
| 68 |
+
"blocking_causes": {},
|
| 69 |
+
"image_quality_distribution": {},
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
responses = [e["response"] for e in entries]
|
| 73 |
+
success_count = sum(1 for r in responses if r.get("success"))
|
| 74 |
+
|
| 75 |
+
confidences = [r["confidence_score"] for r in responses if r.get("confidence_score") is not None]
|
| 76 |
+
avg_confidence = sum(confidences) / len(confidences) if confidences else None
|
| 77 |
+
|
| 78 |
+
blocking_causes = Counter(r["message"] for r in responses if not r.get("success"))
|
| 79 |
+
quality_distribution = Counter(r.get("image_quality") for r in responses)
|
| 80 |
+
|
| 81 |
+
return {
|
| 82 |
+
"total_requests": total,
|
| 83 |
+
"success_rate": round(success_count / total, 3),
|
| 84 |
+
"avg_confidence_score": round(avg_confidence, 3) if avg_confidence is not None else None,
|
| 85 |
+
"blocking_causes": dict(blocking_causes.most_common()),
|
| 86 |
+
"image_quality_distribution": dict(quality_distribution.most_common()),
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def list_failed_requests(log_path: Optional[Path] = None, limit: int = 20) -> List[Dict[str, Any]]:
|
| 91 |
+
"""Retourne les `limit` dernières réponses bloquées (`success=false`),
|
| 92 |
+
les plus récentes en premier — matière première de la revue manuelle
|
| 93 |
+
(voir `web/stats.html` -> `web/feedback.html` et docs/MONITORING.md).
|
| 94 |
+
|
| 95 |
+
Ne renvoie que les champs utiles à l'affichage/la correction, pas
|
| 96 |
+
l'entrée de log complète (pas de `request.filename`, `model_version`...).
|
| 97 |
+
"""
|
| 98 |
+
entries = parse_log_entries(log_path)
|
| 99 |
+
failed = [e["response"] for e in entries if not e["response"].get("success")]
|
| 100 |
+
failed.reverse()
|
| 101 |
+
out = []
|
| 102 |
+
for r in failed[:limit]:
|
| 103 |
+
out.append({
|
| 104 |
+
"photo_reference": r.get("photo_reference"),
|
| 105 |
+
"message": r.get("message"),
|
| 106 |
+
"image_quality": r.get("image_quality"),
|
| 107 |
+
"detected_liters": r.get("detected_liters"),
|
| 108 |
+
"detected_amount": r.get("detected_amount"),
|
| 109 |
+
"fuel_price": r.get("fuel_price"),
|
| 110 |
+
"confidence_score": r.get("confidence_score"),
|
| 111 |
+
"transaction_datetime": r.get("transaction_datetime"),
|
| 112 |
+
})
|
| 113 |
+
return out
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def _print_report(metrics: Dict[str, Any]) -> None:
|
| 117 |
+
print("=== Suivi des performances YELY (logs/api.log) ===")
|
| 118 |
+
print(f"Requêtes totales : {metrics['total_requests']}")
|
| 119 |
+
if metrics["total_requests"] == 0:
|
| 120 |
+
print("Aucun appel journalisé pour l'instant.")
|
| 121 |
+
return
|
| 122 |
+
print(f"Taux de succès : {metrics['success_rate'] * 100:.1f}%")
|
| 123 |
+
conf = metrics["avg_confidence_score"]
|
| 124 |
+
print(f"Confiance moyenne : {conf if conf is None else round(conf * 100, 1)}%")
|
| 125 |
+
print("Répartition qualité image :")
|
| 126 |
+
for label, count in metrics["image_quality_distribution"].items():
|
| 127 |
+
print(f" - {label}: {count}")
|
| 128 |
+
print("Causes de blocage (hors succès) :")
|
| 129 |
+
for message, count in metrics["blocking_causes"].items():
|
| 130 |
+
print(f" - {message}: {count}")
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
if __name__ == "__main__":
|
| 134 |
+
_print_report(compute_metrics())
|
tests/test_monitoring.py
ADDED
|
@@ -0,0 +1,258 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import json
|
| 2 |
+
import tempfile
|
| 3 |
+
import unittest
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from unittest.mock import patch
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
import cv2
|
| 9 |
+
from fastapi.testclient import TestClient
|
| 10 |
+
|
| 11 |
+
from app import main
|
| 12 |
+
from monitoring import feedback as feedback_mod
|
| 13 |
+
from monitoring import metrics as metrics_mod
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TestFeedbackRecording(unittest.TestCase):
|
| 17 |
+
def setUp(self):
|
| 18 |
+
self._tmpdir = tempfile.TemporaryDirectory()
|
| 19 |
+
self.tmp_path = Path(self._tmpdir.name)
|
| 20 |
+
self.feedback_path = self.tmp_path / "feedback.jsonl"
|
| 21 |
+
|
| 22 |
+
def tearDown(self):
|
| 23 |
+
self._tmpdir.cleanup()
|
| 24 |
+
|
| 25 |
+
def test_record_feedback_appends_entry(self):
|
| 26 |
+
entry = feedback_mod.record_feedback(
|
| 27 |
+
"abc.jpg", {"prix": 10000, "volume": 14.28},
|
| 28 |
+
corrected_by="pompiste-7", feedback_path=self.feedback_path,
|
| 29 |
+
)
|
| 30 |
+
self.assertEqual(entry["photo_reference"], "abc.jpg")
|
| 31 |
+
self.assertFalse(entry["converted"])
|
| 32 |
+
|
| 33 |
+
entries = feedback_mod.load_feedback_entries(self.feedback_path)
|
| 34 |
+
self.assertEqual(len(entries), 1)
|
| 35 |
+
self.assertEqual(entries[0]["corrected_fields"]["prix"], 10000)
|
| 36 |
+
|
| 37 |
+
def test_record_feedback_rejects_unknown_field(self):
|
| 38 |
+
with self.assertRaises(ValueError):
|
| 39 |
+
feedback_mod.record_feedback(
|
| 40 |
+
"abc.jpg", {"montant_total": 10000}, feedback_path=self.feedback_path,
|
| 41 |
+
)
|
| 42 |
+
|
| 43 |
+
def test_record_feedback_rejects_empty_corrections(self):
|
| 44 |
+
with self.assertRaises(ValueError):
|
| 45 |
+
feedback_mod.record_feedback("abc.jpg", {}, feedback_path=self.feedback_path)
|
| 46 |
+
|
| 47 |
+
def test_load_feedback_entries_missing_file_returns_empty(self):
|
| 48 |
+
entries = feedback_mod.load_feedback_entries(self.tmp_path / "nope.jsonl")
|
| 49 |
+
self.assertEqual(entries, [])
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class TestFeedbackConversion(unittest.TestCase):
|
| 53 |
+
def setUp(self):
|
| 54 |
+
self._tmpdir = tempfile.TemporaryDirectory()
|
| 55 |
+
self.tmp_path = Path(self._tmpdir.name)
|
| 56 |
+
self.photos_dir = self.tmp_path / "photos"
|
| 57 |
+
self.photos_dir.mkdir()
|
| 58 |
+
self.feedback_path = self.tmp_path / "feedback.jsonl"
|
| 59 |
+
self.annotations_path = self.tmp_path / "annotations.json"
|
| 60 |
+
self.crops_dir = self.tmp_path / "crops"
|
| 61 |
+
|
| 62 |
+
img = np.full((200, 400, 3), 30, dtype=np.uint8)
|
| 63 |
+
cv2.imwrite(str(self.photos_dir / "photo1.jpg"), img)
|
| 64 |
+
|
| 65 |
+
def tearDown(self):
|
| 66 |
+
self._tmpdir.cleanup()
|
| 67 |
+
|
| 68 |
+
def test_convert_creates_pending_review_annotation(self):
|
| 69 |
+
feedback_mod.record_feedback(
|
| 70 |
+
"photo1.jpg", {"prix": 10000, "volume": 14.28, "prix_litre": 700},
|
| 71 |
+
corrected_by="pompiste-7", feedback_path=self.feedback_path,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
count = feedback_mod.convert_feedback_to_annotations(
|
| 75 |
+
photos_dir=self.photos_dir, feedback_path=self.feedback_path,
|
| 76 |
+
annotations_path=self.annotations_path, crops_dir=self.crops_dir,
|
| 77 |
+
)
|
| 78 |
+
self.assertEqual(count, 1)
|
| 79 |
+
|
| 80 |
+
with open(self.annotations_path, "r", encoding="utf-8") as f:
|
| 81 |
+
annotations = json.load(f)
|
| 82 |
+
self.assertIn("photo1.jpg", annotations)
|
| 83 |
+
entry = annotations["photo1.jpg"]
|
| 84 |
+
self.assertEqual(entry["status"], "pending_review")
|
| 85 |
+
self.assertEqual(entry["fields"]["prix"], "10000")
|
| 86 |
+
self.assertEqual(entry["fields"]["volume"], "14.28")
|
| 87 |
+
|
| 88 |
+
entries = feedback_mod.load_feedback_entries(self.feedback_path)
|
| 89 |
+
self.assertTrue(entries[0]["converted"])
|
| 90 |
+
|
| 91 |
+
def test_convert_is_idempotent_for_already_converted_entries(self):
|
| 92 |
+
feedback_mod.record_feedback(
|
| 93 |
+
"photo1.jpg", {"prix": 10000}, feedback_path=self.feedback_path,
|
| 94 |
+
)
|
| 95 |
+
feedback_mod.convert_feedback_to_annotations(
|
| 96 |
+
photos_dir=self.photos_dir, feedback_path=self.feedback_path,
|
| 97 |
+
annotations_path=self.annotations_path, crops_dir=self.crops_dir,
|
| 98 |
+
)
|
| 99 |
+
second_count = feedback_mod.convert_feedback_to_annotations(
|
| 100 |
+
photos_dir=self.photos_dir, feedback_path=self.feedback_path,
|
| 101 |
+
annotations_path=self.annotations_path, crops_dir=self.crops_dir,
|
| 102 |
+
)
|
| 103 |
+
self.assertEqual(second_count, 0)
|
| 104 |
+
|
| 105 |
+
def test_convert_skips_missing_photo(self):
|
| 106 |
+
feedback_mod.record_feedback(
|
| 107 |
+
"ghost.jpg", {"prix": 10000}, feedback_path=self.feedback_path,
|
| 108 |
+
)
|
| 109 |
+
count = feedback_mod.convert_feedback_to_annotations(
|
| 110 |
+
photos_dir=self.photos_dir, feedback_path=self.feedback_path,
|
| 111 |
+
annotations_path=self.annotations_path, crops_dir=self.crops_dir,
|
| 112 |
+
)
|
| 113 |
+
self.assertEqual(count, 0)
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
class TestMetrics(unittest.TestCase):
|
| 117 |
+
def setUp(self):
|
| 118 |
+
self._tmpdir = tempfile.TemporaryDirectory()
|
| 119 |
+
self.log_path = Path(self._tmpdir.name) / "api.log"
|
| 120 |
+
|
| 121 |
+
def tearDown(self):
|
| 122 |
+
self._tmpdir.cleanup()
|
| 123 |
+
|
| 124 |
+
def _write_log_line(self, payload):
|
| 125 |
+
with open(self.log_path, "a", encoding="utf-8") as f:
|
| 126 |
+
f.write(f"2026-07-07 10:00:00,000 | INFO | {json.dumps(payload, ensure_ascii=False)}\n")
|
| 127 |
+
|
| 128 |
+
def test_compute_metrics_on_missing_log_returns_zero(self):
|
| 129 |
+
m = metrics_mod.compute_metrics(self.log_path)
|
| 130 |
+
self.assertEqual(m["total_requests"], 0)
|
| 131 |
+
self.assertIsNone(m["success_rate"])
|
| 132 |
+
|
| 133 |
+
def test_compute_metrics_aggregates_success_and_blocking(self):
|
| 134 |
+
self._write_log_line({
|
| 135 |
+
"model_version": "v2",
|
| 136 |
+
"response": {"success": True, "confidence_score": 0.9, "image_quality": "valid", "message": "ok"},
|
| 137 |
+
})
|
| 138 |
+
self._write_log_line({
|
| 139 |
+
"model_version": "v2",
|
| 140 |
+
"response": {"success": False, "confidence_score": 0.2, "image_quality": "blurry", "message": "Photo floue, veuillez reprendre la photo."},
|
| 141 |
+
})
|
| 142 |
+
with open(self.log_path, "a", encoding="utf-8") as f:
|
| 143 |
+
f.write("2026-07-07 10:00:01,000 | INFO | Modèle CRNN chargé sur cpu.\n")
|
| 144 |
+
|
| 145 |
+
m = metrics_mod.compute_metrics(self.log_path)
|
| 146 |
+
self.assertEqual(m["total_requests"], 2)
|
| 147 |
+
self.assertEqual(m["success_rate"], 0.5)
|
| 148 |
+
self.assertAlmostEqual(m["avg_confidence_score"], 0.55)
|
| 149 |
+
self.assertEqual(m["blocking_causes"]["Photo floue, veuillez reprendre la photo."], 1)
|
| 150 |
+
self.assertEqual(m["image_quality_distribution"]["valid"], 1)
|
| 151 |
+
|
| 152 |
+
def test_list_failed_requests_returns_only_failures_most_recent_first(self):
|
| 153 |
+
self._write_log_line({
|
| 154 |
+
"response": {"success": True, "photo_reference": "ok.jpg", "message": "ok"},
|
| 155 |
+
})
|
| 156 |
+
self._write_log_line({
|
| 157 |
+
"response": {"success": False, "photo_reference": "first-fail.jpg",
|
| 158 |
+
"message": "Photo floue, veuillez reprendre la photo.",
|
| 159 |
+
"image_quality": "blurry", "confidence_score": 0.1},
|
| 160 |
+
})
|
| 161 |
+
self._write_log_line({
|
| 162 |
+
"response": {"success": False, "photo_reference": "second-fail.jpg",
|
| 163 |
+
"message": "Incohérence détectée entre montant, litres et prix.",
|
| 164 |
+
"image_quality": "valid", "confidence_score": 0.4},
|
| 165 |
+
})
|
| 166 |
+
|
| 167 |
+
failures = metrics_mod.list_failed_requests(self.log_path)
|
| 168 |
+
self.assertEqual(len(failures), 2)
|
| 169 |
+
self.assertEqual(failures[0]["photo_reference"], "second-fail.jpg")
|
| 170 |
+
self.assertEqual(failures[1]["photo_reference"], "first-fail.jpg")
|
| 171 |
+
self.assertNotIn("filename", failures[0])
|
| 172 |
+
|
| 173 |
+
def test_list_failed_requests_respects_limit(self):
|
| 174 |
+
for i in range(5):
|
| 175 |
+
self._write_log_line({
|
| 176 |
+
"response": {"success": False, "photo_reference": f"fail{i}.jpg", "message": "x"},
|
| 177 |
+
})
|
| 178 |
+
failures = metrics_mod.list_failed_requests(self.log_path, limit=2)
|
| 179 |
+
self.assertEqual(len(failures), 2)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class TestFeedbackAndMetricsEndpoints(unittest.TestCase):
|
| 183 |
+
def setUp(self):
|
| 184 |
+
self.client = TestClient(main.app)
|
| 185 |
+
self._tmpdir = tempfile.TemporaryDirectory()
|
| 186 |
+
self.fake_photos_dir = Path(self._tmpdir.name)
|
| 187 |
+
(self.fake_photos_dir / "photo.jpg").write_bytes(b"fake")
|
| 188 |
+
self._patch_photos_dir = patch.object(main, "PHOTOS_DIR", self.fake_photos_dir)
|
| 189 |
+
self._patch_photos_dir.start()
|
| 190 |
+
|
| 191 |
+
def tearDown(self):
|
| 192 |
+
self._patch_photos_dir.stop()
|
| 193 |
+
self._tmpdir.cleanup()
|
| 194 |
+
|
| 195 |
+
def test_feedback_rejects_unknown_photo_reference(self):
|
| 196 |
+
resp = self.client.post(
|
| 197 |
+
"/feedback",
|
| 198 |
+
data={"photo_reference": "does-not-exist.jpg", "corrected_prix": "10000"},
|
| 199 |
+
)
|
| 200 |
+
self.assertEqual(resp.status_code, 404)
|
| 201 |
+
|
| 202 |
+
def test_feedback_rejects_no_corrections(self):
|
| 203 |
+
resp = self.client.post("/feedback", data={"photo_reference": "photo.jpg"})
|
| 204 |
+
self.assertEqual(resp.status_code, 400)
|
| 205 |
+
|
| 206 |
+
def test_feedback_success_records_entry(self):
|
| 207 |
+
fake_entry = {"feedback_id": "fake-id"}
|
| 208 |
+
with patch.object(main, "record_feedback", return_value=fake_entry) as mock_record:
|
| 209 |
+
resp = self.client.post(
|
| 210 |
+
"/feedback",
|
| 211 |
+
data={"photo_reference": "photo.jpg", "corrected_prix": "10000", "corrected_by": "pompiste-7"},
|
| 212 |
+
)
|
| 213 |
+
self.assertEqual(resp.status_code, 200)
|
| 214 |
+
body = resp.json()
|
| 215 |
+
self.assertTrue(body["success"])
|
| 216 |
+
self.assertEqual(body["feedback_id"], "fake-id")
|
| 217 |
+
mock_record.assert_called_once()
|
| 218 |
+
args, kwargs = mock_record.call_args
|
| 219 |
+
self.assertEqual(args[0], "photo.jpg")
|
| 220 |
+
self.assertEqual(args[1], {"prix": 10000.0})
|
| 221 |
+
|
| 222 |
+
def test_metrics_endpoint_returns_aggregate(self):
|
| 223 |
+
fake_metrics = {"total_requests": 0, "success_rate": None,
|
| 224 |
+
"avg_confidence_score": None, "blocking_causes": {},
|
| 225 |
+
"image_quality_distribution": {}}
|
| 226 |
+
with patch.object(main, "compute_metrics", return_value=fake_metrics):
|
| 227 |
+
resp = self.client.get("/metrics")
|
| 228 |
+
self.assertEqual(resp.status_code, 200)
|
| 229 |
+
self.assertEqual(resp.json(), fake_metrics)
|
| 230 |
+
|
| 231 |
+
def test_failures_endpoint_returns_list(self):
|
| 232 |
+
fake_failures = [{"photo_reference": "photo.jpg", "message": "Photo floue."}]
|
| 233 |
+
with patch.object(main, "list_failed_requests", return_value=fake_failures):
|
| 234 |
+
resp = self.client.get("/failures")
|
| 235 |
+
self.assertEqual(resp.status_code, 200)
|
| 236 |
+
self.assertEqual(resp.json(), fake_failures)
|
| 237 |
+
|
| 238 |
+
def test_get_photo_returns_file(self):
|
| 239 |
+
resp = self.client.get("/photos/photo.jpg")
|
| 240 |
+
self.assertEqual(resp.status_code, 200)
|
| 241 |
+
self.assertEqual(resp.content, b"fake")
|
| 242 |
+
|
| 243 |
+
def test_get_photo_unknown_reference_returns_404(self):
|
| 244 |
+
resp = self.client.get("/photos/does-not-exist.jpg")
|
| 245 |
+
self.assertEqual(resp.status_code, 404)
|
| 246 |
+
|
| 247 |
+
def test_get_photo_rejects_path_traversal(self):
|
| 248 |
+
outside_file = Path(self._tmpdir.name).parent / "secret.txt"
|
| 249 |
+
outside_file.write_text("should not be servable")
|
| 250 |
+
try:
|
| 251 |
+
resp = self.client.get("/photos/..%2Fsecret.txt")
|
| 252 |
+
self.assertEqual(resp.status_code, 404)
|
| 253 |
+
finally:
|
| 254 |
+
outside_file.unlink(missing_ok=True)
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
if __name__ == "__main__":
|
| 258 |
+
unittest.main()
|
web/feedback.html
ADDED
|
@@ -0,0 +1,228 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>YELY | Corriger une lecture</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0f172a;
|
| 10 |
+
--panel: #1e293b;
|
| 11 |
+
--panel-2: #273449;
|
| 12 |
+
--border: #334155;
|
| 13 |
+
--text: #e2e8f0;
|
| 14 |
+
--muted: #94a3b8;
|
| 15 |
+
--accent: #f59e0b;
|
| 16 |
+
--ok-bg: #052e1f;
|
| 17 |
+
--ok-border: #10b981;
|
| 18 |
+
--ok-text: #6ee7b7;
|
| 19 |
+
--ko-bg: #3b0d0d;
|
| 20 |
+
--ko-border: #ef4444;
|
| 21 |
+
--ko-text: #fca5a5;
|
| 22 |
+
}
|
| 23 |
+
* { box-sizing: border-box; }
|
| 24 |
+
body {
|
| 25 |
+
margin: 0;
|
| 26 |
+
min-height: 100vh;
|
| 27 |
+
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
| 28 |
+
background: var(--bg);
|
| 29 |
+
color: var(--text);
|
| 30 |
+
display: flex;
|
| 31 |
+
justify-content: center;
|
| 32 |
+
padding: 2rem 1rem;
|
| 33 |
+
}
|
| 34 |
+
.app { width: 100%; max-width: 480px; }
|
| 35 |
+
.brand { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.25rem; }
|
| 36 |
+
.brand-left { display: flex; align-items: center; gap: 0.6rem; }
|
| 37 |
+
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
|
| 38 |
+
.brand h1 { font-size: 1.2rem; margin: 0; }
|
| 39 |
+
.brand a { color: var(--muted); font-size: 0.85rem; text-decoration: none; border: 1px solid var(--border); padding: 0.4rem 0.7rem; border-radius: 8px; }
|
| 40 |
+
.brand a:hover { border-color: var(--accent); color: var(--text); }
|
| 41 |
+
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 1.5rem; }
|
| 42 |
+
|
| 43 |
+
.card { background: var(--panel); border: 1px solid var(--border); border-radius: 14px; padding: 1.25rem; margin-bottom: 1rem; }
|
| 44 |
+
|
| 45 |
+
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.9rem 0 0.3rem; }
|
| 46 |
+
label:first-child { margin-top: 0; }
|
| 47 |
+
input {
|
| 48 |
+
width: 100%;
|
| 49 |
+
padding: 0.65rem 0.75rem;
|
| 50 |
+
border-radius: 8px;
|
| 51 |
+
border: 1px solid var(--border);
|
| 52 |
+
background: var(--panel-2);
|
| 53 |
+
color: var(--text);
|
| 54 |
+
font-size: 1rem;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
#photoPreview {
|
| 58 |
+
display: none;
|
| 59 |
+
width: 100%;
|
| 60 |
+
max-height: 280px;
|
| 61 |
+
object-fit: contain;
|
| 62 |
+
border-radius: 10px;
|
| 63 |
+
margin-top: 0.75rem;
|
| 64 |
+
border: 1px solid var(--border);
|
| 65 |
+
background: #000;
|
| 66 |
+
}
|
| 67 |
+
#photoMissing { display: none; color: var(--muted); font-size: 0.85rem; margin-top: 0.6rem; }
|
| 68 |
+
|
| 69 |
+
button {
|
| 70 |
+
width: 100%;
|
| 71 |
+
margin-top: 1.25rem;
|
| 72 |
+
padding: 0.85rem;
|
| 73 |
+
border: none;
|
| 74 |
+
border-radius: 10px;
|
| 75 |
+
background: var(--accent);
|
| 76 |
+
color: #1a1200;
|
| 77 |
+
font-size: 1.05rem;
|
| 78 |
+
font-weight: 700;
|
| 79 |
+
cursor: pointer;
|
| 80 |
+
}
|
| 81 |
+
button:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; }
|
| 82 |
+
|
| 83 |
+
.status { font-size: 0.85rem; color: var(--muted); text-align: center; margin-top: 0.75rem; min-height: 1.2em; }
|
| 84 |
+
|
| 85 |
+
.banner { display: none; border-radius: 12px; padding: 1rem 1.1rem; font-weight: 700; font-size: 1.05rem; margin-bottom: 1rem; border: 1px solid; }
|
| 86 |
+
.banner.ok { display: block; background: var(--ok-bg); border-color: var(--ok-border); color: var(--ok-text); }
|
| 87 |
+
.banner.ko { display: block; background: var(--ko-bg); border-color: var(--ko-border); color: var(--ko-text); }
|
| 88 |
+
|
| 89 |
+
.hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.4rem; }
|
| 90 |
+
</style>
|
| 91 |
+
</head>
|
| 92 |
+
<body>
|
| 93 |
+
<div class="app">
|
| 94 |
+
<div class="brand">
|
| 95 |
+
<div class="brand-left"><span class="dot"></span><h1>YELY | Corriger une lecture</h1></div>
|
| 96 |
+
<a href="stats.html">← Tableau de bord</a>
|
| 97 |
+
</div>
|
| 98 |
+
<p class="subtitle">Renseigne les valeurs réellement affichées sur l'écran, sert à améliorer le modèle (voir docs/MONITORING.md).</p>
|
| 99 |
+
|
| 100 |
+
<div id="banner" class="banner"></div>
|
| 101 |
+
|
| 102 |
+
<form id="form" class="card">
|
| 103 |
+
<label>Référence de la photo</label>
|
| 104 |
+
<input type="text" id="photoReference" placeholder="ex. 3f2a1c9e-....jpg" required />
|
| 105 |
+
<img id="photoPreview" alt="Photo de la transaction" />
|
| 106 |
+
<div id="photoMissing">Photo introuvable pour cette référence (vérifie l'URL de l'API ou la référence).</div>
|
| 107 |
+
|
| 108 |
+
<label>Montant réellement affiché (FCFA)</label>
|
| 109 |
+
<input type="number" id="correctedPrix" step="0.01" placeholder="ex. 10000" />
|
| 110 |
+
|
| 111 |
+
<label>Volume réellement affiché (litres)</label>
|
| 112 |
+
<input type="number" id="correctedVolume" step="0.01" placeholder="ex. 14.28" />
|
| 113 |
+
|
| 114 |
+
<label>Prix du litre réellement affiché (FCFA)</label>
|
| 115 |
+
<input type="number" id="correctedPrixLitre" step="0.01" placeholder="ex. 700" />
|
| 116 |
+
|
| 117 |
+
<label>Corrigé par (optionnel)</label>
|
| 118 |
+
<input type="text" id="correctedBy" placeholder="pompiste-7" />
|
| 119 |
+
|
| 120 |
+
<details style="margin-top: 0.9rem;">
|
| 121 |
+
<summary style="cursor:pointer; color: var(--muted); font-size: 0.8rem;">Configuration (URL de l'API)</summary>
|
| 122 |
+
<label>URL du service (base, sans /analyze)</label>
|
| 123 |
+
<input type="text" id="apiUrl" />
|
| 124 |
+
</details>
|
| 125 |
+
|
| 126 |
+
<button type="submit" id="submitBtn">Envoyer la correction</button>
|
| 127 |
+
<div class="status" id="status"></div>
|
| 128 |
+
<div class="hint">Au moins une valeur est requise ; laisse vide ce qui n'est pas lisible.</div>
|
| 129 |
+
</form>
|
| 130 |
+
</div>
|
| 131 |
+
|
| 132 |
+
<script src="config.js"></script>
|
| 133 |
+
<script>
|
| 134 |
+
const form = document.getElementById('form');
|
| 135 |
+
const submitBtn = document.getElementById('submitBtn');
|
| 136 |
+
const statusEl = document.getElementById('status');
|
| 137 |
+
const banner = document.getElementById('banner');
|
| 138 |
+
const photoRefInput = document.getElementById('photoReference');
|
| 139 |
+
const photoPreview = document.getElementById('photoPreview');
|
| 140 |
+
const photoMissing = document.getElementById('photoMissing');
|
| 141 |
+
const apiUrlInput = document.getElementById('apiUrl');
|
| 142 |
+
|
| 143 |
+
function defaultBaseUrl() {
|
| 144 |
+
const stored = localStorage.getItem('yely_stats_base_url')
|
| 145 |
+
|| localStorage.getItem('yely_api_url')
|
| 146 |
+
|| window.YELY_API_URL || '';
|
| 147 |
+
return stored.replace(/\/analyze\/?$/, '').replace(/\/+$/, '');
|
| 148 |
+
}
|
| 149 |
+
apiUrlInput.value = defaultBaseUrl();
|
| 150 |
+
|
| 151 |
+
const params = new URLSearchParams(window.location.search);
|
| 152 |
+
const prefillRef = params.get('ref') || params.get('photo_reference');
|
| 153 |
+
if (prefillRef) photoRefInput.value = prefillRef;
|
| 154 |
+
|
| 155 |
+
function loadPhotoPreview() {
|
| 156 |
+
const base = apiUrlInput.value.trim().replace(/\/+$/, '');
|
| 157 |
+
const ref = photoRefInput.value.trim();
|
| 158 |
+
photoPreview.style.display = 'none';
|
| 159 |
+
photoMissing.style.display = 'none';
|
| 160 |
+
if (!base || !ref) return;
|
| 161 |
+
|
| 162 |
+
const url = base + '/photos/' + encodeURIComponent(ref);
|
| 163 |
+
photoPreview.onload = () => { photoPreview.style.display = 'block'; };
|
| 164 |
+
photoPreview.onerror = () => { photoMissing.style.display = 'block'; };
|
| 165 |
+
photoPreview.src = url + '?t=' + Date.now();
|
| 166 |
+
}
|
| 167 |
+
|
| 168 |
+
photoRefInput.addEventListener('change', loadPhotoPreview);
|
| 169 |
+
apiUrlInput.addEventListener('change', loadPhotoPreview);
|
| 170 |
+
loadPhotoPreview();
|
| 171 |
+
|
| 172 |
+
form.addEventListener('submit', async (event) => {
|
| 173 |
+
event.preventDefault();
|
| 174 |
+
|
| 175 |
+
const base = apiUrlInput.value.trim().replace(/\/+$/, '');
|
| 176 |
+
localStorage.setItem('yely_stats_base_url', base);
|
| 177 |
+
const ref = photoRefInput.value.trim();
|
| 178 |
+
if (!base || !ref) {
|
| 179 |
+
statusEl.textContent = "Renseigne l'URL de l'API et la référence de la photo.";
|
| 180 |
+
return;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
const prix = document.getElementById('correctedPrix').value;
|
| 184 |
+
const volume = document.getElementById('correctedVolume').value;
|
| 185 |
+
const prixLitre = document.getElementById('correctedPrixLitre').value;
|
| 186 |
+
if (!prix && !volume && !prixLitre) {
|
| 187 |
+
statusEl.textContent = "Renseigne au moins une valeur corrigée.";
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
const formData = new FormData();
|
| 192 |
+
formData.append('photo_reference', ref);
|
| 193 |
+
if (prix) formData.append('corrected_prix', prix);
|
| 194 |
+
if (volume) formData.append('corrected_volume', volume);
|
| 195 |
+
if (prixLitre) formData.append('corrected_prix_litre', prixLitre);
|
| 196 |
+
const correctedBy = document.getElementById('correctedBy').value.trim();
|
| 197 |
+
if (correctedBy) formData.append('corrected_by', correctedBy);
|
| 198 |
+
|
| 199 |
+
submitBtn.disabled = true;
|
| 200 |
+
submitBtn.textContent = 'Envoi…';
|
| 201 |
+
banner.className = 'banner';
|
| 202 |
+
banner.textContent = '';
|
| 203 |
+
statusEl.textContent = 'Envoi de la correction…';
|
| 204 |
+
|
| 205 |
+
try {
|
| 206 |
+
const res = await fetch(base + '/feedback', { method: 'POST', body: formData });
|
| 207 |
+
const data = await res.json();
|
| 208 |
+
if (res.ok && data.success) {
|
| 209 |
+
banner.className = 'banner ok';
|
| 210 |
+
banner.textContent = '✅ Correction enregistrée, merci.';
|
| 211 |
+
statusEl.textContent = '';
|
| 212 |
+
} else {
|
| 213 |
+
banner.className = 'banner ko';
|
| 214 |
+
banner.textContent = '⛔ ' + (data.detail || 'Échec de l\'enregistrement.');
|
| 215 |
+
statusEl.textContent = '';
|
| 216 |
+
}
|
| 217 |
+
} catch (err) {
|
| 218 |
+
banner.className = 'banner ko';
|
| 219 |
+
banner.textContent = "⛔ Impossible de contacter l'API (" + err.message + ").";
|
| 220 |
+
statusEl.textContent = '';
|
| 221 |
+
} finally {
|
| 222 |
+
submitBtn.disabled = false;
|
| 223 |
+
submitBtn.textContent = 'Envoyer la correction';
|
| 224 |
+
}
|
| 225 |
+
});
|
| 226 |
+
</script>
|
| 227 |
+
</body>
|
| 228 |
+
</html>
|
web/stats.html
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="fr">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<title>YELY | Tableau de bord</title>
|
| 7 |
+
<style>
|
| 8 |
+
:root {
|
| 9 |
+
--bg: #0f172a;
|
| 10 |
+
--panel: #1e293b;
|
| 11 |
+
--panel-2: #273449;
|
| 12 |
+
--border: #334155;
|
| 13 |
+
--text: #e2e8f0;
|
| 14 |
+
--muted: #94a3b8;
|
| 15 |
+
--accent: #f59e0b;
|
| 16 |
+
--ok-text: #6ee7b7;
|
| 17 |
+
--ko-text: #fca5a5;
|
| 18 |
+
}
|
| 19 |
+
* { box-sizing: border-box; }
|
| 20 |
+
body {
|
| 21 |
+
margin: 0;
|
| 22 |
+
min-height: 100vh;
|
| 23 |
+
font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
|
| 24 |
+
background: var(--bg);
|
| 25 |
+
color: var(--text);
|
| 26 |
+
display: flex;
|
| 27 |
+
justify-content: center;
|
| 28 |
+
padding: 2rem 1rem;
|
| 29 |
+
}
|
| 30 |
+
.app { width: 100%; max-width: 720px; }
|
| 31 |
+
.brand { display: flex; align-items: center; justify-content: space-between; gap: 0.6rem; margin-bottom: 0.25rem; }
|
| 32 |
+
.brand-left { display: flex; align-items: center; gap: 0.6rem; }
|
| 33 |
+
.brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
|
| 34 |
+
.brand h1 { font-size: 1.3rem; margin: 0; }
|
| 35 |
+
.brand a { color: var(--muted); font-size: 0.85rem; text-decoration: none; border: 1px solid var(--border); padding: 0.4rem 0.7rem; border-radius: 8px; }
|
| 36 |
+
.brand a:hover { border-color: var(--accent); color: var(--text); }
|
| 37 |
+
.subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 1.5rem; }
|
| 38 |
+
|
| 39 |
+
.card {
|
| 40 |
+
background: var(--panel);
|
| 41 |
+
border: 1px solid var(--border);
|
| 42 |
+
border-radius: 14px;
|
| 43 |
+
padding: 1.25rem;
|
| 44 |
+
margin-bottom: 1rem;
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0 0 0.3rem; }
|
| 48 |
+
input {
|
| 49 |
+
width: 100%;
|
| 50 |
+
padding: 0.6rem 0.75rem;
|
| 51 |
+
border-radius: 8px;
|
| 52 |
+
border: 1px solid var(--border);
|
| 53 |
+
background: var(--panel-2);
|
| 54 |
+
color: var(--text);
|
| 55 |
+
font-size: 0.95rem;
|
| 56 |
+
}
|
| 57 |
+
.config-row { display: flex; gap: 0.6rem; align-items: flex-end; }
|
| 58 |
+
.config-row > div:first-child { flex: 1; }
|
| 59 |
+
button {
|
| 60 |
+
padding: 0.6rem 1rem;
|
| 61 |
+
border: none;
|
| 62 |
+
border-radius: 8px;
|
| 63 |
+
background: var(--accent);
|
| 64 |
+
color: #1a1200;
|
| 65 |
+
font-size: 0.9rem;
|
| 66 |
+
font-weight: 700;
|
| 67 |
+
cursor: pointer;
|
| 68 |
+
white-space: nowrap;
|
| 69 |
+
}
|
| 70 |
+
button:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; }
|
| 71 |
+
|
| 72 |
+
.metrics-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0.8rem; }
|
| 73 |
+
.metric { background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 0.9rem; text-align: center; }
|
| 74 |
+
.metric .value { font-size: 1.8rem; font-weight: 700; }
|
| 75 |
+
.metric .label { color: var(--muted); font-size: 0.78rem; margin-top: 0.2rem; }
|
| 76 |
+
|
| 77 |
+
.section-title { font-size: 0.85rem; color: var(--muted); margin: 0 0 0.6rem; text-transform: uppercase; letter-spacing: 0.03em; }
|
| 78 |
+
.bar-row { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.5rem; }
|
| 79 |
+
.bar-row .bar-label { width: 44%; font-size: 0.85rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
| 80 |
+
.bar-row .bar-track { flex: 1; background: var(--panel-2); border-radius: 6px; height: 10px; overflow: hidden; }
|
| 81 |
+
.bar-row .bar-fill { height: 100%; background: var(--accent); }
|
| 82 |
+
.bar-row .bar-count { width: 2.5rem; text-align: right; font-size: 0.85rem; color: var(--muted); }
|
| 83 |
+
.bar-row.blocking .bar-fill { background: var(--ko-text); }
|
| 84 |
+
|
| 85 |
+
.empty { color: var(--muted); font-size: 0.85rem; text-align: center; padding: 1rem 0; }
|
| 86 |
+
.status { font-size: 0.8rem; color: var(--muted); text-align: center; margin-top: 0.5rem; }
|
| 87 |
+
|
| 88 |
+
.failure-list { display: flex; flex-direction: column; gap: 0.8rem; }
|
| 89 |
+
.failure-card { display: flex; gap: 0.8rem; background: var(--panel-2); border: 1px solid var(--border); border-radius: 10px; padding: 0.7rem; align-items: center; }
|
| 90 |
+
.failure-card img { width: 84px; height: 84px; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
|
| 91 |
+
.failure-card .no-photo { width: 84px; height: 84px; border-radius: 8px; background: #000; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 0.7rem; text-align: center; }
|
| 92 |
+
.failure-info { flex: 1; min-width: 0; }
|
| 93 |
+
.failure-info .msg { font-size: 0.85rem; color: var(--ko-text); font-weight: 600; margin-bottom: 0.2rem; }
|
| 94 |
+
.failure-info .meta { font-size: 0.78rem; color: var(--muted); }
|
| 95 |
+
.failure-card a.fix-link {
|
| 96 |
+
flex-shrink: 0; text-decoration: none; background: var(--accent); color: #1a1200;
|
| 97 |
+
font-size: 0.82rem; font-weight: 700; padding: 0.5rem 0.8rem; border-radius: 8px;
|
| 98 |
+
}
|
| 99 |
+
</style>
|
| 100 |
+
</head>
|
| 101 |
+
<body>
|
| 102 |
+
<div class="app">
|
| 103 |
+
<div class="brand">
|
| 104 |
+
<div class="brand-left"><span class="dot"></span><h1>YELY |Tableau de bord</h1></div>
|
| 105 |
+
<a href="index.html">← Démo /analyze</a>
|
| 106 |
+
</div>
|
| 107 |
+
<p class="subtitle">Suivi des performances du module IA en production (voir docs/MONITORING.md).</p>
|
| 108 |
+
|
| 109 |
+
<div class="card">
|
| 110 |
+
<div class="config-row">
|
| 111 |
+
<div>
|
| 112 |
+
<label>URL de l'API (base, sans /analyze)</label>
|
| 113 |
+
<input type="text" id="apiUrl" placeholder="https://xxx.hf.space" />
|
| 114 |
+
</div>
|
| 115 |
+
<button id="refreshBtn">Actualiser</button>
|
| 116 |
+
</div>
|
| 117 |
+
<div class="status" id="status"></div>
|
| 118 |
+
</div>
|
| 119 |
+
|
| 120 |
+
<div class="card">
|
| 121 |
+
<div class="metrics-grid">
|
| 122 |
+
<div class="metric">
|
| 123 |
+
<div class="value" id="mTotal">—</div>
|
| 124 |
+
<div class="label">Requêtes totales</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div class="metric">
|
| 127 |
+
<div class="value" id="mSuccess">—</div>
|
| 128 |
+
<div class="label">Taux de succès</div>
|
| 129 |
+
</div>
|
| 130 |
+
<div class="metric">
|
| 131 |
+
<div class="value" id="mConfidence">—</div>
|
| 132 |
+
<div class="label">Confiance moyenne</div>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</div>
|
| 136 |
+
|
| 137 |
+
<div class="card">
|
| 138 |
+
<div class="section-title">Répartition qualité image</div>
|
| 139 |
+
<div id="qualityBars"><div class="empty">Aucune donnée pour l'instant.</div></div>
|
| 140 |
+
</div>
|
| 141 |
+
|
| 142 |
+
<div class="card">
|
| 143 |
+
<div class="section-title">Causes de blocage</div>
|
| 144 |
+
<div id="blockingBars"><div class="empty">Aucune donnée pour l'instant.</div></div>
|
| 145 |
+
</div>
|
| 146 |
+
|
| 147 |
+
<div class="card">
|
| 148 |
+
<div class="section-title">Requêtes échouées (20 dernières)</div>
|
| 149 |
+
<div id="failureList" class="failure-list"><div class="empty">Aucune donnée pour l'instant.</div></div>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<script src="config.js"></script>
|
| 154 |
+
<script>
|
| 155 |
+
const apiUrlInput = document.getElementById('apiUrl');
|
| 156 |
+
const refreshBtn = document.getElementById('refreshBtn');
|
| 157 |
+
const statusEl = document.getElementById('status');
|
| 158 |
+
const mTotal = document.getElementById('mTotal');
|
| 159 |
+
const mSuccess = document.getElementById('mSuccess');
|
| 160 |
+
const mConfidence = document.getElementById('mConfidence');
|
| 161 |
+
const qualityBars = document.getElementById('qualityBars');
|
| 162 |
+
const blockingBars = document.getElementById('blockingBars');
|
| 163 |
+
const failureList = document.getElementById('failureList');
|
| 164 |
+
|
| 165 |
+
function defaultBaseUrl() {
|
| 166 |
+
const stored = localStorage.getItem('yely_api_url') || window.YELY_API_URL || '';
|
| 167 |
+
return stored.replace(/\/analyze\/?$/, '');
|
| 168 |
+
}
|
| 169 |
+
apiUrlInput.value = localStorage.getItem('yely_stats_base_url') || defaultBaseUrl();
|
| 170 |
+
|
| 171 |
+
function renderBars(container, data, variant) {
|
| 172 |
+
container.innerHTML = '';
|
| 173 |
+
const items = Object.entries(data || {});
|
| 174 |
+
if (items.length === 0) {
|
| 175 |
+
container.innerHTML = '<div class="empty">Aucune donnée pour l\'instant.</div>';
|
| 176 |
+
return;
|
| 177 |
+
}
|
| 178 |
+
const max = Math.max(...items.map(([, count]) => count));
|
| 179 |
+
for (const [label, count] of items) {
|
| 180 |
+
const row = document.createElement('div');
|
| 181 |
+
row.className = 'bar-row' + (variant ? ' ' + variant : '');
|
| 182 |
+
const pct = max > 0 ? Math.round((count / max) * 100) : 0;
|
| 183 |
+
row.innerHTML = `
|
| 184 |
+
<span class="bar-label" title="${label}">${label}</span>
|
| 185 |
+
<span class="bar-track"><span class="bar-fill" style="width:${pct}%"></span></span>
|
| 186 |
+
<span class="bar-count">${count}</span>`;
|
| 187 |
+
container.appendChild(row);
|
| 188 |
+
}
|
| 189 |
+
}
|
| 190 |
+
|
| 191 |
+
function fmtVal(v) {
|
| 192 |
+
return (v === null || v === undefined) ? '—' : v;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
function renderFailures(base, items) {
|
| 196 |
+
failureList.innerHTML = '';
|
| 197 |
+
if (!items || items.length === 0) {
|
| 198 |
+
failureList.innerHTML = '<div class="empty">Aucune requête échouée pour l\'instant.</div>';
|
| 199 |
+
return;
|
| 200 |
+
}
|
| 201 |
+
for (const item of items) {
|
| 202 |
+
const card = document.createElement('div');
|
| 203 |
+
card.className = 'failure-card';
|
| 204 |
+
|
| 205 |
+
let photoHtml;
|
| 206 |
+
if (item.photo_reference) {
|
| 207 |
+
const photoUrl = base + '/photos/' + encodeURIComponent(item.photo_reference);
|
| 208 |
+
photoHtml = `<img src="${photoUrl}" alt="Photo" onerror="this.outerHTML='<div class="no-photo">Photo indisponible</div>'" />`;
|
| 209 |
+
} else {
|
| 210 |
+
photoHtml = '<div class="no-photo">Pas de photo</div>';
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
const fixHref = 'feedback.html?ref=' + encodeURIComponent(item.photo_reference || '');
|
| 214 |
+
|
| 215 |
+
card.innerHTML = `
|
| 216 |
+
${photoHtml}
|
| 217 |
+
<div class="failure-info">
|
| 218 |
+
<div class="msg">${item.message || 'Échec non précisé'}</div>
|
| 219 |
+
<div class="meta">
|
| 220 |
+
qualité: ${fmtVal(item.image_quality)} · confiance: ${item.confidence_score != null ? Math.round(item.confidence_score * 100) + '%' : '—'}<br/>
|
| 221 |
+
montant: ${fmtVal(item.detected_amount)} · litres: ${fmtVal(item.detected_liters)} · prix/L: ${fmtVal(item.fuel_price)}
|
| 222 |
+
</div>
|
| 223 |
+
</div>
|
| 224 |
+
${item.photo_reference ? `<a class="fix-link" href="${fixHref}">Corriger</a>` : ''}`;
|
| 225 |
+
failureList.appendChild(card);
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
+
async function loadMetrics() {
|
| 230 |
+
const base = apiUrlInput.value.trim().replace(/\/+$/, '');
|
| 231 |
+
localStorage.setItem('yely_stats_base_url', base);
|
| 232 |
+
if (!base) {
|
| 233 |
+
statusEl.textContent = "Renseigne l'URL de l'API.";
|
| 234 |
+
return;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
refreshBtn.disabled = true;
|
| 238 |
+
refreshBtn.textContent = 'Chargement…';
|
| 239 |
+
statusEl.textContent = 'Récupération des métriques…';
|
| 240 |
+
|
| 241 |
+
try {
|
| 242 |
+
const [metricsRes, failuresRes] = await Promise.all([
|
| 243 |
+
fetch(base + '/metrics'),
|
| 244 |
+
fetch(base + '/failures'),
|
| 245 |
+
]);
|
| 246 |
+
const data = await metricsRes.json();
|
| 247 |
+
if (!metricsRes.ok) throw new Error('HTTP ' + metricsRes.status);
|
| 248 |
+
|
| 249 |
+
mTotal.textContent = data.total_requests ?? 0;
|
| 250 |
+
mSuccess.textContent = data.success_rate === null || data.success_rate === undefined
|
| 251 |
+
? '—' : Math.round(data.success_rate * 100) + '%';
|
| 252 |
+
mConfidence.textContent = data.avg_confidence_score === null || data.avg_confidence_score === undefined
|
| 253 |
+
? '—' : Math.round(data.avg_confidence_score * 100) + '%';
|
| 254 |
+
|
| 255 |
+
renderBars(qualityBars, data.image_quality_distribution);
|
| 256 |
+
renderBars(blockingBars, data.blocking_causes, 'blocking');
|
| 257 |
+
|
| 258 |
+
if (failuresRes.ok) {
|
| 259 |
+
renderFailures(base, await failuresRes.json());
|
| 260 |
+
}
|
| 261 |
+
|
| 262 |
+
statusEl.textContent = 'Mis à jour à ' + new Date().toLocaleTimeString('fr-FR');
|
| 263 |
+
} catch (err) {
|
| 264 |
+
statusEl.textContent = "Impossible de contacter l'API (" + err.message + ").";
|
| 265 |
+
} finally {
|
| 266 |
+
refreshBtn.disabled = false;
|
| 267 |
+
refreshBtn.textContent = 'Actualiser';
|
| 268 |
+
}
|
| 269 |
+
}
|
| 270 |
+
|
| 271 |
+
refreshBtn.addEventListener('click', loadMetrics);
|
| 272 |
+
apiUrlInput.addEventListener('change', loadMetrics);
|
| 273 |
+
loadMetrics();
|
| 274 |
+
setInterval(loadMetrics, 15000);
|
| 275 |
+
</script>
|
| 276 |
+
</body>
|
| 277 |
+
</html>
|
web/vercel.json
DELETED
|
@@ -1,3 +0,0 @@
|
|
| 1 |
-
{
|
| 2 |
-
"cleanUrls": true
|
| 3 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|