Aurel-test commited on
Commit
c54cfe1
·
verified ·
1 Parent(s): 61130fd

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +1627 -0
app.py ADDED
@@ -0,0 +1,1627 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Web Demo v2 pour la base de données d'œuvres d'art - Version Sécurisée et Optimisée
4
+ Interface multi-étapes avec matching basé sur prénom, date, ville et émotions
5
+ Optimisé pour les performances avec caching et indexation
6
+ Version sécurisée avec validation des entrées et gestion d'état propre
7
+ """
8
+
9
+ import gradio as gr
10
+ import os
11
+ import sys
12
+ import logging
13
+ from logging.handlers import RotatingFileHandler
14
+ import random
15
+ import re
16
+ import json
17
+ import uuid
18
+ import time
19
+ from datetime import datetime
20
+ from typing import List, Dict, Tuple, Optional, Any, Set
21
+ from collections import Counter, defaultdict
22
+ from functools import lru_cache
23
+ from dataclasses import dataclass, field, asdict
24
+ from pathlib import Path
25
+ import pandas as pd
26
+
27
+ # Configuration du logging principal
28
+ logging.basicConfig(
29
+ level=logging.INFO,
30
+ format="[%(asctime)s] %(levelname)s: %(message)s",
31
+ datefmt="%Y-%m-%d %H:%M:%S",
32
+ )
33
+ logger = logging.getLogger(__name__)
34
+
35
+ # Import pour la sauvegarde persistante sur HF Spaces
36
+ try:
37
+ from huggingface_hub import CommitScheduler
38
+
39
+ HF_HUB_AVAILABLE = True
40
+ except ImportError:
41
+ HF_HUB_AVAILABLE = False
42
+ logger.warning(
43
+ "huggingface_hub non installé - Les logs ne seront pas sauvegardés dans un dataset HF"
44
+ )
45
+
46
+ # Configuration du logging des sessions
47
+ SESSION_LOG_FILE = "session_logs.jsonl"
48
+ STATS_LOG_FILE = "statistics.json"
49
+
50
+ # Configuration du dataset HF pour la persistance (modifiez ces valeurs)
51
+ HF_DATASET_ID = os.environ.get(
52
+ "HF_DATASET_ID", "ClickMons/art-matcher-logs"
53
+ ) # Remplacez par votre dataset
54
+ HF_TOKEN = os.environ.get("HF_TOKEN", None) # Token HF pour l'authentification
55
+ LOGS_UPLOAD_INTERVAL = 10 # Upload toutes les 10 minutes
56
+
57
+ # Créer un handler pour le fichier de logs des sessions (local)
58
+ if not os.path.exists("logs"):
59
+ os.makedirs("logs")
60
+
61
+ session_file_handler = RotatingFileHandler(
62
+ filename=os.path.join("logs", SESSION_LOG_FILE),
63
+ maxBytes=10 * 1024 * 1024, # 10MB
64
+ backupCount=5,
65
+ encoding="utf-8",
66
+ )
67
+ session_file_handler.setLevel(logging.INFO)
68
+ session_logger = logging.getLogger("session_logger")
69
+ session_logger.addHandler(session_file_handler)
70
+ session_logger.setLevel(logging.INFO)
71
+
72
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "src"))
73
+
74
+ from art_pieces_db.database import Database
75
+ from art_pieces_db.query import TargetProfile, WeightedLeximaxOptimizer, Optimizer
76
+ from art_pieces_db.emotions import EmotionWheel
77
+ from art_pieces_db.utils import str_to_date
78
+
79
+
80
+ @dataclass
81
+ class ScoringWeights:
82
+ """Centralise toutes les constantes de scoring pour éviter les magic numbers"""
83
+
84
+ PRESELECTION_NAME_WEIGHT: float = 3.0
85
+ PRESELECTION_DATE_WEIGHT: float = 1.0
86
+ PRESELECTION_PLACE_WEIGHT: float = 2.0
87
+ PRESELECTION_EMOTION_WEIGHT: float = 0.0
88
+
89
+ MIN_PRESELECTION_COUNT: int = 20
90
+ MAX_IMAGES_PER_SELECTION: int = 3 # nombre d'images par sélection
91
+ TOTAL_ROUNDS: int = 3 # nombre de rounds avant la recommandation finale
92
+
93
+
94
+ @dataclass
95
+ class SessionState:
96
+ """Gère l'état de session"""
97
+
98
+ firstname: str = ""
99
+ birthday: str = ""
100
+ city: str = ""
101
+
102
+ current_round: int = 0
103
+ selected_images: List[str] = field(default_factory=list)
104
+ current_image_ids: List[str] = field(default_factory=list)
105
+
106
+ preselected_pieces: Optional[pd.DataFrame] = None
107
+
108
+ # Propriétés pour le tracking
109
+ session_id: str = field(
110
+ default_factory=lambda: str(uuid.uuid4())
111
+ ) # ID unique de session
112
+ session_start_time: float = field(default_factory=time.time)
113
+ recommendation_type: str = "" # "name_date_place" ou "emotions"
114
+ final_artwork: str = ""
115
+
116
+ def reset(self):
117
+ """Réinitialise l'état de session"""
118
+ self.firstname = ""
119
+ self.birthday = ""
120
+ self.city = ""
121
+ self.current_round = 0
122
+ self.selected_images = []
123
+ self.current_image_ids = []
124
+ self.preselected_pieces = None
125
+ self.session_id = str(uuid.uuid4()) # Nouveau ID de session
126
+ self.session_start_time = time.time()
127
+ self.recommendation_type = ""
128
+ self.final_artwork = ""
129
+
130
+ def is_complete(self) -> bool:
131
+ """Vérifie si la sélection est complète"""
132
+ return self.current_round >= ScoringWeights.TOTAL_ROUNDS
133
+
134
+
135
+ class SessionLogger:
136
+ """Version améliorée du logger de sessions avec CommitScheduler simplifié"""
137
+
138
+ def __init__(self):
139
+ # Détection de l'environnement HF Spaces
140
+ self.is_hf_space = os.environ.get("SPACE_ID") is not None
141
+
142
+ # Sessions pour le dataset HF (seulement les logs de sessions)
143
+ self.sessions_dir = Path("art_matcher_sessions")
144
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
145
+
146
+ # Statistiques locales uniquement
147
+ self.local_stats_dir = Path("art_matcher_stats")
148
+ self.local_stats_dir.mkdir(parents=True, exist_ok=True)
149
+
150
+ # Chaque redémarrage crée un nouveau fichier
151
+ self.sessions_file = self.sessions_dir / f"train-{uuid.uuid4()}.json"
152
+ self.stats_file = self.local_stats_dir / "global_statistics.json"
153
+
154
+ # Pour compatibilité avec l'ancien code
155
+ self.data_dir = self.sessions_dir
156
+
157
+ # Initialiser le CommitScheduler si sur HF Spaces
158
+ self.scheduler = None
159
+ if self.is_hf_space and HF_HUB_AVAILABLE:
160
+ try:
161
+ # Vérifier que le dataset ID est défini
162
+ if not HF_DATASET_ID:
163
+ raise ValueError("HF_DATASET_ID n'est pas défini")
164
+
165
+ logger.info(
166
+ f"Tentative d'initialisation du CommitScheduler pour {HF_DATASET_ID}..."
167
+ )
168
+ self.scheduler = CommitScheduler(
169
+ repo_id=HF_DATASET_ID,
170
+ repo_type="dataset",
171
+ folder_path=str(self.sessions_dir), # Seulement les sessions!
172
+ path_in_repo="data",
173
+ every=LOGS_UPLOAD_INTERVAL,
174
+ )
175
+ logger.info(
176
+ f"✅ CommitScheduler initialisé avec succès pour {HF_DATASET_ID}"
177
+ )
178
+ logger.info(
179
+ f"📁 Dossier des sessions (HF dataset): {self.sessions_dir}"
180
+ )
181
+ logger.info(
182
+ f"📊 Dossier des stats (local seulement): {self.local_stats_dir}"
183
+ )
184
+ logger.info(f"📝 Fichier de session actuel: {self.sessions_file.name}")
185
+ logger.info(f"⏱️ Upload toutes les {LOGS_UPLOAD_INTERVAL} minutes")
186
+ except Exception as e:
187
+ logger.error(
188
+ f"❌ Erreur lors de l'initialisation du CommitScheduler: {e}"
189
+ )
190
+ logger.info("Les données seront stockées localement uniquement")
191
+ self.scheduler = None
192
+ else:
193
+ if not self.is_hf_space:
194
+ logger.info(
195
+ "🏠 Environnement local détecté - pas de synchronisation HF"
196
+ )
197
+ if not HF_HUB_AVAILABLE:
198
+ logger.warning("📦 huggingface_hub n'est pas installé")
199
+
200
+ def log_session(self, state: SessionState, recommendation_system: str):
201
+ """Enregistre une session de manière thread-safe"""
202
+ session_duration = time.time() - state.session_start_time
203
+
204
+ # Utiliser le session_id unique de l'état, pas l'instance_id
205
+ entry = {
206
+ "session_id": state.session_id, # ID unique de la session
207
+ "datetime": datetime.now().isoformat(),
208
+ "duration_seconds": round(session_duration, 2),
209
+ "recommended_artwork": state.final_artwork,
210
+ "recommendation_type": recommendation_system,
211
+ }
212
+
213
+ # Utiliser le lock du scheduler pour la thread safety
214
+ try:
215
+ if self.scheduler and hasattr(self.scheduler, "lock"):
216
+ with self.scheduler.lock:
217
+ self._write_session(entry)
218
+ self._update_stats(entry)
219
+ logger.info(f"✅ Session écrite avec lock du scheduler")
220
+ else:
221
+ # Sans scheduler, écriture directe
222
+ self._write_session(entry)
223
+ self._update_stats(entry)
224
+ logger.info(f"📝 Session écrite sans scheduler")
225
+
226
+ logger.info(
227
+ f"Session enregistrée - ID: {entry['session_id'][:8]}... - Durée: {entry['duration_seconds']}s"
228
+ )
229
+ logger.info(f"📁 Fichier: {self.sessions_file.name}")
230
+ session_logger.info(json.dumps(entry, ensure_ascii=False))
231
+
232
+ except Exception as e:
233
+ logger.error(f"❌ Erreur lors de l'enregistrement de la session: {e}")
234
+ # Toujours essayer de logger dans le fichier local
235
+ try:
236
+ session_logger.info(json.dumps(entry, ensure_ascii=False))
237
+ except:
238
+ pass
239
+
240
+ def _write_session(self, entry: dict):
241
+ """Écrit une entrée de session dans le fichier JSON (format newline-delimited)"""
242
+ try:
243
+ self.sessions_dir.mkdir(parents=True, exist_ok=True)
244
+
245
+ # Écrire en mode append avec une nouvelle ligne pour chaque entrée
246
+ with self.sessions_file.open("a", encoding="utf-8") as f:
247
+ f.write(json.dumps(entry, ensure_ascii=False) + "\n")
248
+ f.flush() # Forcer l'écriture sur le disque
249
+
250
+ # Vérifier que le fichier existe et a du contenu
251
+ if self.sessions_file.exists():
252
+ size = self.sessions_file.stat().st_size
253
+ logger.debug(
254
+ f"📊 Fichier {self.sessions_file.name} - Taille: {size} octets"
255
+ )
256
+
257
+ except Exception as e:
258
+ logger.error(f"❌ Erreur lors de l'écriture dans {self.sessions_file}: {e}")
259
+
260
+ def _update_stats(self, session_entry: dict):
261
+ """Met à jour les statistiques globales"""
262
+ # Charger les stats existantes
263
+ stats = {}
264
+ if self.stats_file.exists():
265
+ try:
266
+ with self.stats_file.open("r", encoding="utf-8") as f:
267
+ stats = json.load(f)
268
+ except json.JSONDecodeError:
269
+ stats = {}
270
+
271
+ # Initialiser la structure si nécessaire
272
+ if "total_sessions" not in stats:
273
+ stats = {
274
+ "total_sessions": 0,
275
+ "total_duration_seconds": 0,
276
+ "average_duration_seconds": 0,
277
+ "artworks_recommended": {},
278
+ "recommendation_types": {
279
+ "name_date_place": 0,
280
+ "emotions": 0,
281
+ "none": 0,
282
+ },
283
+ "first_session": session_entry["datetime"],
284
+ "last_session": session_entry["datetime"],
285
+ }
286
+
287
+ # Mettre à jour les compteurs
288
+ stats["total_sessions"] += 1
289
+ stats["total_duration_seconds"] += session_entry.get("duration_seconds", 0)
290
+ stats["average_duration_seconds"] = (
291
+ stats["total_duration_seconds"] / stats["total_sessions"]
292
+ )
293
+ stats["last_session"] = session_entry["datetime"]
294
+
295
+ # Compter les types de recommandation
296
+ rec_type = session_entry.get("recommendation_type", "none")
297
+ if rec_type in stats["recommendation_types"]:
298
+ stats["recommendation_types"][rec_type] += 1
299
+
300
+ # Compter les œuvres recommandées
301
+ artwork = session_entry.get("recommended_artwork")
302
+ if artwork and artwork != "Aucune œuvre trouvée":
303
+ if artwork not in stats["artworks_recommended"]:
304
+ stats["artworks_recommended"][artwork] = 0
305
+ stats["artworks_recommended"][artwork] += 1
306
+
307
+ # Trouver l'œuvre la plus populaire
308
+ if stats["artworks_recommended"]:
309
+ most_popular = max(
310
+ stats["artworks_recommended"].items(), key=lambda x: x[1]
311
+ )
312
+ stats["most_popular_artwork"] = {
313
+ "title": most_popular[0],
314
+ "count": most_popular[1],
315
+ "percentage": (most_popular[1] / stats["total_sessions"]) * 100,
316
+ }
317
+
318
+ # Calculer les pourcentages d'utilisation
319
+ total = stats["total_sessions"]
320
+ if total > 0:
321
+ stats["recommendation_percentages"] = {
322
+ k: (v / total) * 100 for k, v in stats["recommendation_types"].items()
323
+ }
324
+
325
+ stats["last_updated"] = datetime.now().isoformat()
326
+
327
+ # Sauvegarder les stats mises à jour
328
+ with self.stats_file.open("w", encoding="utf-8") as f:
329
+ json.dump(stats, f, indent=2, ensure_ascii=False)
330
+
331
+ def get_statistics(self) -> dict:
332
+ """Retourne les statistiques globales"""
333
+ if self.stats_file.exists():
334
+ try:
335
+ with self.stats_file.open("r", encoding="utf-8") as f:
336
+ return json.load(f)
337
+ except Exception as e:
338
+ logger.error(f"Erreur lecture stats: {e}")
339
+ return {}
340
+
341
+
342
+ # Initialiser le logger de sessions
343
+ session_tracker = SessionLogger()
344
+
345
+
346
+ class SecurityValidator:
347
+ """Classe pour centraliser les validations de sécurité"""
348
+
349
+ PATH_TRAVERSAL_PATTERN = re.compile(r"\.\.|\.\/")
350
+ VALID_FILENAME_PATTERN = re.compile(r"^[\w\-\.\s]+$")
351
+ VALID_INPUT_PATTERN = re.compile(
352
+ r"^[\w\-\s\'\.,àâäéèêëïîôûùüÿæœçÀÂÄÉÈÊËÏÎÔÛÙÜŸÆŒÇ]+$", re.UNICODE
353
+ )
354
+ DATE_PATTERN = re.compile(r"^\d{1,2}/\d{1,2}$")
355
+
356
+ @classmethod
357
+ def validate_filename(cls, filename: str) -> bool:
358
+ """Valide qu'un nom de fichier est sécurisé"""
359
+ if not filename:
360
+ return False
361
+
362
+ # Vérifier les tentatives de path traversal
363
+ if cls.PATH_TRAVERSAL_PATTERN.search(filename):
364
+ logger.warning(f"Tentative de path traversal détectée: {filename}")
365
+ return False
366
+
367
+ # Vérifier que le nom ne contient que des caractères autorisés
368
+ base_name = os.path.basename(filename)
369
+ if not cls.VALID_FILENAME_PATTERN.match(base_name):
370
+ logger.warning(f"Nom de fichier invalide: {filename}")
371
+ return False
372
+
373
+ return True
374
+
375
+ @classmethod
376
+ def sanitize_input(cls, input_str: str, max_length: int = 100) -> str:
377
+ """Nettoie et valide une entrée utilisateur"""
378
+ if not input_str:
379
+ return ""
380
+
381
+ # Tronquer si trop long
382
+ input_str = input_str[:max_length].strip()
383
+
384
+ if not cls.VALID_INPUT_PATTERN.match(input_str):
385
+ # Garder seulement les caractères valides
386
+ cleaned = "".join(c for c in input_str if cls.VALID_INPUT_PATTERN.match(c))
387
+ logger.info(f"Input sanitized: '{input_str}' -> '{cleaned}'")
388
+ return cleaned
389
+
390
+ return input_str
391
+
392
+ @classmethod
393
+ def validate_date(cls, date_str: str) -> Tuple[bool, Optional[datetime]]:
394
+ """Valide et parse une date au format JJ/MM"""
395
+ if not date_str:
396
+ return False, None
397
+
398
+ if not cls.DATE_PATTERN.match(date_str):
399
+ return False, None
400
+
401
+ try:
402
+ day, month = map(int, date_str.split("/"))
403
+ if not (1 <= day <= 31 and 1 <= month <= 12):
404
+ return False, None
405
+
406
+ date_obj = datetime(year=2000, month=month, day=day)
407
+ return True, date_obj
408
+ except (ValueError, Exception) as e:
409
+ logger.error(f"Erreur de parsing de date: {e}")
410
+ return False, None
411
+
412
+
413
+ class ImageIndexer:
414
+ """Classe pour indexer et mapper les images depuis la base de données CSV"""
415
+
416
+ # Constants for better maintainability
417
+ IMAGE_EXTENSIONS = (".jpg", ".png")
418
+ COMMON_SUFFIXES = [".jpg", ".png", "_medium"]
419
+ MAR_BVM_TEST_SUFFIXES = ["-001", "-002", "-003"]
420
+
421
+ def __init__(self, images_dir: str):
422
+ self.images_dir = os.path.abspath(images_dir)
423
+ self.available_files = set()
424
+ self.image_lookup = {} # normalized_name -> filename
425
+ self.mar_bvm_lookup = {} # Special handling for MAR-BVM files
426
+ self._build_index()
427
+
428
+ def _strip_file_extensions(self, filename: str) -> str:
429
+ """Remove file extensions from filename"""
430
+ base_name = filename.lower()
431
+ if base_name.endswith("_medium.jpg"):
432
+ return base_name[:-11]
433
+ elif base_name.endswith((".jpg", ".png")):
434
+ return base_name[:-4]
435
+ return base_name
436
+
437
+ def _normalize_basic_patterns(self, name: str) -> str:
438
+ """Apply basic normalization patterns"""
439
+ # Remove trailing comma and normalize whitespace
440
+ normalized = name.lower().strip().rstrip(",")
441
+
442
+ # Remove common suffixes
443
+ for suffix in self.COMMON_SUFFIXES:
444
+ if normalized.endswith(suffix):
445
+ normalized = normalized[: -len(suffix)]
446
+
447
+ # Normalize spaces and underscores to dashes
448
+ return re.sub(r"[\s_]+", "-", normalized)
449
+
450
+ def _normalize_mar_bvm_format(self, name: str) -> str:
451
+ """Handle MAR-BVM specific normalization"""
452
+ if "mar-bvm" not in name:
453
+ return name
454
+
455
+ # Replace .0. with -0- and remaining dots with dashes
456
+ return name.replace(".0.", "-0-").replace(".", "-")
457
+
458
+ def _normalize_name(self, name: str) -> str:
459
+ """Normalise un nom pour la comparaison"""
460
+ normalized = self._normalize_basic_patterns(name)
461
+
462
+ # Special handling for MAR-BVM format
463
+ if "mar-bvm" in normalized:
464
+ normalized = self._normalize_mar_bvm_format(normalized)
465
+ # For files starting with year (like 2022.0.86), keep dots
466
+ elif not normalized.startswith("20"):
467
+ normalized = normalized.replace(".", "-")
468
+
469
+ return normalized
470
+
471
+ def _create_mar_bvm_lookups(self, normalized: str, filename: str):
472
+ """Create additional lookup entries for MAR-BVM files"""
473
+ if "mar-bvm" not in normalized:
474
+ return
475
+
476
+ parts = normalized.split("-")
477
+ for i, part in enumerate(parts):
478
+ if part.isdigit() and i >= 5: # After mar-bvm-7-2022-0
479
+ base_key = "-".join(parts[:6]) # mar-bvm-7-2022-0-22
480
+ if base_key not in self.mar_bvm_lookup:
481
+ self.mar_bvm_lookup[base_key] = []
482
+ self.mar_bvm_lookup[base_key].append(filename)
483
+ break
484
+
485
+ def _process_image_file(self, filename: str):
486
+ """Process a single image file for indexing"""
487
+ if not SecurityValidator.validate_filename(filename):
488
+ logger.warning(f"Fichier ignoré pour raison de sécurité: {filename}")
489
+ return
490
+
491
+ if not filename.lower().endswith(self.IMAGE_EXTENSIONS):
492
+ return
493
+
494
+ self.available_files.add(filename)
495
+
496
+ base_name = self._strip_file_extensions(filename)
497
+ normalized = self._normalize_name(base_name)
498
+ self.image_lookup[normalized] = filename
499
+ self._create_mar_bvm_lookups(normalized, filename)
500
+
501
+ def _build_index(self):
502
+ """Construit un index des images disponibles"""
503
+ try:
504
+ all_files = os.listdir(self.images_dir)
505
+ for filename in all_files:
506
+ self._process_image_file(filename)
507
+
508
+ logger.info(
509
+ f"Index des images construit: {len(self.available_files)} fichiers disponibles, "
510
+ f"{len(self.image_lookup)} entrées normalisées"
511
+ )
512
+ except Exception as e:
513
+ logger.error(f"Erreur lors de la construction de l'index: {e}")
514
+ self.available_files = set()
515
+
516
+ def _clean_input_name(self, image_name: str) -> str:
517
+ """Clean and prepare input name for processing"""
518
+ # Basic cleaning
519
+ cleaned = image_name.strip().rstrip(",").rstrip("-").strip()
520
+ # Remove spaces before -001, -002, etc.
521
+ return re.sub(r"\s+(-\d)", r"\1", cleaned)
522
+
523
+ def _normalize_mar_bvm_input(self, image_name: str) -> str:
524
+ """Handle MAR-BVM specific input normalization"""
525
+ if "MAR-BVM" not in image_name:
526
+ return image_name
527
+
528
+ # Handle missing "7-" in MAR-BVM-2022-0-153
529
+ if "MAR-BVM-2022-0-" in image_name:
530
+ image_name = image_name.replace("MAR-BVM-2022-0-", "MAR-BVM-7-2022-0-")
531
+
532
+ # Convert .0. to -0-
533
+ if ".0." in image_name:
534
+ image_name = image_name.replace(".0.", "-0-")
535
+
536
+ # Handle .001, .002 at the end (convert to -001, -002)
537
+ image_name = re.sub(r"\.(\d{3})$", r"-\1", image_name)
538
+
539
+ # Handle .1 or .2 suffix
540
+ if image_name.endswith(".1"):
541
+ image_name = image_name[:-2] + "-1"
542
+ elif image_name.endswith(".2"):
543
+ image_name = image_name[:-2] + "-2"
544
+
545
+ # Replace any remaining dots with dashes (but be careful not to mess up already processed parts)
546
+ return image_name.replace(".", "-")
547
+
548
+ def _try_mar_bvm_lookups(self, normalized: str) -> Optional[str]:
549
+ """Try various MAR-BVM specific lookup strategies"""
550
+ # Check special MAR-BVM lookup
551
+ if normalized in self.mar_bvm_lookup and self.mar_bvm_lookup[normalized]:
552
+ return self.mar_bvm_lookup[normalized][0]
553
+
554
+ # Try with suffix variations
555
+ for suffix in self.MAR_BVM_TEST_SUFFIXES:
556
+ test_pattern = f"{normalized}{suffix}"
557
+ if test_pattern in self.image_lookup:
558
+ return self.image_lookup[test_pattern]
559
+
560
+ return None
561
+
562
+ def _try_year_format_lookup(self, image_name: str) -> Optional[str]:
563
+ """Handle special case for files starting with year"""
564
+ if not image_name.startswith("20"):
565
+ return None
566
+
567
+ test_name = image_name.lower().replace(" ", "-")
568
+ return self.image_lookup.get(test_name)
569
+
570
+ def _try_partial_matching(self, normalized: str) -> Optional[str]:
571
+ """Try partial matching as last resort"""
572
+ for key, filename in self.image_lookup.items():
573
+ if key.startswith(normalized) or normalized in key:
574
+ return filename
575
+ return None
576
+
577
+ def _split_multiple_names(self, image_name: str) -> List[str]:
578
+ """Split image names that contain multiple names separated by commas or slashes"""
579
+ # First try comma separation
580
+ if "," in image_name:
581
+ return [name.strip() for name in image_name.split(",") if name.strip()]
582
+
583
+ # Then try slash separation
584
+ if "/" in image_name:
585
+ return [name.strip() for name in image_name.split("/") if name.strip()]
586
+
587
+ # Handle " - " separation (for cases like "MAR-BVM-7-2022.0.81 - 2022.0.81")
588
+ if " - " in image_name and image_name.count(" - ") == 1:
589
+ parts = [name.strip() for name in image_name.split(" - ")]
590
+ # Only use the first part if they look like duplicates
591
+ if len(parts) == 2:
592
+ first, second = parts
593
+ # Check if second part is a suffix of the first (like duplicate year)
594
+ if first.endswith(second) or second in first:
595
+ return [first]
596
+ return parts
597
+
598
+ return [image_name]
599
+
600
+ def find_image(self, image_name: str) -> Optional[str]:
601
+ """Trouve un fichier image correspondant au nom donné"""
602
+ if not image_name:
603
+ return None
604
+
605
+ # Handle multiple image names in one field
606
+ possible_names = self._split_multiple_names(image_name)
607
+
608
+ # Try each name individually
609
+ for name in possible_names:
610
+ result = self._find_single_image(name)
611
+ if result:
612
+ return result
613
+
614
+ return None
615
+
616
+ def _find_single_image(self, image_name: str) -> Optional[str]:
617
+ """Find a single image by name"""
618
+ # Clean and normalize the input
619
+ cleaned_name = self._clean_input_name(image_name)
620
+ processed_name = self._normalize_mar_bvm_input(cleaned_name)
621
+ normalized = self._normalize_name(processed_name)
622
+
623
+ # Try direct lookup first
624
+ if normalized in self.image_lookup:
625
+ return self.image_lookup[normalized]
626
+
627
+ # Try MAR-BVM specific lookups
628
+ if "mar-bvm" in normalized:
629
+ result = self._try_mar_bvm_lookups(normalized)
630
+ if result:
631
+ return result
632
+
633
+ # Try year format lookup
634
+ result = self._try_year_format_lookup(image_name)
635
+ if result:
636
+ return result
637
+
638
+ # Try partial matching as last resort
639
+ return self._try_partial_matching(normalized)
640
+
641
+ def get_all_files(self) -> Set[str]:
642
+ """Retourne tous les fichiers disponibles"""
643
+ return self.available_files.copy()
644
+
645
+
646
+ class ArtMatcherV2:
647
+ """Classe principale pour le matching d'œuvres d'art"""
648
+
649
+ def __init__(self, csv_path: str, images_dir: str):
650
+ """Initialise le système avec la base de données et le répertoire d'images"""
651
+ self.db = Database(csv_path)
652
+ self.images_dir = os.path.abspath(images_dir)
653
+ self.emotion_wheel = EmotionWheel()
654
+ self.weights = ScoringWeights()
655
+
656
+ self.optimizer_helper = WeightedLeximaxOptimizer(TargetProfile(), {})
657
+
658
+ self.image_indexer = ImageIndexer(images_dir)
659
+
660
+ df = self.db.get_dataframe()
661
+ self.df_with_images = df[
662
+ df["name_image"].notna()
663
+ & (df["name_image"] != "")
664
+ & (df["name_image"].str.strip() != "")
665
+ ].copy()
666
+
667
+ self.df_with_images["database_id_str"] = self.df_with_images[
668
+ "database_id"
669
+ ].astype(str)
670
+ self.id_to_index = {
671
+ str(row["database_id"]): idx for idx, row in self.df_with_images.iterrows()
672
+ }
673
+
674
+ self.artwork_images = self._build_artwork_image_index()
675
+
676
+ self.temp_db_with_images = Database.__new__(Database)
677
+ self.temp_db_with_images.dataframe = self.df_with_images
678
+
679
+ logger.info(f"Base de données chargée: {self.db.n_pieces()} œuvres")
680
+ logger.info(f"Œuvres avec images: {len(self.df_with_images)}")
681
+ logger.info(f"Index des images: {len(self.artwork_images)} œuvres mappées")
682
+
683
+ def _sanitize_input(self, input_str: str) -> str:
684
+ """Nettoie et valide une entrée utilisateur"""
685
+ return SecurityValidator.sanitize_input(input_str)
686
+
687
+ def _parse_date(self, date_str: str) -> Optional[datetime]:
688
+ """Parse une date avec validation"""
689
+ is_valid, date_obj = SecurityValidator.validate_date(date_str)
690
+ return date_obj if is_valid else None
691
+
692
+ def _build_artwork_image_index(self) -> Dict[str, List[str]]:
693
+ """Construit un index artwork_id -> [image_paths] au démarrage"""
694
+ artwork_images = {}
695
+
696
+ for idx, row in self.df_with_images.iterrows():
697
+ artwork_id = str(row["database_id"])
698
+ image_paths = []
699
+
700
+ if row["name_image"] and str(row["name_image"]).strip():
701
+ # Parse the image names - handle special separators
702
+ image_string = str(row["name_image"]).strip().strip('"')
703
+
704
+ # Handle cases with " / " or " - " separators
705
+ if " / " in image_string:
706
+ # Take first part before the slash
707
+ image_string = image_string.split(" / ")[0].strip()
708
+
709
+ # Special case: if it has " - 2022" it's a separator, not part of the name
710
+ if " - 2022" in image_string:
711
+ # Take the part before " - 2022"
712
+ image_string = image_string.split(" - 2022")[0].strip()
713
+ elif " - " in image_string and "MAR-BVM-7-2022-0-" not in image_string:
714
+ # For other MAR-BVM formats with " - " separator
715
+ parts = image_string.split(" - ")
716
+ if "MAR-BVM" in parts[0]:
717
+ image_string = parts[0].strip()
718
+
719
+ # Clean up trailing " -" or spaces before "-001"
720
+ image_string = re.sub(
721
+ r"\s+-\s*$", "", image_string
722
+ ) # Remove trailing " -"
723
+ image_string = re.sub(
724
+ r"\s+(-\d)", r"\1", image_string
725
+ ) # Remove spaces before -001
726
+
727
+ # Parse comma-separated list
728
+ images = [
729
+ img.strip()
730
+ for img in re.split(r"[,/]", image_string)
731
+ if img.strip()
732
+ ]
733
+
734
+ for img_name in images:
735
+ # Find the actual file for this image name
736
+ matched_file = self.image_indexer.find_image(img_name)
737
+ if matched_file:
738
+ img_path = os.path.join(self.images_dir, matched_file)
739
+ image_paths.append(img_path)
740
+
741
+ if image_paths:
742
+ artwork_images[artwork_id] = image_paths
743
+
744
+ return artwork_images
745
+
746
+ def preselect_artworks(
747
+ self, firstname: str, birthday: str, city: str
748
+ ) -> pd.DataFrame:
749
+ """
750
+ Pré-sélectionne les œuvres selon la hiérarchie: prénom > date > ville
751
+ """
752
+ logger.info("=== DÉBUT PRÉ-SÉLECTION ===")
753
+
754
+ # Nettoyer les entrées
755
+ firstname = self._sanitize_input(firstname)
756
+ city = self._sanitize_input(city)
757
+
758
+ logger.info(
759
+ f"Critères de pré-sélection: prénom='{firstname}', date='{birthday}', ville='{city}'"
760
+ )
761
+
762
+ birth_date = self._parse_date(birthday)
763
+ if birth_date:
764
+ logger.info(f"Date convertie: {birth_date.strftime('%d/%m')}")
765
+
766
+ profile = TargetProfile()
767
+ profile.set_target_name(firstname)
768
+ profile.set_target_date(birth_date)
769
+ profile.set_target_place(city)
770
+
771
+ weights = {
772
+ "related_names": self.weights.PRESELECTION_NAME_WEIGHT,
773
+ "related_dates": self.weights.PRESELECTION_DATE_WEIGHT,
774
+ "related_places": self.weights.PRESELECTION_PLACE_WEIGHT,
775
+ "related_emotions": self.weights.PRESELECTION_EMOTION_WEIGHT,
776
+ }
777
+
778
+ logger.info(
779
+ f"Poids utilisés: nom={weights['related_names']}, date={weights['related_dates']}, lieu={weights['related_places']}, émotions={weights['related_emotions']}"
780
+ )
781
+
782
+ optimizer = WeightedLeximaxOptimizer(profile, weights)
783
+ result = optimizer.optimize_max(self.temp_db_with_images)
784
+
785
+ preselected = result[result["score"] > (0, 0, 0)]
786
+ logger.info(f"Œuvres avec score > 0: {len(preselected)}")
787
+
788
+ if len(preselected) < self.weights.MIN_PRESELECTION_COUNT:
789
+ preselected = result.head(self.weights.MIN_PRESELECTION_COUNT)
790
+ logger.info(f"Ajustement au minimum requis: {len(preselected)} œuvres")
791
+
792
+ logger.info("Top 5 pré-sélections:")
793
+ for i, (idx, piece) in enumerate(preselected.head(5).iterrows()):
794
+ logger.info(
795
+ f" {i+1}. Œuvre #{piece['database_id']} - Score: {piece['score']}"
796
+ )
797
+ if firstname and piece["related_names"]:
798
+ name_score = Optimizer.name_similarity(
799
+ firstname, piece["related_names"]
800
+ )
801
+ if name_score > 0:
802
+ logger.info(
803
+ f" → Nom: {piece['related_names']} (score: {name_score:.2f})"
804
+ )
805
+ if birth_date and piece["related_dates"]:
806
+ date_score = Optimizer.date_similarity(
807
+ birth_date, piece["related_dates"]
808
+ )
809
+ if date_score > 0:
810
+ logger.info(
811
+ f" → Dates: {[d.strftime('%d/%m') for d in piece['related_dates']]} (score: {date_score:.2f})"
812
+ )
813
+ if city and piece["related_places"]:
814
+ place_score = self.optimizer_helper.place_similarity(
815
+ city, piece["related_places"]
816
+ )
817
+ if place_score > 0:
818
+ logger.info(
819
+ f" → Lieux: {piece['related_places']} (score: {place_score:.2f})"
820
+ )
821
+
822
+ logger.info("=== FIN PRÉ-SÉLECTION ===")
823
+ return preselected
824
+
825
+ def get_random_images_for_selection(
826
+ self, round_num: int, already_selected: List[str] = None
827
+ ) -> List[Tuple[str, str]]:
828
+ """
829
+ Retourne 3 images aléatoires depuis l'index pré-construit
830
+ Exclut les œuvres déjà sélectionnées dans les tours précédents
831
+ """
832
+ logger.info(f"=== SÉLECTION D'IMAGES POUR LE TOUR {round_num} ===")
833
+
834
+ if already_selected:
835
+ logger.info(f"Œuvres déjà sélectionnées à exclure: {already_selected}")
836
+
837
+ available_artworks = list(self.artwork_images.keys())
838
+
839
+ # Exclure les œuvres déjà sélectionnées
840
+ if already_selected:
841
+ already_selected_set = set(already_selected)
842
+ available_artworks = [
843
+ a for a in available_artworks if a not in already_selected_set
844
+ ]
845
+
846
+ logger.info(
847
+ f"Nombre total d'œuvres avec images disponibles: {len(available_artworks)}"
848
+ )
849
+
850
+ if len(available_artworks) < self.weights.MAX_IMAGES_PER_SELECTION:
851
+ logger.warning(
852
+ f"Seulement {len(available_artworks)} œuvres avec images disponibles"
853
+ )
854
+ direct_images = []
855
+ for filename in list(self.image_indexer.get_all_files())[:10]:
856
+ if filename.endswith(".jpg"):
857
+ img_path = os.path.join(self.images_dir, filename)
858
+ direct_images.append((img_path, "0"))
859
+ return direct_images[: self.weights.MAX_IMAGES_PER_SELECTION]
860
+
861
+ num_to_select = min(
862
+ self.weights.MAX_IMAGES_PER_SELECTION, len(available_artworks)
863
+ )
864
+ selected_artworks = random.sample(available_artworks, num_to_select)
865
+
866
+ logger.info(f"Œuvres sélectionnées aléatoirement: {selected_artworks}")
867
+
868
+ selected = []
869
+ for artwork_id in selected_artworks:
870
+ img_path = random.choice(self.artwork_images[artwork_id])
871
+ selected.append((img_path, artwork_id))
872
+ if artwork_id in self.id_to_index:
873
+ idx = self.id_to_index[artwork_id]
874
+ artwork = self.df_with_images.loc[idx]
875
+ logger.info(f" Image {len(selected)}: Œuvre #{artwork_id}")
876
+ logger.info(f" Type: {artwork['art_piece_type']}")
877
+ logger.info(f" Émotions: {artwork['related_emotions']}")
878
+
879
+ logger.info(f"=== FIN SÉLECTION IMAGES TOUR {round_num} ===")
880
+ return selected
881
+
882
+ def extract_emotions_from_image_id(self, database_id: str) -> List[str]:
883
+ """
884
+ Extrait les émotions associées à une œuvre via son ID
885
+ Utilise l'index pré-calculé pour éviter les conversions répétées
886
+ """
887
+ if database_id in self.id_to_index:
888
+ idx = self.id_to_index[database_id]
889
+ emotions = self.df_with_images.loc[idx, "related_emotions"]
890
+ if isinstance(emotions, list):
891
+ return emotions
892
+ return []
893
+
894
+ @lru_cache(maxsize=1024)
895
+ def _cached_emotion_similarity(self, emotion1: str, emotion2: str) -> float:
896
+ """Cache les calculs de similarité émotionnelle"""
897
+ return self.emotion_wheel.calculate_emotion_similarity(emotion1, emotion2)
898
+
899
+ def calculate_emotion_profile(self, selected_ids: List[str]) -> Dict[str, float]:
900
+ """
901
+ Calcule le profil émotionnel basé sur les images sélectionnées
902
+ """
903
+ logger.info("=== CALCUL DU PROFIL ÉMOTIONNEL ===")
904
+ logger.info(f"Images sélectionnées: {selected_ids}")
905
+
906
+ emotion_counter = Counter()
907
+
908
+ for db_id in selected_ids:
909
+ emotions = self.extract_emotions_from_image_id(db_id)
910
+ logger.info(f" Image {db_id}: émotions = {emotions}")
911
+ emotion_counter.update(emotions)
912
+
913
+ total = sum(emotion_counter.values())
914
+ if total > 0:
915
+ emotion_profile = {
916
+ emotion: count / total for emotion, count in emotion_counter.items()
917
+ }
918
+ logger.info(f"Profil émotionnel calculé: {emotion_profile}")
919
+ else:
920
+ emotion_profile = {}
921
+ logger.info("Aucune émotion trouvée dans les images sélectionnées")
922
+
923
+ logger.info("=== FIN CALCUL PROFIL ÉMOTIONNEL ===")
924
+ return emotion_profile
925
+
926
+ def _get_artwork_image(self, artwork) -> Optional[str]:
927
+ """Retourne le chemin de l'image pour une œuvre d'art"""
928
+ artwork_id = str(artwork["database_id"])
929
+
930
+ # Simply return the first image from our pre-built index
931
+ if artwork_id in self.artwork_images:
932
+ return self.artwork_images[artwork_id][0]
933
+
934
+ return None
935
+
936
+ def find_best_match(
937
+ self, firstname: str, birthday: str, city: str, selected_image_ids: List[str]
938
+ ) -> Tuple[Optional[str], str, Dict]:
939
+ """
940
+ Trouve la meilleure correspondance selon la hiérarchie du scénario:
941
+ 1. Match exact (name/date/city) = gagnant automatique
942
+ 2. Si pré-sélection existe: utiliser émotions pour départager
943
+ 3. Si aucune pré-sélection: utiliser émotions seules
944
+ 4. Type d'objet comme critère de départage final
945
+ """
946
+ firstname = self._sanitize_input(firstname)
947
+ city = self._sanitize_input(city)
948
+ birth_date = self._parse_date(birthday)
949
+
950
+ logger.info(
951
+ f"Recherche de correspondance pour: {firstname}, {birthday}, {city}"
952
+ )
953
+
954
+ preselected = self.preselect_artworks(firstname, birthday, city)
955
+
956
+ logger.info("=== DÉTECTION DE MATCH EXACT ===")
957
+ for idx, piece in preselected.iterrows():
958
+ if firstname and piece["related_names"]:
959
+ name_score = Optimizer.name_similarity(
960
+ firstname, piece["related_names"]
961
+ )
962
+ if name_score >= 0.95:
963
+ logger.info(
964
+ f"🎯 MATCH EXACT TROUVÉ: prénom '{firstname}' → œuvre #{piece['database_id']} (score: {name_score:.2f})"
965
+ )
966
+ logger.info(f" Noms dans l'œuvre: {piece['related_names']}")
967
+ match_image = self._get_artwork_image(piece)
968
+ match_info = {
969
+ "title": f"Œuvre #{piece['database_id']}",
970
+ "type": piece["art_piece_type"],
971
+ "place": piece["art_piece_place"],
972
+ "emotions": piece["related_emotions"],
973
+ "explanation": piece["explanation"],
974
+ }
975
+ return (
976
+ match_image,
977
+ f"Prénom '{firstname}' correspond exactement",
978
+ match_info,
979
+ )
980
+
981
+ if birth_date and piece["related_dates"]:
982
+ date_score = Optimizer.date_similarity(
983
+ birth_date, piece["related_dates"]
984
+ )
985
+ if date_score == 1.0:
986
+ logger.info(
987
+ f"🎯 MATCH EXACT TROUVÉ: date '{birthday}' → œuvre #{piece['database_id']}"
988
+ )
989
+ logger.info(
990
+ f" Dates dans l'œuvre: {[d.strftime('%d/%m/%Y') for d in piece['related_dates']]}"
991
+ )
992
+ match_image = self._get_artwork_image(piece)
993
+ match_info = {
994
+ "title": f"Œuvre #{piece['database_id']}",
995
+ "type": piece["art_piece_type"],
996
+ "place": piece["art_piece_place"],
997
+ "emotions": piece["related_emotions"],
998
+ "explanation": piece["explanation"],
999
+ }
1000
+ return (
1001
+ match_image,
1002
+ f"Date d'anniversaire {birthday} correspond exactement",
1003
+ match_info,
1004
+ )
1005
+
1006
+ if city and piece["related_places"]:
1007
+ place_score = self.optimizer_helper.place_similarity(
1008
+ city, piece["related_places"]
1009
+ )
1010
+ if place_score == 1.0:
1011
+ logger.info(
1012
+ f"🎯 MATCH EXACT TROUVÉ: ville '{city}' → œuvre #{piece['database_id']}"
1013
+ )
1014
+ logger.info(f" Lieux dans l'œuvre: {piece['related_places']}")
1015
+ match_image = self._get_artwork_image(piece)
1016
+ match_info = {
1017
+ "title": f"Œuvre #{piece['database_id']}",
1018
+ "type": piece["art_piece_type"],
1019
+ "place": piece["art_piece_place"],
1020
+ "emotions": piece["related_emotions"],
1021
+ "explanation": piece["explanation"],
1022
+ }
1023
+ return (
1024
+ match_image,
1025
+ f"Ville '{city}' correspond exactement",
1026
+ match_info,
1027
+ )
1028
+
1029
+ logger.info("Aucun match exact trouvé, passage à la sélection par émotions")
1030
+
1031
+ emotion_profile = self.calculate_emotion_profile(selected_image_ids)
1032
+
1033
+ logger.info("=== STRATÉGIE DE MATCHING ===")
1034
+ valid_preselection = preselected[preselected["score"] > (0, 0, 0)]
1035
+
1036
+ if len(valid_preselection) > 0:
1037
+ logger.info(
1038
+ f"📋 CAS A: {len(valid_preselection)} œuvres pré-sélectionnées - utilisation des émotions pour départager"
1039
+ )
1040
+ candidates = valid_preselection
1041
+ else:
1042
+ logger.info(
1043
+ f"📋 CAS B: Aucune pré-sélection valide - recherche par émotions sur {len(self.df_with_images)} œuvres"
1044
+ )
1045
+ candidates = self.df_with_images
1046
+
1047
+ # Exclure les œuvres déjà sélectionnées par l'utilisateur
1048
+ selected_artwork_ids = set(selected_image_ids)
1049
+ candidates = candidates[
1050
+ ~candidates["database_id"].astype(str).isin(selected_artwork_ids)
1051
+ ]
1052
+ logger.info(
1053
+ f"Après exclusion des œuvres déjà sélectionnées {selected_artwork_ids}: {len(candidates)} candidats restants"
1054
+ )
1055
+
1056
+ logger.info("=== CALCUL DES SCORES ÉMOTIONNELS ===")
1057
+ best_matches = []
1058
+ best_emotion_score = -1
1059
+
1060
+ for idx, piece in candidates.iterrows():
1061
+ emotion_score = 0
1062
+
1063
+ if emotion_profile and piece["related_emotions"]:
1064
+ for user_emotion, weight in emotion_profile.items():
1065
+ best_similarity = 0
1066
+ for piece_emotion in piece["related_emotions"]:
1067
+ similarity = self._cached_emotion_similarity(
1068
+ user_emotion, piece_emotion
1069
+ )
1070
+ if similarity > best_similarity:
1071
+ best_similarity = similarity
1072
+ emotion_score += best_similarity * weight
1073
+
1074
+ if len(piece["related_emotions"]) > 0:
1075
+ emotion_score /= len(piece["related_emotions"])
1076
+
1077
+ if emotion_score > best_emotion_score:
1078
+ best_emotion_score = emotion_score
1079
+ best_matches = [piece]
1080
+ logger.info(
1081
+ f" Nouveau meilleur score émotionnel: {emotion_score:.3f} - Œuvre #{piece['database_id']}"
1082
+ )
1083
+ elif emotion_score == best_emotion_score and emotion_score > 0:
1084
+ best_matches.append(piece)
1085
+ logger.info(
1086
+ f" Score égal au meilleur: {emotion_score:.3f} - Œuvre #{piece['database_id']}"
1087
+ )
1088
+
1089
+ logger.info(
1090
+ f"Nombre de meilleures correspondances: {len(best_matches)} avec score {best_emotion_score:.3f}"
1091
+ )
1092
+
1093
+ if len(best_matches) > 1:
1094
+ logger.info("=== DÉPARTAGE PAR TYPE D'OBJET ===")
1095
+ selected_types = []
1096
+ for img_id in selected_image_ids:
1097
+ if img_id in self.id_to_index:
1098
+ idx = self.id_to_index[img_id]
1099
+ selected_types.append(
1100
+ self.df_with_images.loc[idx, "art_piece_type"]
1101
+ )
1102
+
1103
+ selected_types_counter = Counter(selected_types)
1104
+
1105
+ type_scored_matches = []
1106
+ best_type_score = -1
1107
+
1108
+ for piece in best_matches:
1109
+ type_score = selected_types_counter.get(piece["art_piece_type"], 0)
1110
+ if type_score > best_type_score:
1111
+ best_type_score = type_score
1112
+ type_scored_matches = [piece]
1113
+ elif type_score == best_type_score:
1114
+ type_scored_matches.append(piece)
1115
+
1116
+ if len(type_scored_matches) > 1:
1117
+ logger.info(
1118
+ f" {len(type_scored_matches)} œuvres avec le même score de type ({best_type_score}) - sélection aléatoire"
1119
+ )
1120
+ best_match = random.choice(type_scored_matches)
1121
+ match_reason = (
1122
+ "Sélection aléatoire parmi les meilleures correspondances"
1123
+ )
1124
+ else:
1125
+ best_match = type_scored_matches[0]
1126
+ match_reason = f"Type d'objet '{best_match['art_piece_type']}' préféré"
1127
+ logger.info(
1128
+ f" Type '{best_match['art_piece_type']}' sélectionné avec score {best_type_score}"
1129
+ )
1130
+ elif len(best_matches) == 1:
1131
+ best_match = best_matches[0]
1132
+ match_reason = "Meilleure correspondance émotionnelle"
1133
+ else:
1134
+ logger.info("Aucune correspondance trouvée")
1135
+ return None, "Aucune correspondance trouvée", {}
1136
+
1137
+ reasons = []
1138
+ if len(valid_preselection) > 0:
1139
+ if firstname and best_match["related_names"]:
1140
+ name_score = Optimizer.name_similarity(
1141
+ firstname, best_match["related_names"]
1142
+ )
1143
+ if name_score > 0:
1144
+ reasons.append(f"prénom '{firstname}' trouvé")
1145
+
1146
+ if birth_date and best_match["related_dates"]:
1147
+ date_score = Optimizer.date_similarity(
1148
+ birth_date, best_match["related_dates"]
1149
+ )
1150
+ if date_score > 0:
1151
+ reasons.append(
1152
+ f"date {'exacte' if date_score == 1.0 else 'partielle'}"
1153
+ )
1154
+
1155
+ if city and best_match["related_places"]:
1156
+ place_score = self.optimizer_helper.place_similarity(
1157
+ city, best_match["related_places"]
1158
+ )
1159
+ if place_score > 0:
1160
+ reasons.append(f"ville '{city}' trouvée")
1161
+
1162
+ if best_emotion_score > 0:
1163
+ reasons.append(f"correspondance émotionnelle")
1164
+
1165
+ if len(reasons) == 0:
1166
+ reasons.append(match_reason)
1167
+
1168
+ final_reason = " ; ".join(reasons)
1169
+
1170
+ logger.info(f"\n🏆 RÉSULTAT FINAL: Œuvre #{best_match['database_id']}")
1171
+ logger.info(f" Raison: {final_reason}")
1172
+ logger.info(f" Type: {best_match['art_piece_type']}")
1173
+ logger.info(f" Lieu: {best_match['art_piece_place']}")
1174
+
1175
+ match_image = self._get_artwork_image(best_match)
1176
+
1177
+ match_info = {
1178
+ "title": f"Œuvre #{best_match['database_id']}",
1179
+ "type": best_match["art_piece_type"],
1180
+ "place": best_match["art_piece_place"],
1181
+ "emotions": best_match["related_emotions"],
1182
+ "explanation": best_match["explanation"],
1183
+ }
1184
+
1185
+ return match_image, final_reason, match_info
1186
+
1187
+
1188
+ csv_path = "PP1-Collection_Database_new-cleaned.csv"
1189
+ images_dir = "pictures_data"
1190
+
1191
+ if not os.path.exists(csv_path):
1192
+ logger.error(f"Fichier CSV introuvable: {csv_path}")
1193
+ if not os.path.exists(images_dir):
1194
+ logger.error(f"Répertoire images introuvable: {images_dir}")
1195
+
1196
+ matcher = ArtMatcherV2(csv_path, images_dir)
1197
+
1198
+
1199
+ def process_user_info(firstname: str, birthday: str, city: str, state: SessionState):
1200
+ """Traite les informations utilisateur avec validation"""
1201
+ firstname = SecurityValidator.sanitize_input(firstname)
1202
+ city = SecurityValidator.sanitize_input(city)
1203
+
1204
+ state.firstname = firstname
1205
+ state.birthday = birthday
1206
+ state.city = city
1207
+
1208
+ if not firstname or not birthday:
1209
+ return (
1210
+ gr.update(visible=True),
1211
+ gr.update(visible=False),
1212
+ gr.update(visible=False),
1213
+ "Veuillez remplir au moins votre prénom et date de naissance.",
1214
+ state,
1215
+ )
1216
+
1217
+ is_valid, _ = SecurityValidator.validate_date(birthday)
1218
+ if not is_valid:
1219
+ return (
1220
+ gr.update(visible=True),
1221
+ gr.update(visible=False),
1222
+ gr.update(visible=False),
1223
+ "Format de date invalide. Utilisez JJ/MM (ex: 15/03)",
1224
+ state,
1225
+ )
1226
+
1227
+ return (
1228
+ gr.update(visible=False),
1229
+ gr.update(visible=True),
1230
+ gr.update(visible=False),
1231
+ "Informations enregistrées ! Passons à la sélection d'images.",
1232
+ state,
1233
+ )
1234
+
1235
+
1236
+ def load_images_for_round(round_num: int, state: SessionState):
1237
+ """Charge 3 images pour un tour de sélection"""
1238
+ images_data = matcher.get_random_images_for_selection(
1239
+ round_num, state.selected_images
1240
+ )
1241
+
1242
+ if len(images_data) < ScoringWeights.MAX_IMAGES_PER_SELECTION:
1243
+ logger.warning(f"Seulement {len(images_data)} images disponibles")
1244
+ return (
1245
+ [None, None, None],
1246
+ [],
1247
+ f"Pas assez d'images disponibles (seulement {len(images_data)} trouvées)",
1248
+ state,
1249
+ )
1250
+
1251
+ images = [img[0] for img in images_data]
1252
+ ids = [img[1] for img in images_data]
1253
+
1254
+ state.current_image_ids = ids
1255
+
1256
+ return (
1257
+ images,
1258
+ ids,
1259
+ f"Tour {round_num + 1}/{ScoringWeights.TOTAL_ROUNDS} : Sélectionnez l'image qui vous attire le plus",
1260
+ state,
1261
+ )
1262
+
1263
+
1264
+ def select_image(choice: Optional[int], state: SessionState):
1265
+ """Traite la sélection d'image"""
1266
+ if choice is None:
1267
+ return (
1268
+ gr.update(),
1269
+ gr.update(),
1270
+ gr.update(),
1271
+ gr.update(),
1272
+ "Veuillez sélectionner une image",
1273
+ state,
1274
+ )
1275
+
1276
+ if state.current_image_ids and len(state.current_image_ids) > choice:
1277
+ selected_id = state.current_image_ids[choice]
1278
+ else:
1279
+ return (
1280
+ gr.update(),
1281
+ gr.update(),
1282
+ gr.update(),
1283
+ gr.update(),
1284
+ "Erreur: image non trouvée",
1285
+ state,
1286
+ )
1287
+
1288
+ state.selected_images.append(selected_id)
1289
+ state.current_round += 1
1290
+
1291
+ logger.info(
1292
+ f"Tour {state.current_round}: Image {choice+1} sélectionnée (ID: {selected_id})"
1293
+ )
1294
+
1295
+ if state.current_round < ScoringWeights.TOTAL_ROUNDS:
1296
+ new_images, new_ids, message, state = load_images_for_round(
1297
+ state.current_round, state
1298
+ )
1299
+ return (
1300
+ gr.update(value=new_images[0]),
1301
+ gr.update(value=new_images[1]),
1302
+ gr.update(value=new_images[2]),
1303
+ gr.update(value=None),
1304
+ message,
1305
+ state,
1306
+ gr.update(visible=True), # keep selection_section visible
1307
+ gr.update(visible=False), # keep loading_section hidden
1308
+ )
1309
+ else:
1310
+ # Toutes les sélections sont terminées, afficher le loading
1311
+ return (
1312
+ gr.update(), # img1
1313
+ gr.update(), # img2
1314
+ gr.update(), # img3
1315
+ gr.update(), # image_choice
1316
+ "", # status_message vide
1317
+ state,
1318
+ gr.update(visible=False), # hide selection_section
1319
+ gr.update(visible=True), # show loading_section
1320
+ )
1321
+
1322
+
1323
+ def show_results(state: SessionState):
1324
+ """Affiche les résultats finaux"""
1325
+ if not state.is_complete():
1326
+ return (
1327
+ gr.update(visible=False), # info_section
1328
+ gr.update(visible=True), # selection_section
1329
+ gr.update(visible=False), # loading_section
1330
+ gr.update(visible=False), # results_section
1331
+ None,
1332
+ "",
1333
+ "",
1334
+ )
1335
+
1336
+ match_image, reason, info = matcher.find_best_match(
1337
+ state.firstname,
1338
+ state.birthday,
1339
+ state.city,
1340
+ state.selected_images,
1341
+ )
1342
+
1343
+ if match_image:
1344
+ # Déterminer le type de système de recommandation utilisé
1345
+ if "correspond exactement" in reason.lower():
1346
+ # Match exact sur nom, date ou lieu
1347
+ recommendation_type = "name_date_place"
1348
+ else:
1349
+ # Match basé sur les émotions
1350
+ recommendation_type = "emotions"
1351
+
1352
+ # Enregistrer l'œuvre finale et le type de recommandation
1353
+ state.final_artwork = info.get("title", "Œuvre inconnue")
1354
+ state.recommendation_type = recommendation_type
1355
+
1356
+ # Logger la session
1357
+ session_tracker.log_session(state, recommendation_type)
1358
+
1359
+ explanation = f"""
1360
+ **Votre œuvre correspondante a été trouvée !**
1361
+
1362
+ **Raison du match :** {reason}
1363
+
1364
+ **Détails de l'œuvre :**
1365
+ - Type : {info.get('type', 'Non spécifié')}
1366
+ - Lieu : {info.get('place', 'Non spécifié')}
1367
+ - Émotions : {', '.join(info.get('emotions', [])) if info.get('emotions') else 'Non spécifiées'}
1368
+
1369
+ **Description :**
1370
+ {info.get('explanation', 'Aucune description disponible')}
1371
+ """
1372
+ else:
1373
+ # Aucune œuvre trouvée - logger quand même
1374
+ state.final_artwork = "Aucune œuvre trouvée"
1375
+ state.recommendation_type = "none"
1376
+ session_tracker.log_session(state, "none")
1377
+
1378
+ explanation = "Désolé, aucune œuvre correspondante n'a pu être trouvée."
1379
+
1380
+ return (
1381
+ gr.update(visible=False), # info_section
1382
+ gr.update(visible=False), # selection_section
1383
+ gr.update(visible=False), # loading_section
1384
+ gr.update(visible=True), # results_section
1385
+ match_image,
1386
+ info.get("title", "Œuvre non trouvée") if match_image else "Œuvre non trouvée",
1387
+ explanation,
1388
+ )
1389
+
1390
+
1391
+ with gr.Blocks(
1392
+ title="Art Matcher",
1393
+ theme=gr.themes.Soft(primary_hue="teal", secondary_hue="teal", neutral_hue="zinc"),
1394
+ ) as demo:
1395
+ gr.Markdown(
1396
+ """
1397
+ # 🎨 Art Matcher
1398
+ ### Découvrez l'œuvre d'art qui vous correspond !
1399
+
1400
+ Cette application utilise vos informations personnelles et vos préférences visuelles
1401
+ pour trouver l'œuvre d'art qui vous correspond le mieux dans notre collection.
1402
+ """
1403
+ )
1404
+
1405
+ session_state = gr.State(SessionState())
1406
+
1407
+ with gr.Group(visible=True) as info_section:
1408
+ gr.Markdown("### Étape 1 : Vos informations")
1409
+ with gr.Row():
1410
+ firstname_input = gr.Textbox(
1411
+ label="Prénom", placeholder="Entrez votre prénom", max_lines=1
1412
+ )
1413
+ birthday_input = gr.Textbox(
1414
+ label="Date d'anniversaire (JJ/MM)",
1415
+ placeholder="Ex: 25/12",
1416
+ max_lines=1,
1417
+ )
1418
+ city_input = gr.Textbox(
1419
+ label="Ville de résidence", placeholder="Ex: Paris", max_lines=1
1420
+ )
1421
+
1422
+ submit_info_btn = gr.Button("Valider mes informations", variant="primary")
1423
+
1424
+ with gr.Group(visible=False) as selection_section:
1425
+ selection_title = gr.Markdown("### Étape 2 : Sélection d'images")
1426
+
1427
+ with gr.Row():
1428
+ img1 = gr.Image(label="Image 1", type="filepath", height=300)
1429
+ img2 = gr.Image(label="Image 2", type="filepath", height=300)
1430
+ img3 = gr.Image(label="Image 3", type="filepath", height=300)
1431
+
1432
+ image_choice = gr.Radio(
1433
+ choices=["Image 1", "Image 2", "Image 3"],
1434
+ label="Quelle image vous attire le plus ?",
1435
+ type="index",
1436
+ )
1437
+
1438
+ select_btn = gr.Button("Valider mon choix", variant="primary")
1439
+
1440
+ with gr.Group(visible=False) as loading_section:
1441
+ gr.Markdown("### ⏳ Analyse en cours...")
1442
+ gr.HTML(
1443
+ """
1444
+ <div style="text-align: center; padding: 40px;">
1445
+ <div style="display: inline-block; width: 60px; height: 60px; border: 6px solid #f3f3f3; border-top: 6px solid #14b8a6; border-radius: 50%; animation: spin 1s linear infinite;"></div>
1446
+ <style>
1447
+ @keyframes spin {
1448
+ 0% { transform: rotate(0deg); }
1449
+ 100% { transform: rotate(360deg); }
1450
+ }
1451
+ </style>
1452
+ <p style="margin-top: 20px; font-size: 18px; color: #666;">
1453
+ <strong>Traitement de vos sélections...</strong><br>
1454
+ <span style="font-size: 14px;">Nous analysons votre profil pour trouver l'œuvre parfaite</span>
1455
+ </p>
1456
+ </div>
1457
+ """
1458
+ )
1459
+
1460
+ with gr.Group(visible=False) as results_section:
1461
+ gr.Markdown("### Votre œuvre correspondante")
1462
+
1463
+ with gr.Row():
1464
+ with gr.Column(scale=1):
1465
+ result_image = gr.Image(label="Votre œuvre", height=400)
1466
+ result_title = gr.Markdown("## Titre de l'œuvre")
1467
+
1468
+ with gr.Column(scale=1):
1469
+ result_explanation = gr.Markdown("")
1470
+
1471
+ restart_btn = gr.Button("Recommencer", variant="secondary")
1472
+
1473
+ status_message = gr.Markdown("")
1474
+
1475
+ def on_info_submit(firstname, birthday, city, state):
1476
+ state.reset()
1477
+
1478
+ info_vis, select_vis, results_vis, message, state = process_user_info(
1479
+ firstname, birthday, city, state
1480
+ )
1481
+
1482
+ if select_vis["visible"]:
1483
+ images, ids, round_message, state = load_images_for_round(0, state)
1484
+ return (
1485
+ info_vis,
1486
+ select_vis,
1487
+ results_vis,
1488
+ images[0] if len(images) > 0 else None,
1489
+ images[1] if len(images) > 1 else None,
1490
+ images[2] if len(images) > 2 else None,
1491
+ round_message,
1492
+ state,
1493
+ )
1494
+ else:
1495
+ return (info_vis, select_vis, results_vis, None, None, None, message, state)
1496
+
1497
+ submit_info_btn.click(
1498
+ fn=on_info_submit,
1499
+ inputs=[firstname_input, birthday_input, city_input, session_state],
1500
+ outputs=[
1501
+ info_section,
1502
+ selection_section,
1503
+ results_section,
1504
+ img1,
1505
+ img2,
1506
+ img3,
1507
+ status_message,
1508
+ session_state,
1509
+ ],
1510
+ )
1511
+
1512
+ def on_image_select(choice, state):
1513
+ result = select_image(choice, state)
1514
+
1515
+ # La fonction select_image retourne maintenant 8 valeurs
1516
+ if len(result) == 8:
1517
+ (
1518
+ img1_update,
1519
+ img2_update,
1520
+ img3_update,
1521
+ choice_update,
1522
+ message,
1523
+ state,
1524
+ selection_vis,
1525
+ loading_vis,
1526
+ ) = result
1527
+ return (
1528
+ gr.update(), # info_section
1529
+ selection_vis, # selection_section
1530
+ loading_vis, # loading_section
1531
+ gr.update(), # results_section
1532
+ img1_update, # img1
1533
+ img2_update, # img2
1534
+ img3_update, # img3
1535
+ choice_update, # image_choice
1536
+ message, # status_message
1537
+ state,
1538
+ )
1539
+ else:
1540
+ # Format avec 6 valeurs (cas sans loading)
1541
+ (img1_update, img2_update, img3_update, choice_update, message, state) = (
1542
+ result
1543
+ )
1544
+ return (
1545
+ gr.update(), # info_section
1546
+ gr.update(), # selection_section
1547
+ gr.update(), # loading_section
1548
+ gr.update(), # results_section
1549
+ img1_update, # img1
1550
+ img2_update, # img2
1551
+ img3_update, # img3
1552
+ choice_update, # image_choice
1553
+ message, # status_message
1554
+ state,
1555
+ )
1556
+
1557
+ def handle_final_results(state):
1558
+ if state.is_complete():
1559
+ return show_results(state)
1560
+ else:
1561
+ return gr.update(), gr.update(), gr.update(), gr.update(), None, "", ""
1562
+
1563
+ select_btn.click(
1564
+ fn=on_image_select,
1565
+ inputs=[image_choice, session_state],
1566
+ outputs=[
1567
+ info_section,
1568
+ selection_section,
1569
+ loading_section,
1570
+ results_section,
1571
+ img1,
1572
+ img2,
1573
+ img3,
1574
+ image_choice,
1575
+ status_message,
1576
+ session_state,
1577
+ ],
1578
+ ).then(
1579
+ fn=handle_final_results,
1580
+ inputs=[session_state],
1581
+ outputs=[
1582
+ info_section,
1583
+ selection_section,
1584
+ loading_section,
1585
+ results_section,
1586
+ result_image,
1587
+ result_title,
1588
+ result_explanation,
1589
+ ],
1590
+ )
1591
+
1592
+ def restart_app(state):
1593
+ state.reset()
1594
+
1595
+ return (
1596
+ gr.update(visible=True), # info_section
1597
+ gr.update(visible=False), # selection_section
1598
+ gr.update(visible=False), # loading_section
1599
+ gr.update(visible=False), # results_section
1600
+ "", # firstname_input
1601
+ "", # birthday_input
1602
+ "", # city_input
1603
+ None, # image_choice
1604
+ "Application réinitialisée. Veuillez entrer vos informations.", # status_message
1605
+ state,
1606
+ )
1607
+
1608
+ restart_btn.click(
1609
+ fn=restart_app,
1610
+ inputs=[session_state],
1611
+ outputs=[
1612
+ info_section,
1613
+ selection_section,
1614
+ loading_section,
1615
+ results_section,
1616
+ firstname_input,
1617
+ birthday_input,
1618
+ city_input,
1619
+ image_choice,
1620
+ status_message,
1621
+ session_state,
1622
+ ],
1623
+ )
1624
+
1625
+
1626
+ if __name__ == "__main__":
1627
+ demo.launch()