MathieuGAL commited on
Commit
eabeb0b
·
verified ·
1 Parent(s): 09bd272

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +307 -418
app.py CHANGED
@@ -1,58 +1,45 @@
1
- import os
2
  import pandas as pd
3
  import chromadb
4
- import requests
5
- import json
6
- import smtplib
7
- from email.message import EmailMessage
8
  from google import genai
9
  from sentence_transformers import SentenceTransformer, CrossEncoder
10
  from typing import List, Dict
11
  from flask import Flask, request, jsonify
12
- from flask_cors import CORS
13
  from datetime import datetime
14
- import time
15
 
16
  # ======================================================================
17
- # ⚙️ CONFIGURATION EMAIL (REMPLACEMENT TÉLÉGRAM)
18
- # ======================================================================
19
-
20
- # Adresses et mot de passe d'application
21
- EMAIL_ADDRESS = "bnbskynet@gmail.com"
22
- RECEIVER_ADDRESS = "galiniermathieu06@gmail.com"
23
-
24
- # Mot de passe d'application (App Password) généré par Google
25
- # Utilisé en deux parties pour éviter la détection (À recombiner)
26
- PART1 = "qmflmiziyrrs"
27
- PART2 = "jlni"
28
- EMAIL_PASSWORD = PART1 + PART2
29
-
30
-
31
- # ======================================================================
32
- # CONFIGURATION RAG
33
  # ======================================================================
34
 
35
  DATA_FILE_PATH = "data/QR.csv"
36
- CHROMA_DB_PATH = "/tmp/bdd_ChromaDB"
 
 
 
37
  COLLECTION_NAME = "qr_data_dual_embeddings"
38
 
39
  Q_COLUMN_NAME = "Question"
40
  R_COLUMN_NAME = "Reponse"
41
  SYSTEM_PROMPT_PATH = "data/system_prompt.txt"
42
 
 
43
  SRC_CROSS_ENCODER = "models/mmarco-mMiniLMv2-L12-H384-v1"
44
  SRC_PARAPHRASE = "models/paraphrase-mpnet-base-v2"
45
 
46
  N_RESULTS_RETRIEVAL = 10
47
  N_RESULTS_RERANK = 3
48
 
49
- GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "AIzaSyDXXY7uSXryTxZ51jQFsSLcPnC_Ivt9V1g")
 
 
50
  GEMINI_MODEL = "gemini-2.5-flash"
51
 
52
  MAX_CONVERSATION_HISTORY = 10
53
 
 
54
  API_HOST = '0.0.0.0'
55
- API_PORT = 1212
56
 
57
  # ======================================================================
58
  # VARIABLES GLOBALES
@@ -67,292 +54,187 @@ gemini_client: genai.Client = None
67
  conversation_histories: Dict[str, List[Dict[str, str]]] = {}
68
  conversation_start_times: Dict[str, str] = {}
69
 
70
-
71
  # ======================================================================
72
- # 📧 FONCTION D'ENVOI D'EMAIL
73
- # ======================================================================
74
-
75
- def send_email_notification(subject: str, html_body: str, sender: str, receiver: str, password: str):
76
- """
77
- Envoie un email via SMTP en utilisant les informations d'authentification.
78
- """
79
- msg = EmailMessage()
80
- msg['Subject'] = subject
81
- msg['From'] = sender
82
- msg['To'] = receiver
83
- msg.set_content(html_body, subtype='html')
84
-
85
- try:
86
- # Connexion sécurisée au serveur SMTP de Gmail (Port 465)
87
- with smtplib.SMTP_SSL('smtp.gmail.com', 465) as smtp:
88
- smtp.login(sender, password)
89
- smtp.send_message(msg)
90
- print(f"✅ Succès : Email de notification envoyé à {receiver}!")
91
- return True
92
- except smtplib.SMTPAuthenticationError:
93
- print("❌ Erreur d'authentification SMTP : Vérifiez EMAIL_ADDRESS et EMAIL_PASSWORD.")
94
- except Exception as e:
95
- print(f"❌ Erreur lors de l'envoi de l'email : {e}")
96
- return False
97
-
98
- def send_llm_interaction_email(question: str, reponse_llm: str, session_id: str):
99
- """
100
- Construit l'email d'interaction Q/R et l'envoie.
101
- """
102
- SUBJECT = f"🔔 [RAG App] Nouvelle Interaction de Session ID: {session_id}"
103
-
104
- # Contenu HTML pour l'interaction spécifique
105
- HTML_BODY = f"""
106
- <html>
107
- <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
108
- <h2 style="color: #007bff;">Nouvelle Interaction Détectée ({time.strftime('%Y-%m-%d %H:%M:%S')})</h2>
109
- <p><strong>Session ID:</strong> <code>{session_id}</code></p>
110
-
111
- <div style="margin-top: 20px; padding: 15px; border: 1px solid #ccc; border-radius: 5px;">
112
- <h3 style="color: #28a745;">Question de l'Utilisateur :</h3>
113
- <p style="white-space: pre-wrap; background-color: #f9f9f9; padding: 10px; border-left: 4px solid #28a745;">{question}</p>
114
- </div>
115
-
116
- <div style="margin-top: 20px; padding: 15px; border: 1px solid #ccc; border-radius: 5px;">
117
- <h3 style="color: #ffc107;">Réponse Générée par le LLM :</h3>
118
- <p style="white-space: pre-wrap; background-color: #fffbe6; padding: 10px; border-left: 4px solid #ffc107;">{reponse_llm}</p>
119
- </div>
120
-
121
- <p style="margin-top: 30px;">Ceci est une notification automatisée de votre application RAG.</p>
122
- </body>
123
- </html>
124
- """
125
-
126
- send_email_notification(
127
- subject=SUBJECT,
128
- html_body=HTML_BODY,
129
- sender=EMAIL_ADDRESS,
130
- receiver=RECEIVER_ADDRESS,
131
- password=EMAIL_PASSWORD
132
- )
133
-
134
- def send_startup_email():
135
- """
136
- Envoie l'email de démarrage en utilisant le modèle de mail de prospection
137
- avec une légère adaptation.
138
- """
139
- SUBJECT = "🚀 Lancement de l'Application RAG - Vérification du Service"
140
-
141
- HTML_BODY = f"""
142
- <html>
143
- <body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
144
-
145
- <p>Cher(ère) Mathieu,</p>
146
-
147
- <p>Je suis <strong>Thomas Aubertin</strong>, dirigeant de <strong style="color: #007bff;">VALLAL</strong>, votre application RAG vient de démarrer avec succès.</p>
148
-
149
- <p>Notre outil de veille automatisée a été initialisé. Tous les modèles et la base de données ChromaDB sont chargés et prêts à répondre aux requêtes. La fonction de notification par email est maintenant activée pour les interactions.</p>
150
-
151
- <div style="background-color: #f7f7f7; padding: 15px; border-radius: 8px; margin: 20px 0;">
152
- <h3 style="color: #28a745; margin-top: 0;">Statut Actuel (Heure: {time.strftime('%Y-%m-%d %H:%M:%S')}) :</h3>
153
- <ul>
154
- <li><strong>Statut du Service:</strong> En ligne et prêt à l'emploi.</li>
155
- <li><strong>Base de Données:</strong> ChromaDB chargée et accessible.</li>
156
- <li><strong>Notification:</strong> Email activée (Test de connexion SMTP réussi).</li>
157
- </ul>
158
- </div>
159
-
160
- <p>Je vous souhaite une bonne journée !</p>
161
-
162
- <p style="margin-top: 30px;">
163
- Bien cordialement,<br><br>
164
- <strong>Thomas Aubertin</strong><br>
165
- Dirigeant, VALLAL Conciergerie Premium<br>
166
- <span style="color: #007bff;"><strong>&#9742; +33 6 52 38 69 95</strong></span><br>
167
- Mon avatar digital : <a href="https://mgline.duckdns.org/AvatarThomas" style="color: #007bff; text-decoration: underline;">Cliquez ici</a>
168
- </p>
169
-
170
- </body>
171
- </html>
172
- """
173
- send_email_notification(
174
- subject=SUBJECT,
175
- html_body=HTML_BODY,
176
- sender=EMAIL_ADDRESS,
177
- receiver=RECEIVER_ADDRESS,
178
- password=EMAIL_PASSWORD
179
- )
180
-
181
-
182
- # ======================================================================
183
- # CHARGEMENT DES RESSOURCES (Pas de changements)
184
  # ======================================================================
185
 
186
  def load_models():
187
- """Charge les modèles SentenceTransformer et CrossEncoder."""
188
- print("⏳ Chargement des modèles...")
189
- try:
190
- cross_encoder = CrossEncoder(
191
- SRC_CROSS_ENCODER if os.path.exists(SRC_CROSS_ENCODER)
192
- else "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
193
- )
194
- paraphrase = SentenceTransformer(
195
- SRC_PARAPHRASE if os.path.exists(SRC_PARAPHRASE)
196
- else "sentence-transformers/paraphrase-mpnet-base-v2"
197
- )
198
- print("✅ Modèles chargés avec succès.")
199
- return cross_encoder, paraphrase
200
- except Exception as e:
201
- print(f"❌ Erreur chargement modèles: {e}")
202
- raise
 
 
203
 
204
  def load_data():
205
- """Charge le DataFrame depuis le CSV."""
206
- try:
207
- if not os.path.exists(DATA_FILE_PATH):
208
- print(f"⚠️ Fichier {DATA_FILE_PATH} non trouvé. Utilisation d'exemple.")
209
- df = pd.DataFrame({
210
- Q_COLUMN_NAME: ["Où est le soleil?", "Qui est l'IA?"],
211
- R_COLUMN_NAME: ["Le soleil est une étoile.", "L'IA est l'intelligence artificielle."]
212
- })
213
- else:
214
- df = pd.read_csv(DATA_FILE_PATH)
215
- print(f"✅ {len(df)} lignes chargées depuis {DATA_FILE_PATH}.")
216
- return df
217
- except Exception as e:
218
- print(f"❌ Erreur chargement données: {e}")
219
- raise
220
 
221
  def load_system_prompt():
222
- """Charge le system prompt."""
223
- try:
224
- with open(SYSTEM_PROMPT_PATH, 'r', encoding='utf-8') as f:
225
- return f.read().strip()
226
- except FileNotFoundError:
227
- default = "Tu es un assistant utile et concis. Réponds à la requête de l'utilisateur."
228
- print(f"⚠️ System prompt non trouvé à {SYSTEM_PROMPT_PATH}. Utilisation du prompt par défaut.")
229
- return default
230
 
231
  def initialize_gemini_client():
232
- """Initialise le client Google Gemini."""
233
- if GEMINI_API_KEY == "AIzaSyDXXY7uSXryTxZ51jQFsSLcPnC_Ivt9V1g":
234
- print("⚠️ AVIS: Clé Gemini par défaut/placeholder détectée. Veuillez la remplacer par un secret d'environnement nommé 'GEMINI_API_KEY' pour la production.")
235
- try:
236
- return genai.Client(api_key=GEMINI_API_KEY)
237
- except Exception as e:
238
- print(f"❌ Erreur lors de l'initialisation du client Gemini: {e}")
239
- raise
240
 
241
  # ======================================================================
242
- # CHROMADB SETUP (Pas de changements)
243
  # ======================================================================
244
 
245
  def setup_chromadb_collection(client, df, model_paraphrase):
246
- """Configure et remplit la collection ChromaDB."""
247
- total_docs = len(df) * 2
248
-
249
- os.makedirs(CHROMA_DB_PATH, exist_ok=True)
250
-
251
- try:
252
- collection = client.get_or_create_collection(name=COLLECTION_NAME)
253
- except Exception as e:
254
- print(f"❌ Erreur lors de l'accès à la collection ChromaDB: {e}")
255
- raise
256
-
257
- if collection.count() == total_docs and total_docs > 0:
258
- print(f"✅ Collection déjà remplie ({collection.count()} docs) dans {CHROMA_DB_PATH}.")
259
- return collection
260
-
261
- if total_docs == 0:
262
- print("⚠️ DataFrame vide. Collection non remplie.")
263
- return collection
264
-
265
- print(f"⏳ Remplissage de ChromaDB ({len(df)} lignes) à l'emplacement: {CHROMA_DB_PATH}...")
266
-
267
- docs, metadatas, ids = [], [], []
268
-
269
- for i, row in df.iterrows():
270
- question = str(row[Q_COLUMN_NAME])
271
- reponse = str(row[R_COLUMN_NAME])
272
- meta = {Q_COLUMN_NAME: question, R_COLUMN_NAME: reponse, "source_row": i}
273
-
274
- docs.append(question)
275
- metadatas.append({**meta, "type": "question"})
276
- ids.append(f"id_{i}_Q")
277
-
278
- docs.append(reponse)
279
- metadatas.append({**meta, "type": "reponse"})
280
- ids.append(f"id_{i}_R")
281
-
282
- embeddings = model_paraphrase.encode(docs, show_progress_bar=False).tolist()
283
-
284
- try:
285
- client.delete_collection(name=COLLECTION_NAME)
286
- except:
287
- pass
288
-
289
- collection = client.get_or_create_collection(name=COLLECTION_NAME)
290
- collection.add(embeddings=embeddings, documents=docs, metadatas=metadatas, ids=ids)
291
-
292
- print(f"✅ Collection remplie: {collection.count()} documents.")
293
- return collection
 
 
294
 
295
  # ======================================================================
296
- # RAG - RETRIEVAL & RERANKING (Pas de changements)
297
  # ======================================================================
298
 
299
  def retrieve_and_rerank(query_text, collection, model_paraphrase, model_cross_encoder):
300
- """Récupère et rerank les résultats."""
301
- print(f"🔍 Récupération pour: '{query_text[:40]}...'")
302
-
303
- query_emb = model_paraphrase.encode([query_text]).tolist()
304
- results = collection.query(
305
- query_embeddings=query_emb,
306
- n_results=N_RESULTS_RETRIEVAL,
307
- include=['documents', 'metadatas', 'distances']
308
- )
309
-
310
- if not results['ids'][0]:
311
- print("⚠️ Aucun résultat trouvé.")
312
- return pd.DataFrame()
313
-
314
- candidates = []
315
- cross_input = []
316
-
317
- for i, doc in enumerate(results['documents'][0]):
318
- meta = results['metadatas'][0][i]
319
- candidates.append({
320
- 'question': meta[Q_COLUMN_NAME],
321
- 'reponse': meta[R_COLUMN_NAME],
322
- 'doc_type': meta.get('type'),
323
- 'text_reranked': doc,
324
- 'initial_distance': results['distances'][0][i]
325
- })
326
- cross_input.append([query_text, doc])
327
-
328
- scores = model_cross_encoder.predict(cross_input)
329
- for i, score in enumerate(scores):
330
- candidates[i]['rerank_score'] = score
331
-
332
- df = pd.DataFrame(candidates).sort_values('rerank_score', ascending=False)
333
- df = df.drop_duplicates(subset=['question', 'reponse'], keep='first')
334
-
335
- return df.head(N_RESULTS_RERANK)
336
 
337
  def generate_rag_prompt(query_text, df_results, conversation_history):
338
- """Génère le prompt RAG final."""
339
- context = []
340
- if not df_results.empty:
341
- for _, row in df_results.iterrows():
342
- context.append(f"Q: {row['question']}\nR: {row['reponse']}")
343
-
344
- context_str = "\n---\n".join(context)
345
-
346
- history_str = ""
347
- if conversation_history:
348
- history_str = "HISTORIQUE:\n"
349
- display_history = conversation_history[-(MAX_CONVERSATION_HISTORY * 2):]
350
- for msg in display_history:
351
- role = "USER" if msg["role"] == "user" else "ASSISTANT"
352
- history_str += f"{role}: {msg['content']}\n"
353
- history_str += "\n"
354
-
355
- return f"""{history_str}UTILISATEUR: {query_text}
 
 
 
356
 
357
  CONTEXTE (si utile):
358
  [{context_str}]
@@ -365,174 +247,181 @@ INSTRUCTIONS:
365
  - Mentionne obligatoirement Mathieu dans ta réponse"""
366
 
367
  # ======================================================================
368
- # GESTION HISTORIQUE (Pas de changements)
369
  # ======================================================================
370
 
371
  def get_conversation_history(session_id):
372
- """Récupère l'historique d'une session."""
373
- return conversation_histories.get(session_id, [])
374
 
375
  def add_to_history(session_id, role, content):
376
- """Ajoute un message à l'historique."""
377
- if session_id not in conversation_histories:
378
- conversation_histories[session_id] = []
379
-
380
- conversation_histories[session_id].append({"role": role, "content": content})
381
-
382
- if len(conversation_histories[session_id]) > MAX_CONVERSATION_HISTORY * 2:
383
- conversation_histories[session_id] = conversation_histories[session_id][-(MAX_CONVERSATION_HISTORY * 2):]
 
384
 
385
  def clear_history(session_id):
386
- """Efface l'historique d'une session."""
387
- conversation_histories[session_id] = []
388
 
389
  # ======================================================================
390
- # CALL GEMINI (Pas de changements)
391
  # ======================================================================
392
 
393
  def call_gemini(rag_prompt, system_prompt, gemini_client):
394
- """Appelle Google Gemini."""
395
- try:
396
- response = gemini_client.models.generate_content(
397
- model=GEMINI_MODEL,
398
- contents=f"{system_prompt}\n\n{rag_prompt}"
399
- )
400
- return response.text.replace("*", "")
401
- except Exception as e:
402
- print(f"❌ Erreur Gemini: {e}")
403
- return f"Erreur: {str(e)}"
404
 
405
  # ======================================================================
406
- # ANSWER PROCESS (Pas de changements)
407
  # ======================================================================
408
 
409
  def get_answer(query_text, collection, model_paraphrase, model_cross_encoder, conversation_history):
410
- """Exécute le processus RAG complet."""
411
- print(f"\n{'='*50}")
412
- print(f"🚀 Traitement: '{query_text}'")
413
- print(f"{'='*50}")
414
-
415
- df_results = retrieve_and_rerank(query_text, collection, model_paraphrase, model_cross_encoder)
416
- final_prompt = generate_rag_prompt(query_text, df_results, conversation_history)
417
-
418
- return final_prompt
 
419
 
420
  # ======================================================================
421
- # INITIALISATION GLOBALE (Pas de changements)
422
  # ======================================================================
423
 
424
  def initialize_global_resources():
425
- """Initialise tous les modèles et ressources."""
426
- global model_cross_encoder, model_paraphrase, collection, system_prompt, gemini_client
427
-
428
- print("\n" + "="*50)
429
- print("⚙️ INITIALISATION RAG")
430
- print("="*50)
431
-
432
- try:
433
- model_cross_encoder, model_paraphrase = load_models()
434
- df = load_data()
435
- system_prompt = load_system_prompt()
436
- gemini_client = initialize_gemini_client()
437
- except Exception:
438
- return False
439
-
440
- try:
441
- print(f"⏳ Initialisation de ChromaDB à l'emplacement: {CHROMA_DB_PATH}")
442
- chroma_client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
443
- collection = setup_chromadb_collection(chroma_client, df, model_paraphrase)
444
- print(" INITIALISATION COMPLÈTE\n")
445
- return True
446
- except Exception as e:
447
- print(f"❌ Erreur lors de l'initialisation de ChromaDB ou du remplissage: {e}")
448
- return False
 
 
 
 
449
 
450
  # ======================================================================
451
  # FLASK API
452
  # ======================================================================
453
 
454
  app = Flask(__name__)
455
- CORS(app)
 
456
 
457
  @app.route('/status', methods=['GET'])
458
  def api_status():
459
- """Route de ping pour vérifier l'état de l'API."""
460
- return jsonify({"status": "everything is good"}), 200
461
 
462
  @app.route('/api/get_answer', methods=['POST'])
463
  def api_get_answer():
464
- """Endpoint principal pour obtenir une réponse et envoyer la notification Email."""
465
- if any(x is None for x in [model_cross_encoder, model_paraphrase, collection, system_prompt, gemini_client]):
466
- return jsonify({"error": "Ressources non chargées. Veuillez vérifier les logs d'initialisation."}), 500
467
-
468
- try:
469
- data = request.get_json()
470
- query_text = data.get('query_text')
471
- session_id = data.get('session_id', 'archive')
472
-
473
- if not query_text:
474
- generic_message = "Requête vide."
475
- return jsonify({"error": generic_message}), 400
476
-
477
- history = get_conversation_history(session_id)
478
- rag_prompt = get_answer(query_text, collection, model_paraphrase, model_cross_encoder, history)
479
- response = call_gemini(rag_prompt, system_prompt, gemini_client)
480
-
481
- add_to_history(session_id, "user", query_text)
482
- add_to_history(session_id, "assistant", response)
483
-
484
- # 🚀 ENVOI DE LA NOTIFICATION EMAIL
485
- send_llm_interaction_email(
486
- question=query_text,
487
- reponse_llm=response,
488
- session_id=session_id
489
- )
490
-
491
- return jsonify({"generated_response": response})
492
-
493
- except Exception as e:
494
- print(f"❌ Erreur générale de l'API: {e}")
495
- generic_message = "Problème avec l'API, veuillez réessayer plus tard."
496
- return jsonify({"error": generic_message}), 500
497
 
498
  @app.route('/api/clear_history', methods=['POST'])
499
  def api_clear_history():
500
- """Efface l'historique d'une session."""
501
- try:
502
- data = request.get_json()
503
- session_id = data.get('session_id', 'archive')
504
- clear_history(session_id)
505
-
506
- return jsonify({"message": f"Historique effacé: {session_id}"})
507
- except Exception as e:
508
- generic_message = "Problème avec l'API, veuillez réessayer plus tard."
509
- return jsonify({"error": generic_message}), 500
510
 
511
  # ======================================================================
512
  # MAIN
513
  # ======================================================================
514
 
515
  if __name__ == '__main__':
516
- print("start app.py")
517
- if initialize_global_resources():
518
-
519
- # ➡️ ENVOI DE L'EMAIL AU DÉMARRAGE
520
- send_startup_email()
521
-
522
- try:
523
- import socket
524
- s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
525
- s.connect(("8.8.8.8", 80))
526
- local_ip = s.getsockname()[0]
527
- s.close()
528
- except Exception:
529
- local_ip = "127.0.0.1"
530
-
531
- print("\n" + "="*50)
532
- print("🌐 SERVEUR DÉMARRÉ")
533
- print(f" API accessible à l'URL (via l'interface réseau locale): http://{local_ip}:{API_PORT}")
534
- print("="*50 + "\n")
535
-
536
- app.run(host=API_HOST, port=API_PORT, debug=False)
537
- else:
538
- print("❌ Impossible de démarrer le serveur. Veuillez vérifier les logs pour les erreurs d'initialisation.")
 
 
1
+ import os 
2
  import pandas as pd
3
  import chromadb
 
 
 
 
4
  from google import genai
5
  from sentence_transformers import SentenceTransformer, CrossEncoder
6
  from typing import List, Dict
7
  from flask import Flask, request, jsonify
8
+ from flask_cors import CORS 
9
  from datetime import datetime
 
10
 
11
  # ======================================================================
12
+ # CONFIGURATION
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
13
  # ======================================================================
14
 
15
  DATA_FILE_PATH = "data/QR.csv"
16
+
17
+ # CORRECTION CRITIQUE: Déplacement de la DB vers /tmp
18
+ # Ce répertoire est le seul garanti en écriture sur Hugging Face Spaces.
19
+ CHROMA_DB_PATH = "/tmp/bdd_ChromaDB" 
20
  COLLECTION_NAME = "qr_data_dual_embeddings"
21
 
22
  Q_COLUMN_NAME = "Question"
23
  R_COLUMN_NAME = "Reponse"
24
  SYSTEM_PROMPT_PATH = "data/system_prompt.txt"
25
 
26
+ # Les chemins des modèles sont conservés (ils se mettront en cache dans /tmp grâce au Dockerfile)
27
  SRC_CROSS_ENCODER = "models/mmarco-mMiniLMv2-L12-H384-v1"
28
  SRC_PARAPHRASE = "models/paraphrase-mpnet-base-v2"
29
 
30
  N_RESULTS_RETRIEVAL = 10
31
  N_RESULTS_RERANK = 3
32
 
33
+ # Récupération de la clé depuis l'environnement (Hugging Face Secrets)
34
+ # Si non trouvée, utilise la clé de placeholder.
35
+ GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "AIzaSyDXXY7uSXryTxZ51jQFsSLcPnC_Ivt9V1g") 
36
  GEMINI_MODEL = "gemini-2.5-flash"
37
 
38
  MAX_CONVERSATION_HISTORY = 10
39
 
40
+ # Configuration pour l'accès externe (host et port)
41
  API_HOST = '0.0.0.0'
42
+ API_PORT = 1212 # Le port 1212 est conservé, il doit être configuré dans le README.md
43
 
44
  # ======================================================================
45
  # VARIABLES GLOBALES
 
54
  conversation_histories: Dict[str, List[Dict[str, str]]] = {}
55
  conversation_start_times: Dict[str, str] = {}
56
 
 
57
  # ======================================================================
58
+ # CHARGEMENT DES RESSOURCES
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  # ======================================================================
60
 
61
  def load_models():
62
+     """Charge les modèles SentenceTransformer et CrossEncoder."""
63
+     print("⏳ Chargement des modèles...")
64
+     try:
65
+         # Tente de charger localement, sinon télécharge (le cache se fera dans /tmp)
66
+         cross_encoder = CrossEncoder(
67
+             SRC_CROSS_ENCODER if os.path.exists(SRC_CROSS_ENCODER) 
68
+             else "cross-encoder/mmarco-mMiniLMv2-L12-H384-v1"
69
+         )
70
+         paraphrase = SentenceTransformer(
71
+             SRC_PARAPHRASE if os.path.exists(SRC_PARAPHRASE) 
72
+             else "sentence-transformers/paraphrase-mpnet-base-v2"
73
+         )
74
+         print("✅ Modèles chargés avec succès.")
75
+         return cross_encoder, paraphrase
76
+     except Exception as e:
77
+         print(f"❌ Erreur chargement modèles: {e}")
78
+         # Note: L'erreur de PermissionError est maintenant gérée par le Dockerfile
79
+         raise
80
 
81
  def load_data():
82
+     """Charge le DataFrame depuis le CSV."""
83
+     try:
84
+         if not os.path.exists(DATA_FILE_PATH):
85
+             print(f"��️ Fichier {DATA_FILE_PATH} non trouvé. Utilisation d'exemple.")
86
+             df = pd.DataFrame({
87
+                 Q_COLUMN_NAME: ["Où est le soleil?", "Qui est l'IA?"],
88
+                 R_COLUMN_NAME: ["Le soleil est une étoile.", "L'IA est l'intelligence artificielle."]
89
+             })
90
+         else:
91
+             df = pd.read_csv(DATA_FILE_PATH)
92
+             print(f"✅ {len(df)} lignes chargées depuis {DATA_FILE_PATH}.")
93
+         return df
94
+     except Exception as e:
95
+         print(f"❌ Erreur chargement données: {e}")
96
+         raise
97
 
98
  def load_system_prompt():
99
+     """Charge le system prompt."""
100
+     try:
101
+         with open(SYSTEM_PROMPT_PATH, 'r', encoding='utf-8') as f:
102
+             return f.read().strip()
103
+     except FileNotFoundError:
104
+         default = "Tu es un assistant utile et concis. Réponds à la requête de l'utilisateur."
105
+         print(f"⚠️ System prompt non trouvé à {SYSTEM_PROMPT_PATH}. Utilisation du prompt par défaut.")
106
+         return default
107
 
108
  def initialize_gemini_client():
109
+     """Initialise le client Google Gemini."""
110
+     if GEMINI_API_KEY == "AIzaSyDXXY7uSXryTxZ51jQFsSLcPnC_Ivt9V1g":
111
+         print("⚠️ AVIS: Clé Gemini par défaut/placeholder détectée. Veuillez la remplacer par un secret d'environnement nommé 'GEMINI_API_KEY' pour la production.")
112
+     try:
113
+         return genai.Client(api_key=GEMINI_API_KEY)
114
+     except Exception as e:
115
+         print(f"❌ Erreur lors de l'initialisation du client Gemini: {e}")
116
+         raise
117
 
118
  # ======================================================================
119
+ # CHROMADB SETUP
120
  # ======================================================================
121
 
122
  def setup_chromadb_collection(client, df, model_paraphrase):
123
+     """Configure et remplit la collection ChromaDB."""
124
+     total_docs = len(df) * 2
125
+     
126
+     # S'assurer que le répertoire de la DB existe
127
+     os.makedirs(CHROMA_DB_PATH, exist_ok=True)
128
+     
129
+     try:
130
+         collection = client.get_or_create_collection(name=COLLECTION_NAME)
131
+     except Exception as e:
132
+         print(f"❌ Erreur lors de l'accès à la collection ChromaDB: {e}")
133
+         raise
134
+     
135
+     if collection.count() == total_docs and total_docs > 0:
136
+         print(f"✅ Collection déjà remplie ({collection.count()} docs) dans {CHROMA_DB_PATH}.")
137
+         return collection
138
+     
139
+     if total_docs == 0:
140
+         print("⚠️ DataFrame vide. Collection non remplie.")
141
+         return collection
142
+     
143
+     print(f"⏳ Remplissage de ChromaDB ({len(df)} lignes) à l'emplacement: {CHROMA_DB_PATH}...")
144
+     
145
+     docs, metadatas, ids = [], [], []
146
+     
147
+     for i, row in df.iterrows():
148
+         question = str(row[Q_COLUMN_NAME])
149
+         reponse = str(row[R_COLUMN_NAME])
150
+         meta = {Q_COLUMN_NAME: question, R_COLUMN_NAME: reponse, "source_row": i}
151
+         
152
+         docs.append(question)
153
+         metadatas.append({**meta, "type": "question"})
154
+         ids.append(f"id_{i}_Q")
155
+         
156
+         docs.append(reponse)
157
+         metadatas.append({**meta, "type": "reponse"})
158
+         ids.append(f"id_{i}_R")
159
+     
160
+     embeddings = model_paraphrase.encode(docs, show_progress_bar=False).tolist()
161
+     
162
+     # Nettoyage et recréation (pour le cas où les données CSV ont changé)
163
+     try:
164
+         client.delete_collection(name=COLLECTION_NAME)
165
+     except:
166
+         pass
167
+     
168
+     collection = client.get_or_create_collection(name=COLLECTION_NAME)
169
+     collection.add(embeddings=embeddings, documents=docs, metadatas=metadatas, ids=ids)
170
+     
171
+     print(f"✅ Collection remplie: {collection.count()} documents.")
172
+     return collection
173
 
174
  # ======================================================================
175
+ # RAG - RETRIEVAL & RERANKING
176
  # ======================================================================
177
 
178
  def retrieve_and_rerank(query_text, collection, model_paraphrase, model_cross_encoder):
179
+     """Récupère et rerank les résultats."""
180
+     print(f"🔍 Récupération pour: '{query_text[:40]}...'")
181
+     
182
+     query_emb = model_paraphrase.encode([query_text]).tolist()
183
+     results = collection.query(
184
+         query_embeddings=query_emb,
185
+         n_results=N_RESULTS_RETRIEVAL,
186
+         include=['documents', 'metadatas', 'distances']
187
+     )
188
+     
189
+     if not results['ids'][0]:
190
+         print("⚠️ Aucun résultat trouvé.")
191
+         return pd.DataFrame()
192
+     
193
+     candidates = []
194
+     cross_input = []
195
+     
196
+     for i, doc in enumerate(results['documents'][0]):
197
+         meta = results['metadatas'][0][i]
198
+         candidates.append({
199
+             'question': meta[Q_COLUMN_NAME],
200
+             'reponse': meta[R_COLUMN_NAME],
201
+             'doc_type': meta.get('type'),
202
+             'text_reranked': doc,
203
+             'initial_distance': results['distances'][0][i]
204
+         })
205
+         cross_input.append([query_text, doc])
206
+     
207
+     scores = model_cross_encoder.predict(cross_input)
208
+     for i, score in enumerate(scores):
209
+         candidates[i]['rerank_score'] = score
210
+     
211
+     df = pd.DataFrame(candidates).sort_values('rerank_score', ascending=False)
212
+     df = df.drop_duplicates(subset=['question', 'reponse'], keep='first')
213
+     
214
+     return df.head(N_RESULTS_RERANK)
215
 
216
  def generate_rag_prompt(query_text, df_results, conversation_history):
217
+     """Génère le prompt RAG final."""
218
+     context = []
219
+     if not df_results.empty:
220
+         for _, row in df_results.iterrows():
221
+             context.append(f"Q: {row['question']}\nR: {row['reponse']}")
222
+     
223
+     context_str = "\n---\n".join(context)
224
+     
225
+     history_str = ""
226
+     if conversation_history:
227
+         history_str = "HISTORIQUE:\n"
228
+         # Ajout du contexte pour le LLM, mais on ne veut pas l'historique complet
229
+         # On va limiter l'historique à l'affichage si on dépasse MAX_CONVERSATION_HISTORY
230
+         display_history = conversation_history[-(MAX_CONVERSATION_HISTORY * 2):] 
231
+         for msg in display_history:
232
+             role = "USER" if msg["role"] == "user" else "ASSISTANT"
233
+             # On utilise 'content' pour le texte du message
234
+             history_str += f"{role}: {msg['content']}\n"
235
+         history_str += "\n"
236
+     
237
+     return f"""{history_str}UTILISATEUR: {query_text}
238
 
239
  CONTEXTE (si utile):
240
  [{context_str}]
 
247
  - Mentionne obligatoirement Mathieu dans ta réponse"""
248
 
249
  # ======================================================================
250
+ # GESTION HISTORIQUE
251
  # ======================================================================
252
 
253
  def get_conversation_history(session_id):
254
+     """Récupère l'historique d'une session."""
255
+     return conversation_histories.get(session_id, [])
256
 
257
  def add_to_history(session_id, role, content):
258
+     """Ajoute un message à l'historique."""
259
+     if session_id not in conversation_histories:
260
+         conversation_histories[session_id] = []
261
+     
262
+     conversation_histories[session_id].append({"role": role, "content": content})
263
+     
264
+     # Limiter la taille de l'historique conservé en mémoire
265
+     if len(conversation_histories[session_id]) > MAX_CONVERSATION_HISTORY * 2:
266
+         conversation_histories[session_id] = conversation_histories[session_id][-(MAX_CONVERSATION_HISTORY * 2):]
267
 
268
  def clear_history(session_id):
269
+     """Efface l'historique d'une session."""
270
+     conversation_histories[session_id] = []
271
 
272
  # ======================================================================
273
+ # CALL GEMINI
274
  # ======================================================================
275
 
276
  def call_gemini(rag_prompt, system_prompt, gemini_client):
277
+     """Appelle Google Gemini."""
278
+     try:
279
+         response = gemini_client.models.generate_content(
280
+             model=GEMINI_MODEL,
281
+             contents=f"{system_prompt}\n\n{rag_prompt}"
282
+         )
283
+         return response.text.replace("*", "")
284
+     except Exception as e:
285
+         print(f"❌ Erreur Gemini: {e}")
286
+         return f"Erreur: {str(e)}"
287
 
288
  # ======================================================================
289
+ # ANSWER PROCESS
290
  # ======================================================================
291
 
292
  def get_answer(query_text, collection, model_paraphrase, model_cross_encoder, conversation_history):
293
+     """Exécute le processus RAG complet."""
294
+     print(f"\n{'='*50}")
295
+     print(f"🚀 Traitement: '{query_text}'")
296
+     print(f"{'='*50}")
297
+     
298
+     df_results = retrieve_and_rerank(query_text, collection, model_paraphrase, model_cross_encoder)
299
+     final_prompt = generate_rag_prompt(query_text, df_results, conversation_history)
300
+     
301
+     # On retourne le prompt final RAG pour référence, mais l'appel Gemini est fait après
302
+     return final_prompt
303
 
304
  # ======================================================================
305
+ # INITIALISATION GLOBALE
306
  # ======================================================================
307
 
308
  def initialize_global_resources():
309
+     """Initialise tous les modèles et ressources."""
310
+     global model_cross_encoder, model_paraphrase, collection, system_prompt, gemini_client
311
+     
312
+     print("\n" + "="*50)
313
+     print("⚙️  INITIALISATION RAG")
314
+     print("="*50)
315
+     
316
+     # Le répertoire /tmp est géré par la variable CHROMA_DB_PATH
317
+     
318
+     try:
319
+         model_cross_encoder, model_paraphrase = load_models()
320
+         df = load_data()
321
+         system_prompt = load_system_prompt()
322
+         gemini_client = initialize_gemini_client()
323
+     except Exception:
324
+         # L'erreur est déjà print dans les fonctions de chargement
325
+         return False
326
+     
327
+     try:
328
+         print(f" Initialisation de ChromaDB à l'emplacement: {CHROMA_DB_PATH}")
329
+         # Le PersistentClient créera les fichiers dans le chemin spécifié (maintenant dans /tmp)
330
+         chroma_client = chromadb.PersistentClient(path=CHROMA_DB_PATH)
331
+         collection = setup_chromadb_collection(chroma_client, df, model_paraphrase)
332
+         print("✅ INITIALISATION COMPLÈTE\n")
333
+         return True
334
+     except Exception as e:
335
+         print(f"❌ Erreur lors de l'initialisation de ChromaDB ou du remplissage: {e}")
336
+         return False
337
 
338
  # ======================================================================
339
  # FLASK API
340
  # ======================================================================
341
 
342
  app = Flask(__name__)
343
+ # CORS activé, permet les requêtes depuis n'importe quelle origine
344
+ CORS(app) 
345
 
346
  @app.route('/status', methods=['GET'])
347
  def api_status():
348
+     """Route de ping pour vérifier l'état de l'API."""
349
+     return jsonify({"status": "everything is good"}), 200
350
 
351
  @app.route('/api/get_answer', methods=['POST'])
352
  def api_get_answer():
353
+     """Endpoint principal pour obtenir une réponse."""
354
+     if any(x is None for x in [model_cross_encoder, model_paraphrase, collection, system_prompt, gemini_client]):
355
+         return jsonify({"error": "Ressources non chargées. Veuillez vérifier les logs d'initialisation."}), 500
356
+     
357
+     try:
358
+         data = request.get_json()
359
+         query_text = data.get('query_text')
360
+         session_id = data.get('session_id', 'archive')
361
+         
362
+         if not query_text:
363
+             generic_message = "Problème avec l'API, veuillez réessayer plus tard."
364
+             return jsonify({"error": generic_message}), 500
365
+         
366
+         # Récupère historique
367
+         history = get_conversation_history(session_id)
368
+         
369
+         # Génère prompt RAG
370
+         rag_prompt = get_answer(query_text, collection, model_paraphrase, model_cross_encoder, history)
371
+         
372
+         # Appelle Gemini
373
+         response = call_gemini(rag_prompt, system_prompt, gemini_client)
374
+         
375
+         # Sauvegarde réponse
376
+         add_to_history(session_id, "user", query_text)
377
+         add_to_history(session_id, "assistant", response)
378
+         
379
+         return jsonify({"generated_response": response})
380
+     
381
+     except Exception as e:
382
+         print(f"❌ Erreur générale de l'API: {e}")
383
+         generic_message = "Problème avec l'API, veuillez réessayer plus tard."
384
+         return jsonify({"error": generic_message}), 500
 
385
 
386
  @app.route('/api/clear_history', methods=['POST'])
387
  def api_clear_history():
388
+     """Efface l'historique d'une session."""
389
+     try:
390
+         data = request.get_json()
391
+         session_id = data.get('session_id', 'archive')
392
+         clear_history(session_id)
393
+         
394
+         return jsonify({"message": f"Historique effacé: {session_id}"})
395
+     except Exception as e:
396
+         generic_message = "Problème avec l'API, veuillez réessayer plus tard."
397
+         return jsonify({"error": generic_message}), 500
398
 
399
  # ======================================================================
400
  # MAIN
401
  # ======================================================================
402
 
403
  if __name__ == '__main__':
404
+     print("start app.py")
405
+     if initialize_global_resources():
406
+         
407
+         # Récupération de l'adresse IP si possible (pour l'affichage)
408
+         try:
409
+             import socket
410
+             s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
411
+             s.connect(("8.8.8.8", 80)) # Connecte à un serveur externe pour trouver l'IP locale utilisée
412
+             local_ip = s.getsockname()[0]
413
+             s.close()
414
+         except Exception:
415
+             local_ip = "127.0.0.1" # Fallback si échec
416
+         
417
+         print("\n" + "="*50)
418
+         print("🌐 SERVEUR DÉMARRÉ")
419
+         print(f" API accessible à l'URL (via l'interface réseau locale): http://{local_ip}:{API_PORT}")
420
+         print(f" Route Status: http://{local_ip}:{API_PORT}/status")
421
+         print(f"💡 N'oubliez pas de configurer 'app_port: 1212' et 'sdk: docker' dans votre README.md !")
422
+         print("="*50 + "\n")
423
+         
424
+         # L'utilisation de host='0.0.0.0' dans app.run() permet l'accès depuis l'extérieur
425
+         app.run(host=API_HOST, port=API_PORT, debug=False)
426
+     else:
427
+         print("❌ Impossible de démarrer le serveur. Veuillez vérifier les logs pour les erreurs d'initialisation.")