Hakim18 commited on
Commit
6fd7d0c
·
verified ·
1 Parent(s): 6332bf1

Upload 10 files

Browse files
Files changed (10) hide show
  1. Deploy.py +40 -0
  2. Dockerfile +26 -0
  3. README.txt +78 -0
  4. app.py +172 -0
  5. dataset_2026.csv +0 -0
  6. embeddings.py +54 -0
  7. embeddings_questions.pt +3 -0
  8. requirements.txt +9 -0
  9. static/script.js +386 -0
  10. templates/index.html +472 -0
Deploy.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, ssl
2
+ from huggingface_hub import HfApi, create_repo
3
+
4
+ # --- PATCH SSL ---
5
+ if hasattr(ssl, '_create_unverified_context'):
6
+ ssl._create_default_https_context = ssl._create_unverified_context
7
+
8
+ # --- CONFIGURATION ---
9
+ TOKEN = "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" # Allez sur hf.co/settings/tokens (Rôle: WRITE)
10
+ USER = "OUAREDAEK"
11
+ SPACE_NAME = "AskLAQ3"
12
+ REPO_ID = f"{USER}/{SPACE_NAME}"
13
+
14
+ def deploy():
15
+ api = HfApi(token=TOKEN)
16
+ print(f"🚀 Création du Space {REPO_ID}...")
17
+ create_repo(repo_id=REPO_ID, repo_type="space", space_sdk="gradio", exist_ok=True, token=TOKEN)
18
+
19
+ print("📤 Envoi des fichiers en cours...")
20
+ # Liste spécifique des fichiers à envoyer
21
+ files_to_upload = [
22
+ "app.py", "requirements.txt", "dataset_2026.csv", "embeddings_questions.pt"
23
+ ]
24
+
25
+ # Envoi des fichiers racines
26
+ for file in files_to_upload:
27
+ if os.path.exists(file):
28
+ api.upload_file(path_or_fileobj=file, path_in_repo=file, repo_id=REPO_ID, repo_type="space")
29
+
30
+ # Envoi des dossiers templates et static
31
+ for folder in ["templates", "static"]:
32
+ if os.path.exists(folder):
33
+ api.upload_folder(folder_path=folder, path_in_repo=folder, repo_id=REPO_ID, repo_type="space")
34
+
35
+ print("\n" + "="*50)
36
+ print(f"✅ TERMINÉ ! Accès : https://{USER.lower()}-{SPACE_NAME.lower()}.hf.space")
37
+ print("="*50)
38
+
39
+ if __name__ == "__main__":
40
+ deploy()
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.10-slim
2
+
3
+ # Évite les problèmes d'affichage logs
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ # Installer dépendances système (important pour torch & pandas)
7
+ RUN apt-get update && apt-get install -y \
8
+ git \
9
+ build-essential \
10
+ && rm -rf /var/lib/apt/lists/*
11
+
12
+ # Dossier de travail
13
+ WORKDIR /app
14
+
15
+ # Copier fichiers
16
+ COPY . /app
17
+
18
+ # Installer dépendances Python
19
+ RUN pip install --no-cache-dir --upgrade pip
20
+ RUN pip install --no-cache-dir -r requirements.txt
21
+
22
+ # Port utilisé par Hugging Face
23
+ EXPOSE 7860
24
+
25
+ # Lancer ton app
26
+ CMD ["python", "app.py"]
README.txt ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AskLAQ2 - Local Q&A System
2
+
3
+ ## Overview
4
+ AskLAQ2 is a local question-answering system that runs completely offline on your machine. It uses sentence embeddings to find the most relevant answers from your dataset.
5
+
6
+ ## System Requirements
7
+ - Windows 7/8/10/11, macOS, or Linux
8
+ - 4GB RAM minimum (8GB recommended)
9
+ - 2GB free disk space
10
+ - Python 3.8 or higher
11
+
12
+ ## Installation
13
+
14
+ ### Option 1: Using the Installer (Windows)
15
+ 1. Double-click `install.bat`
16
+ 2. Follow the on-screen instructions
17
+
18
+ ### Option 2: Manual Installation
19
+ 1. Ensure Python 3.8+ is installed
20
+ 2. Open terminal/command prompt in this folder
21
+ 3. Run: `pip install -r requirements.txt`
22
+
23
+ ## Running the Application
24
+
25
+ ### Windows:
26
+ - Double-click `AskLAQ2.exe`
27
+ - OR Run `launch_app.py` with Python
28
+
29
+ ### Mac/Linux:
30
+ - Open terminal in this folder
31
+ - Run: `python launch_app.py`
32
+
33
+ ## Application Structure
34
+ - `app.py` - Main Flask application
35
+ - `gradio_app.py` - Gradio interface wrapper
36
+ - `launch_app.py` - Application launcher
37
+ - `dataset_2026.csv` - Your dataset
38
+ - `embeddings_questions.pt` - Pre-computed embeddings
39
+ - `user_interactions.json` - User interaction log
40
+ - `templates/index.html` - Web interface
41
+ - `static/script.js` - Frontend JavaScript
42
+
43
+ ## How to Use
44
+ 1. Launch the application
45
+ 2. A browser window will open automatically
46
+ 3. Type your question in the input box
47
+ 4. Click "Get Answer" or press Enter
48
+ 5. View the response from your dataset
49
+
50
+ ## Troubleshooting
51
+
52
+ ### Application won't start:
53
+ - Ensure all files are in the same folder
54
+ - Check if Python is installed correctly
55
+ - Try running: `python app.py` directly
56
+
57
+ ### No answers returned:
58
+ - Check if `dataset_2026.csv` exists
59
+ - Verify `embeddings_questions.pt` is in the folder
60
+
61
+ ### Performance issues:
62
+ - Close other applications to free memory
63
+ - Consider using a smaller dataset
64
+
65
+ ## Updating
66
+ To update the dataset:
67
+ 1. Replace `dataset_2026.csv` with your new file
68
+ 2. Delete `embeddings_questions.pt` (it will be regenerated)
69
+ 3. Restart the application
70
+
71
+ ## Support
72
+ For issues or questions, please check:
73
+ 1. Application logs in the terminal
74
+ 2. `user_interactions.json` for error history
75
+ 3. Ensure all required files are present
76
+
77
+ ## Version: 1.0.0
78
+ © 2024 AskLAQ2 Application
app.py ADDED
@@ -0,0 +1,172 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os, random
2
+ import torch
3
+ import pandas as pd
4
+ from flask import Flask, render_template, request, jsonify
5
+ from sentence_transformers import SentenceTransformer, util
6
+ import gradio as gr
7
+
8
+ BASE_DIR = os.path.abspath(os.path.dirname(__file__))
9
+
10
+ app = Flask(
11
+ __name__,
12
+ template_folder=os.path.join(BASE_DIR, "templates"),
13
+ static_folder=os.path.join(BASE_DIR, "static")
14
+ )
15
+
16
+ CSV_DATA = "dataset_2026.csv"
17
+ EMB_FILE = "embeddings_questions.pt"
18
+ TOP_K_RECOMMANDATIONS = 5
19
+
20
+ print("🔄 Chargement du modèle...")
21
+ try:
22
+ model = SentenceTransformer(
23
+ "OrdalieTech/Solon-embeddings-mini-beta-1.1",
24
+ device="cpu",
25
+ trust_remote_code=True
26
+ )
27
+ print("✓ Modèle principal (Solon) chargé")
28
+ except Exception as e:
29
+ print(f"⚠️ Échec du modèle principal (probablement dû à la version Hugging Face Hub): {e}")
30
+ print("🔄 Chargement du modèle de secours (paraphrase-multilingual)...")
31
+ model = SentenceTransformer(
32
+ "paraphrase-multilingual-MiniLM-L12-v2",
33
+ device="cpu"
34
+ )
35
+ print("✓ Modèle de secours chargé avec succès !")
36
+
37
+ df_cache = None
38
+
39
+ def load_data():
40
+ global df_cache
41
+ if df_cache is None:
42
+ try:
43
+ # We enforce reading the Latin-1 CSV using ';' as sep.
44
+ df_cache = pd.read_csv(CSV_DATA, sep=";", encoding="latin-1")
45
+ df_cache.columns = df_cache.columns.str.strip()
46
+ except:
47
+ # Fallback if somehow it's utf-8 with comma
48
+ df_cache = pd.read_csv(CSV_DATA, sep=None, engine='python', encoding="utf-8")
49
+ df_cache.columns = df_cache.columns.str.strip()
50
+ return df_cache
51
+
52
+ def load_or_create_embeddings(df):
53
+ if os.path.exists(EMB_FILE):
54
+ emb = torch.load(EMB_FILE, map_location="cpu")
55
+ if emb.shape[0] == len(df) and emb.shape[1] == model.get_sentence_embedding_dimension():
56
+ return emb
57
+ print("⚠️ Dimensions embeddings incorrectes, recréation...")
58
+
59
+ print("🔨 Création embeddings...")
60
+ questions = df["Question"].astype(str).tolist()
61
+ emb = model.encode(
62
+ questions,
63
+ convert_to_tensor=True,
64
+ normalize_embeddings=True
65
+ )
66
+ torch.save(emb, EMB_FILE)
67
+ return emb
68
+
69
+ def enrich_message(base):
70
+ return random.choice([
71
+ f"Bonne question 🙂\n\n{base}",
72
+ f"Voici la réponse détaillée :\n\n{base}",
73
+ f"Voici ce que j'ai trouvé :\n\n{base}",
74
+ base
75
+ ])
76
+
77
+ def process_question(question):
78
+ df = load_data()
79
+ emb_base = load_or_create_embeddings(df)
80
+
81
+ emb_q = model.encode(question, convert_to_tensor=True, normalize_embeddings=True)
82
+ scores = util.pytorch_cos_sim(emb_q, emb_base)[0]
83
+
84
+ best_idx = torch.argmax(scores).item()
85
+ confidence = int(scores[best_idx].item() * 100)
86
+
87
+ if confidence < 40:
88
+ recs = df["Question"].sample(min(TOP_K_RECOMMANDATIONS, len(df))).tolist()
89
+ return {
90
+ "response": "Je ne suis pas sûr",
91
+ "confidence": confidence,
92
+ "matched": "—",
93
+ "intent": "Inconnu",
94
+ "recs": recs,
95
+ "service": None,
96
+ "lat": None,
97
+ "lon": None
98
+ }
99
+
100
+ row = df.iloc[best_idx]
101
+
102
+ service_val = str(row["Service"]).strip() if pd.notna(row["Service"]) else None
103
+ if service_val and service_val.lower() in ['nan', 'none', 'null', '']: service_val = None
104
+
105
+ link_val = str(row["ServiceLink"]).strip() if pd.notna(row["ServiceLink"]) else None
106
+ if link_val and link_val.lower() in ['nan', 'none', 'null', '']: link_val = None
107
+
108
+ try:
109
+ raw_lat = str(row["Latitude"]).replace(',', '.')
110
+ raw_lon = str(row["Longitude"]).replace(',', '.')
111
+ lat_val = float(raw_lat)
112
+ lon_val = float(raw_lon)
113
+ except Exception:
114
+ lat_val = None
115
+ lon_val = None
116
+
117
+ return {
118
+ "response": enrich_message(row["Response"]),
119
+ "confidence": confidence,
120
+ "matched": row["Question"],
121
+ "intent": row["Intent"],
122
+ "recs": [],
123
+ "service": service_val,
124
+ "link": link_val,
125
+ "lat": lat_val,
126
+ "lon": lon_val
127
+ }
128
+
129
+ @app.route("/")
130
+ def index():
131
+ return render_template("index.html")
132
+
133
+ @app.route("/ask", methods=["POST"])
134
+ def ask():
135
+ return jsonify(process_question(request.json.get("question", "")))
136
+
137
+ @app.route("/api/services", methods=["GET"])
138
+ def get_services():
139
+ df = load_data()
140
+ valid_df = df.dropna(subset=["Service", "Latitude", "Longitude"])
141
+
142
+ services = []
143
+ seen = set()
144
+
145
+ for _, row in valid_df.iterrows():
146
+ try:
147
+ name = str(row["Service"]).strip()
148
+ lat = float(str(row["Latitude"]).replace(',', '.'))
149
+ lon = float(str(row["Longitude"]).replace(',', '.'))
150
+
151
+ if name and name not in seen:
152
+ services.append({"service": name, "lat": lat, "lon": lon})
153
+ seen.add(name)
154
+ except Exception:
155
+ pass
156
+
157
+ return jsonify({"status": "success", "services": services})
158
+
159
+ # ==============================
160
+ # HUGGING FACE SPACES (GRADIO)
161
+ # ==============================
162
+ def gradio_chat(message, history):
163
+ return process_question(message)["response"]
164
+
165
+ iface = gr.ChatInterface(
166
+ fn=gradio_chat,
167
+ title="AskLaQ Assistant"
168
+ )
169
+
170
+ if __name__ == "__main__":
171
+ print("🚀 Serveur Flask lancé sur http://127.0.0.1:7860")
172
+ app.run(host="0.0.0.0", port=7860, debug=True, use_reloader=False)
dataset_2026.csv ADDED
The diff for this file is too large to render. See raw diff
 
embeddings.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import torch
2
+ import pandas as pd
3
+ from sentence_transformers import SentenceTransformer
4
+
5
+ # ملفات
6
+ CSV_DATA = "dataset_2026.csv"
7
+ EMB_FILE = "embeddings_questions.pt"
8
+
9
+ # ✅ موديل مستقر يدعم العربية/الفرنسية/الإنجليزية
10
+ model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2")
11
+
12
+ print("📥 Chargement du dataset...")
13
+
14
+ try:
15
+ df = pd.read_excel(CSV_DATA, engine="openpyxl")
16
+ except Exception:
17
+ # Fallback to CSV if it's genuinely a CSV
18
+ df = pd.read_csv(CSV_DATA, sep=None, engine="python", encoding="utf-8", on_bad_lines="skip")
19
+
20
+ # ✅ nettoyage colonnes (fix BOM + espaces)
21
+ df.columns = df.columns.str.replace('\ufeff', '', regex=True).str.strip()
22
+
23
+ print("📊 Colonnes détectées :", df.columns.tolist())
24
+
25
+ required_cols = ["Intent", "SubIntent", "Question"]
26
+ for col in required_cols:
27
+ if col not in df.columns:
28
+ raise ValueError(f"❌ Column '{col}' not found. Found: {df.columns}")
29
+
30
+ print("🧠 Construction des phrases enrichies...")
31
+ texts = (
32
+ df["Intent"].astype(str) + " " +
33
+ df["SubIntent"].astype(str) + " " +
34
+ df["Question"].astype(str)
35
+ ).tolist()
36
+
37
+ print(f"✅ {len(texts)} entrées chargées")
38
+ print("🧠 Calcul des embeddings...")
39
+
40
+ # ✅ batching لتفادي مشاكل الذاكرة وتسريع العملية
41
+ embeddings = model.encode(
42
+ texts,
43
+ batch_size=32, # تقدر تنقصها إذا كان RAM ضعيف
44
+ show_progress_bar=True,
45
+ convert_to_tensor=True,
46
+ normalize_embeddings=True
47
+ )
48
+
49
+ print("💾 Sauvegarde des embeddings...")
50
+
51
+ # ✅ حفظ embeddings
52
+ torch.save(embeddings, EMB_FILE)
53
+
54
+ print("✅ Terminé :", embeddings.shape)
embeddings_questions.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f7e519df203613a3e4556e0e9962da294fc835c3ac03058b58977428cf7176b7
3
+ size 1537668
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ flask
2
+ torch
3
+ pandas
4
+ sentence-transformers
5
+ gradio
6
+ fastapi
7
+ uvicorn
8
+ a2wsgi
9
+ nest_asyncio
static/script.js ADDED
@@ -0,0 +1,386 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ document.addEventListener('DOMContentLoaded', () => {
2
+ const input = document.getElementById('question');
3
+ const sendBtn = document.getElementById('sendBtn');
4
+ const chatContainer = document.getElementById('chat');
5
+ const globalIntent = document.getElementById('global-intent');
6
+ const globalConfidence = document.getElementById('global-confidence');
7
+ const inputContainer = document.getElementById('input-container');
8
+
9
+ let isWaiting = false;
10
+ let messageCounter = 0; // To generate unique IDs for maps
11
+
12
+ // Auto-resize textarea
13
+ input.addEventListener('input', function() {
14
+ this.style.height = 'auto';
15
+ this.style.height = (this.scrollHeight) + 'px';
16
+ if (parseFloat(this.style.height) > 200) {
17
+ this.style.overflowY = 'auto';
18
+ } else {
19
+ this.style.overflowY = 'hidden';
20
+ }
21
+
22
+ // Enable/disable send button
23
+ if(this.value.trim().length > 0 && !isWaiting) {
24
+ sendBtn.removeAttribute('disabled');
25
+ } else {
26
+ sendBtn.setAttribute('disabled', 'true');
27
+ }
28
+ });
29
+
30
+ input.addEventListener('focus', () => inputContainer.classList.add('focused'));
31
+ input.addEventListener('blur', () => inputContainer.classList.remove('focused'));
32
+
33
+ input.addEventListener('keydown', (e) => {
34
+ if (e.key === 'Enter' && !e.shiftKey) {
35
+ e.preventDefault();
36
+ sendMessage();
37
+ }
38
+ });
39
+
40
+ sendBtn.addEventListener('click', () => {
41
+ sendMessage();
42
+ });
43
+
44
+ // Handle clicks on recommendation chips
45
+ chatContainer.addEventListener('click', (e) => {
46
+ if (e.target.classList.contains('rec-chip')) {
47
+ const question = e.target.textContent;
48
+ input.value = question;
49
+ // Hack to trigger auto-resize logic before sending
50
+ input.dispatchEvent(new Event('input'));
51
+ sendMessage();
52
+ }
53
+ });
54
+
55
+ async function sendMessage() {
56
+ const text = input.value.trim();
57
+ if (!text || isWaiting) return;
58
+
59
+ // Reset input
60
+ input.value = '';
61
+ input.style.height = 'auto';
62
+ sendBtn.setAttribute('disabled', 'true');
63
+
64
+ // Add User Message
65
+ appendMessage('user', text);
66
+
67
+ // Scroll to bottom
68
+ scrollToBottom();
69
+
70
+ // Show loading indicator
71
+ isWaiting = true;
72
+ const loadingId = appendLoading();
73
+ scrollToBottom();
74
+
75
+ try {
76
+ const response = await fetch('/ask', {
77
+ method: 'POST',
78
+ headers: {
79
+ 'Content-Type': 'application/json',
80
+ },
81
+ body: JSON.stringify({ question: text })
82
+ });
83
+
84
+ if (!response.ok) {
85
+ throw new Error('Erreur de communication avec le serveur');
86
+ }
87
+
88
+ const data = await response.json();
89
+
90
+ // Remove loading
91
+ document.getElementById(loadingId).remove();
92
+
93
+ // Handle Bot Response
94
+ handleBotResponse(data);
95
+
96
+ } catch (error) {
97
+ console.error('Error:', error);
98
+ document.getElementById(loadingId).remove();
99
+ appendMessage('bot', 'Désolé, une erreur est survenue lors de la communication avec le serveur.');
100
+ } finally {
101
+ isWaiting = false;
102
+ if(input.value.trim().length > 0) {
103
+ sendBtn.removeAttribute('disabled');
104
+ }
105
+ scrollToBottom();
106
+ }
107
+ }
108
+
109
+ function appendMessage(role, text) {
110
+ messageCounter++;
111
+ const row = document.createElement('div');
112
+ row.className = `message-row ${role}`;
113
+
114
+ const avatarSvg = role === 'user'
115
+ ? '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>'
116
+ : '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path><path d="M7 10v4"></path><path d="M11 10v4"></path></svg>';
117
+
118
+ // Format basic markdown-like text elements (newlines)
119
+ const formattedText = text.replace(/\n/g, '<br>');
120
+
121
+ row.innerHTML = `
122
+ <div class="message-content">
123
+ <div class="avatar ${role}">${avatarSvg}</div>
124
+ <div class="message-body">
125
+ <p>${formattedText}</p>
126
+ </div>
127
+ </div>
128
+ `;
129
+
130
+ chatContainer.appendChild(row);
131
+ return row;
132
+ }
133
+
134
+ function appendLoading() {
135
+ const id = 'loading-' + Date.now();
136
+ const row = document.createElement('div');
137
+ row.className = 'message-row bot';
138
+ row.id = id;
139
+
140
+ row.innerHTML = `
141
+ <div class="message-content">
142
+ <div class="avatar bot">
143
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path><path d="M7 10v4"></path><path d="M11 10v4"></path></svg>
144
+ </div>
145
+ <div class="message-body">
146
+ <div class="typing-indicator">
147
+ <div class="typing-dot"></div>
148
+ <div class="typing-dot"></div>
149
+ <div class="typing-dot"></div>
150
+ </div>
151
+ </div>
152
+ </div>
153
+ `;
154
+
155
+ chatContainer.appendChild(row);
156
+ return id;
157
+ }
158
+
159
+ function handleBotResponse(data) {
160
+ messageCounter++;
161
+ const row = document.createElement('div');
162
+ row.className = 'message-row bot';
163
+
164
+ const contentDiv = document.createElement('div');
165
+ contentDiv.className = 'message-content';
166
+
167
+ const avatar = document.createElement('div');
168
+ avatar.className = 'avatar bot';
169
+ avatar.innerHTML = '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path><path d="M7 10v4"></path><path d="M11 10v4"></path></svg>';
170
+
171
+ const bodyDiv = document.createElement('div');
172
+ bodyDiv.className = 'message-body';
173
+
174
+ // 1. Add Answer Text
175
+ const textP = document.createElement('p');
176
+ textP.innerHTML = (data.response || '').replace(/\n/g, '<br>');
177
+ bodyDiv.appendChild(textP);
178
+
179
+ // Update header tracking
180
+ if (data.intent) globalIntent.textContent = `Intent: ${data.intent}`;
181
+ if (data.confidence !== undefined) {
182
+ const confVal = typeof data.confidence === 'number' ? data.confidence.toFixed(1) : parseFloat(data.confidence).toFixed(1);
183
+ globalConfidence.textContent = `Confiance: ${confVal}%`;
184
+ }
185
+
186
+ // Rich Content Container
187
+ const richContainer = document.createElement('div');
188
+ richContainer.className = 'rich-content';
189
+
190
+ // 2. Service Card
191
+ const hasService = data.service && data.service !== '';
192
+ const hasLocation = data.lat !== null && data.lon !== null && data.lat !== undefined;
193
+
194
+ if (hasService || hasLocation || (data.link && data.link !== '')) {
195
+ const serviceCard = document.createElement('div');
196
+ serviceCard.className = 'service-card';
197
+
198
+ let btnHtml = '';
199
+ if (data.link && data.link !== '') {
200
+ // Ensure proper link parsing if needed
201
+ btnHtml = `
202
+ <a href="${data.link}" target="_blank" class="btn btn-outline" title="${data.link}">
203
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><polyline points="15 3 21 3 21 9"></polyline><line x1="10" y1="14" x2="21" y2="3"></line></svg>
204
+ Ouvrir le lien
205
+ </a>`;
206
+ }
207
+
208
+ let navBtnHtml = '';
209
+ if (data.lat && data.lon) {
210
+ const mapUrl = `https://www.google.com/maps?q=${data.lat},${data.lon}`;
211
+ navBtnHtml = `
212
+ <a href="${mapUrl}" target="_blank" class="btn btn-nav">
213
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="3 11 22 2 13 21 11 13 3 11"></polygon></svg>
214
+ Y aller
215
+ </a>`;
216
+ }
217
+
218
+ const serviceName = hasService ? data.service : (hasLocation ? "Emplacement trouvé" : "Lien identifié");
219
+
220
+ serviceCard.innerHTML = `
221
+ <div class="service-info">
222
+ <h3>${serviceName}</h3>
223
+ <p>Information identifiée pour cette requête</p>
224
+ </div>
225
+ <div class="service-actions">
226
+ ${btnHtml}
227
+ ${navBtnHtml}
228
+ </div>
229
+ `;
230
+ richContainer.appendChild(serviceCard);
231
+ }
232
+
233
+ // 3. Map Container
234
+ let mapId = null;
235
+ if (data.lat && data.lon) {
236
+ mapId = `map-${messageCounter}`;
237
+ const mapWrapper = document.createElement('div');
238
+ mapWrapper.className = 'map-container';
239
+ mapWrapper.id = mapId;
240
+ richContainer.appendChild(mapWrapper);
241
+ }
242
+
243
+ // 4. Recommendations
244
+ if (data.recs && Array.isArray(data.recs) && data.recs.length > 0) {
245
+ const recsWrapper = document.createElement('div');
246
+ recsWrapper.className = 'recs-container';
247
+ data.recs.forEach(rec => {
248
+ if (rec.trim() !== '') {
249
+ const chip = document.createElement('button');
250
+ chip.className = 'rec-chip';
251
+ chip.textContent = rec;
252
+ recsWrapper.appendChild(chip);
253
+ }
254
+ });
255
+ richContainer.appendChild(recsWrapper);
256
+ }
257
+
258
+ if (richContainer.children.length > 0) {
259
+ bodyDiv.appendChild(richContainer);
260
+ }
261
+
262
+ // Add analytics quietly at bottom of message
263
+ const analyticsP = document.createElement('div');
264
+ analyticsP.className = 'analytics-data';
265
+ analyticsP.innerHTML = `
266
+ <span>
267
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><polyline points="12 16 16 12 12 8"></polyline><line x1="8" y1="12" x2="16" y2="12"></line></svg>
268
+ Intent: ${data.intent || 'N/A'}
269
+ </span>
270
+ <span>
271
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"></circle><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"></path><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>
272
+ Confiance: ${data.confidence !== undefined ? (typeof data.confidence === 'number' ? data.confidence.toFixed(1) : parseFloat(data.confidence).toFixed(1)) : '--'}%
273
+ </span>
274
+ `;
275
+ bodyDiv.appendChild(analyticsP);
276
+
277
+ contentDiv.appendChild(avatar);
278
+ contentDiv.appendChild(bodyDiv);
279
+ row.appendChild(contentDiv);
280
+ chatContainer.appendChild(row);
281
+
282
+
283
+
284
+ // Initialize Map after DOM insertion
285
+ if (mapId) {
286
+ setTimeout(() => {
287
+ initMap(mapId, parseFloat(data.lat), parseFloat(data.lon), data.service || 'Emplacement');
288
+ }, 100);
289
+ }
290
+
291
+ scrollToBottom();
292
+ }
293
+
294
+ function initMap(elementId, lat, lon, title) {
295
+ const map = L.map(elementId).setView([lat, lon], 15);
296
+
297
+ // Use CartoDB Dark Matter tiles to match the ChatGPT dark theme natively
298
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
299
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/">CARTO</a>',
300
+ subdomains: 'abcd',
301
+ maxZoom: 20
302
+ }).addTo(map);
303
+
304
+ const marker = L.marker([lat, lon]).addTo(map);
305
+ marker.bindPopup(`<b>${title}</b>`).openPopup();
306
+
307
+ // Invalidate size to ensure it renders correctly in a dynamic container
308
+ setTimeout(() => map.invalidateSize(), 300);
309
+ }
310
+
311
+
312
+
313
+ function scrollToBottom() {
314
+ chatContainer.scrollTop = chatContainer.scrollHeight;
315
+ }
316
+
317
+ // =====================================
318
+ // GLOBAL MAP MODAL LOGIC
319
+ // =====================================
320
+ const globalMapBtn = document.getElementById('global-map-btn');
321
+ const globalModal = document.getElementById('global-map-modal');
322
+ const closeModal = document.getElementById('close-modal-span');
323
+ let globalLeafletMap = null;
324
+
325
+ if (globalMapBtn) {
326
+ globalMapBtn.addEventListener('click', async () => {
327
+ globalModal.style.display = 'block';
328
+
329
+ // Initialize map only once
330
+ if (!globalLeafletMap) {
331
+ globalLeafletMap = L.map('global-map').setView([34.88, -1.30], 13); // Default view
332
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
333
+ attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
334
+ subdomains: 'abcd',
335
+ maxZoom: 20
336
+ }).addTo(globalLeafletMap);
337
+ }
338
+
339
+ // Must invalidate size since it was display:none
340
+ setTimeout(() => globalLeafletMap.invalidateSize(), 300);
341
+
342
+ // Fetch all services
343
+ try {
344
+ const resp = await fetch('/api/services');
345
+ const data = await resp.json();
346
+
347
+ if (data.status === 'success' && data.services && data.services.length > 0) {
348
+ const bounds = [];
349
+ data.services.forEach(srv => {
350
+ const marker = L.marker([srv.lat, srv.lon]).addTo(globalLeafletMap);
351
+
352
+ // Action button inside popup to search the service directly
353
+ const popupContent = `
354
+ <div style="text-align:center;">
355
+ <b style="display:block;margin-bottom:8px;">${srv.service}</b>
356
+ <button onclick="document.getElementById('question').value='${srv.service.replace(/'/g, "\\'")}'; document.getElementById('sendBtn').click(); document.getElementById('global-map-modal').style.display='none';" style="background:var(--accent-color);color:white;border:none;padding:5px 10px;border-radius:4px;cursor:pointer;">Demander</button>
357
+ </div>
358
+ `;
359
+ marker.bindPopup(popupContent);
360
+ bounds.push([srv.lat, srv.lon]);
361
+ });
362
+
363
+ // Center map on all pins
364
+ if (bounds.length > 0) {
365
+ globalLeafletMap.fitBounds(bounds, {padding: [50, 50]});
366
+ }
367
+ }
368
+ } catch(e) {
369
+ console.error("Error loading global map services", e);
370
+ }
371
+ });
372
+ }
373
+
374
+ if (closeModal) {
375
+ closeModal.addEventListener('click', () => {
376
+ globalModal.style.display = 'none';
377
+ });
378
+ }
379
+
380
+ window.addEventListener('click', (e) => {
381
+ if (e.target == globalModal) {
382
+ globalModal.style.display = 'none';
383
+ }
384
+ });
385
+
386
+ });
templates/index.html ADDED
@@ -0,0 +1,472 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>AskLAQ Assistant</title>
7
+ <!-- Leaflet CSS -->
8
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>>
9
+ <!-- Google Fonts -->
10
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet">
11
+ <!-- Custom CSS -->
12
+ <style>
13
+ :root {
14
+ /* ChatGPT inspired color palette */
15
+ --bg-color: #343541;
16
+ --chat-bg: #444654;
17
+ --text-color: #ECECF1;
18
+ --input-bg: #40414F;
19
+ --border-color: rgba(255,255,255,0.1);
20
+ --accent-color: #10A37F;
21
+ --accent-hover: #1A7F64;
22
+ --bot-icon-bg: #10A37F;
23
+ --user-icon-bg: #5436DA;
24
+ }
25
+
26
+ * {
27
+ box-sizing: border-box;
28
+ margin: 0;
29
+ padding: 0;
30
+ font-family: 'Inter', sans-serif;
31
+ }
32
+
33
+ body {
34
+ background-color: var(--bg-color);
35
+ color: var(--text-color);
36
+ display: flex;
37
+ height: 100vh;
38
+ overflow: hidden;
39
+ flex-direction: column;
40
+ }
41
+
42
+ /* Header */
43
+ header {
44
+ padding: 1rem 2rem;
45
+ border-bottom: 1px solid var(--border-color);
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ background-color: var(--bg-color);
50
+ z-index: 10;
51
+ }
52
+
53
+ header h1 {
54
+ font-size: 1.2rem;
55
+ font-weight: 500;
56
+ display: flex;
57
+ align-items: center;
58
+ gap: 10px;
59
+ }
60
+
61
+ .status-container {
62
+ font-size: 0.85rem;
63
+ color: #ccc;
64
+ display: flex;
65
+ gap: 15px;
66
+ align-items: center;
67
+ }
68
+
69
+ .status-badge {
70
+ background: var(--chat-bg);
71
+ padding: 4px 10px;
72
+ border-radius: 12px;
73
+ border: 1px solid var(--border-color);
74
+ }
75
+
76
+ /* Chat Container */
77
+ .chat-container {
78
+ flex: 1;
79
+ overflow-y: auto;
80
+ scroll-behavior: smooth;
81
+ }
82
+
83
+ .message-row {
84
+ padding: 1.5rem 0;
85
+ border-bottom: 1px solid rgba(0,0,0,0.1);
86
+ }
87
+
88
+ .message-row.bot {
89
+ background-color: var(--chat-bg);
90
+ border-bottom: 1px solid rgba(0,0,0,0.2);
91
+ }
92
+
93
+ .message-content {
94
+ max-width: 800px;
95
+ margin: 0 auto;
96
+ display: flex;
97
+ gap: 1.5rem;
98
+ padding: 0 1rem;
99
+ }
100
+
101
+ .avatar {
102
+ width: 30px;
103
+ height: 30px;
104
+ border-radius: 4px;
105
+ display: flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ font-size: 1rem;
109
+ flex-shrink: 0;
110
+ }
111
+
112
+ .avatar.user { background-color: var(--user-icon-bg); }
113
+ .avatar.bot { background-color: var(--bot-icon-bg); }
114
+
115
+ .message-body {
116
+ flex: 1;
117
+ line-height: 1.6;
118
+ font-size: 1rem;
119
+ overflow-wrap: break-word;
120
+ }
121
+
122
+ /* Rich Content Elements */
123
+ .rich-content {
124
+ margin-top: 1rem;
125
+ display: flex;
126
+ flex-direction: column;
127
+ gap: 1rem;
128
+ }
129
+
130
+ /* Service Card */
131
+ .service-card {
132
+ background-color: var(--bg-color);
133
+ border: 1px solid var(--border-color);
134
+ border-radius: 8px;
135
+ padding: 1.2rem;
136
+ display: flex;
137
+ justify-content: space-between;
138
+ align-items: center;
139
+ flex-wrap: wrap;
140
+ gap: 1rem;
141
+ }
142
+
143
+ .service-info h3 {
144
+ font-size: 1.1rem;
145
+ margin-bottom: 0.3rem;
146
+ font-weight: 600;
147
+ }
148
+
149
+ .service-info p {
150
+ font-size: 0.9rem;
151
+ color: #aaa;
152
+ }
153
+
154
+ .btn {
155
+ background-color: var(--accent-color);
156
+ color: white;
157
+ border: none;
158
+ padding: 0.6rem 1rem;
159
+ border-radius: 6px;
160
+ cursor: pointer;
161
+ font-weight: 500;
162
+ text-decoration: none;
163
+ display: inline-flex;
164
+ align-items: center;
165
+ gap: 8px;
166
+ transition: background 0.2s ease;
167
+ font-size: 0.9rem;
168
+ white-space: nowrap;
169
+ }
170
+
171
+ .btn:hover { background-color: var(--accent-hover); }
172
+
173
+ .btn-outline {
174
+ background-color: transparent;
175
+ border: 1px solid var(--border-color);
176
+ }
177
+ .btn-outline:hover {
178
+ background-color: rgba(255,255,255,0.05);
179
+ }
180
+
181
+ .btn-nav {
182
+ background-color: #2563EB; /* A distinct blue */
183
+ color: white;
184
+ }
185
+ .btn-nav:hover {
186
+ background-color: #1D4ED8;
187
+ }
188
+
189
+ .service-actions {
190
+ display: flex;
191
+ gap: 10px;
192
+ flex-wrap: wrap;
193
+ }
194
+
195
+ /* Map Container */
196
+ .map-container {
197
+ height: 250px;
198
+ width: 100%;
199
+ border-radius: 8px;
200
+ border: 1px solid var(--border-color);
201
+ z-index: 1; /* Keep map behind header/input */
202
+ }
203
+
204
+ /* Recommendations */
205
+ .recs-container {
206
+ display: flex;
207
+ flex-wrap: wrap;
208
+ gap: 10px;
209
+ margin-top: 1rem;
210
+ }
211
+
212
+ .rec-chip {
213
+ background-color: var(--bg-color);
214
+ border: 1px solid var(--border-color);
215
+ color: var(--text-color);
216
+ padding: 0.5rem 1rem;
217
+ border-radius: 20px;
218
+ cursor: pointer;
219
+ font-size: 0.85rem;
220
+ transition: all 0.2s ease;
221
+ }
222
+
223
+ .rec-chip:hover {
224
+ background-color: rgba(255,255,255,0.1);
225
+ border-color: var(--accent-color);
226
+ }
227
+
228
+ /* Analytics Data */
229
+ .analytics-data {
230
+ font-size: 0.85rem;
231
+ color: #888;
232
+ margin-top: 1rem;
233
+ display: flex;
234
+ gap: 15px;
235
+ padding-top: 0.8rem;
236
+ border-top: 1px solid rgba(255, 255, 255, 0.05);
237
+ }
238
+
239
+ .analytics-data span {
240
+ display: flex;
241
+ align-items: center;
242
+ gap: 5px;
243
+ }
244
+
245
+ /* Input Area */
246
+ .input-area {
247
+ padding: 2rem;
248
+ background: linear-gradient(180deg, transparent, var(--bg-color) 20%);
249
+ }
250
+
251
+ .input-form {
252
+ max-width: 800px;
253
+ margin: 0 auto;
254
+ position: relative;
255
+ display: flex;
256
+ align-items: flex-end;
257
+ background-color: var(--input-bg);
258
+ border: 1px solid var(--border-color);
259
+ border-radius: 12px;
260
+ box-shadow: 0 0 15px rgba(0,0,0,0.1);
261
+ padding: 8px;
262
+ transition: border-color 0.2s ease, box-shadow 0.2s ease;
263
+ }
264
+
265
+ .input-form.focused {
266
+ border-color: rgba(255,255,255,0.2);
267
+ box-shadow: 0 0 15px rgba(0,0,0,0.2);
268
+ }
269
+
270
+ .input-form textarea {
271
+ flex: 1;
272
+ background: transparent;
273
+ border: none;
274
+ color: var(--text-color);
275
+ padding: 8px 12px;
276
+ font-size: 1rem;
277
+ resize: none;
278
+ outline: none;
279
+ max-height: 200px;
280
+ overflow-y: auto;
281
+ min-height: 24px;
282
+ line-height: 1.5;
283
+ }
284
+
285
+ .send-btn {
286
+ background-color: var(--accent-color);
287
+ color: white;
288
+ border: none;
289
+ border-radius: 8px;
290
+ width: 32px;
291
+ height: 32px;
292
+ display: flex;
293
+ align-items: center;
294
+ justify-content: center;
295
+ cursor: pointer;
296
+ transition: background 0.2s ease;
297
+ margin-bottom: 2px;
298
+ margin-right: 4px;
299
+ flex-shrink: 0;
300
+ }
301
+
302
+ .send-btn[disabled] {
303
+ background-color: transparent;
304
+ color: #666;
305
+ cursor: not-allowed;
306
+ }
307
+
308
+ .send-btn:not([disabled]):hover {
309
+ background-color: var(--accent-hover);
310
+ }
311
+
312
+ /* Loading Dots */
313
+ .typing-indicator {
314
+ display: inline-flex;
315
+ gap: 4px;
316
+ padding: 4px 0;
317
+ }
318
+
319
+ .typing-dot {
320
+ width: 6px;
321
+ height: 6px;
322
+ background-color: #888;
323
+ border-radius: 50%;
324
+ animation: typing 1.4s infinite ease-in-out both;
325
+ }
326
+
327
+ .typing-dot:nth-child(1) { animation-delay: -0.32s; }
328
+ .typing-dot:nth-child(2) { animation-delay: -0.16s; }
329
+
330
+ @keyframes typing {
331
+ 0%, 80%, 100% { transform: scale(0); }
332
+ 40% { transform: scale(1); }
333
+ }
334
+
335
+ /* Markdown-like formatting helpers */
336
+ .message-body strong { font-weight: 600; color: #fff; }
337
+ .message-body p { margin-bottom: 0.8rem; }
338
+ .message-body p:last-child { margin-bottom: 0; }
339
+ .message-body a { color: var(--accent-color); text-decoration: none; }
340
+ .message-body a:hover { text-decoration: underline; }
341
+
342
+ /* Custom scrollbar */
343
+ ::-webkit-scrollbar { width: 8px; }
344
+ ::-webkit-scrollbar-track { background: transparent; }
345
+ ::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.2); border-radius: 4px; }
346
+ ::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.3); }
347
+
348
+ /* Responsive refinements */
349
+ @media (max-width: 768px) {
350
+ .input-area { padding: 1rem; }
351
+ .message-content { padding: 0 0.5rem; gap: 1rem; }
352
+ .service-card { flex-direction: column; align-items: flex-start; }
353
+ header { padding: 1rem; }
354
+ .status-container { display: none; } /* Hide statuses on very small screens to save space */
355
+ }
356
+
357
+ .survey-link {
358
+ text-align: center;
359
+ margin-top: 12px;
360
+ font-size: 0.8rem;
361
+ color: #888;
362
+ }
363
+
364
+ .survey-link a {
365
+ color: #888;
366
+ text-decoration: none;
367
+ transition: color 0.2s;
368
+ }
369
+
370
+ .survey-link a:hover {
371
+ color: var(--accent-color);
372
+ text-decoration: underline;
373
+ }
374
+
375
+ /* Modal Styles */
376
+ .modal {
377
+ display: none;
378
+ position: fixed;
379
+ z-index: 1000;
380
+ left: 0;
381
+ top: 0;
382
+ width: 100%;
383
+ height: 100%;
384
+ background-color: rgba(0,0,0,0.8);
385
+ }
386
+
387
+ .modal-content {
388
+ background-color: var(--chat-bg);
389
+ margin: 5vh auto;
390
+ padding: 20px;
391
+ border: 1px solid var(--border-color);
392
+ border-radius: 12px;
393
+ width: 90%;
394
+ max-width: 1000px;
395
+ box-shadow: 0 4px 20px rgba(0,0,0,0.5);
396
+ }
397
+
398
+ .modal-content h2 {
399
+ margin-top: 0;
400
+ margin-bottom: 15px;
401
+ font-weight: 500;
402
+ }
403
+
404
+ .close-modal {
405
+ color: #aaa;
406
+ float: right;
407
+ font-size: 28px;
408
+ font-weight: bold;
409
+ cursor: pointer;
410
+ }
411
+
412
+ .close-modal:hover {
413
+ color: white;
414
+ }
415
+ </style>
416
+ </head>
417
+ <body>
418
+
419
+ <header>
420
+ <h1>
421
+ <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path><path d="M21 8a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v2"></path></svg>
422
+ AskLAQ
423
+ </h1>
424
+ <div class="status-container">
425
+ <button id="global-map-btn" class="btn btn-outline" style="padding: 4px 10px; font-size: 0.8rem; margin-right: 10px;">🗺️ Carte des Services</button>
426
+ <div class="status-badge" id="global-intent">Intent: En attente</div>
427
+ <div class="status-badge" id="global-confidence">Confiance: --%</div>
428
+ </div>
429
+ </header>
430
+
431
+ <div class="chat-container" id="chat">
432
+ <!-- Initial Message -->
433
+ <div class="message-row bot">
434
+ <div class="message-content">
435
+ <div class="avatar bot">
436
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"></path><path d="M7 10v4"></path><path d="M11 10v4"></path></svg>
437
+ </div>
438
+ <div class="message-body">
439
+ <p>Bonjour ! Je suis l'assistant <strong>AskLAQ</strong>.</p>
440
+ <p>Je suis prêt à analyser vos questions et à vous orienter vers les meilleurs services. Comment puis-je vous aider aujourd'hui ?</p>
441
+ </div>
442
+ </div>
443
+ </div>
444
+ </div>
445
+
446
+ <div class="input-area">
447
+ <div class="input-form" id="input-container">
448
+ <textarea id="question" rows="1" placeholder="Envoyer un message à AskLAQ..."></textarea>
449
+ <button id="sendBtn" class="send-btn" disabled>
450
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"></line><polygon points="22 2 15 22 11 13 2 9 22 2"></polygon></svg>
451
+ </button>
452
+ </div>
453
+ <div class="survey-link">
454
+ AskLAQ peut faire des erreurs. <a href="https://docs.google.com/forms/d/e/1FAIpQLSeU80CFppkSmMxCB6K2opel8MSc0Qi8QfKvQtXlIXeHfX7_TQ/viewform?fbzx=9108896931053451286" target="_blank">Aidez-nous à l'améliorer avec ce questionnaire de satisfaction</a>.
455
+ </div>
456
+ </div>
457
+
458
+ <!-- Global Map Modal -->
459
+ <div id="global-map-modal" class="modal">
460
+ <div class="modal-content">
461
+ <span class="close-modal" id="close-modal-span">&times;</span>
462
+ <h2>Carte Globale des Services</h2>
463
+ <div id="global-map" style="height: 60vh; width: 100%; border-radius: 8px;"></div>
464
+ </div>
465
+ </div>
466
+
467
+ <!-- Leaflet JS -->
468
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
469
+ <!-- Custom JS -->
470
+ <script src="/static/script.js"></script>
471
+ </body>
472
+ </html>