Rzx69 commited on
Commit
ffd7ba6
·
1 Parent(s): 058f0e4

Probleme de Localisation et nouvel attribut Role Media

Browse files
Check_claim_gemini.py CHANGED
@@ -281,6 +281,18 @@ ZONE_FR_TO_PART = {
281
  "pare-choc arriere": "back-bumper",
282
  "pare choc arriere": "back-bumper",
283
 
 
 
 
 
 
 
 
 
 
 
 
 
284
  # Capot / coffre / toit
285
  "capot": "hood",
286
  "coffre": "trunk",
@@ -302,6 +314,13 @@ ZONE_FR_TO_PART = {
302
  "portiere arriere droite": "right_back-door",
303
  "porte arriere droite": "right_back-door",
304
 
 
 
 
 
 
 
 
305
  # Rétroviseurs
306
  "retroviseur gauche": "left_mirror",
307
  "retro gauche": "left_mirror",
@@ -365,6 +384,47 @@ ZONE_FR_TO_PART = {
365
  # Tri par longueur de clé décroissante : on veut matcher "aile arriere droite" avant un éventuel motif plus générique qui s'y trouverait inclus.
366
  _ZONE_KEYS_SORTED = sorted(ZONE_FR_TO_PART.keys(), key=len, reverse=True)
367
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
368
  EXTRACTION_PROMPT_TEMPLATE = """Tu es un extracteur d'informations strict. On te donne une phrase décrivant une réclamation client sur un véhicule.
369
 
370
  Extrait UNIQUEMENT deux informations :
@@ -1283,6 +1343,74 @@ def save_claim_result(result, vehicle_id, output_dir):
1283
  return out_path
1284
 
1285
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1286
  # ─── TRAITEMENT EN LOT, MODÈLES CHARGÉS UNE SEULE FOIS ──────────
1287
 
1288
  def process_claims_batch(claims, output_dir):
 
281
  "pare-choc arriere": "back-bumper",
282
  "pare choc arriere": "back-bumper",
283
 
284
+ # Pare-choc(s) SANS précision avant/arrière (ex: "le pare-chocs est
285
+ # abîmé") -> on ne devine PAS lequel des deux : on couvre les DEUX
286
+ # (avant ET arrière) plutôt que de tomber sur "zone non spécifiée"
287
+ # (qui élargirait la vérification à toute la voiture au lieu de la
288
+ # restreindre aux pare-chocs). Placées après les variantes avant/arrière
289
+ # ci-dessus : peu importe, le tri par longueur de clé (_ZONE_KEYS_SORTED)
290
+ # garantit que "pare-choc avant"/"pare-choc arriere" sont essayées avant.
291
+ "pare-choc": ["front-bumper", "back-bumper"],
292
+ "pare choc": ["front-bumper", "back-bumper"],
293
+ "pare-chocs": ["front-bumper", "back-bumper"],
294
+ "pare chocs": ["front-bumper", "back-bumper"],
295
+
296
  # Capot / coffre / toit
297
  "capot": "hood",
298
  "coffre": "trunk",
 
314
  "portiere arriere droite": "right_back-door",
315
  "porte arriere droite": "right_back-door",
316
 
317
+ # Porte(s)/portière(s) SANS précision de côté NI d'axe avant/arrière
318
+ # (ex: "une porte est enfoncée") -> comme pour le pare-choc, on ne
319
+ # devine pas laquelle : on couvre les 4 portes plutôt que d'élargir la
320
+ # vérification à toute la voiture.
321
+ "portiere": ["left_front-door", "right_front-door", "left_back-door", "right_back-door"],
322
+ "porte": ["left_front-door", "right_front-door", "left_back-door", "right_back-door"],
323
+
324
  # Rétroviseurs
325
  "retroviseur gauche": "left_mirror",
326
  "retro gauche": "left_mirror",
 
384
  # Tri par longueur de clé décroissante : on veut matcher "aile arriere droite" avant un éventuel motif plus générique qui s'y trouverait inclus.
385
  _ZONE_KEYS_SORTED = sorted(ZONE_FR_TO_PART.keys(), key=len, reverse=True)
386
 
387
+
388
+ # ─── CORRESPONDANCE CODE PIÈCE (nomenclature CV) -> LIBELLÉ FRANÇAIS ─────────
389
+ # Utilisé pour afficher un libellé lisible (colonne AppSheet "Localisation
390
+ # Carrosserie") plutôt que le code technique interne (ex: "front-bumper").
391
+ CODE_TO_FR_LABEL = {
392
+ "front-bumper": "Pare-choc avant",
393
+ "back-bumper": "Pare-choc arrière",
394
+ "hood": "Capot",
395
+ "trunk": "Coffre",
396
+ "roof": "Toit",
397
+ "license-plate": "Plaque d'immatriculation",
398
+ "grille": "Calandre",
399
+ "windshield": "Pare-brise avant",
400
+ "back-windshield": "Pare-brise arrière",
401
+ "left_front-door": "Portière avant gauche",
402
+ "right_front-door": "Portière avant droite",
403
+ "left_back-door": "Portière arrière gauche",
404
+ "right_back-door": "Portière arrière droite",
405
+ "left_mirror": "Rétroviseur gauche",
406
+ "right_mirror": "Rétroviseur droit",
407
+ "left_headlight": "Phare avant gauche",
408
+ "right_headlight": "Phare avant droit",
409
+ "left_tail-light": "Feu arrière gauche",
410
+ "right_tail-light": "Feu arrière droit",
411
+ "left_front-wheel": "Roue avant gauche",
412
+ "right_front-wheel": "Roue avant droite",
413
+ "left_back-wheel": "Roue arrière gauche",
414
+ "right_back-wheel": "Roue arrière droite",
415
+ "left_front-window": "Vitre avant gauche",
416
+ "right_front-window": "Vitre avant droite",
417
+ "left_back-window": "Vitre arrière gauche",
418
+ "right_back-window": "Vitre arrière droite",
419
+ "left_rocker-panel": "Bas de caisse gauche",
420
+ "right_rocker-panel": "Bas de caisse droit",
421
+ "left_fender": "Aile avant gauche",
422
+ "right_fender": "Aile avant droite",
423
+ "left_quarter-panel": "Aile arrière gauche",
424
+ "right_quarter-panel": "Aile arrière droite",
425
+ }
426
+
427
+
428
  EXTRACTION_PROMPT_TEMPLATE = """Tu es un extracteur d'informations strict. On te donne une phrase décrivant une réclamation client sur un véhicule.
429
 
430
  Extrait UNIQUEMENT deux informations :
 
1343
  return out_path
1344
 
1345
 
1346
+ # ─── RÔLE DES MÉDIAS (table AppSheet "Medias Cameras", colonne "Role Media") ──
1347
+ # Valeurs possibles de l'enum "Role Media" :
1348
+ # - "Dommages" : le dommage réclamé est confirmé visible sur ce média.
1349
+ # - "Parties Visibles" : ce média montre la pièce réclamée, mais sans dommage
1350
+ # confirmé dessus (utile pour visualiser l'état du
1351
+ # véhicule à l'entrée).
1352
+ # - "Inutile" : ce média ne montre pas la pièce réclamée.
1353
+ ROLE_DOMMAGES = "Dommages"
1354
+ ROLE_PARTIES_VISIBLES = "Parties Visibles"
1355
+ ROLE_INUTILE = "Inutile"
1356
+
1357
+
1358
+ def build_media_role_resolver(claim_result: dict):
1359
+ """
1360
+ À partir du résultat de process_claim()/process_multi_claim(), construit
1361
+ une fonction `resolver(file_id, is_image) -> role` qui donne le rôle
1362
+ ("Dommages" / "Parties Visibles" / "Inutile") d'un média d'entrée à
1363
+ partir de son ID Drive (utilisé comme nom de fichier local par
1364
+ drive_enrichment.py / drive_lookup.py, ex: "abcd1234.png") et de son
1365
+ type (photo ou vidéo).
1366
+
1367
+ Règles :
1368
+ - Photo : "Dommages" si un dommage y a ��té confirmé (damage_visible=True
1369
+ sur au moins un "check" qui ne vient pas d'une frame vidéo) ; sinon
1370
+ "Parties Visibles" si elle couvre la pièce réclamée (présente dans
1371
+ candidate_images d'au moins un dommage) ; sinon "Inutile" (ne montre
1372
+ pas la pièce réclamée par la réclamation).
1373
+ - Vidéo : suit la même logique que leur upload dans Preuves / Parties
1374
+ Visibles (finalize_claim.executer_bilan_final /
1375
+ report_builder.construire_bilan_final) : "Dommages" si le verdict
1376
+ global est OUI, "Parties Visibles" si la zone n'a pas pu être
1377
+ précisée (mapped_part=None -> toutes les vidéos sont ajoutées pour
1378
+ vérification à l'oeil nu), "Inutile" sinon.
1379
+ """
1380
+ sous_resultats = claim_result.get("damages") or [claim_result]
1381
+
1382
+ photos_avec_dommage = set()
1383
+ photos_visibles = set()
1384
+ for r in sous_resultats:
1385
+ for check in r.get("checks", []):
1386
+ if check.get("from_video"):
1387
+ continue
1388
+ file_id = Path(check["image_path"]).stem
1389
+ photos_visibles.add(file_id)
1390
+ if check.get("damage_visible") is True:
1391
+ photos_avec_dommage.add(file_id)
1392
+ for chemin_image in r.get("candidate_images", []):
1393
+ photos_visibles.add(Path(chemin_image).stem)
1394
+
1395
+ if claim_result.get("verdict") == "OUI":
1396
+ role_video = ROLE_DOMMAGES
1397
+ elif claim_result.get("mapped_part") is None:
1398
+ role_video = ROLE_PARTIES_VISIBLES
1399
+ else:
1400
+ role_video = ROLE_INUTILE
1401
+
1402
+ def resolver(file_id: str, is_image: bool) -> str:
1403
+ if not is_image:
1404
+ return role_video
1405
+ if file_id in photos_avec_dommage:
1406
+ return ROLE_DOMMAGES
1407
+ if file_id in photos_visibles:
1408
+ return ROLE_PARTIES_VISIBLES
1409
+ return ROLE_INUTILE
1410
+
1411
+ return resolver
1412
+
1413
+
1414
  # ─── TRAITEMENT EN LOT, MODÈLES CHARGÉS UNE SEULE FOIS ──────────
1415
 
1416
  def process_claims_batch(claims, output_dir):
appsheet_client.py CHANGED
@@ -129,6 +129,13 @@ def ajouter_medias(rows: list):
129
  appsheet_action(TABLE_MEDIAS, "Add", rows)
130
 
131
 
 
 
 
 
 
 
 
132
  def find_rows(table: str, selector: str = None) -> list:
133
  """Lit des lignes d'une table AppSheet (action Find), avec un filtre
134
  optionnel au format expression AppSheet, ex:
 
129
  appsheet_action(TABLE_MEDIAS, "Add", rows)
130
 
131
 
132
+ def edit_media(row_id: str, id_media: str, champs: dict):
133
+ """Met à jour une ligne existante de Medias Cameras (ex: colonne
134
+ "Role Media"). `champs` est un dict {nom_colonne_appsheet: valeur}."""
135
+ row = {"Row ID": row_id, "ID Media": id_media, **champs}
136
+ appsheet_action(TABLE_MEDIAS, "Edit", [row])
137
+
138
+
139
  def find_rows(table: str, selector: str = None) -> list:
140
  """Lit des lignes d'une table AppSheet (action Find), avec un filtre
141
  optionnel au format expression AppSheet, ex:
extract_claim_from_email.py CHANGED
@@ -182,13 +182,14 @@ valide, sans aucun texte avant ou après, avec exactement ces clés :
182
  "vehicle_model": "Marque et modèle du véhicule si mentionné, sinon null",
183
  "claim_message": "Reformulation neutre, factuelle et concise du/des dommage(s), en français naturel, sans code technique. Si PLUSIEURS dommages distincts sont mentionnés (zones et/ou types différents), ils doivent TOUS être repris, sous forme d'une phrase par dommage séparée par un point-virgule (ex: 'Dégradation constatée sur le pare-choc avant.' ou 'Rayure sur le pare-choc avant ; enfoncement sur la portière arrière droite.'). Ne jamais en omettre un au profit d'un seul.",
184
  "claim_demand": "Ce que le client demande explicitement (ex: dédommagement, remboursement, informations sur les démarches, prise en charge), en une phrase courte. Si rien n'est explicitement demandé, mets null.",
185
- "damaged_part": "La pièce endommagée. DOIT être EXACTEMENT l'une des valeurs suivantes : {allowed_parts}. Si aucune partie ne correspond, trouve celle qui se rapproche le plus. Si aucune partie n'est identifiable dans le mail, mets null."
186
  }}
187
 
188
  Règles :
189
  - Si une information est absente du mail, mets la valeur JSON `null` (pas une chaîne vide).
190
  - Ne devine jamais une plaque ou un nom qui ne serait pas explicitement présent dans le texte.
191
  - "claim_message" doit rester factuel et concis, utilisable directement dans un rapport.
 
192
  """
193
 
194
 
@@ -201,8 +202,8 @@ def extract_claim_info_from_email(email_text):
201
  """
202
  client = get_genai_client()
203
  # Codes pièce (nomenclature CV), dédoublonnés en gardant l'ordre —
204
- # ce sont ces codes que le modèle doit renvoyer dans "damaged_part",
205
- # ensuite traduits en libellé français via CODE_TO_FR_LABEL.
206
  allowed_parts = list(dict.fromkeys(ZONE_FR_TO_PART.values()))
207
 
208
  prompt = EXTRACTION_PROMPT.format(
@@ -232,10 +233,12 @@ def extract_claim_info_from_email(email_text):
232
  "vehicle_model",
233
  "claim_message",
234
  "claim_demand",
235
- "damaged_part",
236
  ]
237
  for key in expected_keys:
238
  data.setdefault(key, None)
 
 
239
 
240
  # Sécurise le retraitement : si le client ne précise pas explicitement
241
  # sa demande, on pose une valeur par défaut plutôt que de laisser le
@@ -243,13 +246,19 @@ def extract_claim_info_from_email(email_text):
243
  if not data.get("claim_demand"):
244
  data["claim_demand"] = DEFAULT_CLAIM_DEMAND
245
 
246
- # Traduction du code pièce (nomenclature CV) en libellé français lisible,
247
- # pour la colonne AppSheet "Localisation Carrosserie". Si aucune pièce
248
- # n'est identifiable dans le mail, on pose "Non Spécifié" plutôt que de
249
- # laisser le champ vide.
250
- data["localisation_carrosserie"] = (
251
- CODE_TO_FR_LABEL.get(data["damaged_part"]) or DEFAULT_LOCALISATION_CARROSSERIE
252
- )
 
 
 
 
 
 
253
 
254
  return data
255
 
 
182
  "vehicle_model": "Marque et modèle du véhicule si mentionné, sinon null",
183
  "claim_message": "Reformulation neutre, factuelle et concise du/des dommage(s), en français naturel, sans code technique. Si PLUSIEURS dommages distincts sont mentionnés (zones et/ou types différents), ils doivent TOUS être repris, sous forme d'une phrase par dommage séparée par un point-virgule (ex: 'Dégradation constatée sur le pare-choc avant.' ou 'Rayure sur le pare-choc avant ; enfoncement sur la portière arrière droite.'). Ne jamais en omettre un au profit d'un seul.",
184
  "claim_demand": "Ce que le client demande explicitement (ex: dédommagement, remboursement, informations sur les démarches, prise en charge), en une phrase courte. Si rien n'est explicitement demandé, mets null.",
185
+ "damaged_parts": "Un TABLEAU des pièces endommagées. Chaque élément DOIT être EXACTEMENT l'une des valeurs suivantes : {allowed_parts}. Si le mail précise suffisamment la zone (ex: 'pare-choc avant', 'portière arrière droite'), mets UNIQUEMENT la pièce exacte correspondante (tableau d'un seul élément). Si le mail mentionne une catégorie SANS préciser laquelle des pièces de cette catégorie est concernée (ex: 'pare-chocs' sans dire avant ou arrière, 'une portière' sans dire laquelle), mets TOUTES les pièces de cette catégorie dans le tableau plutôt que d'en deviner une seule au hasard (ex: pour 'pare-chocs' -> [\"front-bumper\", \"back-bumper\"] ; pour 'portière' -> [\"left_front-door\", \"right_front-door\", \"left_back-door\", \"right_back-door\"]). Si aucune partie n'est identifiable dans le mail, mets un tableau vide []."
186
  }}
187
 
188
  Règles :
189
  - Si une information est absente du mail, mets la valeur JSON `null` (pas une chaîne vide).
190
  - Ne devine jamais une plaque ou un nom qui ne serait pas explicitement présent dans le texte.
191
  - "claim_message" doit rester factuel et concis, utilisable directement dans un rapport.
192
+ - Ne choisis JAMAIS arbitrairement une seule pièce quand le mail ne permet pas de trancher entre plusieurs pièces d'une même catégorie (avant/arrière, gauche/droite) : liste-les toutes dans "damaged_parts" (voir consigne ci-dessus).
193
  """
194
 
195
 
 
202
  """
203
  client = get_genai_client()
204
  # Codes pièce (nomenclature CV), dédoublonnés en gardant l'ordre —
205
+ # ce sont ces codes que le modèle doit renvoyer dans "damaged_parts",
206
+ # ensuite traduits en libellés français via CODE_TO_FR_LABEL.
207
  allowed_parts = list(dict.fromkeys(ZONE_FR_TO_PART.values()))
208
 
209
  prompt = EXTRACTION_PROMPT.format(
 
233
  "vehicle_model",
234
  "claim_message",
235
  "claim_demand",
236
+ "damaged_parts",
237
  ]
238
  for key in expected_keys:
239
  data.setdefault(key, None)
240
+ if not isinstance(data.get("damaged_parts"), list):
241
+ data["damaged_parts"] = []
242
 
243
  # Sécurise le retraitement : si le client ne précise pas explicitement
244
  # sa demande, on pose une valeur par défaut plutôt que de laisser le
 
246
  if not data.get("claim_demand"):
247
  data["claim_demand"] = DEFAULT_CLAIM_DEMAND
248
 
249
+ # Traduction des codes pièce (nomenclature CV) en libellés français
250
+ # lisibles, pour la colonne AppSheet "Localisation Carrosserie". Une
251
+ # catégorie ambiguë (ex: "pare-chocs" sans avant/arrière, "portière" sans
252
+ # précision) donne PLUSIEURS codes dans "damaged_parts" (cf. prompt
253
+ # d'extraction) : on les liste TOUS plutôt que d'en choisir un seul. Si
254
+ # aucune pièce n'est identifiable dans le mail, on pose "Non Spécifié"
255
+ # plutôt que de laisser le champ vide.
256
+ labels = [
257
+ CODE_TO_FR_LABEL[code]
258
+ for code in dict.fromkeys(data["damaged_parts"])
259
+ if code in CODE_TO_FR_LABEL
260
+ ]
261
+ data["localisation_carrosserie"] = " / ".join(labels) if labels else DEFAULT_LOCALISATION_CARROSSERIE
262
 
263
  return data
264
 
finalize_claim.py CHANGED
@@ -61,11 +61,11 @@ from pathlib import Path
61
  import gspread
62
  from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
63
 
64
- from appsheet_client import edit_reclamation, find_rows, TABLE_RECLAMATIONS, TABLE_MEDIAS
65
  from drive_enrichment import get_drive_service, trouver_dossier_plaque
66
  from oauth_drive import get_user_drive_service, get_user_credentials
67
  from Inspect_car_parts import run_parts_inspection
68
- from Check_claim_gemini import process_multi_claim
69
 
70
  logging.basicConfig(level=logging.INFO)
71
  logger = logging.getLogger("finalize_claim")
@@ -250,6 +250,24 @@ def executer_bilan_final(id_reclamation: str):
250
  claim_result = process_multi_claim(claim_message, str(parts_json), videos_dir=videos_dir)
251
  verdict = claim_result.get("verdict") # "OUI" / "NON" / None (cas d'erreur)
252
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
253
  # 5. Dossiers Drive sous BDD Claim/Plaque/<plaque>/Bilan Final/ -------
254
  plaque = reclamation.get("Vehicule Plaque", "")
255
  dossier_plaque = trouver_dossier_plaque(service, plaque)
 
61
  import gspread
62
  from googleapiclient.http import MediaFileUpload, MediaIoBaseDownload
63
 
64
+ from appsheet_client import edit_reclamation, edit_media, find_rows, TABLE_RECLAMATIONS, TABLE_MEDIAS
65
  from drive_enrichment import get_drive_service, trouver_dossier_plaque
66
  from oauth_drive import get_user_drive_service, get_user_credentials
67
  from Inspect_car_parts import run_parts_inspection
68
+ from Check_claim_gemini import process_multi_claim, build_media_role_resolver
69
 
70
  logging.basicConfig(level=logging.INFO)
71
  logger = logging.getLogger("finalize_claim")
 
250
  claim_result = process_multi_claim(claim_message, str(parts_json), videos_dir=videos_dir)
251
  verdict = claim_result.get("verdict") # "OUI" / "NON" / None (cas d'erreur)
252
 
253
+ # 4bis. Rôle de chaque média (colonne "Role Media" de Medias Cameras) :
254
+ # "Dommages" si le dommage réclamé y est confirmé, "Parties Visibles"
255
+ # si le média montre la pièce réclamée sans dommage confirmé,
256
+ # "Inutile" sinon (cf. Check_claim_gemini.build_media_role_resolver).
257
+ resolver_role_media = build_media_role_resolver(claim_result)
258
+ for m in medias:
259
+ file_id = _extraire_id_drive(m.get("Fichier Media", ""))
260
+ if not file_id:
261
+ continue
262
+ role = resolver_role_media(file_id, m.get("Type Media") == "Image")
263
+ try:
264
+ edit_media(m.get("Row ID"), m.get("ID Media"), {"Role Media": role})
265
+ except Exception:
266
+ logger.exception(
267
+ "Échec de la mise à jour de 'Role Media' pour le média %s (réclamation %s)",
268
+ file_id, id_reclamation,
269
+ )
270
+
271
  # 5. Dossiers Drive sous BDD Claim/Plaque/<plaque>/Bilan Final/ -------
272
  plaque = reclamation.get("Vehicule Plaque", "")
273
  dossier_plaque = trouver_dossier_plaque(service, plaque)