Daly26 commited on
Commit
a547d17
·
0 Parent(s):

initial clean push for Hugging Face

Browse files
.DS_Store ADDED
Binary file (6.15 kB). View file
 
.gitattributes ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ models/** filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python virtual environment
2
+ seatech_chatbot/
3
+ *.pyc
4
+ __pycache__/
5
+ *.pyd
6
+ *.whl
7
+ *.zip
8
+ *.env
9
+
10
+ # Data / cache / models
11
+ models/
12
+ cache/
13
+ database/
Dockerfile ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10
2
+
3
+ RUN useradd -m -u 1000 user
4
+ USER user
5
+ ENV PATH="/home/user/.local/bin:$PATH"
6
+
7
+ WORKDIR /app
8
+
9
+ COPY --chown=user requirements.txt requirements.txt
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY --chown=user . /app
13
+
14
+ CMD ["python", "LesChatsDeSeatech.py"]
LesChatsDeSeatech.py ADDED
@@ -0,0 +1,1059 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import json
4
+ import pickle
5
+ import time
6
+ import uuid
7
+ import vosk
8
+ import io
9
+ import wave
10
+ import logging
11
+ from datetime import datetime
12
+ from flask import Flask, request, render_template, jsonify, send_from_directory, session
13
+ from dotenv import load_dotenv
14
+ load_dotenv() #Charge les données de l'environnement de développement dnas le fichier main.
15
+
16
+
17
+ import urllib.request
18
+ import zipfile
19
+
20
+ model_url = "https://alphacephei.com/vosk/models/vosk-model-small-fr-0.22.zip"
21
+ model_path = "models/vosk-model-small-fr-0.22"
22
+
23
+ if not os.path.exists(model_path):
24
+ print("Downloading speech model...")
25
+ os.makedirs("models", exist_ok=True)
26
+ urllib.request.urlretrieve(model_url, "model.zip")
27
+
28
+ with zipfile.ZipFile("model.zip", 'r') as zip_ref:
29
+ zip_ref.extractall("models")
30
+
31
+ os.remove("model.zip")
32
+
33
+ # Importations ML et API
34
+ try:
35
+ import torch
36
+ import numpy as np
37
+ import faiss
38
+ from sentence_transformers import SentenceTransformer
39
+ from sklearn.metrics.pairwise import cosine_similarity
40
+ ML_IMPORTS_SUCCESS = True
41
+ except ImportError:
42
+ ML_IMPORTS_SUCCESS = False
43
+
44
+
45
+ try:
46
+ from groq import Groq
47
+ GROQ_IMPORT_SUCCESS = True
48
+ except ImportError:
49
+ GROQ_IMPORT_SUCCESS = False
50
+
51
+ # Configuration du logging
52
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
53
+ logger = logging.getLogger("seatech_chatbot")
54
+
55
+ # Répertoires et fichiers
56
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
57
+ DATA_DIR = os.path.join(BASE_DIR, "data")
58
+ USER_DB_DIR = os.path.join(BASE_DIR, "database", "user_database")
59
+ CACHE_DIR = os.path.join(BASE_DIR, "cache")
60
+ for directory in [DATA_DIR, USER_DB_DIR, CACHE_DIR]:
61
+ os.makedirs(directory, exist_ok=True)
62
+ EMBEDDINGS_CACHE = os.path.join(CACHE_DIR, "chunk_embeddings.pkl")
63
+ CHUNKS_CACHE = os.path.join(CACHE_DIR, "chunks_with_sources.pkl")
64
+ QA_STORAGE = os.path.join(CACHE_DIR, "user_qa_memory.json")
65
+
66
+ # ===== CONFIGURATION =====
67
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY", "")
68
+
69
+ LLM_MODEL = "llama-3.3-70b-versatile"
70
+ CONFIDENCE_THRESHOLD = 0.93
71
+
72
+ # Acronymes utilisés
73
+ ACRONYMS = {
74
+ "SN": "Systèmes Numériques",
75
+ "MTX": "Matériaux",
76
+ "APP": "Apprentissage",
77
+ "FISE": "Formation Initiale Sous Statut d'Étudiant",
78
+ "FISA": "Formation Initiale Sous Statut d'Apprenti",
79
+ "UTLN": "Université de Toulon"
80
+ }
81
+
82
+ # Mapping des profils avec leurs descriptions et mots-clés de priorisation
83
+ profile_mapping = {
84
+ "Étudiant": {
85
+ "description": "Fournis des informations utiles aux étudiants actuels de SeaTech : cours, emplois du temps, projets, vie associative, stages.",
86
+ "keywords": ["cours", "emploi du temps", "projet", "stage", "vie associative", "examen", "planning", "td", "tp", "association", "événement étudiant", "logement", "bourse"],
87
+ "priority_topics": ["cours", "planning", "examens", "stages", "projets"]
88
+ },
89
+ "Enseignant": {
90
+ "description": "Fournis des informations utiles aux enseignants de SeaTech : ressources pédagogiques, contacts administratifs, organisation des cours.",
91
+ "keywords": ["ressources pédagogiques", "contact administratif", "organisation", "enseignement", "programme", "évaluation", "moodle", "scolarité", "administration"],
92
+ "priority_topics": ["ressources", "administration", "organisation", "programmes"]
93
+ },
94
+ "Candidat": {
95
+ "description": "Fournis des informations utiles aux candidats intéressés par SeaTech : admissions, concours, dossiers, procédures, spécialités disponibles.",
96
+ "keywords": ["admission", "concours", "dossier", "procédure", "candidature", "inscription", "formation", "spécialité", "parcours", "prérequis", "sélection"],
97
+ "priority_topics": ["admissions", "formations", "candidature", "spécialités"]
98
+ },
99
+ "Alumni": {
100
+ "description": "Fournis des informations utiles aux anciens étudiants de SeaTech : réseau alumni, partenariats, événements, relations entreprises.",
101
+ "keywords": ["alumni", "réseau", "partenariat", "entreprise", "carrière", "emploi", "contact professionnel", "événement alumni", "relation entreprise"],
102
+ "priority_topics": ["réseau", "carrière", "partenariats", "emploi"]
103
+ }
104
+ }
105
+
106
+ # Pour l'exemple, nous définissons CONTACTS vide (à compléter selon vos besoins)
107
+ CONTACTS = {}
108
+
109
+ # ===== INITIALISATION DES CLIENTS =====
110
+ if GROQ_IMPORT_SUCCESS:
111
+ try:
112
+ groq_client = Groq(api_key=GROQ_API_KEY)
113
+ logger.info("Client GROQ initialisé")
114
+ except Exception as e:
115
+ logger.error(f"Erreur initialisation client GROQ: {e}")
116
+ groq_client = None
117
+ else:
118
+ groq_client = None
119
+ logger.warning("GROQ non disponible - vérifiez l'installation")
120
+
121
+ if ML_IMPORTS_SUCCESS:
122
+ try:
123
+ device = "cuda" if torch.cuda.is_available() else "cpu"
124
+ #embedding_model = SentenceTransformer('intfloat/e5-large-v2', device='cpu')
125
+ embedding_model = SentenceTransformer('all-MiniLM-L6-v2', device='cpu')
126
+ embedding_model.max_seq_length = 256 # Réduire la longueur max
127
+ logger.info(f"Modèle d'embedding chargé sur {device}")
128
+ except Exception as e:
129
+ logger.error(f"Erreur chargement modèle d'embedding: {e}")
130
+ embedding_model = None
131
+ else:
132
+ embedding_model = None
133
+ logger.warning("Bibliothèques ML non disponibles")
134
+
135
+
136
+ def handle_role_selection(session_id, selected_role=None):
137
+ """Gère la sélection de rôle utilisateur et initialise la conversation."""
138
+ if session_id not in conversation_history_global:
139
+ conversation_history_global[session_id] = []
140
+
141
+ # Si un rôle est sélectionné, l'enregistrer dans la session
142
+ if selected_role and selected_role in profile_mapping:
143
+ if 'user_profile' not in session:
144
+ session['user_profile'] = {}
145
+ session['user_profile']['role'] = selected_role
146
+ session['user_profile']['confirmed'] = True
147
+
148
+ # Message de bienvenue personnalisé selon le rôle
149
+ role_config = profile_mapping[selected_role]
150
+ welcome_message = f"""
151
+ <p>Parfait ! Je vais adapter mes réponses pour un profil <strong>{selected_role}</strong>.</p>
152
+ <p>{role_config['description']}</p>
153
+ <p>Vous pouvez maintenant me poser vos questions sur SeaTech !</p>
154
+ """
155
+
156
+ conversation_history_global[session_id].append({
157
+ "role": "assistant",
158
+ "content": welcome_message,
159
+ "is_system_message": True
160
+ })
161
+
162
+ return selected_role
163
+
164
+ return None
165
+ # ===== FONCTIONS UTILITAIRES =====
166
+ def detect_user_role(query, conversation_history=None):
167
+ """
168
+ Détecte automatiquement le rôle de l'utilisateur basé sur sa question et l'historique.
169
+ Retourne le rôle détecté et un score de confiance.
170
+ """
171
+ query_lower = query.lower()
172
+ role_scores = {}
173
+
174
+ # Analyse basée sur les mots-clés
175
+ for role, config in profile_mapping.items():
176
+ score = 0
177
+ keywords = config["keywords"]
178
+
179
+ # Score basé sur la présence de mots-clés
180
+ for keyword in keywords:
181
+ if keyword in query_lower:
182
+ score += 2
183
+
184
+ # Score basé sur les sujets prioritaires (poids plus élevé)
185
+ for priority in config["priority_topics"]:
186
+ if priority in query_lower:
187
+ score += 3
188
+
189
+ role_scores[role] = score
190
+
191
+ # Analyse de l'historique de conversation si disponible
192
+ if conversation_history:
193
+ for entry in conversation_history[-3:]: # Dernières 3 interactions
194
+ if entry['role'] == 'user':
195
+ content_lower = entry['content'].lower()
196
+ for role, config in profile_mapping.items():
197
+ for keyword in config["keywords"]:
198
+ if keyword in content_lower:
199
+ role_scores[role] += 1
200
+
201
+ # Détection de phrases spécifiques
202
+ role_patterns = {
203
+ "Candidat": [r"comment (postuler|candidater|s'inscrire)", r"quelles sont les conditions", r"admission", r"je veux intégrer"],
204
+ "Étudiant": [r"mes cours", r"mon emploi du temps", r"quand est l'examen", r"projet de fin d'étude"],
205
+ "Enseignant": [r"ressources pour", r"contact administration", r"organisation des cours"],
206
+ "Alumni": [r"réseau", r"ancien élève", r"après diplôme", r"carrière"]
207
+ }
208
+
209
+ for role, patterns in role_patterns.items():
210
+ for pattern in patterns:
211
+ if re.search(pattern, query_lower):
212
+ role_scores[role] += 4
213
+
214
+ # Retourner le rôle avec le score le plus élevé
215
+ if role_scores:
216
+ best_role = max(role_scores, key=role_scores.get)
217
+ confidence = role_scores[best_role]
218
+
219
+ # Si aucun score significatif, retourner "Candidat" par défaut (plus général)
220
+ if confidence == 0:
221
+ return "Candidat", 0.1
222
+
223
+ return best_role, min(confidence / 10, 1.0) # Normaliser entre 0 et 1
224
+
225
+ return "Candidat", 0.1
226
+
227
+ def filter_chunks_by_role(chunks_results, detected_role, role_confidence):
228
+ """
229
+ Filtre et réordonne les chunks en fonction du rôle détecté de l'utilisateur.
230
+ """
231
+ if role_confidence < 0.3: # Si la confiance est faible, ne pas filtrer
232
+ return chunks_results
233
+
234
+ role_config = profile_mapping.get(detected_role, profile_mapping["Candidat"])
235
+ keywords = role_config["keywords"]
236
+ priority_topics = role_config["priority_topics"]
237
+
238
+ filtered_results = []
239
+
240
+ for chunk_text, source, original_score in chunks_results:
241
+ chunk_lower = chunk_text.lower()
242
+ role_relevance_score = 0
243
+
244
+ # Score basé sur les mots-clés du rôle
245
+ for keyword in keywords:
246
+ if keyword in chunk_lower:
247
+ role_relevance_score += 0.1
248
+
249
+ # Score bonus pour les sujets prioritaires
250
+ for priority in priority_topics:
251
+ if priority in chunk_lower:
252
+ role_relevance_score += 0.2
253
+
254
+ # Score combiné (original + pertinence rôle)
255
+ combined_score = original_score + (role_relevance_score * role_confidence)
256
+
257
+ filtered_results.append((chunk_text, source, combined_score, role_relevance_score))
258
+
259
+ # Trier par score combiné
260
+ filtered_results = sorted(filtered_results, key=lambda x: x[2], reverse=True)
261
+
262
+ # Reconvertir au format original en gardant le score combiné
263
+ return [(text, source, combined_score) for text, source, combined_score, _ in filtered_results]
264
+
265
+ def expand_acronyms_in_query(query):
266
+ """Étend les acronymes dans la requête avec une meilleure détection."""
267
+ expanded_query = query
268
+ for acronym, expansion in ACRONYMS.items():
269
+ # Détection plus précise avec une expression régulière
270
+ pattern = r'\b' + re.escape(acronym) + r'\b'
271
+ if re.search(pattern, query, re.IGNORECASE):
272
+ expanded_query = re.sub(pattern, f"{acronym} ({expansion})", expanded_query, flags=re.IGNORECASE)
273
+ logger.info(f"Acronyme détecté et étendu: {acronym} -> {expansion}")
274
+
275
+ # Si des acronymes ont été étendus, on ajoute une note
276
+ if expanded_query != query:
277
+ expanded_query += " " + " ".join([expansion for acronym, expansion in ACRONYMS.items()
278
+ if re.search(r'\b' + re.escape(acronym) + r'\b', query, re.IGNORECASE)])
279
+
280
+ return expanded_query
281
+
282
+ def convert_markdown_to_html(text):
283
+ """
284
+ Conversion améliorée du Markdown vers HTML avec prise en charge de plus de formats.
285
+ """
286
+ # Gestion des titres (#, ##, etc.)
287
+ def replace_heading(match):
288
+ hashes = match.group(1)
289
+ level = len(hashes)
290
+ title = match.group(2).strip()
291
+ return f"<h{level}>{title}</h{level}>"
292
+
293
+ text = re.sub(r'^(#{1,6})\s+(.*)$', replace_heading, text, flags=re.MULTILINE)
294
+
295
+ # Conversion des formats gras et italique
296
+ text = re.sub(r'\*\*(.*?)\*\*', r'<strong>\1</strong>', text)
297
+ text = re.sub(r'\*(.*?)\*', r'<em>\1</em>', text)
298
+ text = re.sub(r'__(.*?)__', r'<strong>\1</strong>', text) # Alternative pour le gras
299
+ text = re.sub(r'_(.*?)_', r'<em>\1</em>', text) # Alternative pour l'italique
300
+
301
+ # Gestion des liens [texte](url)
302
+ text = re.sub(r'\[(.*?)\]\((.*?)\)', r'<a href="\2" target="_blank">\1</a>', text)
303
+
304
+ # Traitement des listes à puces et numérotées
305
+ lines = text.splitlines()
306
+ html_lines = []
307
+ in_ul = False
308
+ in_ol = False
309
+
310
+ for line in lines:
311
+ # Liste à puces
312
+ if line.strip().startswith("* ") or line.strip().startswith("- "):
313
+ if not in_ul:
314
+ if in_ol:
315
+ html_lines.append("</ol>")
316
+ in_ol = False
317
+ html_lines.append("<ul>")
318
+ in_ul = True
319
+ content = re.sub(r'^\s*[\*\-]\s+(.*)', r'\1', line)
320
+ html_lines.append(f"<li>{content}</li>")
321
+
322
+ # Liste numérotée
323
+ elif re.match(r'^\s*\d+\.\s+', line):
324
+ if not in_ol:
325
+ if in_ul:
326
+ html_lines.append("</ul>")
327
+ in_ul = False
328
+ html_lines.append("<ol>")
329
+ in_ol = True
330
+ content = re.sub(r'^\s*\d+\.\s+(.*)', r'\1', line)
331
+ html_lines.append(f"<li>{content}</li>")
332
+
333
+ # Ligne normale
334
+ else:
335
+ if in_ul:
336
+ html_lines.append("</ul>")
337
+ in_ul = False
338
+ if in_ol:
339
+ html_lines.append("</ol>")
340
+ in_ol = False
341
+ html_lines.append(line)
342
+
343
+ # Fermer les listes si nécessaire
344
+ if in_ul:
345
+ html_lines.append("</ul>")
346
+ if in_ol:
347
+ html_lines.append("</ol>")
348
+
349
+ text = "\n".join(html_lines)
350
+
351
+ # Découpage en paragraphes pour les blocs de texte non déjà formatés
352
+ paragraphs = []
353
+ for block in re.split(r'\n\s*\n', text):
354
+ block = block.strip()
355
+ # Vérifier si le bloc contient déjà des balises HTML
356
+ if not re.match(r'^<\/?(h\d|ul|ol|li|blockquote|pre|table)', block):
357
+ if block: # Ne pas ajouter de paragraphe vide
358
+ block = f"<p>{block}</p>"
359
+ paragraphs.append(block)
360
+
361
+ return "\n".join(paragraphs)
362
+
363
+ def generate_basic_answer(query, context, found_info):
364
+ """Fallback basique en l'absence du client GROQ."""
365
+ answer = "<p>Désolé, le service de génération de réponse n'est pas disponible actuellement. Veuillez réessayer plus tard ou contacter l'administrateur.</p>"
366
+ return answer
367
+
368
+ # ===== GESTION DES DONNÉES =====
369
+ def create_default_data():
370
+ """Crée des fichiers de données par défaut si DATA_DIR est vide."""
371
+ if not os.listdir(DATA_DIR):
372
+ # Création d'un fichier d'acronymes
373
+ with open(os.path.join(DATA_DIR, "acronymes.txt"), "w", encoding="utf-8") as f:
374
+ f.write("# Acronymes utilisés à SeaTech\n\n")
375
+ for acronym, meaning in ACRONYMS.items():
376
+ f.write(f"{acronym}: {meaning}\n")
377
+ # Fichier de contacts (seulement si CONTACTS est renseigné)
378
+ with open(os.path.join(DATA_DIR, "contacts.txt"), "w", encoding="utf-8") as f:
379
+ f.write("# Contacts importants à SeaTech\n\n")
380
+ if CONTACTS:
381
+ for name, info in CONTACTS.items():
382
+ f.write(f"{name}: {info.get('role', 'N/A')} - {info.get('email', 'N/A')}\n")
383
+ else:
384
+ f.write("Aucun contact défini.\n")
385
+ # Fichier d'information générale
386
+ with open(os.path.join(DATA_DIR, "info_generale.json"), "w", encoding="utf-8") as f:
387
+ f.write("# SeaTech - École d'ingénieurs\n\n")
388
+ f.write("SeaTech est une école d'ingénieurs de l'Université de Toulon (UTLN). ")
389
+ f.write("Elle propose plusieurs formations d'ingénieur dont les spécialités SN (Systèmes Numériques) ")
390
+ f.write("et MTX (Matériaux). Les formations peuvent être suivies en statut étudiant (FISE) ou en apprentissage (FISA).\n")
391
+
392
+ def load_data():
393
+ """Charge les fichiers dans DATA_DIR et découpe le contenu en chunks avec timestamp de dernière modification."""
394
+ create_default_data()
395
+ chunks_with_sources = []
396
+ valid_extensions = ['.txt', '.md', '.html', '.csv','.json']
397
+
398
+ # Charger un cache des timestamps si disponible
399
+ timestamp_cache_path = os.path.join(CACHE_DIR, "file_timestamps.json")
400
+ file_timestamps = {}
401
+ reload_all = False
402
+
403
+ if os.path.exists(timestamp_cache_path):
404
+ try:
405
+ with open(timestamp_cache_path, "r", encoding="utf-8") as f:
406
+ file_timestamps = json.load(f)
407
+ except Exception as e:
408
+ logger.error(f"Erreur chargement cache timestamps: {e}")
409
+ reload_all = True
410
+
411
+ # Vérifier et charger les fichiers
412
+ for filename in os.listdir(DATA_DIR):
413
+ file_path = os.path.join(DATA_DIR, filename)
414
+ if os.path.isfile(file_path) and any(filename.endswith(ext) for ext in valid_extensions):
415
+ # Vérifier si le fichier a été modifié
416
+ mtime = os.path.getmtime(file_path)
417
+
418
+ if reload_all or filename not in file_timestamps or file_timestamps[filename] < mtime:
419
+ try:
420
+ with open(file_path, "r", encoding="utf-8") as f:
421
+ content = f.read()
422
+ # Découpage en chunks selon les paragraphes
423
+ raw_chunks = [chunk.strip() for chunk in re.split(r"\n\s*\n", content) if chunk.strip()]
424
+ for chunk in raw_chunks:
425
+ chunks_with_sources.append((chunk, filename))
426
+ logger.info(f"Fichier {filename} chargé : {len(raw_chunks)} chunks")
427
+ file_timestamps[filename] = mtime
428
+ except Exception as e:
429
+ logger.error(f"Erreur chargement {filename}: {e}")
430
+
431
+ # Sauvegarder les timestamps mis à jour
432
+ try:
433
+ with open(timestamp_cache_path, "w", encoding="utf-8") as f:
434
+ json.dump(file_timestamps, f)
435
+ except Exception as e:
436
+ logger.error(f"Erreur sauvegarde cache timestamps: {e}")
437
+
438
+ return chunks_with_sources
439
+
440
+ def compute_embeddings(chunks_with_sources):
441
+ """Calcule ou charge les embeddings pour les chunks."""
442
+ if not embedding_model:
443
+ logger.warning("Modèle d'embedding non disponible")
444
+ dummy_embeddings = np.zeros((len(chunks_with_sources), 384))
445
+ return dummy_embeddings, chunks_with_sources
446
+ if os.path.exists(EMBEDDINGS_CACHE) and os.path.exists(CHUNKS_CACHE):
447
+ try:
448
+ with open(EMBEDDINGS_CACHE, "rb") as f:
449
+ cached_embeddings = pickle.load(f)
450
+ with open(CHUNKS_CACHE, "rb") as f:
451
+ cached_chunks = pickle.load(f)
452
+ if len(cached_embeddings) == len(cached_chunks):
453
+ logger.info(f"Cache chargé : {len(cached_embeddings)} embeddings")
454
+ return cached_embeddings, cached_chunks
455
+ except Exception as e:
456
+ logger.error(f"Erreur chargement cache: {e}")
457
+ try:
458
+ chunk_texts = [chunk[0] for chunk in chunks_with_sources]
459
+ embeddings = embedding_model.encode(chunk_texts, batch_size=8, convert_to_tensor=True)
460
+ chunk_embeddings = embeddings.cpu().numpy() if torch.is_tensor(embeddings) else np.array(embeddings)
461
+ with open(EMBEDDINGS_CACHE, "wb") as f:
462
+ pickle.dump(chunk_embeddings, f)
463
+ with open(CHUNKS_CACHE, "wb") as f:
464
+ pickle.dump(chunks_with_sources, f)
465
+ logger.info(f"Embeddings calculés et sauvegardés : {len(chunk_embeddings)}")
466
+ return chunk_embeddings, chunks_with_sources
467
+ except Exception as e:
468
+ logger.error(f"Erreur calcul embeddings: {e}")
469
+ dummy_embeddings = np.zeros((len(chunks_with_sources), 384))
470
+ return dummy_embeddings, chunks_with_sources
471
+
472
+ # ===== INDEXATION AVEC FAISS =====
473
+ def setup_search_index(embeddings):
474
+ """
475
+ Configure l'index FAISS.
476
+ Utilise IndexIVFFlat pour un grand nombre d'embeddings, sinon IndexFlatL2.
477
+ """
478
+ if not ML_IMPORTS_SUCCESS or embeddings.size == 0:
479
+ logger.warning("Index de recherche non disponible")
480
+ return None, False
481
+ try:
482
+ d = embeddings.shape[1]
483
+ if embeddings.shape[0] > 1000:
484
+ quantizer = faiss.IndexFlatL2(d)
485
+ index = faiss.IndexIVFFlat(quantizer, d, 100, faiss.METRIC_L2)
486
+ index.train(embeddings)
487
+ index.add(embeddings)
488
+ logger.info(f"Index FAISS IVFFlat créé avec {index.ntotal} vecteurs")
489
+ return index, True
490
+ else:
491
+ index = faiss.IndexFlatL2(d)
492
+ index.add(embeddings)
493
+ logger.info(f"Index FAISS FlatL2 créé avec {index.ntotal} vecteurs")
494
+ return index, True
495
+ except Exception as e:
496
+ logger.error(f"Erreur initialisation index: {e}")
497
+ return None, False
498
+
499
+ def search_similar_chunks_with_confirmed_role(query, index, is_faiss, embeddings, chunks_data, conversation_history=None, confirmed_role=None, top_n=5):
500
+ """Recherche avec rôle confirmé par l'utilisateur."""
501
+
502
+ # Recherche de base (identique à l'ancienne fonction)
503
+ if not embedding_model or not index:
504
+ results = keyword_search(query, chunks_data, top_n)
505
+ else:
506
+ try:
507
+ query_expanded = expand_acronyms_in_query(query)
508
+ query_embedding = embedding_model.encode(query_expanded, convert_to_tensor=False)
509
+ query_embedding = np.array(query_embedding, dtype=np.float32).reshape(1, -1)
510
+
511
+ if is_faiss:
512
+ distances, indices = index.search(query_embedding, min(top_n * 2, len(chunks_data)))
513
+ distances, indices = distances[0], indices[0]
514
+ results = []
515
+ for i, idx in enumerate(indices):
516
+ if 0 <= idx < len(chunks_data):
517
+ score = 1 / (1 + distances[i])
518
+ results.append((chunks_data[idx][0], chunks_data[idx][1], score))
519
+ else:
520
+ similarities = cosine_similarity(query_embedding, embeddings)[0]
521
+ top_indices = np.argsort(similarities)[-top_n*2:][::-1]
522
+ results = [(chunks_data[idx][0], chunks_data[idx][1], similarities[idx])
523
+ for idx in top_indices if similarities[idx] > 0.1]
524
+
525
+ results = sorted(results, key=lambda x: x[2], reverse=True)[:top_n*2]
526
+ except Exception as e:
527
+ logger.error(f"Erreur de recherche: {e}")
528
+ results = keyword_search(query, chunks_data, top_n)
529
+
530
+ # Utiliser le rôle confirmé ou détecter automatiquement
531
+ if confirmed_role:
532
+ detected_role = confirmed_role
533
+ role_confidence = 1.0 # Confiance maximale car confirmé par l'utilisateur
534
+ else:
535
+ detected_role, role_confidence = detect_user_role(query, conversation_history)
536
+
537
+ # Filtrer et réorganiser les résultats selon le rôle
538
+ filtered_results = filter_chunks_by_role(results, detected_role, role_confidence)
539
+
540
+ return filtered_results[:top_n], detected_role, role_confidence
541
+ # 3. Fonction pour vérifier si l'utilisateur a confirmé son rôle
542
+ def is_role_confirmed(session_id):
543
+ """Vérifie si l'utilisateur a confirmé son rôle."""
544
+ user_profile = session.get('user_profile', {})
545
+ return user_profile.get('confirmed', False)
546
+
547
+ def get_confirmed_role(session_id):
548
+ """Récupère le rôle confirmé de l'utilisateur."""
549
+ user_profile = session.get('user_profile', {})
550
+ return user_profile.get('role', None)
551
+ def keyword_search(query, chunks_data, top_n=5):
552
+ """Recherche par mots-clés en fallback."""
553
+ query_terms = query.lower().split()
554
+ results = []
555
+ # Recherche d'acronymes
556
+ for acronym in ACRONYMS:
557
+ if acronym.lower() in query.lower():
558
+ for chunk, source in chunks_data:
559
+ if acronym in chunk:
560
+ results.append((chunk, source, 0.85))
561
+ for chunk, source in chunks_data:
562
+ score = sum(0.1 for term in query_terms if term in chunk.lower())
563
+ if score > 0:
564
+ results.append((chunk, source, score))
565
+ if not results:
566
+ results.append(("Aucune information spécifique trouvée.", "fallback.txt", 0.1))
567
+ return sorted(results, key=lambda x: x[2], reverse=True)[:top_n]
568
+
569
+ # ===== FORMATAGE DES SOURCES ET LOGS =====
570
+ def format_sources(results, for_freddy=False, detected_role=None):
571
+ """Formate les résultats en HTML pour l'affichage des sources."""
572
+ html = '<div class="sources-container">'
573
+
574
+ if detected_role:
575
+ role_info = profile_mapping.get(detected_role, {})
576
+ html += f'<div class="role-detection-info"><strong>Profil détecté:</strong> {detected_role}</div>'
577
+
578
+ for text, source, score in results:
579
+ relevance_class = "high-relevance" if score > 0.8 else "medium-relevance" if score > 0.6 else "low-relevance"
580
+ if for_freddy:
581
+ preview = text[:200] + ("..." if len(text) > 200 else "")
582
+ html += f'''
583
+ <div class="freddy-source-block" onclick="this.classList.toggle('expanded')">
584
+ <div class="freddy-source-header">
585
+ <span class="source-name">{source}</span>
586
+ <span class="{relevance_class}">{score:.2f}</span>
587
+ </div>
588
+ <div class="freddy-source-content">{preview}</div>
589
+ </div>
590
+ '''
591
+ else:
592
+ html += f'''
593
+ <div class="source-block">
594
+ <div class="source-header">
595
+ <span class="source-name">{source}</span>
596
+ <span class="relevance-score">{score:.2f}</span>
597
+ </div>
598
+ <div class="source-content">{text}</div>
599
+ </div>
600
+ '''
601
+ html += '</div>'
602
+ return html
603
+
604
+ def create_freddy_logs(query, results, detected_role=None, role_confidence=None):
605
+ """Crée des logs HTML détaillés pour le module Freddy."""
606
+ current_time = datetime.now().strftime("%H:%M:%S")
607
+ high_relevance = sum(1 for _, _, score in results if score > 0.8)
608
+ medium_relevance = sum(1 for _, _, score in results if 0.6 < score <= 0.8)
609
+ low_relevance = sum(1 for _, _, score in results if score <= 0.6)
610
+
611
+ html = f'''
612
+ <div class="freddy-logs">
613
+ <div class="freddy-log-entry">
614
+ <span class="log-time">{current_time}</span>
615
+ <span class="log-action">Analyse de la question</span>
616
+ </div>
617
+ <div class="freddy-log-entry">
618
+ <span class="log-detail">Recherche pour : <strong>"{query}"</strong></span>
619
+ </div>
620
+ '''
621
+
622
+ if detected_role and role_confidence:
623
+ html += f'''
624
+ <div class="freddy-log-entry">
625
+ <span class="log-time">{current_time}</span>
626
+ <span class="log-action">Détection de profil</span>
627
+ </div>
628
+ <div class="freddy-log-entry">
629
+ <span class="log-detail">Profil détecté : <strong>{detected_role}</strong> (confiance: {role_confidence:.2f})</span>
630
+ </div>
631
+ '''
632
+
633
+ html += f'''
634
+ <div class="freddy-log-entry">
635
+ <span class="log-time">{current_time}</span>
636
+ <span class="log-action">Résultats filtrés par profil</span>
637
+ </div>
638
+ <div class="freddy-log-entry">
639
+ <span class="log-detail">Détails :
640
+ <span class="high-relevance">{high_relevance} très pertinents</span>,
641
+ <span class="medium-relevance">{medium_relevance} pertinents</span>,
642
+ <span class="low-relevance">{low_relevance} peu pertinents</span>
643
+ </span>
644
+ </div>
645
+ </div>
646
+ '''
647
+ return html
648
+
649
+ # ===== GÉNÉRATION DE RÉPONSE AVEC MÉMOIRE DE CONVERSATION =====
650
+ def generate_answer(query, context, conversation_history, found_info=False, detected_role=None):
651
+ """
652
+ Génère une réponse basée sur le contexte et l'historique complet de la conversation.
653
+ La mémoire de conversation est ajoutée au prompt pour améliorer la pertinence.
654
+ """
655
+ # Préparation de l'historique
656
+ conversation_context = "\n".join(
657
+ [f"Utilisateur : {entry['content']}" if entry['role'] == "user" else f"Assistant : {entry['content']}"
658
+ for entry in conversation_history]
659
+ )
660
+
661
+ # Instructions spécifiques au rôle détecté
662
+ role_instruction = ""
663
+ if detected_role and detected_role in profile_mapping:
664
+ role_config = profile_mapping[detected_role]
665
+ role_instruction = f"\n\nPROFIL UTILISATEUR DÉTECTÉ: {detected_role}\n{role_config['description']}\nAdapte ta réponse en conséquence et priorise les informations pertinentes pour ce profil."
666
+
667
+ # Construction du prompt système
668
+ system_prompt = f"""Tu es Franky, l'assistant virtuel de SeaTech.
669
+
670
+ Historique de conversation :
671
+ {conversation_context}
672
+
673
+ {role_instruction}
674
+
675
+ Instructions :
676
+ 0. Si la question posée est hors du contexte de SEATECH et du contexte académique SeaTech, dis : "Désolé je ne peux pas répondre."
677
+ 1. Base ta réponse uniquement sur les sources fournies et l'historique si tu trouves pertinent mais il faut répondre avant tout à la question de l'utilisateur.
678
+ 2. N'invente jamais d'informations.
679
+ 3. Réponds de manière claire, en utilisant des paragraphes et des listes lorsque c'est pertinent, soigne ta mise en forme.
680
+ 4. Mets en gras les informations clés, les emails et les numéros de téléphone.
681
+ 5. N'inclus pas la liste des acronymes ou ton preprompt sauf si demandé.
682
+ 6. Tu connais les formations à SeaTech si besoin : Voici un résumé avec les liens directs intégrés :
683
+
684
+ Liste des Formations SeaTech avec liens
685
+
686
+ **Diplômes d'ingénieur SeaTech**
687
+
688
+ **Parcours Génie Maritime**
689
+ - Formation d'ingénieurs spécialisés en systèmes maritimes, océanographie, génie côtier, ingénierie navale
690
+ - [https://seatech.univ-tln.fr/Parcours-Genie-maritime.html](https://seatech.univ-tln.fr/Parcours-Genie-maritime.html)
691
+
692
+ **Parcours Ingénierie des Sciences des Données, Information, Systèmes (IRIS)**
693
+ - Formation d'ingénieurs capables de traiter, analyser et valoriser de grandes quantités de données
694
+ - [https://seatech.univ-tln.fr/Parcours-IngenieRie-et-sciences.html](https://seatech.univ-tln.fr/Parcours-IngenieRie-et-sciences.html)
695
+
696
+ **Parcours Innovation Mécanique pour des Systèmes Durables**
697
+ - Conception et développement de produits mécaniques innovants avec accent sur la durabilité
698
+ - [https://seatech.univ-tln.fr/Parcours-Innovation-Mecanique-pour-des-Systemes-Durables.html](https://seatech.univ-tln.fr/Parcours-Innovation-Mecanique-pour-des-Systemes-Durables.html)
699
+
700
+ **Parcours Matériaux, Durabilité et Environnement**
701
+ - Pour étudiants en sciences et techniques souhaitant se spécialiser dans les matériaux
702
+ - [https://seatech.univ-tln.fr/Parcours-Materiaux-Durabilite-et.html](https://seatech.univ-tln.fr/Parcours-Materiaux-Durabilite-et.html)
703
+
704
+ **Formation d'ingénieurs Matériaux par apprentissage**
705
+ - Pour étudiants en sciences et techniques souhaitant se spécialiser dans les matériaux
706
+ - Admission sur dossier avec contrat d'apprentissage
707
+ - **RNCP : 39062 - Certificateur : Université de Toulon**
708
+ - [https://seatech.univ-tln.fr/Formation-d-ingenieurs-Materiaux-par-apprentissage.html](https://seatech.univ-tln.fr/Formation-d-ingenieurs-Materiaux-par-apprentissage.html)
709
+
710
+ **Parcours Modélisation et Calculs Fluides et Structures**
711
+ - Simulation numérique et modélisation des comportements physiques
712
+ - [https://seatech.univ-tln.fr/Parcours-Modelisation-et-Calculs.html](https://seatech.univ-tln.fr/Parcours-Modelisation-et-Calculs.html)
713
+
714
+ **Parcours Systèmes Mécatroniques et Robotiques**
715
+ - Conception et développement de systèmes intégrant mécanique, électronique et informatique
716
+ - [https://seatech.univ-tln.fr/Parcours-Systemes-mecatroniques-et.html](https://seatech.univ-tln.fr/Parcours-Systemes-mecatroniques-et.html)
717
+
718
+ **Formation d'ingénieurs en Systèmes Numériques par apprentissage**
719
+ - Pour étudiants en sciences et techniques
720
+ - Admission sur dossier avec contrat d'apprentissage
721
+ - **RNCP : 37901 - Certificateur : Université de Toulon**
722
+ - [https://seatech.univ-tln.fr/Formation-d-ingenieurs-en-systemes-numeriques-par-apprentissage.html](https://seatech.univ-tln.fr/Formation-d-ingenieurs-en-systemes-numeriques-par-apprentissage.html)
723
+
724
+ **Informations générales**
725
+
726
+ - **Formations complètes** : [https://seatech.univ-tln.fr/Formations.html](https://seatech.univ-tln.fr/Formations.html)
727
+ - **Déroulement des études** : [https://seatech.univ-tln.fr/Deroulement-des-etudes.html](https://seatech.univ-tln.fr/Deroulement-des-etudes.html)
728
+ - **Admissions** : [https://seatech.univ-tln.fr/admission.html](https://seatech.univ-tln.fr/admission.html)
729
+ - **Doubles diplômes** : [https://seatech.univ-tln.fr/doubles-diplomes.html](https://seatech.univ-tln.fr/doubles-diplomes.html)
730
+ - **Page d'accueil** : [https://seatech.univ-tln.fr/](https://seatech.univ-tln.fr/)
731
+
732
+ Données d'aide pour répondre :
733
+ {context}
734
+ """
735
+ try:
736
+ if groq_client:
737
+ response = groq_client.chat.completions.create(
738
+ model=LLM_MODEL,
739
+ messages=[
740
+ {"role": "system", "content": system_prompt},
741
+ {"role": "user", "content": query}
742
+ ],
743
+ temperature=0.95,
744
+ max_tokens=28500,
745
+ # Ajout d'un timeout pour éviter les attentes trop longues
746
+ timeout=40
747
+ )
748
+ answer = response.choices[0].message.content
749
+ answer = convert_markdown_to_html(answer)
750
+ # Détection d'hallucinations simples
751
+ if any(x in answer.lower() for x in ["@freddy", "freddy@"]):
752
+ logger.warning("Hallucination détectée - correction appliquée")
753
+ answer = "<p>⚠️ Je ne peux pas inventer de contacts inexistants.</p>"
754
+ return answer
755
+ else:
756
+ # Amélioration du message de fallback
757
+ return "<p>Désolé, le service de génération de réponse n'est pas disponible actuellement. Veuillez réessayer plus tard ou contacter l'administrateur.</p>"
758
+ except Exception as e:
759
+ logger.error(f"Erreur génération réponse: {e}")
760
+ # Message d'erreur plus informatif
761
+ error_message = "<p>Désolé, une erreur est survenue lors de la génération de la réponse. Détails techniques:</p>"
762
+ error_message += f"<p><code>{str(e)[:100]}...</code></p>"
763
+ error_message += "<p>Veuillez réessayer avec une question différente ou plus tard.</p>"
764
+ return error_message
765
+
766
+ # ===== INITIALISATION DES DONNÉES =====
767
+ chunks_with_sources = load_data()
768
+ chunk_embeddings, chunks_with_sources = compute_embeddings(chunks_with_sources)
769
+ search_index, use_faiss = setup_search_index(chunk_embeddings)
770
+
771
+ # ===== APPLICATION FLASK =====
772
+ app = Flask(__name__)
773
+ app.secret_key = 'seatech_chat_secret_key'
774
+ conversation_history_global = {}
775
+ # ajout Daly
776
+
777
+ # Charger modèle Vosk une seule fois au démarrage
778
+ # vosk_model = vosk.Model("models/vosk-model-fr-0.6-linto-2.2.0")
779
+ vosk_model = vosk.Model("models/vosk-model-small-fr-0.22")
780
+
781
+ #fin ajout
782
+
783
+ @app.route("/", methods=["GET", "POST"])
784
+ def index():
785
+ """Page d'accueil du chatbot avec gestion de la sélection de rôle."""
786
+ if 'session_id' not in session:
787
+ session['session_id'] = str(uuid.uuid4())
788
+ session_id = session['session_id']
789
+
790
+ if session_id not in conversation_history_global:
791
+ conversation_history_global[session_id] = []
792
+
793
+ # Gérer la sélection de rôle si fournie
794
+ selected_role = request.form.get("role_selection") if request.method == "POST" else None
795
+ if selected_role:
796
+ handle_role_selection(session_id, selected_role)
797
+
798
+ if request.method == "POST":
799
+ user_query = request.form.get("query", "").strip()
800
+ if user_query:
801
+ # Vérifier si le rôle est confirmé
802
+ if not is_role_confirmed(session_id):
803
+ # Rediriger vers la sélection de rôle
804
+ error_message = {
805
+ "role": "assistant",
806
+ "content": "<p>Veuillez d'abord sélectionner votre profil ci-dessus pour que je puisse mieux vous aider.</p>",
807
+ "is_system_message": True
808
+ }
809
+ conversation_history_global[session_id].append(error_message)
810
+ else:
811
+ conversation_history_global[session_id].append({"role": "user", "content": user_query})
812
+ start_time = time.time()
813
+
814
+ confirmed_role = get_confirmed_role(session_id)
815
+
816
+ # Recherche avec rôle confirmé
817
+ results, detected_role, role_confidence = search_similar_chunks_with_confirmed_role(
818
+ user_query, search_index, use_faiss, chunk_embeddings, chunks_with_sources,
819
+ conversation_history_global[session_id], confirmed_role
820
+ )
821
+
822
+ # Contexte des chunks trouvés
823
+ context_chunks = "\n\n".join([text for text, _, _ in results])
824
+ found_info = any(score > CONFIDENCE_THRESHOLD for _, _, score in results)
825
+
826
+ # Génération de réponse avec le rôle confirmé
827
+ answer = generate_answer(user_query, context_chunks, conversation_history_global[session_id], found_info, detected_role)
828
+ processing_time = time.time() - start_time
829
+
830
+ # Formatage des sources avec information de rôle
831
+ sources_html = format_sources(results, detected_role=detected_role)
832
+ freddy_html = create_freddy_logs(user_query, results, detected_role, role_confidence) + format_sources(results, for_freddy=True, detected_role=detected_role)
833
+
834
+ conversation_history_global[session_id].append({
835
+ "role": "assistant",
836
+ "content": answer,
837
+ "sources": sources_html,
838
+ "freddy_logs": freddy_html,
839
+ "processing_time": f"{processing_time:.2f}s",
840
+ "detected_role": detected_role,
841
+ "role_confidence": role_confidence
842
+ })
843
+
844
+ conv = conversation_history_global.get(session_id, [])
845
+ current_datetime = datetime.now()
846
+ user_profile = session.get('user_profile', {})
847
+
848
+ return render_template("index.html",
849
+ conversation=conv,
850
+ query="",
851
+ current_datetime=current_datetime,
852
+ user_profile=user_profile,
853
+ profile_mapping=profile_mapping)
854
+
855
+
856
+ @app.route("/api/ask", methods=["POST"])
857
+ def api_ask():
858
+ """Endpoint API pour la recherche avec gestion de rôle."""
859
+ start_time = time.time()
860
+ try:
861
+ data = request.get_json()
862
+ user_query = data.get("query", "").strip()
863
+ role_selection = data.get("role_selection", None)
864
+
865
+ # AJOUT Daly
866
+ if data.get("mode") == "conversation":
867
+ prompt = "Réponds de façon brève, 1 à 2 phrases maximum, pour être lue à voix haute."
868
+ else:
869
+ prompt = "Réponse normale"
870
+ # Fin ajout
871
+
872
+ if not user_query and not role_selection:
873
+ return jsonify({"error": "Question vide"}), 400
874
+
875
+ # Gestion de la session
876
+ if 'session_id' not in session:
877
+ session['session_id'] = str(uuid.uuid4())
878
+ session_id = session['session_id']
879
+ if session_id not in conversation_history_global:
880
+ conversation_history_global[session_id] = []
881
+
882
+ # Gérer la sélection de rôle
883
+ if role_selection:
884
+ handle_role_selection(session_id, role_selection)
885
+ return jsonify({
886
+ "response": f"<p>Rôle <strong>{role_selection}</strong> sélectionné avec succès ! Vous pouvez maintenant poser vos questions.</p>",
887
+ "role_confirmed": True,
888
+ "selected_role": role_selection,
889
+ "status": "role_selected"
890
+ })
891
+
892
+ # Vérifier si le rôle est confirmé
893
+ if not is_role_confirmed(session_id):
894
+ return jsonify({
895
+ "response": "<p>Veuillez d'abord sélectionner votre profil pour que je puisse mieux vous aider.</p>",
896
+ "role_confirmed": False,
897
+ "status": "role_required"
898
+ })
899
+
900
+ # Ajout à l'historique de conversation
901
+ conversation_history_global[session_id].append({"role": "user", "content": user_query})
902
+
903
+ confirmed_role = get_confirmed_role(session_id)
904
+
905
+ # Recherche d'informations avec rôle confirmé
906
+ try:
907
+ results, detected_role, role_confidence = search_similar_chunks_with_confirmed_role(
908
+ user_query, search_index, use_faiss, chunk_embeddings, chunks_with_sources,
909
+ conversation_history_global[session_id], confirmed_role
910
+ )
911
+ context_chunks = "\n\n".join([text for text, _, _ in results])
912
+ found_info = any(score > CONFIDENCE_THRESHOLD for _, _, score in results)
913
+ except Exception as search_error:
914
+ logger.error(f"Erreur de recherche: {search_error}")
915
+ results = [("Une erreur s'est produite lors de la recherche.", "error.txt", 0.1)]
916
+ context_chunks = "Informations non disponibles en raison d'une erreur."
917
+ found_info = False
918
+ detected_role = confirmed_role or "Candidat"
919
+ role_confidence = 1.0 if confirmed_role else 0.1
920
+
921
+ # Génération de réponse avec le rôle confirmé
922
+ answer = generate_answer(user_query, context_chunks, conversation_history_global[session_id], found_info, detected_role)
923
+
924
+ # Formatage des sources et logs avec information de rôle
925
+ sources_html = format_sources(results, detected_role=detected_role)
926
+ freddy_html = create_freddy_logs(user_query, results, detected_role, role_confidence) + format_sources(results, for_freddy=True, detected_role=detected_role)
927
+
928
+ # Calcul du temps de traitement
929
+ processing_time = time.time() - start_time
930
+
931
+ # Mise à jour de l'historique de conversation
932
+ conversation_history_global[session_id].append({
933
+ "role": "assistant",
934
+ "content": answer,
935
+ "sources": sources_html,
936
+ "freddy_logs": freddy_html,
937
+ "processing_time": f"{processing_time:.2f}s",
938
+ "detected_role": detected_role,
939
+ "role_confidence": role_confidence
940
+ })
941
+
942
+ # Réponse de l'API
943
+ return jsonify({
944
+ "response": answer,
945
+ "freddy_logs": freddy_html,
946
+ "sources": sources_html,
947
+ "sources_found": found_info,
948
+ "processing_time": f"{processing_time:.2f}s",
949
+ "detected_role": detected_role,
950
+ "confirmed_role": confirmed_role,
951
+ "role_confidence": f"{role_confidence:.2f}",
952
+ "role_confirmed": True,
953
+ "status": "success"
954
+ })
955
+
956
+ except Exception as e:
957
+ logger.error(f"Erreur API globale: {e}")
958
+ processing_time = time.time() - start_time
959
+
960
+ return jsonify({
961
+ "response": f"<p>Désolé, une erreur s'est produite: {str(e)[:50]}...</p><p>Veuillez réessayer.</p>",
962
+ "freddy_logs": f"<div class='freddy-logs'><div class='freddy-log-entry'><span class='log-action'>Erreur</span><span class='log-detail'>{str(e)[:100]}...</span></div></div>",
963
+ "sources_found": False,
964
+ "processing_time": f"{processing_time:.2f}s",
965
+ "detected_role": "Candidat",
966
+ "role_confidence": "0.00",
967
+ "status": "error",
968
+ "error_type": str(type(e).__name__)
969
+ }), 500
970
+
971
+ @app.route("/api/role-info", methods=["GET"])
972
+ def api_role_info():
973
+ """Endpoint pour récupérer les informations sur les rôles disponibles."""
974
+ return jsonify({
975
+ "roles": {role: config["description"] for role, config in profile_mapping.items()},
976
+ "status": "success"
977
+ })
978
+ @app.route("/api/reset-role", methods=["POST"])
979
+ def api_reset_role():
980
+ """Permet de réinitialiser le rôle sélectionné."""
981
+ if 'user_profile' in session:
982
+ session.pop('user_profile')
983
+
984
+ if 'session_id' in session:
985
+ session_id = session['session_id']
986
+ if session_id in conversation_history_global:
987
+ conversation_history_global[session_id] = []
988
+
989
+ return jsonify({
990
+ "response": "<p>Rôle réinitialisé. Veuillez sélectionner votre nouveau profil.</p>",
991
+ "role_confirmed": False,
992
+ "status": "role_reset"
993
+ })
994
+ @app.route('/static/<path:path>')
995
+ def send_static(path):
996
+ """Fournit les fichiers statiques."""
997
+ return send_from_directory('static', path)
998
+
999
+ @app.route('/api/ask-audio', methods=['POST'])
1000
+ def ask_audio():
1001
+ if 'audio' not in request.files:
1002
+ return jsonify({"error": "Aucun fichier audio reçu"}), 400
1003
+
1004
+ audio_file = request.files['audio']
1005
+ wf = wave.open(io.BytesIO(audio_file.read()), "rb")
1006
+
1007
+ if wf.getnchannels() != 1 or wf.getsampwidth() != 2 or wf.getframerate() not in [8000, 16000]:
1008
+ return jsonify({"error": "Format audio non supporté. Utilise WAV PCM mono 16kHz."}), 400
1009
+
1010
+ rec = vosk.KaldiRecognizer(vosk_model, wf.getframerate())
1011
+ text = ""
1012
+
1013
+ while True:
1014
+ data = wf.readframes(4000)
1015
+ if len(data) == 0:
1016
+ break
1017
+ if rec.AcceptWaveform(data):
1018
+ res = json.loads(rec.Result())
1019
+ text += " " + res.get("text", "")
1020
+
1021
+ final_res = json.loads(rec.FinalResult())
1022
+ text += " " + final_res.get("text", "")
1023
+
1024
+ text = text.strip()
1025
+ if not text:
1026
+ return jsonify({"response": "Désolé, je n’ai pas compris l’audio."})
1027
+
1028
+ # Réutiliser ton pipeline existant
1029
+ session_id = request.cookies.get("session_id", "default")
1030
+ search_results, freddy_logs = search_similar_chunks_with_confirmed_role(text, session_id)
1031
+ answer, sources, processing_time = generate_answer(text, search_results, session_id, user_profile)
1032
+
1033
+ return jsonify({
1034
+ "response": answer,
1035
+ "sources": sources,
1036
+ "freddy_logs": freddy_logs,
1037
+ "processing_time": processing_time,
1038
+ "query": text
1039
+ })
1040
+
1041
+ if __name__ == "__main__":
1042
+ # Création des répertoires statiques si nécessaires
1043
+ if not os.path.exists("static"):
1044
+ os.makedirs("static", exist_ok=True)
1045
+ os.makedirs("static/css", exist_ok=True)
1046
+ os.makedirs("static/img", exist_ok=True)
1047
+ templates_dir = os.path.join(BASE_DIR, "templates")
1048
+ os.makedirs(templates_dir, exist_ok=True)
1049
+
1050
+ # IMPORTANT : Récupérer le port depuis les variables d'environnement
1051
+ port = int(os.environ.get("PORT", 7860))
1052
+ app.run(host="0.0.0.0", port=port, debug=False) # debug=False en production
1053
+
1054
+
1055
+
1056
+
1057
+ # Charger modèle Vosk une fois au démarrage
1058
+ #vosk_model = vosk.Model("model-fr")
1059
+
README.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Les Chats De SeaTech
3
+ emoji: 🦀
4
+ colorFrom: red
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ short_description: Chatbot-Seatech
9
+ ---
10
+
11
+ Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
data/.DS_Store ADDED
Binary file (6.15 kB). View file
 
data/acronymes.txt ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === Acronymes SeaTech ===
2
+
3
+ SN : Systèmes Numériques
4
+ APP : Apprentissage
5
+ FISE : Formation Initiale Sous Statut d'Étudiant
6
+ FISA : Formation Initiale Sous Statut d'Apprenti
7
+ MTX : Matériaux
8
+ GM : Génie Maritime
9
+ MI : Mécanique et Innovation
10
+ IR : Informatique et Réseaux
11
+ MS : Modélisation et Simulation
12
+ MR : Mécatronique et Robotique
13
+ MAPIEM : Matériaux Polymères-Interfaces-Environnement Marin
14
+ IMATH : Institut de Mathématiques de Toulon
15
+ LSIS : Laboratoire des Sciences de l’Information et des Systèmes
16
+ MIO : Institut Méditerranéen d’Océanographie
17
+ CTI : Commission des Titres d’Ingénieur
18
+ CFA : Centre de Formation d’Apprentis
19
+ UTLN : Université de Toulon
20
+
21
+ === Formations et Parcours SeaTech ===
22
+
23
+ - Diplôme SeaTech (formation d’ingénieur généraliste avec 6 parcours)
24
+ - Diplôme SeaTech spécialité Matériaux (en apprentissage)
25
+ - Diplôme SeaTech spécialité Systèmes numériques (en apprentissage ou formation continue)
26
+
27
+ Parcours de formation proposés :
28
+ • Génie Maritime
29
+ • Ingénierie et Sciences des Données, Information, Systèmes (IRIS)
30
+ • Innovation Mécanique pour des Systèmes Durables
31
+ • Matériaux, Durabilité et Environnement
32
+ • Modélisation et Calculs Fluides-Structures (MOCA)
33
+ • Systèmes Mécatroniques et Robotiques (SYSMER)
34
+
35
+ Ce fichier est basé sur la base de données SeaTech disponible (&#8203;:contentReference[oaicite:0]{index=0}) et vise à aider le chatbot à comprendre et à associer les acronymes ainsi que les formations et parcours proposés par SeaTech.
data/contacts.txt ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === Contacts SeaTech - Informations Complètes ===
2
+
3
+ 1. Contact Général de SeaTech
4
+ --------------------------------
5
+ École d’Ingénieurs SeaTech - Université de Toulon
6
+ Adresse : Avenue de l’Université, 83130 LA GARDE, France
7
+ Téléphone : 04 94 14 26 40
8
+ Email : info.seatech@univ-tln.fr
9
+ Site Web : https://seatech.univ-tln.fr/
10
+ (Source général – :contentReference[oaicite:0]{index=0})
11
+
12
+ 2. Pôle Relations École/Entreprises
13
+ --------------------------------------
14
+ - Email général : ecole-entreprise@seatech.fr
15
+ - Responsable du pôle :
16
+ • Nom : Christiane Morlet
17
+ • Email : christiane.morlet@univ-tln.fr
18
+ - Secrétariat :
19
+ • Nom : Sabine Ségal
20
+ • Email : sabine.segal@univ-tln.fr
21
+ (Source – :contentReference[oaicite:1]{index=1})
22
+
23
+ 3. Contacts Formation et Responsables Pédagogiques
24
+ -----------------------------------------------------
25
+ A. Formation d’ingénieurs en systèmes numériques par apprentissage
26
+ - Responsable de la formation apprentissage :
27
+ • Nom : Nadège THIRION-MOREAU
28
+ • Email : nadege.thirion-moreau@univ-tln.fr
29
+ - Assistante de la formation :
30
+ • Nom : Laura CHARPENTIER
31
+ • Téléphone : 04 83 16 66 60 (du lundi au vendredi de 08h00 à 12h00 et de 14h00 à 17h00)
32
+ • Email : laura.charpentier@univ-tln.fr
33
+ (Source – :contentReference[oaicite:2]{index=2})
34
+
35
+ B. Formation d’ingénieurs en matériaux par apprentissage
36
+ (Les informations spécifiques pour cette formation ne sont pas explicitement détaillées dans la base, à compléter selon les mises à jour disponibles sur le site officiel.)
37
+
38
+ C. Autres formations et responsables
39
+ - Pour les formations en mode classique (diplôme SeaTech généraliste et spécialités), les coordonnées de contact se font via le formulaire en ligne et le standard de l’école :
40
+ • Email : info.seatech@univ-tln.fr
41
+ • Téléphone : 04 94 14 26 40
42
+ - Les responsables pédagogiques et secrétaires des formations sont généralement accessibles via la plateforme eCandidat ou par le biais du secrétariat administratif central de SeaTech.
43
+ (Pour plus de précisions, consulter la rubrique « Formation d’ingénieurs » sur le site officiel de SeaTech.)
44
+
45
+ 4. Pôle Career Center – Offres de Stage et d’Emploi
46
+ ------------------------------------------------------
47
+ - Plateforme en partenariat avec JobTeaser :
48
+ https://univ-tln.jobteaser.com/fr/recruiter_account/sign_in
49
+ - Contact général pour les offres : info.seatech@univ-tln.fr
50
+ (Source – :contentReference[oaicite:3]{index=3})
51
+
52
+ 5. Réseau des Anciens (Alumni)
53
+ --------------------------------
54
+ - Association Alumni SeaTech
55
+ • Présidente : Marine FLORES
56
+ - Email : association@alumni-seatech.fr
57
+ • Liens :
58
+ - Facebook : https://www.facebook.com/SeaTech.alumni
59
+ - LinkedIn : https://www.linkedin.com/company/seatech-alumni/
60
+ (Source – :contentReference[oaicite:4]{index=4})
61
+
62
+ 6. Contacts pour les Événements et Séminaires
63
+ ------------------------------------------------
64
+ - Directrice des Études (pour les séminaires et événements académiques) :
65
+ • Nom : Lyudmyla YUSHCHENKO
66
+ • Téléphone : 04 83 16 66 06
67
+ • Email : lyudmyla.yushchenko@univ-tln.fr
68
+ (Source – :contentReference[oaicite:5]{index=5})
69
+
70
+ 7. Autres Contacts Spécifiques et Informations Complémentaires
71
+ ----------------------------------------------------------------
72
+ - Pour les questions relatives à la Semaine du Développement Soutenable :
73
+ • Contact : Audrey Minghelli
74
+ - Email : audrey.minghelli@univ-tln.fr
75
+ - Pour déposer une offre de stage ou d’emploi, utiliser le formulaire en ligne accessible via le Career Center.
76
+ - Pour toutes autres demandes, veuillez utiliser le formulaire de contact disponible sur le site officiel.
77
+ (Source – :contentReference[oaicite:6]{index=6})
78
+
79
+ === Fin des Contacts SeaTech ===
data/info_generale.txt ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ === SeaTech - Informations Générales Complètes ===
2
+
3
+ I. Présentation Générale
4
+ SeaTech est une école d'ingénieurs rattachée à l'Université de Toulon, implantée sur le littoral méditerranéen en région Sud Provence-Alpes-Côte d'Azur. Reconnue pour son excellence académique, son approche pluridisciplinaire et sa forte implication dans l'innovation, SeaTech prépare ses étudiants aux défis technologiques et environnementaux actuels et futurs.
5
+
6
+ II. Historique et Évolution
7
+ Créée pour répondre aux besoins croissants en ingénierie maritime et technologique, SeaTech s’est imposée comme une référence régionale et nationale dans la formation d’ingénieurs :
8
+ - **Origines et Création** : Fondée [insérer année de création], l'école a débuté avec une vocation d’excellence pour former des ingénieurs aptes à innover dans le secteur maritime.
9
+ - **Intégration et Développement** : Intégrée à l'Université de Toulon, SeaTech a progressivement élargi son offre pédagogique, en développant des partenariats avec des laboratoires de recherche et des entreprises innovantes.
10
+ - **Jalons Historiques** :
11
+ • Lancement des premiers diplômes d’ingénieur.
12
+ • Évolution vers une approche intégrée mêlant théorie, pratique et recherche.
13
+ • Mise en place de programmes d’échanges internationaux et de partenariats stratégiques.
14
+
15
+ III. Formations et Parcours Proposés
16
+ SeaTech offre plusieurs diplômes d'ingénieur adaptés aux exigences du marché :
17
+ - **Diplôme SeaTech Généraliste** : Une formation d’ingénieur articulée autour de six parcours spécialisés.
18
+ - **Spécialités** :
19
+ • **Systèmes Numériques (SN)**
20
+ • **Matériaux (MTX)**
21
+ • **Génie Maritime (GM)**
22
+ • **Mécanique et Innovation (MI)**
23
+ • **Informatique et Réseaux (IR)**
24
+ • Autres parcours complémentaires en fonction des besoins industriels.
25
+ - **Modalités d'Admission** :
26
+ • **Concours Commun INP (CCINP)** pour les étudiants issus des classes préparatoires.
27
+ • **Admissions sur titres** pour les candidats issus d’études universitaires.
28
+ • **Formations en apprentissage** avec des statuts spécifiques (FISE pour étudiants et FISA pour apprentis).
29
+
30
+ IV. Infrastructure et Environnement
31
+ Le campus de SeaTech se distingue par :
32
+ - Des installations modernes et des laboratoires de pointe, favorisant la recherche appliquée.
33
+ - Un environnement d'apprentissage dynamique et un cadre de vie étudiante propice à l'innovation.
34
+ - Un accès direct aux ressources technologiques et industrielles de la région.
35
+
36
+ V. Partenariats et Rayonnement International
37
+ SeaTech entretient des collaborations étroites avec :
38
+ - Des entreprises locales et internationales qui facilitent l’insertion professionnelle des diplômés.
39
+ - Des laboratoires de recherche et institutions académiques, contribuant à des projets innovants.
40
+ - Des programmes d’échanges et des doubles diplômes, renforçant son rayonnement à l’international.
41
+
42
+ VI. Mission et Vision
43
+ La mission de SeaTech est de former des ingénieurs compétents, innovants et responsables, capables de relever les défis du futur. L'école s'engage à promouvoir une approche interdisciplinaire, mêlant recherche appliquée, enseignement théorique et expérience pratique, afin de garantir une insertion professionnelle réussie.
44
+
45
+ VII. Coordonnées et Informations de Contact
46
+ - **Site officiel** : https://seatech.univ-tln.fr/
47
+ - **Adresse** : École d'ingénieurs SeaTech, Avenue de l'Université, CS 60584, 83041 Toulon Cedex 9, France.
48
+ - **Téléphone** : 04 94 14 26 40
49
+ - **Email** : info.seatech@univ-tln.fr
50
+ - **Réseaux Sociaux** : [Ajouter les liens officiels vers les pages Facebook, Twitter, LinkedIn, etc.]
51
+
52
+ VIII. Ressources et Liens Utiles
53
+ - Brochures et présentations téléchargeables.
54
+ - Informations sur les modalités d’admission et les concours.
55
+ - Actualités, événements et journées portes ouvertes.
56
+ - Plateformes d'échanges et partenariats internationaux.
57
+
58
+ === Fin des Informations Générales Complètes ===
data/seatech_database.txt ADDED
The diff for this file is too large to render. See raw diff
 
requirements.txt ADDED
Binary file (176 Bytes). View file
 
static/css/style.css ADDED
@@ -0,0 +1,1276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Variables de couleurs SeaTech */
2
+ :root {
3
+ --primary-color: #1E3A6E;
4
+ --primary-hover: #2c4d8c;
5
+ --secondary-color: #1CA0E1;
6
+ --secondary-hover: #35b5f5;
7
+ --accent-color: #FF9800;
8
+ --accent-hover: #ffad33;
9
+ --text-color: #333333;
10
+ --text-light: #666666;
11
+ --background-color: #f5f7fa;
12
+ --card-bg: #FFFFFF;
13
+ --hover-color: #F0F4F8;
14
+ --border-color: #E1E7EF;
15
+ --shadow-color: rgba(0, 0, 0, 0.08);
16
+ --shadow-hover: rgba(0, 0, 0, 0.15);
17
+ --freddy-bg: #FFF8E1;
18
+ --freddy-border: #FFE0B2;
19
+ --high-relevance: #4CAF50;
20
+ --medium-relevance: #2196F3;
21
+ --low-relevance: #9E9E9E;
22
+
23
+ /* Transitions */
24
+ --transition-fast: 0.2s ease;
25
+ --transition-normal: 0.3s ease;
26
+ --transition-slow: 0.5s ease;
27
+
28
+ /* Animations */
29
+ --scale-hover: scale(1.02);
30
+ --translate-up: translateY(-3px);
31
+ }
32
+
33
+ /* RESET ET BASE */
34
+ *, *::before, *::after {
35
+ margin: 0;
36
+ padding: 0;
37
+ box-sizing: border-box;
38
+ }
39
+
40
+ body {
41
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
42
+ line-height: 1.6;
43
+ color: var(--text-color);
44
+ background-color: var(--background-color);
45
+ display: flex;
46
+ flex-direction: column;
47
+ min-height: 100vh;
48
+ -webkit-font-smoothing: antialiased;
49
+ }
50
+
51
+ /* LAYOUT PRINCIPAL */
52
+ .main-container {
53
+ display: grid;
54
+ grid-template-columns: 250px 1fr 300px;
55
+ gap: 1.5rem;
56
+ padding: 1rem;
57
+ width: 100%;
58
+ flex: 1;
59
+ }
60
+
61
+ /* COMPOSANTS COMMUNS - CARDS */
62
+ .card {
63
+ background-color: var(--card-bg);
64
+ border-radius: 16px;
65
+ box-shadow: 0 4px 12px var(--shadow-color);
66
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
67
+ }
68
+
69
+ .card:hover {
70
+ box-shadow: 0 6px 18px var(--shadow-hover);
71
+ }
72
+
73
+ /* SIDEBAR */
74
+ .sidebar {
75
+ background-color: var(--card-bg);
76
+ border-radius: 16px;
77
+ padding: 1.5rem;
78
+ box-shadow: 0 4px 12px var(--shadow-color);
79
+ display: flex;
80
+ flex-direction: column;
81
+ gap: 1.5rem;
82
+ height: fit-content;
83
+ position: sticky;
84
+ top: 1rem;
85
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
86
+ }
87
+
88
+ .sidebar:hover {
89
+ box-shadow: 0 6px 18px var(--shadow-hover);
90
+ }
91
+
92
+ .sidebar-header {
93
+ text-align: center;
94
+ padding-bottom: 1rem;
95
+ border-bottom: 1px solid var(--border-color);
96
+ }
97
+
98
+ .sidebar h2 {
99
+ color: var(--primary-color);
100
+ font-size: 1.3rem;
101
+ margin-bottom: 1rem;
102
+ }
103
+
104
+ .cats-logo {
105
+ display: flex;
106
+ justify-content: center;
107
+ gap: 1rem;
108
+ }
109
+
110
+ .cat-avatar {
111
+ width: 50px;
112
+ height: 50px;
113
+ border-radius: 50%;
114
+ border: 3px solid var(--secondary-color);
115
+ transition: transform var(--transition-fast);
116
+ cursor: pointer;
117
+ }
118
+
119
+ .cat-avatar:hover {
120
+ transform: scale(1.1) rotate(5deg);
121
+ border-color: var(--secondary-hover);
122
+ }
123
+
124
+ .sidebar-section h3 {
125
+ color: var(--secondary-color);
126
+ font-size: 1.1rem;
127
+ margin-bottom: 0.8rem;
128
+ position: relative;
129
+ padding-left: 1.3rem;
130
+ transition: color var(--transition-fast);
131
+ }
132
+
133
+ .sidebar-section:hover h3 {
134
+ color: var(--secondary-hover);
135
+ }
136
+
137
+ .sidebar-section h3::before {
138
+ content: "🐾";
139
+ position: absolute;
140
+ left: 0;
141
+ top: 0;
142
+ font-size: 0.9rem;
143
+ }
144
+
145
+ .sidebar ul {
146
+ list-style: none;
147
+ padding-left: 1.2rem;
148
+ }
149
+
150
+ .sidebar li {
151
+ margin-bottom: 0.7rem;
152
+ position: relative;
153
+ }
154
+
155
+ .sidebar li::before {
156
+ content: "•";
157
+ position: absolute;
158
+ left: -1rem;
159
+ color: var(--secondary-color);
160
+ transition: transform var(--transition-fast), color var(--transition-fast);
161
+ }
162
+
163
+ .sidebar li:hover::before {
164
+ transform: scale(1.3);
165
+ color: var(--accent-color);
166
+ }
167
+
168
+ .sidebar a {
169
+ color: var(--text-color);
170
+ text-decoration: none;
171
+ transition: color var(--transition-fast), transform var(--transition-fast);
172
+ display: inline-block;
173
+ }
174
+
175
+ .sidebar a:hover {
176
+ color: var(--secondary-hover);
177
+ transform: translateX(3px);
178
+ }
179
+
180
+ .sidebar-footer {
181
+ margin-top: auto;
182
+ text-align: center;
183
+ font-style: italic;
184
+ color: var(--text-light);
185
+ font-size: 0.9rem;
186
+ border-top: 1px solid var(--border-color);
187
+ padding-top: 1rem;
188
+ }
189
+
190
+ /* CONTENU CENTRAL */
191
+ .container {
192
+ display: flex;
193
+ flex-direction: column;
194
+ }
195
+
196
+ .header {
197
+ text-align: center;
198
+ margin-bottom: 2rem;
199
+ animation: fadeIn 0.8s ease-out;
200
+ }
201
+
202
+ .logo-container {
203
+ width: 250px;
204
+ margin: 0 auto 0.8rem;
205
+ transition: transform var(--transition-normal);
206
+ }
207
+
208
+ .logo-container:hover {
209
+ transform: var(--scale-hover);
210
+ }
211
+
212
+ .logo {
213
+ width: 100%;
214
+ height: auto;
215
+ }
216
+
217
+ h1 {
218
+ color: var(--primary-color);
219
+ font-weight: 600;
220
+ font-size: 1.8rem;
221
+ margin-bottom: 0.5rem;
222
+ }
223
+
224
+ .subtitle {
225
+ color: var(--text-light);
226
+ font-weight: 400;
227
+ font-size: 1.1rem;
228
+ max-width: 600px;
229
+ margin: 0 auto;
230
+ }
231
+
232
+ /* CHAT CONTAINER */
233
+ .chat-container {
234
+ background-color: var(--card-bg);
235
+ border-radius: 16px;
236
+ box-shadow: 0 4px 20px var(--shadow-color);
237
+ display: flex;
238
+ flex-direction: column;
239
+ overflow: hidden;
240
+ flex-grow: 1;
241
+ transition: box-shadow var(--transition-normal);
242
+ }
243
+
244
+ .chat-container:hover {
245
+ box-shadow: 0 8px 24px var(--shadow-hover);
246
+ }
247
+
248
+ .chat-history {
249
+ padding: 1.8rem;
250
+ flex-grow: 1;
251
+ max-height: calc(100vh - 280px);
252
+ overflow-y: auto;
253
+ scroll-behavior: smooth;
254
+ }
255
+
256
+ .chat-history::-webkit-scrollbar {
257
+ width: 6px;
258
+ }
259
+
260
+ .chat-history::-webkit-scrollbar-thumb {
261
+ background-color: rgba(0, 0, 0, 0.1);
262
+ border-radius: 10px;
263
+ transition: background-color var(--transition-fast);
264
+ }
265
+
266
+ .chat-history::-webkit-scrollbar-thumb:hover {
267
+ background-color: rgba(0, 0, 0, 0.2);
268
+ }
269
+
270
+ .no-messages {
271
+ text-align: center;
272
+ color: var(--text-light);
273
+ margin-top: 2rem;
274
+ font-size: 0.9rem;
275
+ font-style: italic;
276
+ }
277
+
278
+ /* MESSAGES */
279
+ .message {
280
+ margin-bottom: 2rem;
281
+ display: flex;
282
+ gap: 12px;
283
+ position: relative;
284
+ animation: fadeIn 0.3s ease-out forwards;
285
+ }
286
+
287
+ @keyframes fadeIn {
288
+ from { opacity: 0; transform: translateY(10px); }
289
+ to { opacity: 1; transform: translateY(0); }
290
+ }
291
+
292
+ .user-message {
293
+ flex-direction: row-reverse;
294
+ }
295
+
296
+ .message-avatar {
297
+ flex: 0 0 40px;
298
+ height: 40px;
299
+ border-radius: 50%;
300
+ overflow: hidden;
301
+ align-self: flex-start;
302
+ transition: transform var(--transition-fast);
303
+ }
304
+
305
+ .message-avatar:hover {
306
+ transform: scale(1.1);
307
+ }
308
+
309
+ .bot-message .message-avatar {
310
+ border: 3px solid var(--primary-color);
311
+ }
312
+
313
+ .user-message .message-avatar {
314
+ border: 3px solid var(--secondary-color);
315
+ }
316
+
317
+ .avatar {
318
+ width: 100%;
319
+ height: 100%;
320
+ object-fit: cover;
321
+ border-radius: 50%;
322
+ }
323
+
324
+ .message-content {
325
+ max-width: calc(100% - 60px);
326
+ padding: 1rem 1.2rem;
327
+ border-radius: 18px;
328
+ box-shadow: 0 1px 2px var(--shadow-color);
329
+ position: relative;
330
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
331
+ }
332
+
333
+ .message-content:hover {
334
+ box-shadow: 0 3px 6px var(--shadow-hover);
335
+ }
336
+
337
+ .bot-message .message-content {
338
+ background-color: var(--hover-color);
339
+ color: var(--text-color);
340
+ border-top-left-radius: 4px;
341
+ }
342
+
343
+ .bot-message .message-content:hover {
344
+ transform: translateX(2px);
345
+ }
346
+
347
+ .user-message .message-content {
348
+ background-color: var(--secondary-color);
349
+ color: white;
350
+ border-top-right-radius: 4px;
351
+ }
352
+
353
+ .user-message .message-content:hover {
354
+ transform: translateX(-2px);
355
+ background-color: var(--secondary-hover);
356
+ }
357
+
358
+ .message-header {
359
+ margin-bottom: 0.5rem;
360
+ display: flex;
361
+ align-items: center;
362
+ justify-content: space-between;
363
+ }
364
+
365
+ .message-header strong {
366
+ color: var(--primary-color);
367
+ }
368
+
369
+ .message-actions {
370
+ display: flex;
371
+ gap: 0.5rem;
372
+ align-items: center;
373
+ }
374
+
375
+ .show-sources-btn {
376
+ background-color: var(--primary-color);
377
+ color: white;
378
+ border: none;
379
+ border-radius: 12px;
380
+ padding: 3px 8px;
381
+ font-size: 0.75rem;
382
+ cursor: pointer;
383
+ transition: all var(--transition-fast);
384
+ display: flex;
385
+ align-items: center;
386
+ gap: 5px;
387
+ }
388
+
389
+ .show-sources-btn:hover {
390
+ background-color: var(--secondary-hover);
391
+ transform: var(--translate-up);
392
+ box-shadow: 0 3px 8px rgba(28, 160, 225, 0.3);
393
+ }
394
+
395
+ .processing-time {
396
+ font-size: 0.7rem;
397
+ color: var(--text-light);
398
+ padding: 2px 4px;
399
+ border-radius: 4px;
400
+ background: rgba(0,0,0,0.05);
401
+ transition: background var(--transition-fast);
402
+ }
403
+
404
+ .processing-time:hover {
405
+ background: rgba(0,0,0,0.1);
406
+ }
407
+
408
+ .message-content strong {
409
+ color: var(--primary-color);
410
+ font-weight: 600;
411
+ transition: color var(--transition-fast);
412
+ }
413
+
414
+ .message-content strong:hover {
415
+ color: var(--primary-hover);
416
+ }
417
+
418
+ .user-message .message-content strong {
419
+ color: white;
420
+ }
421
+
422
+ .message-content ul,
423
+ .message-content ol {
424
+ padding-left: 1.5rem;
425
+ margin: 0.8rem 0;
426
+ }
427
+
428
+ .message-content li {
429
+ margin-bottom: 0.5rem;
430
+ transition: transform var(--transition-fast);
431
+ }
432
+
433
+ .message-content li:hover {
434
+ transform: translateX(3px);
435
+ }
436
+
437
+ .message-content a {
438
+ color: var(--secondary-color);
439
+ text-decoration: none;
440
+ font-weight: 500;
441
+ transition: all var(--transition-fast);
442
+ position: relative;
443
+ }
444
+
445
+ .message-content a:hover {
446
+ color: var(--secondary-hover);
447
+ }
448
+
449
+ .message-content a::after {
450
+ content: '';
451
+ position: absolute;
452
+ width: 0;
453
+ height: 1px;
454
+ bottom: 0;
455
+ left: 0;
456
+ background-color: var(--secondary-hover);
457
+ transition: width var(--transition-normal);
458
+ }
459
+
460
+ .message-content a:hover::after {
461
+ width: 100%;
462
+ }
463
+
464
+ /* PANNEAU DES SOURCES */
465
+ .sources-panel {
466
+ position: fixed;
467
+ top: 0;
468
+ right: -400px;
469
+ width: 380px;
470
+ height: 100vh;
471
+ background-color: white;
472
+ box-shadow: -4px 0 15px rgba(0, 0, 0, 0.1);
473
+ z-index: 1000;
474
+ transition: right var(--transition-normal);
475
+ display: flex;
476
+ flex-direction: column;
477
+ }
478
+
479
+ .sources-panel.active {
480
+ right: 0;
481
+ }
482
+
483
+ .sources-header {
484
+ background-color: var(--primary-color);
485
+ color: white;
486
+ padding: 1rem;
487
+ display: flex;
488
+ justify-content: space-between;
489
+ align-items: center;
490
+ }
491
+
492
+ .sources-header h3 {
493
+ font-size: 1.1rem;
494
+ font-weight: 500;
495
+ }
496
+
497
+ .close-btn {
498
+ background: none;
499
+ border: none;
500
+ color: white;
501
+ font-size: 1.5rem;
502
+ cursor: pointer;
503
+ transition: transform var(--transition-fast);
504
+ }
505
+
506
+ .close-btn:hover {
507
+ transform: scale(1.2);
508
+ }
509
+
510
+ .sources-content {
511
+ padding: 1rem;
512
+ overflow-y: auto;
513
+ flex: 1;
514
+ }
515
+
516
+ .sources-container {
517
+ display: flex;
518
+ flex-direction: column;
519
+ gap: 0.8rem;
520
+ }
521
+
522
+ .source-block {
523
+ margin-bottom: 0.8rem;
524
+ border: 1px solid var(--border-color);
525
+ border-radius: 8px;
526
+ overflow: hidden;
527
+ transition: transform var(--transition-fast), box-shadow var(--transition-fast);
528
+ }
529
+
530
+ .source-block:hover {
531
+ transform: var(--scale-hover);
532
+ box-shadow: 0 4px 8px var(--shadow-color);
533
+ }
534
+
535
+ .source-header {
536
+ background-color: var(--hover-color);
537
+ padding: 0.8rem;
538
+ display: flex;
539
+ justify-content: space-between;
540
+ font-size: 0.9rem;
541
+ }
542
+
543
+ .source-name {
544
+ font-weight: bold;
545
+ color: var(--primary-color);
546
+ }
547
+
548
+ .source-content {
549
+ padding: 1rem;
550
+ font-size: 0.9rem;
551
+ }
552
+
553
+ /* PANNEAU FREDDY */
554
+ .freddy-panel {
555
+ background-color: var(--freddy-bg);
556
+ border-radius: 16px;
557
+ overflow: hidden;
558
+ box-shadow: 0 4px 12px var(--shadow-color);
559
+ display: flex;
560
+ flex-direction: column;
561
+ height: fit-content;
562
+ position: sticky;
563
+ top: 1rem;
564
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
565
+ }
566
+
567
+ .freddy-panel:hover {
568
+ transform: var(--scale-hover);
569
+ box-shadow: 0 6px 18px var(--shadow-hover);
570
+ }
571
+
572
+ .freddy-header {
573
+ background-color: var(--accent-color);
574
+ color: white;
575
+ padding: 1rem;
576
+ display: flex;
577
+ justify-content: space-between;
578
+ align-items: center;
579
+ transition: background-color var(--transition-fast);
580
+ }
581
+
582
+ .freddy-panel:hover .freddy-header {
583
+ background-color: var(--accent-hover);
584
+ }
585
+
586
+ .freddy-header h3 {
587
+ font-size: 1.1rem;
588
+ font-weight: 500;
589
+ display: flex;
590
+ align-items: center;
591
+ gap: 8px;
592
+ }
593
+
594
+ .freddy-header h3 img {
595
+ width: 28px;
596
+ height: 28px;
597
+ border-radius: 50%;
598
+ border: 2px solid white;
599
+ transition: transform var(--transition-fast);
600
+ }
601
+
602
+ .freddy-header h3 img:hover {
603
+ transform: scale(1.2) rotate(10deg);
604
+ }
605
+
606
+ .freddy-content {
607
+ padding: 1rem;
608
+ overflow-y: auto;
609
+ max-height: calc(100vh - 150px);
610
+ }
611
+
612
+ .freddy-content::-webkit-scrollbar {
613
+ width: 4px;
614
+ }
615
+
616
+ .freddy-content::-webkit-scrollbar-thumb {
617
+ background-color: var(--accent-color);
618
+ border-radius: 10px;
619
+ }
620
+
621
+ .freddy-content::-webkit-scrollbar-thumb:hover {
622
+ background-color: var(--accent-hover);
623
+ }
624
+
625
+ .freddy-intro {
626
+ margin-bottom: 1.5rem;
627
+ padding: 1rem;
628
+ background-color: white;
629
+ border-radius: 12px;
630
+ border-left: 4px solid var(--accent-color);
631
+ display: flex;
632
+ align-items: center;
633
+ gap: 12px;
634
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
635
+ }
636
+
637
+ .freddy-intro:hover {
638
+ transform: translateY(-3px);
639
+ box-shadow: 0 4px 10px rgba(255, 152, 0, 0.2);
640
+ }
641
+
642
+ .freddy-intro img {
643
+ width: 50px;
644
+ height: 50px;
645
+ border-radius: 50%;
646
+ border: 2px solid var(--accent-color);
647
+ transition: transform var(--transition-fast);
648
+ }
649
+
650
+ .freddy-intro img:hover {
651
+ transform: scale(1.1) rotate(10deg);
652
+ }
653
+
654
+ .freddy-intro p {
655
+ font-size: 0.9rem;
656
+ margin-bottom: 0.3rem;
657
+ }
658
+
659
+ .freddy-logs-title,
660
+ .freddy-sources-title {
661
+ margin: 1.5rem 0 0.5rem;
662
+ color: var(--accent-color);
663
+ font-weight: bold;
664
+ font-size: 1.1rem;
665
+ display: flex;
666
+ align-items: center;
667
+ gap: 8px;
668
+ transition: transform var(--transition-fast);
669
+ }
670
+
671
+ .freddy-logs-title:hover,
672
+ .freddy-sources-title:hover {
673
+ transform: translateX(5px);
674
+ }
675
+
676
+ .freddy-logs {
677
+ padding: 0.8rem;
678
+ background-color: rgba(255, 255, 255, 0.7);
679
+ border-radius: 10px;
680
+ border: 1px solid var(--freddy-border);
681
+ transition: background-color var(--transition-normal);
682
+ }
683
+
684
+ .freddy-logs:hover {
685
+ background-color: rgba(255, 255, 255, 0.9);
686
+ }
687
+
688
+ .freddy-log-entry {
689
+ margin-bottom: 0.5rem;
690
+ padding: 0.3rem 0;
691
+ border-bottom: 1px dotted rgba(255, 152, 0, 0.2);
692
+ font-size: 0.9rem;
693
+ transition: padding-left var(--transition-fast);
694
+ }
695
+
696
+ .freddy-log-entry:hover {
697
+ padding-left: 5px;
698
+ background-color: rgba(255, 152, 0, 0.05);
699
+ border-radius: 5px;
700
+ }
701
+
702
+ .freddy-log-entry:last-child {
703
+ border-bottom: none;
704
+ }
705
+
706
+ .log-time {
707
+ display: inline-block;
708
+ min-width: 70px;
709
+ font-family: monospace;
710
+ font-size: 0.8rem;
711
+ color: var(--text-light);
712
+ }
713
+
714
+ .log-action {
715
+ font-weight: bold;
716
+ color: var(--accent-color);
717
+ transition: color var(--transition-fast);
718
+ }
719
+
720
+ .freddy-log-entry:hover .log-action {
721
+ color: var(--accent-hover);
722
+ }
723
+
724
+ .log-detail {
725
+ display: block;
726
+ padding: 0.2rem 0;
727
+ margin-left: 70px;
728
+ color: var(--text-color);
729
+ font-size: 0.85rem;
730
+ }
731
+
732
+ .freddy-sources-container {
733
+ display: flex;
734
+ flex-direction: column;
735
+ gap: 0.8rem;
736
+ }
737
+
738
+ .freddy-source-block {
739
+ background-color: white;
740
+ border-radius: 10px;
741
+ overflow: hidden;
742
+ border: 1px solid var(--freddy-border);
743
+ box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
744
+ transition: transform var(--transition-normal), box-shadow var(--transition-normal);
745
+ }
746
+
747
+ .freddy-source-block:hover {
748
+ transform: var(--scale-hover);
749
+ box-shadow: 0 4px 10px rgba(255, 152, 0, 0.15);
750
+ }
751
+
752
+ .freddy-source-header {
753
+ padding: 0.8rem;
754
+ display: flex;
755
+ justify-content: space-between;
756
+ align-items: center;
757
+ font-size: 0.9rem;
758
+ background-color: rgba(255, 152, 0, 0.1);
759
+ border-bottom: 1px solid var(--freddy-border);
760
+ transition: background-color var(--transition-fast);
761
+ }
762
+
763
+ .freddy-source-block:hover .freddy-source-header {
764
+ background-color: rgba(255, 152, 0, 0.2);
765
+ }
766
+
767
+ .freddy-source-content {
768
+ padding: 0.8rem;
769
+ font-size: 0.9rem;
770
+ max-height: 120px;
771
+ overflow-y: hidden;
772
+ position: relative;
773
+ color: var(--text-color);
774
+ line-height: 1.5;
775
+ }
776
+
777
+ .freddy-source-content::after {
778
+ content: "";
779
+ position: absolute;
780
+ bottom: 0;
781
+ left: 0;
782
+ width: 100%;
783
+ height: 40px;
784
+ background: linear-gradient(transparent, white);
785
+ }
786
+
787
+ .high-relevance,
788
+ .medium-relevance,
789
+ .low-relevance {
790
+ display: inline-block;
791
+ padding: 2px 6px;
792
+ border-radius: 4px;
793
+ font-size: 0.8rem;
794
+ transition: transform var(--transition-fast);
795
+ }
796
+
797
+ .high-relevance:hover,
798
+ .medium-relevance:hover,
799
+ .low-relevance:hover {
800
+ transform: scale(1.1);
801
+ }
802
+
803
+ .high-relevance {
804
+ background-color: rgba(76, 175, 80, 0.2);
805
+ color: var(--high-relevance);
806
+ font-weight: bold;
807
+ }
808
+
809
+ .medium-relevance {
810
+ background-color: rgba(33, 150, 243, 0.2);
811
+ color: var(--medium-relevance);
812
+ }
813
+
814
+ .low-relevance {
815
+ background-color: rgba(158, 158, 158, 0.2);
816
+ color: var(--low-relevance);
817
+ }
818
+
819
+ /* LOADING INDICATOR */
820
+ .loading {
821
+ display: none;
822
+ padding: 1rem 1.8rem;
823
+ }
824
+
825
+ .loading-container {
826
+ display: flex;
827
+ align-items: center;
828
+ gap: 12px;
829
+ background-color: var(--hover-color);
830
+ padding: 0.8rem 1.2rem;
831
+ border-radius: 18px;
832
+ max-width: 85%;
833
+ box-shadow: 0 1px 2px var(--shadow-color);
834
+ transition: transform var(--transition-normal);
835
+ }
836
+
837
+ .loading-container:hover {
838
+ transform: var(--scale-hover);
839
+ }
840
+
841
+ .searching-cat {
842
+ width: 50px;
843
+ height: 50px;
844
+ border-radius: 50%;
845
+ object-fit: cover;
846
+ transition: transform var(--transition-slow);
847
+ animation: searchingCat 3s infinite alternate;
848
+ }
849
+
850
+ @keyframes searchingCat {
851
+ 0% { transform: rotate(-5deg); }
852
+ 100% { transform: rotate(5deg); }
853
+ }
854
+
855
+ #loadingText {
856
+ color: var(--text-color);
857
+ font-size: 0.95rem;
858
+ }
859
+
860
+ .loading-dots {
861
+ display: inline-block;
862
+ }
863
+
864
+ .loading-dots::after {
865
+ content: '...';
866
+ animation: dots 1.5s steps(4, end) infinite;
867
+ }
868
+
869
+ @keyframes dots {
870
+ 0%, 20% { content: '.'; }
871
+ 40% { content: '..'; }
872
+ 60% { content: '...'; }
873
+ 80%, 100% { content: ''; }
874
+ }
875
+
876
+ /* CHAT FORM */
877
+ .chat-form {
878
+ display: flex;
879
+ align-items: center;
880
+ padding: 1.2rem;
881
+ border-top: 1px solid var(--border-color);
882
+ background-color: var(--card-bg);
883
+ }
884
+
885
+ .chat-input-container {
886
+ flex: 1;
887
+ position: relative;
888
+ }
889
+
890
+ .chat-input {
891
+ width: 100%;
892
+ padding: 0.9rem 1.2rem 0.9rem 4rem;
893
+ border: 1px solid var(--border-color);
894
+ border-radius: 24px;
895
+ font-size: 1rem;
896
+ outline: none;
897
+ background-color: var(--hover-color);
898
+ transition: all var(--transition-normal);
899
+ }
900
+
901
+ .chat-input:focus {
902
+ border-color: var(--secondary-color);
903
+ box-shadow: 0 0 0 4px rgba(28, 160, 225, 0.1);
904
+ background-color: white;
905
+ }
906
+
907
+ .cat-walking {
908
+ position: absolute;
909
+ top: 50%;
910
+ left: 15px;
911
+ transform: translateY(-50%);
912
+ width: 34px;
913
+ height: 34px;
914
+ z-index: 2;
915
+ transition: transform var(--transition-normal);
916
+ }
917
+
918
+ .chat-input:focus + .cat-walking,
919
+ .chat-input-container:hover .cat-walking {
920
+ animation: catWalking 2s infinite alternate;
921
+ }
922
+
923
+ @keyframes catWalking {
924
+ 0% { transform: translateY(-50%) translateX(-5px); }
925
+ 100% { transform: translateY(-50%) translateX(5px); }
926
+ }
927
+
928
+ .send-button {
929
+ background-color: var(--primary-color);
930
+ color: white;
931
+ border: none;
932
+ border-radius: 24px;
933
+ padding: 0.8rem 1.2rem;
934
+ margin-left: 0.8rem;
935
+ cursor: pointer;
936
+ font-weight: 500;
937
+ transition: all var(--transition-normal);
938
+ display: flex;
939
+ align-items: center;
940
+ justify-content: center;
941
+ position: relative;
942
+ overflow: hidden;
943
+ }
944
+
945
+ .send-button::before {
946
+ content: '';
947
+ position: absolute;
948
+ top: 0;
949
+ left: -100%;
950
+ width: 100%;
951
+ height: 100%;
952
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.2), transparent);
953
+ transition: left 0.8s;
954
+ }
955
+
956
+ .send-button:hover {
957
+ background-color: var(--secondary-color);
958
+ transform: var(--translate-up);
959
+ box-shadow: 0 4px 12px rgba(28, 160, 225, 0.3);
960
+ }
961
+
962
+ .send-button:hover::before {
963
+ left: 100%;
964
+ }
965
+
966
+ .send-button:active {
967
+ transform: scale(0.98);
968
+ }
969
+
970
+ .cat-icon {
971
+ width: 20px;
972
+ height: 20px;
973
+ margin-left: 8px;
974
+ transition: transform var(--transition-fast);
975
+ }
976
+
977
+ .send-button:hover .cat-icon {
978
+ animation: catJump 0.5s ease;
979
+ }
980
+
981
+ @keyframes catJump {
982
+ 0%, 100% { transform: translateY(0); }
983
+ 50% { transform: translateY(-5px); }
984
+ }
985
+
986
+ /* FOOTER */
987
+ .footer {
988
+ text-align: center;
989
+ padding: 1.2rem;
990
+ font-size: 0.875rem;
991
+ color: var(--text-light);
992
+ background-color: var(--card-bg);
993
+ margin-top: 2rem;
994
+ border-top: 1px solid var(--border-color);
995
+ transition: background-color var(--transition-normal);
996
+ }
997
+
998
+ .footer:hover {
999
+ background-color: var(--hover-color);
1000
+ }
1001
+
1002
+ /* MEDIA QUERIES */
1003
+ @media (max-width: 1200px) {
1004
+ .main-container {
1005
+ grid-template-columns: 220px 1fr 250px;
1006
+ gap: 1rem;
1007
+ }
1008
+ }
1009
+
1010
+ @media (max-width: 1024px) {
1011
+ .main-container {
1012
+ grid-template-columns: 1fr;
1013
+ gap: 1.5rem;
1014
+ }
1015
+ .sidebar, .freddy-panel {
1016
+ position: static;
1017
+ }
1018
+ .freddy-panel {
1019
+ order: 3;
1020
+ }
1021
+ }
1022
+
1023
+ @media (max-width: 768px) {
1024
+ .main-container {
1025
+ padding: 0.5rem;
1026
+ }
1027
+ .header {
1028
+ margin-bottom: 1rem;
1029
+ }
1030
+ .logo-container {
1031
+ width: 140px;
1032
+ }
1033
+ h1 {
1034
+ font-size: 1.4rem;
1035
+ }
1036
+ .chat-history {
1037
+ padding: 1rem;
1038
+ }
1039
+ .message-content {
1040
+ padding: 0.8rem 1rem;
1041
+ }
1042
+ .sources-panel {
1043
+ width: 280px;
1044
+ }
1045
+ }
1046
+
1047
+ @media (max-width: 480px) {
1048
+ .message {
1049
+ flex-direction: column;
1050
+ gap: 8px;
1051
+ }
1052
+ .user-message {
1053
+ align-items: flex-end;
1054
+ }
1055
+ .bot-message {
1056
+ align-items: flex-start;
1057
+ }
1058
+ .message-content {
1059
+ max-width: 90%;
1060
+ }
1061
+ .chat-form {
1062
+ padding: 0.8rem;
1063
+ }
1064
+ .send-button {
1065
+ padding: 0.6rem 1rem;
1066
+ }
1067
+ .sources-panel {
1068
+ width: 100%;
1069
+ }
1070
+ }
1071
+
1072
+
1073
+
1074
+ .role-selection-container {
1075
+ background: rgb(98, 98, 237);
1076
+ border-radius: 15px;
1077
+ padding: 25px;
1078
+ margin: 20px 0;
1079
+ box-shadow: 0 10px 25px rgba(0,0,0,0.15);
1080
+ border: none;
1081
+ }
1082
+
1083
+ .role-selection-header {
1084
+ text-align: center;
1085
+ margin-bottom: 20px;
1086
+ }
1087
+
1088
+ .role-selection-header h3 {
1089
+ color: white;
1090
+ font-size: 1.4em;
1091
+ margin-bottom: 10px;
1092
+ text-shadow: 0 2px 4px rgba(0,0,0,0.3);
1093
+ }
1094
+
1095
+ .role-selection-header p {
1096
+ color: rgba(255,255,255,0.9);
1097
+ font-size: 1em;
1098
+ margin: 0;
1099
+ }
1100
+
1101
+ .role-buttons-grid {
1102
+ display: grid;
1103
+ grid-template-columns: repeat(2, 1fr);
1104
+ gap: 15px;
1105
+ margin-bottom: 20px;
1106
+ }
1107
+
1108
+ @media (max-width: 768px) {
1109
+ .role-buttons-grid {
1110
+ grid-template-columns: 1fr;
1111
+ }
1112
+ }
1113
+
1114
+ .role-button {
1115
+ background: rgba(255,255,255,0.1);
1116
+ border: 2px solid rgba(255,255,255,0.2);
1117
+ border-radius: 12px;
1118
+ padding: 20px;
1119
+ cursor: pointer;
1120
+ transition: all 0.3s ease;
1121
+ color: white;
1122
+ text-align: left;
1123
+ backdrop-filter: blur(10px);
1124
+ position: relative;
1125
+ overflow: hidden;
1126
+ }
1127
+
1128
+ .role-button::before {
1129
+ content: '';
1130
+ position: absolute;
1131
+ top: 0;
1132
+ left: -100%;
1133
+ width: 100%;
1134
+ height: 100%;
1135
+ background: linear-gradient(90deg, transparent, rgba(255,255,255,0.1), transparent);
1136
+ transition: left 0.5s ease;
1137
+ }
1138
+
1139
+ .role-button:hover::before {
1140
+ left: 100%;
1141
+ }
1142
+
1143
+ .role-button:hover {
1144
+ transform: translateY(-5px);
1145
+ border-color: rgba(255,255,255,0.4);
1146
+ box-shadow: 0 15px 30px rgba(0,0,0,0.2);
1147
+ background: rgba(255,255,255,0.15);
1148
+ }
1149
+
1150
+ .role-button.selected {
1151
+ background: rgba(255,255,255,0.25);
1152
+ border-color: #ffd700;
1153
+ box-shadow: 0 0 20px rgba(255, 215, 0, 0.4);
1154
+ }
1155
+
1156
+ .role-button-header {
1157
+ display: flex;
1158
+ align-items: center;
1159
+ margin-bottom: 10px;
1160
+ }
1161
+
1162
+ .role-icon {
1163
+ font-size: 1.5em;
1164
+ margin-right: 10px;
1165
+ opacity: 0.8;
1166
+ }
1167
+
1168
+ .role-title {
1169
+ font-weight: bold;
1170
+ font-size: 1.1em;
1171
+ }
1172
+
1173
+ .role-description {
1174
+ font-size: 0.9em;
1175
+ opacity: 0.85;
1176
+ line-height: 1.4;
1177
+ }
1178
+
1179
+ .confirm-role-section {
1180
+ display: none;
1181
+ text-align: center;
1182
+ margin-top: 15px;
1183
+ }
1184
+
1185
+ .confirm-role-section.active {
1186
+ display: block;
1187
+ }
1188
+
1189
+ .confirm-button, .reset-button {
1190
+ background: linear-gradient(135deg, #28a745, #20c997);
1191
+ border: none;
1192
+ color: white;
1193
+ padding: 12px 25px;
1194
+ border-radius: 25px;
1195
+ cursor: pointer;
1196
+ font-weight: bold;
1197
+ font-size: 1em;
1198
+ margin: 0 10px;
1199
+ transition: all 0.3s ease;
1200
+ box-shadow: 0 4px 15px rgba(40, 167, 69, 0.3);
1201
+ }
1202
+
1203
+ .confirm-button:hover {
1204
+ transform: translateY(-2px);
1205
+ box-shadow: 0 6px 20px rgba(40, 167, 69, 0.4);
1206
+ }
1207
+
1208
+ .reset-button {
1209
+ background: linear-gradient(135deg, #dc3545, #c82333);
1210
+ box-shadow: 0 4px 15px rgba(220, 53, 69, 0.3);
1211
+ }
1212
+
1213
+ .reset-button:hover {
1214
+ transform: translateY(-2px);
1215
+ box-shadow: 0 6px 20px rgba(220, 53, 69, 0.4);
1216
+ }
1217
+
1218
+ .current-role-display {
1219
+ background: rgba(40, 167, 69, 0.1);
1220
+ border: 2px solid rgba(40, 167, 69, 0.3);
1221
+ border-radius: 10px;
1222
+ padding: 15px;
1223
+ margin: 15px 0;
1224
+ color: #155724;
1225
+ text-align: center;
1226
+ }
1227
+
1228
+ .current-role-display .role-name {
1229
+ font-weight: bold;
1230
+ font-size: 1.1em;
1231
+ color: #28a745;
1232
+ }
1233
+
1234
+ .chat-form.disabled {
1235
+ opacity: 0.6;
1236
+ pointer-events: none;
1237
+ }
1238
+
1239
+ .role-required-notice {
1240
+ background: #fff3cd;
1241
+ border: 1px solid #ffeaa7;
1242
+ border-radius: 8px;
1243
+ padding: 12px;
1244
+ margin: 15px 0;
1245
+ color: #856404;
1246
+ text-align: center;
1247
+ }
1248
+ .mic-button {
1249
+ background: none;
1250
+ border: none;
1251
+ cursor: pointer;
1252
+ font-size: 1.5rem;
1253
+ margin-left: 8px;
1254
+ color: #444;
1255
+ }
1256
+ .mic-button:disabled {
1257
+ color: #aaa;
1258
+ cursor: not-allowed;
1259
+ }
1260
+
1261
+ /* décale légèrement le bouton conversation vers la droite */
1262
+ .chat-form .conversation-button {
1263
+ margin-left: 12px; /* augmente si tu veux plus d'espace */
1264
+ padding: 8px 12px;
1265
+ border-radius: 6px;
1266
+ border: 1px solid #ccc;
1267
+ background: #fff;
1268
+ cursor: pointer;
1269
+ }
1270
+
1271
+ /* style quand actif */
1272
+ .chat-form .conversation-button.active {
1273
+ background: red;
1274
+ color: #fff;
1275
+ border-color: #27ae60;
1276
+ }
static/img/Franky.png ADDED
static/img/Freddy.png ADDED
static/img/cat-icon.png ADDED
static/img/cat_walking.png ADDED
static/img/searching-cat.gif ADDED
static/img/seatech_logo.png ADDED
static/img/user.png ADDED
templates/index.html ADDED
@@ -0,0 +1,737 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <meta name="description"
8
+ content="Les Chats De SeaTech - Votre guide félin pour l'école d'ingénieurs de l'Université de Toulon">
9
+ <title>Les Chats De SeaTech</title>
10
+ <link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
11
+ <link rel="icon" href="{{ url_for('static', filename='img/seatech_logo.png') }}" type="image/png">
12
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/5.15.4/css/all.min.css">
13
+
14
+ </head>
15
+
16
+ <body>
17
+ <div class="main-container">
18
+ <!-- Sidebar -->
19
+ <aside class="sidebar card">
20
+ <div class="sidebar-header">
21
+ <h2>Les Chats De SeaTech</h2>
22
+ <div class="cats-logo">
23
+ <img src="{{ url_for('static', filename='img/franky.png') }}" alt="Franky" class="cat-avatar"
24
+ loading="lazy">
25
+ <img src="{{ url_for('static', filename='img/freddy.png') }}" alt="Freddy" class="cat-avatar"
26
+ loading="lazy">
27
+ </div>
28
+ </div>
29
+ <nav>
30
+ <div class="sidebar-section">
31
+ <h3>Liens SeaTech</h3>
32
+ <ul>
33
+ <li><a href="https://seatech.univ-tln.fr/" target="_blank" rel="noopener">SeaTech Officiel</a>
34
+ </li>
35
+ <li><a href="https://www.univ-tln.fr/" target="_blank" rel="noopener">Université de Toulon</a>
36
+ </li>
37
+ </ul>
38
+ </div>
39
+ <div class="sidebar-section">
40
+ <h3>Formations</h3>
41
+ <ul>
42
+ <li><a href="https://seatech.univ-tln.fr/devenir-ingenieur" target="_blank"
43
+ rel="noopener">Devenir Ingénieur</a></li>
44
+ <li><a href="https://seatech.univ-tln.fr/Formation-d-ingenieurs-Materiaux-par-apprentissage.html"
45
+ target="_blank" rel="noopener">Matériaux (Apprentissage)</a></li>
46
+ <li><a href="https://seatech.univ-tln.fr/Formation-d-ingenieurs-en-systemes-numeriques-par-apprentissage.html"
47
+ target="_blank" rel="noopener">Systèmes Numériques (Apprentissage)</a></li>
48
+ </ul>
49
+ </div>
50
+ <div class="sidebar-section">
51
+ <h3>Informations</h3>
52
+ <ul>
53
+ <li><a href="https://seatech.univ-tln.fr/recherche" target="_blank" rel="noopener">Recherche</a>
54
+ </li>
55
+ <li><a href="https://seatech.univ-tln.fr/international" target="_blank"
56
+ rel="noopener">International</a></li>
57
+ <li><a href="https://seatech.univ-tln.fr/Contacts.html" target="_blank"
58
+ rel="noopener">Contacts</a></li>
59
+ </ul>
60
+ </div>
61
+ </nav>
62
+ <div class="sidebar-footer">
63
+ <p>Posez-moi vos questions sur SeaTech!</p>
64
+ </div>
65
+ </aside>
66
+
67
+ <!-- Contenu principal -->
68
+ <main class="container">
69
+ <header class="header">
70
+ <div class="logo-container">
71
+ <img src="{{ url_for('static', filename='img/seatech_logo.png') }}" alt="Logo SeaTech" class="logo"
72
+ width="250" height="auto">
73
+ </div>
74
+ <h1>Les Chats De SeaTech</h1>
75
+ <p class="subtitle">Votre guide félin pour l'école d'ingénieurs de l'Université de Toulon</p>
76
+ </header>
77
+
78
+ <!-- Section de sélection de rôle -->
79
+ {% if not user_profile.get('confirmed') %}
80
+ <section class="role-selection-container">
81
+ <div class="role-selection-header">
82
+ <h3><i class="fas fa-user-circle"></i> Choisissez votre profil</h3>
83
+ <p>Pour mieux vous aider, veuillez sélectionner votre profil :</p>
84
+ </div>
85
+
86
+ <div class="role-buttons-grid">
87
+ <div class="role-button" data-role="Étudiant">
88
+ <div class="role-button-header">
89
+ <div class="role-icon"><i class="fas fa-graduation-cap"></i></div>
90
+ <div class="role-title">Étudiant</div>
91
+ </div>
92
+ <div class="role-description">
93
+ Étudiant actuel de SeaTech cherchant des informations sur les cours, emplois du temps,
94
+ projets, vie associative...
95
+ </div>
96
+ </div>
97
+
98
+ <div class="role-button" data-role="Candidat">
99
+ <div class="role-button-header">
100
+ <div class="role-icon"><i class="fas fa-user-plus"></i></div>
101
+ <div class="role-title">Candidat</div>
102
+ </div>
103
+ <div class="role-description">
104
+ Candidat intéressé par SeaTech : admissions, concours, formations, spécialités
105
+ disponibles...
106
+ </div>
107
+ </div>
108
+
109
+ <div class="role-button" data-role="Enseignant">
110
+ <div class="role-button-header">
111
+ <div class="role-icon"><i class="fas fa-chalkboard-teacher"></i></div>
112
+ <div class="role-title">Enseignant</div>
113
+ </div>
114
+ <div class="role-description">
115
+ Enseignant cherchant des ressources pédagogiques, contacts administratifs, organisation des
116
+ cours...
117
+ </div>
118
+ </div>
119
+
120
+ <div class="role-button" data-role="Alumni">
121
+ <div class="role-button-header">
122
+ <div class="role-icon"><i class="fas fa-user-tie"></i></div>
123
+ <div class="role-title">Alumni</div>
124
+ </div>
125
+ <div class="role-description">
126
+ Ancien étudiant de SeaTech intéressé par le réseau alumni, partenariats, événements...
127
+ </div>
128
+ </div>
129
+ </div>
130
+
131
+ <div class="confirm-role-section">
132
+ <p>Rôle sélectionné : <strong id="selectedRoleName"></strong></p>
133
+ <button class="confirm-button" id="confirmRoleBtn">
134
+ <i class="fas fa-check"></i> Confirmer mon profil
135
+ </button>
136
+ </div>
137
+ </section>
138
+ {% else %}
139
+ <!-- Affichage du rôle actuel -->
140
+ <div class="current-role-display">
141
+ <p><i class="fas fa-user-check"></i> Profil actuel : <span class="role-name">{{ user_profile.role
142
+ }}</span></p>
143
+ <button class="reset-button" id="resetRoleBtn">
144
+ <i class="fas fa-redo"></i> Changer de profil
145
+ </button>
146
+ </div>
147
+ {% endif %}
148
+
149
+ <section class="chat-container">
150
+ <div class="chat-history" id="chatHistory">
151
+ <article class="message bot-message">
152
+ <div class="message-avatar">
153
+ <img src="{{ url_for('static', filename='img/franky.png') }}" alt="Franky" class="avatar"
154
+ loading="lazy">
155
+ </div>
156
+ <div class="message-content">
157
+ <div class="message-header">
158
+ <strong>Franky</strong>
159
+ </div>
160
+ <p>Miaaaou ! Je suis <strong>Franky</strong>, votre guide félin de SeaTech ! 🐱 Mon ami
161
+ <strong>Freddy</strong> et moi sommes là pour vous aider.
162
+ {% if not user_profile.get('confirmed') %}
163
+ Commencez par sélectionner votre profil ci-dessus pour que je puisse adapter mes
164
+ réponses à vos besoins.
165
+ {% else %}
166
+ Posez-moi n'importe quelle question sur l'école et je vous répondrai en tant
167
+ qu'assistant spécialisé pour les {{ user_profile.role }}s.
168
+ {% endif %}
169
+ </p>
170
+ </div>
171
+ </article>
172
+
173
+ {% if conversation %}
174
+ {% for msg in conversation %}
175
+ <article class="message {% if msg.role == 'user' %}user-message{% else %}bot-message{% endif %}">
176
+ {% if msg.role != 'user' %}
177
+ <div class="message-avatar">
178
+ <img src="{{ url_for('static', filename='img/franky.png') }}" alt="Franky" class="avatar"
179
+ loading="lazy">
180
+ </div>
181
+ {% endif %}
182
+ <div class="message-content">
183
+ {% if msg.role != 'user' %}
184
+ <div class="message-header">
185
+ <strong>Franky</strong>
186
+ <div class="message-actions">
187
+ {% if not msg.get('is_system_message') %}
188
+ <button class="show-sources-btn" aria-label="Afficher les sources"><i
189
+ class="fas fa-file-alt"></i> Sources</button>
190
+ {% endif %}
191
+ {% if msg.processing_time %}
192
+ <span class="processing-time">{{ msg.processing_time }}</span>
193
+ {% endif %}
194
+ </div>
195
+ </div>
196
+ {% endif %}
197
+ {{ msg.content | safe }}
198
+ </div>
199
+ {% if msg.role == 'user' %}
200
+ <div class="message-avatar">
201
+ <img src="{{ url_for('static', filename='img/user.png') }}" alt="User" class="avatar"
202
+ loading="lazy">
203
+ </div>
204
+ {% endif %}
205
+ </article>
206
+ {% endfor %}
207
+ {% else %}
208
+ {% if not user_profile.get('confirmed') %}
209
+ <div class="role-required-notice">
210
+ <i class="fas fa-info-circle"></i> Veuillez sélectionner votre profil ci-dessus pour commencer à
211
+ poser vos questions.
212
+ </div>
213
+ {% else %}
214
+ <div class="no-messages">Posez votre première question !</div>
215
+ {% endif %}
216
+ {% endif %}
217
+ </div>
218
+
219
+ <div class="loading" id="loadingIndicator">
220
+ <div class="loading-container">
221
+ <img src="{{ url_for('static', filename='img/searching-cat.gif') }}" alt="Chat qui cherche"
222
+ class="searching-cat">
223
+ <span id="loadingText">Je demande à Freddy<span class="loading-dots"></span></span>
224
+ </div>
225
+ </div>
226
+
227
+ <form class="chat-form{% if not user_profile.get('confirmed') %} disabled{% endif %}" id="chatForm"
228
+ method="POST" action="/">
229
+ <div class="chat-input-container">
230
+ <img src="{{ url_for('static', filename='img/cat_walking.png') }}" alt="Chat qui marche"
231
+ class="cat-walking">
232
+ <input type="text" name="query" id="queryInput" class="chat-input"
233
+ placeholder="{% if user_profile.get('confirmed') %}Posez votre question sur SeaTech...{% else %}Sélectionnez d'abord votre profil ci-dessus{% endif %}"
234
+ {% if not user_profile.get('confirmed') %}disabled{% endif %} required autocomplete="off"
235
+ aria-label="Votre question">
236
+
237
+
238
+ </div>
239
+ <!-- Bouton micro -->
240
+ <button type="button" id="micButton" class="mic-button" {% if not user_profile.get('confirmed')
241
+ %}disabled{% endif %}>
242
+ <i class="fas fa-microphone"></i>
243
+ </button>
244
+
245
+ <!-- Bouton : mode conversation-->
246
+ <button type="button" id="conversationBtn" class="conversation-button">
247
+ <i class="fas fa-comments"></i> Start conversation
248
+ </button>
249
+
250
+ <button type="submit" class="send-button" {% if not user_profile.get('confirmed') %}disabled{% endif
251
+ %}>
252
+ Envoyer
253
+ <img src="{{ url_for('static', filename='img/cat-icon.png') }}" alt="Chat" class="cat-icon"
254
+ width="20" height="20">
255
+ </button>
256
+ </form>
257
+ </section>
258
+ </main>
259
+
260
+ <!-- Panneau des sources (caché par défaut) -->
261
+ <aside class="sources-panel" id="sourcesPanel" aria-hidden="true">
262
+ <div class="sources-header">
263
+ <h3>Sources consultées par Freddy</h3>
264
+ <button id="closeSourcesBtn" class="close-btn" aria-label="Fermer le panneau des sources">×</button>
265
+ </div>
266
+ <div class="sources-content" id="sourcesContent">
267
+ <!-- Sources insérées dynamiquement -->
268
+ </div>
269
+ </aside>
270
+
271
+ <!-- Panneau de Freddy (toujours visible) -->
272
+ <aside class="freddy-panel card">
273
+ <div class="freddy-header">
274
+ <h3>
275
+ <img src="{{ url_for('static', filename='img/freddy.png') }}" alt="Freddy" loading="lazy">
276
+ Recherche par Freddy
277
+ </h3>
278
+ </div>
279
+ <div class="freddy-content">
280
+ <div class="freddy-intro">
281
+ <img src="{{ url_for('static', filename='img/freddy.png') }}" alt="Freddy" loading="lazy">
282
+ <div>
283
+ <p><strong>Miaaaou !</strong> Je suis <strong>Freddy</strong>, l'assistant de recherche de
284
+ SeaTech.</p>
285
+ <p>Je fouille dans les documents pour trouver les informations les plus pertinentes
286
+ {% if user_profile.get('confirmed') %}pour les {{ user_profile.role }}s{% endif %} !</p>
287
+ </div>
288
+ </div>
289
+
290
+ <div class="freddy-logs-title">
291
+ <i class="fas fa-search"></i> Analyse de la recherche
292
+ </div>
293
+ <div id="freddyLogs" class="freddy-logs">
294
+ <div class="freddy-log-entry">
295
+ <span class="log-time">{{ current_datetime.strftime('%H:%M:%S') }}</span>
296
+ {% if user_profile.get('confirmed') %}
297
+ <span class="log-action">Prêt à chercher pour un profil {{ user_profile.role }}</span>
298
+ {% else %}
299
+ <span class="log-action">En attente de sélection de profil...</span>
300
+ {% endif %}
301
+ </div>
302
+ </div>
303
+
304
+ <div class="freddy-sources-title">
305
+ <i class="fas fa-file-alt"></i> Sources consultées
306
+ </div>
307
+ <div id="freddySources" class="freddy-sources-container">
308
+ <div class="freddy-source-block">
309
+ <div class="freddy-source-header">
310
+ <span class="source-name">Informations SeaTech</span>
311
+ {% if user_profile.get('confirmed') %}
312
+ <span class="medium-relevance">Filtrage par profil {{ user_profile.role }}...</span>
313
+ {% else %}
314
+ <span class="medium-relevance">En attente de profil...</span>
315
+ {% endif %}
316
+ </div>
317
+ <div class="freddy-source-content">
318
+ {% if user_profile.get('confirmed') %}
319
+ Prêt à chercher des informations spécifiquement adaptées pour les {{ user_profile.role }}s.
320
+ {% else %}
321
+ Sélectionnez votre profil pour que je puisse filtrer les informations selon vos besoins.
322
+ {% endif %}
323
+ </div>
324
+ </div>
325
+ </div>
326
+ </div>
327
+ </aside>
328
+ </div>
329
+
330
+ <footer class="footer">
331
+ <p>&copy; {{ current_datetime.year }} École d'ingénieurs SeaTech - Université de Toulon | Tous droits réservés
332
+ </p>
333
+ </footer>
334
+
335
+ <script>
336
+ document.addEventListener('DOMContentLoaded', function () {
337
+ let isConversationMode = false; // mode conversation activé/désactivé
338
+
339
+
340
+ const chatForm = document.getElementById('chatForm');
341
+ const chatHistory = document.getElementById('chatHistory');
342
+ const loadingIndicator = document.getElementById('loadingIndicator');
343
+ const queryInput = document.getElementById('queryInput');
344
+ const loadingText = document.getElementById('loadingText');
345
+ const sourcesPanel = document.getElementById('sourcesPanel');
346
+ const sourcesContent = document.getElementById('sourcesContent');
347
+ const closeSourcesBtn = document.getElementById('closeSourcesBtn');
348
+ const freddyLogs = document.getElementById('freddyLogs');
349
+ const freddySources = document.getElementById('freddySources');
350
+
351
+ // Éléments de sélection de rôle
352
+ const roleButtons = document.querySelectorAll('.role-button');
353
+ const confirmRoleSection = document.querySelector('.confirm-role-section');
354
+ const confirmRoleBtn = document.getElementById('confirmRoleBtn');
355
+ const resetRoleBtn = document.getElementById('resetRoleBtn');
356
+ const selectedRoleName = document.getElementById('selectedRoleName');
357
+
358
+ let selectedRole = null;
359
+
360
+ const loadingMessages = [
361
+ "Freddy fouille dans les archives...",
362
+ "Freddy déchiffre les acronymes de SeaTech... 🐱",
363
+ "Freddy explore les données de l'école...",
364
+ "Miaaaou! Freddy a repéré une information intéressante...",
365
+ "Freddy chasse les informations pertinentes... 🐾",
366
+ "Les moustaches de Freddy frémissent... il est sur une piste!"
367
+ ];
368
+
369
+ // Gestion de la sélection de rôle
370
+ roleButtons.forEach(button => {
371
+ button.addEventListener('click', function () {
372
+ roleButtons.forEach(btn => btn.classList.remove('selected'));
373
+ this.classList.add('selected');
374
+ selectedRole = this.dataset.role;
375
+ selectedRoleName.textContent = selectedRole;
376
+ confirmRoleSection.classList.add('active');
377
+ });
378
+ });
379
+
380
+ if (confirmRoleBtn) {
381
+ confirmRoleBtn.addEventListener('click', function () {
382
+ if (selectedRole) {
383
+ fetch('/api/ask', {
384
+ method: 'POST',
385
+ headers: { 'Content-Type': 'application/json' },
386
+ body: JSON.stringify({ role_selection: selectedRole }),
387
+ credentials: 'same-origin'
388
+ })
389
+ .then(response => response.json())
390
+ .then(data => {
391
+ if (data.status === 'role_selected') {
392
+ window.location.reload();
393
+ }
394
+ })
395
+ .catch(error => console.error('Erreur lors de la sélection de rôle:', error));
396
+ }
397
+ });
398
+ }
399
+
400
+ if (resetRoleBtn) {
401
+ resetRoleBtn.addEventListener('click', function () {
402
+ fetch('/api/reset-role', {
403
+ method: 'POST',
404
+ headers: { 'Content-Type': 'application/json' },
405
+ credentials: 'same-origin'
406
+ })
407
+ .then(response => response.json())
408
+ .then(data => {
409
+ if (data.status === 'role_reset') {
410
+ window.location.reload();
411
+ }
412
+ })
413
+ .catch(error => console.error('Erreur lors de la réinitialisation:', error));
414
+ });
415
+ }
416
+
417
+ function extractSources(html) {
418
+ if (!html) return null;
419
+ const parser = new DOMParser();
420
+ const doc = parser.parseFromString(html, 'text/html');
421
+ const sourcesContainer = doc.querySelector('.sources-container');
422
+ return sourcesContainer ? sourcesContainer.innerHTML : null;
423
+ }
424
+
425
+ function updateFreddyContent(html) {
426
+ if (!html) return;
427
+ const parser = new DOMParser();
428
+ const doc = parser.parseFromString(html, 'text/html');
429
+ const logsSection = doc.querySelector('.freddy-logs');
430
+ if (logsSection) freddyLogs.innerHTML = logsSection.innerHTML;
431
+ const sourcesSection = doc.querySelector('.freddy-sources-container');
432
+ if (sourcesSection) freddySources.innerHTML = sourcesSection.innerHTML;
433
+ }
434
+
435
+ closeSourcesBtn.addEventListener('click', function () {
436
+ sourcesPanel.classList.remove('active');
437
+ sourcesPanel.setAttribute('aria-hidden', 'true');
438
+ });
439
+
440
+ let messageIndex = 0;
441
+ let loadingInterval;
442
+
443
+ function updateLoadingMessage() {
444
+ loadingText.innerHTML = `${loadingMessages[messageIndex]}<span class="loading-dots"></span>`;
445
+ messageIndex = (messageIndex + 1) % loadingMessages.length;
446
+ }
447
+
448
+ function scrollToBottom() {
449
+ chatHistory.scrollTop = chatHistory.scrollHeight;
450
+ }
451
+ scrollToBottom();
452
+
453
+ function initButtons() {
454
+ document.querySelectorAll('.show-sources-btn').forEach(button => {
455
+ button.addEventListener('click', function () {
456
+ sourcesPanel.classList.add('active');
457
+ sourcesPanel.setAttribute('aria-hidden', 'false');
458
+ });
459
+ });
460
+ }
461
+ initButtons();
462
+
463
+ if (chatForm) {
464
+ chatForm.addEventListener('submit', function (e) {
465
+ e.preventDefault();
466
+ const query = queryInput.value.trim();
467
+ if (!query) return;
468
+
469
+ const userMessageDiv = document.createElement('article');
470
+ userMessageDiv.className = 'message user-message';
471
+ userMessageDiv.innerHTML = `
472
+ <div class="message-content">${query}</div>
473
+ <div class="message-avatar">
474
+ <img src="${window.location.origin}/static/img/user.png" alt="User" class="avatar" loading="lazy">
475
+ </div>
476
+ `;
477
+ chatHistory.appendChild(userMessageDiv);
478
+ scrollToBottom();
479
+
480
+ loadingIndicator.style.display = 'block';
481
+ messageIndex = 0;
482
+ updateLoadingMessage();
483
+ loadingInterval = setInterval(updateLoadingMessage, 2000);
484
+
485
+ fetch('/api/ask', {
486
+ method: 'POST',
487
+ headers: { 'Content-Type': 'application/json' },
488
+ body: JSON.stringify({ query: query }),
489
+ credentials: 'same-origin'
490
+ })
491
+ .then(response => response.json())
492
+ .then(data => {
493
+ clearInterval(loadingInterval);
494
+ loadingIndicator.style.display = 'none';
495
+
496
+ if (data.status === 'role_required') {
497
+ const errorDiv = document.createElement('article');
498
+ errorDiv.className = 'message bot-message';
499
+ errorDiv.innerHTML = `
500
+ <div class="message-avatar">
501
+ <img src="${window.location.origin}/static/img/franky.png" alt="Franky" class="avatar" loading="lazy">
502
+ </div>
503
+ <div class="message-content">
504
+ <div class="message-header"><strong>Franky</strong></div>
505
+ ${data.response}
506
+ </div>
507
+ `;
508
+ chatHistory.appendChild(errorDiv);
509
+ scrollToBottom();
510
+ return;
511
+ }
512
+
513
+ const sourcesHtml = data.sources || extractSources(data.response);
514
+ let cleanResponse = data.response;
515
+
516
+ if (sourcesHtml && cleanResponse.includes("sources-container")) {
517
+ const tempDiv = document.createElement('div');
518
+ tempDiv.innerHTML = cleanResponse;
519
+ const sourcesContainer = tempDiv.querySelector('.sources-container');
520
+ if (sourcesContainer) sourcesContainer.remove();
521
+ cleanResponse = tempDiv.innerHTML;
522
+ }
523
+
524
+ if (sourcesHtml) sourcesContent.innerHTML = sourcesHtml;
525
+ if (data.freddy_logs) updateFreddyContent(data.freddy_logs);
526
+
527
+ const botMessageDiv = document.createElement('article');
528
+ botMessageDiv.className = 'message bot-message';
529
+ botMessageDiv.innerHTML = `
530
+ <div class="message-avatar">
531
+ <img src="${window.location.origin}/static/img/franky.png" alt="Franky" class="avatar" loading="lazy">
532
+ </div>
533
+ <div class="message-content">
534
+ <div class="message-header">
535
+ <strong>Franky</strong>
536
+ <div class="message-actions">
537
+ ${sourcesHtml ? '<button class="show-sources-btn" aria-label="Afficher les sources"><i class="fas fa-file-alt"></i> Sources</button>' : ''}
538
+ ${data.processing_time ? '<span class="processing-time">' + data.processing_time + '</span>' : ''}
539
+ ${data.confirmed_role ? '<span class="role-indicator">Profil: ' + data.confirmed_role + '</span>' : ''}
540
+ </div>
541
+ </div>
542
+ ${cleanResponse}
543
+ </div>
544
+ `;
545
+
546
+ chatHistory.appendChild(botMessageDiv);
547
+ queryInput.value = '';
548
+ queryInput.focus();
549
+ //ajout Daly
550
+ // Lecture vocale de la réponse (TTS)
551
+ // Lecture vocale seulement si mode conversation actif
552
+ if (isConversationMode && 'speechSynthesis' in window && data.response) {
553
+ let textToRead = data.response
554
+ .replace(/<[^>]+>/g, ' ')
555
+ .replace(/[^\w\sàâäçéèêëîïôöùûüÿñœ]/gi, '')
556
+ .replace(/\s+/g, ' ')
557
+ .trim();
558
+ const utterance = new SpeechSynthesisUtterance(textToRead);
559
+ utterance.lang = "fr-FR";
560
+ utterance.rate = 1.0;
561
+ utterance.pitch = 1.0;
562
+ speechSynthesis.cancel();
563
+ speechSynthesis.speak(utterance);
564
+ }
565
+
566
+ //fin ajout
567
+ scrollToBottom();
568
+ initButtons();
569
+ })
570
+ .catch(error => {
571
+ console.error('Erreur:', error);
572
+ clearInterval(loadingInterval);
573
+ loadingIndicator.style.display = 'none';
574
+
575
+ const errorDiv = document.createElement('article');
576
+ errorDiv.className = 'message bot-message';
577
+ errorDiv.innerHTML = `
578
+ <div class="message-avatar">
579
+ <img src="${window.location.origin}/static/img/franky.png" alt="Franky" class="avatar" loading="lazy">
580
+ </div>
581
+ <div class="message-content">
582
+ <div class="message-header"><strong>Franky</strong></div>
583
+ <p>Désolé, une erreur s'est produite pendant la recherche. Veuillez réessayer.</p>
584
+ </div>
585
+ `;
586
+ chatHistory.appendChild(errorDiv);
587
+ scrollToBottom();
588
+ });
589
+ });
590
+ }
591
+
592
+ // === Reconnaissance vocale ===
593
+ const micBtn = document.getElementById('micButton'); // utilise l'ID réel du bouton
594
+ if (micBtn && 'webkitSpeechRecognition' in window) {
595
+ const recognition = new webkitSpeechRecognition();
596
+ recognition.lang = "fr-FR";
597
+ recognition.continuous = false;
598
+ recognition.interimResults = false;
599
+
600
+ micBtn.addEventListener('click', () => {
601
+ recognition.start();
602
+ micBtn.classList.add("listening"); // effet visuel pendant l'écoute
603
+ });
604
+
605
+ recognition.onresult = (event) => {
606
+ let transcript = event.results[0][0].transcript;
607
+
608
+ // Corrections automatiques pour SeaTech
609
+ const corrections = {
610
+ "steak": "SeaTech",
611
+ "scitec": "SeaTech",
612
+ "sitec": "SeaTech",
613
+ "citech": "SeaTech",
614
+ "citek": "SeaTech",
615
+ "sutec": "SeaTech",
616
+ "sitac": "SeaTech",
617
+ };
618
+
619
+ for (const [wrong, correct] of Object.entries(corrections)) {
620
+ const regex = new RegExp(`\\b${wrong}\\b`, "gi"); // cherche le mot entier, insensible à la casse
621
+ transcript = transcript.replace(regex, correct);
622
+ }
623
+
624
+ queryInput.value = transcript; // place le texte corrigé dans la barre de saisie
625
+ //chatForm.requestSubmit(); // si tu veux envoyer auto, tu décommentes
626
+ micBtn.classList.remove("listening");
627
+ };
628
+
629
+
630
+ recognition.onerror = (event) => {
631
+ console.error("Erreur vocale:", event.error);
632
+ micBtn.classList.remove("listening");
633
+ };
634
+
635
+ recognition.onend = () => {
636
+ micBtn.classList.remove("listening");
637
+ };
638
+ }
639
+
640
+
641
+
642
+ // === Mode conversation ===
643
+ const conversationBtn = document.getElementById("conversationBtn");
644
+ if (conversationBtn && 'webkitSpeechRecognition' in window) {
645
+ const convRecognition = new webkitSpeechRecognition();
646
+ convRecognition.lang = "fr-FR";
647
+ convRecognition.continuous = false;
648
+ convRecognition.interimResults = false;
649
+
650
+ // ✅ Toggle ON/OFF du mode conversation
651
+ conversationBtn.addEventListener("click", () => {
652
+ isConversationMode = !isConversationMode;
653
+
654
+ if (isConversationMode) {
655
+ conversationBtn.classList.add("active");
656
+ conversationBtn.innerHTML = '<i class="fas fa-comments"></i> Stop conversation';
657
+ convRecognition.start();
658
+ } else {
659
+ conversationBtn.classList.remove("active");
660
+ conversationBtn.innerHTML = '<i class="fas fa-comments"></i> Start conversation';
661
+ convRecognition.stop();
662
+ speechSynthesis.cancel(); // stoppe toute lecture en cours
663
+ }
664
+ });
665
+
666
+ convRecognition.onresult = (event) => {
667
+ let transcript = event.results[0][0].transcript;
668
+
669
+ // Corrections automatiques pour SeaTech
670
+ const corrections = { "steak": "SeaTech", "scitec": "SeaTech", "sitec": "SeaTech" };
671
+ for (const [wrong, correct] of Object.entries(corrections)) {
672
+ const regex = new RegExp(`\\b${wrong}\\b`, "gi");
673
+ transcript = transcript.replace(regex, correct);
674
+ }
675
+
676
+ // Envoi automatique si mode conversation actif
677
+ if (isConversationMode) {
678
+ fetch('/api/ask', {
679
+ method: 'POST',
680
+ headers: { 'Content-Type': 'application/json' },
681
+ body: JSON.stringify({ query: transcript, mode: "conversation" }),
682
+ credentials: 'same-origin'
683
+ })
684
+ .then(response => response.json())
685
+ .then(data => {
686
+ const botMessageDiv = document.createElement('article');
687
+ botMessageDiv.className = 'message bot-message';
688
+ botMessageDiv.innerHTML = `
689
+ <div class="message-avatar">
690
+ <img src="${window.location.origin}/static/img/franky.png" alt="Franky" class="avatar" loading="lazy">
691
+ </div>
692
+ <div class="message-content">
693
+ <div class="message-header"><strong>Franky</strong></div>
694
+ ${data.response}
695
+ </div>`;
696
+ chatHistory.appendChild(botMessageDiv);
697
+ scrollToBottom();
698
+
699
+ // ✅ Lecture vocale courte et propre
700
+ if ('speechSynthesis' in window && isConversationMode) {
701
+ let textToRead = data.response
702
+ .replace(/<[^>]+>/g, ' ') // supprime balises HTML
703
+ .replace(/[^\w\sàâäçéèêëîïôöùûüÿñœ]/gi, '') // supprime symboles
704
+ .replace(/\s+/g, ' ') // normalise espaces
705
+ .trim();
706
+
707
+ const utterance = new SpeechSynthesisUtterance(textToRead);
708
+ utterance.lang = "fr-FR";
709
+ utterance.rate = 1.0;
710
+ utterance.pitch = 1.0;
711
+ speechSynthesis.cancel(); // stoppe lectures précédentes
712
+ speechSynthesis.speak(utterance);
713
+ }
714
+
715
+ convRecognition.start(); // relance automatiquement pour écoute suivante
716
+ })
717
+ .catch(error => {
718
+ console.error("Erreur conversation:", error);
719
+ });
720
+ }
721
+ };
722
+
723
+ convRecognition.onerror = (event) => {
724
+ console.error("Erreur vocale (mode conv):", event.error);
725
+ conversationBtn.classList.remove("listening");
726
+ };
727
+
728
+ convRecognition.onend = () => {
729
+ if (isConversationMode) convRecognition.start(); // boucle tant que mode actif
730
+ };
731
+ }
732
+
733
+ });
734
+ </script>
735
+ </body>
736
+
737
+ </html>