danielxdata commited on
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 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
- LOG_DIR = MODULE_ROOT / "logs"
 
 
 
 
 
 
 
 
33
  LOG_DIR.mkdir(parents=True, exist_ok=True)
34
- PHOTOS_DIR = MODULE_ROOT / "photos"
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", "https://yely-demo.netlify.app/")
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
- # Le flou/la luminosité sont des propriétés intrinsèques de l'image et
29
- # doivent être vérifiés avant le repli "unclear" (nombre de résultats),
30
- # sinon une image nette et bien éclairée mais avec peu de détections
31
- # serait à tort classée "unclear" plutôt que diagnostiquée correctement.
32
- if blur < cfg.blur_threshold:
33
- label = "blurry"
34
- elif mean_brightness < cfg.dark_threshold:
 
 
 
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
- │ └── test_integration.py # à faire : vrai modèle, vraies images
 
19
  ├── tools/
20
  │ └── check_seen_image.py # ✅ fait
21
- ├── monitoring/ # à créer — §2
22
- │ ├── logger.py # log structuré prédiction + vérité terrain
23
- │ ├── metrics.py # calcul accuracy glissante, drift
24
- │ └── feedback.py # collecte des corrections pompiste
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/ # à créer — §5, interface de démo
35
- │ ├── index.html / app Next.js
36
- │ └── vercel.json
 
 
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=&quot;no-photo&quot;>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
- }