Mohamed-20 commited on
Commit
1337ed3
·
0 Parent(s):

Deploy backend to HF Spaces

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +14 -0
  2. .gitignore +12 -0
  3. DEPLOY_STEPS.txt +197 -0
  4. Dockerfile +25 -0
  5. README.md +14 -0
  6. Up.txt +1000 -0
  7. backend-rag/faiss_vector_store/index.faiss +0 -0
  8. backend-rag/faiss_vector_store/index.pkl +0 -0
  9. backend-rag/faiss_vector_store_MINNOVA/index.faiss +0 -0
  10. backend-rag/faiss_vector_store_MINNOVA/index.pkl +0 -0
  11. backend-rag/vector_stores/6609ea99cc4c/index.faiss +0 -0
  12. backend-rag/vector_stores/6609ea99cc4c/index.pkl +0 -0
  13. backend-rag/vector_stores/6ae136762cd8/index.faiss +0 -0
  14. backend-rag/vector_stores/6ae136762cd8/index.pkl +0 -0
  15. backend-rag/vector_stores/6be4e86b2c8a/index.faiss +0 -0
  16. backend-rag/vector_stores/6be4e86b2c8a/index.pkl +0 -0
  17. backend-rag/vector_stores/6efe87b2e45a/index.faiss +0 -0
  18. backend-rag/vector_stores/6efe87b2e45a/index.pkl +0 -0
  19. backend-rag/vector_stores/dc3d1c1f39ef/index.faiss +0 -0
  20. backend-rag/vector_stores/dc3d1c1f39ef/index.pkl +0 -0
  21. backend/.env.example +18 -0
  22. backend/.gitignore +10 -0
  23. backend/README.md +44 -0
  24. backend/THIRD_PARTY_NOTES.md +19 -0
  25. backend/alembic.ini +41 -0
  26. backend/alembic/README.md +1 -0
  27. backend/alembic/env.py +46 -0
  28. backend/alembic/script.py.mako +26 -0
  29. backend/alembic/versions/2026_06_03_0001_initial_schema.py +119 -0
  30. backend/backend_chatdoc.py +2453 -0
  31. backend/data/businesses.json +249 -0
  32. backend/data/conversations.json +58 -0
  33. backend/data/documents.json +152 -0
  34. backend/data/inquiries.json +1 -0
  35. backend/data/messages.json +222 -0
  36. backend/data/owners.json +44 -0
  37. backend/requirements.txt +20 -0
  38. backend/scripts/check_biz.py +18 -0
  39. backend/scripts/check_db.py +15 -0
  40. backend/scripts/check_db2.py +10 -0
  41. backend/scripts/check_frontend.py +12 -0
  42. backend/scripts/check_saghro.py +11 -0
  43. backend/scripts/check_social.py +7 -0
  44. backend/scripts/create_flyer.py +194 -0
  45. backend/scripts/gen_seed_json.py +115 -0
  46. backend/scripts/migrate_social.py +17 -0
  47. backend/scripts/seed_saghro.py +227 -0
  48. backend/scripts/seed_social.py +36 -0
  49. backend/scripts/seed_social2.py +35 -0
  50. backend/scripts/test_api.py +35 -0
.dockerignore ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ venv/
2
+ __pycache__/
3
+ *.pyc
4
+ .env
5
+ *.log
6
+ sahel.db
7
+ website/
8
+ dashboard/
9
+ node_modules/
10
+ .git/
11
+ .gitignore
12
+ THIRD_PARTY_NOTES.md
13
+ images/
14
+ tests/
.gitignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .env
4
+ *.log
5
+ venv/
6
+ .venv/
7
+ node_modules/
8
+ dist/
9
+ .pytest_cache/
10
+ .DS_Store
11
+ sahel.db
12
+ *.db
DEPLOY_STEPS.txt ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ═══════════════════════════════════════════════════════════════
2
+ SAHEL.AI — PROCÉDURE DE DÉPLOIEMENT (ZÉRO €, ZÉRO CB)
3
+ ═══════════════════════════════════════════════════════════════
4
+
5
+ ═══════════════════════════════════════════════════════════════
6
+ ÉTAPE 1 : Créer les comptes gratuits
7
+ ═══════════════════════════════════════════════════════════════
8
+
9
+ 1.1 Hugging Face (pour héberger le backend)
10
+ → Aller sur https://huggingface.co/join
11
+ → Créer un compte (email + mot de passe)
12
+ → AUCUNE carte bancaire demandée
13
+
14
+ 1.2 Vercel (pour héberger le frontend)
15
+ → Aller sur https://vercel.com/signup
16
+ → S'inscrire avec GitHub
17
+ → AUCUNE carte bancaire demandée
18
+
19
+ 1.3 Groq (pour l'API IA Llama 3 gratuite)
20
+ → Aller sur https://console.groq.com/keys
21
+ → Créer un compte
22
+ → Générer une API Key (gratuite, 30 requêtes/min)
23
+
24
+
25
+ ═══════════════════════════════════════════════════════════════
26
+ ÉTAPE 2 : Configuration locale (avant de déployer)
27
+ ═══════════════════════════════════════════════════════════════
28
+
29
+ 2.1 Ouvrir le fichier backend/.env
30
+ → REMPLACER la ligne DATABASE_URL par :
31
+ DATABASE_URL=sqlite:///./sahel.db
32
+ → Vérifier que GROQ_API_KEY contient la clé Groq
33
+ → Vérifier que APP_SECRET_KEY contient une clé secrète
34
+ (tu peux en générer une ici : https://generate-secret.vercel.app/)
35
+
36
+ 2.2 Tester que tout marche en local (optionnel mais recommandé) :
37
+ → Ouvrir un terminal dans le dossier backend/
38
+ → Lancer : python -m uvicorn backend_chatdoc:app --port 8000
39
+ → Ouvrir http://localhost:8000/health dans le navigateur
40
+ → Si ça répond {"status":"ok"}, c'est bon
41
+
42
+
43
+ ═══════════════════════════════════════════════════════════════
44
+ ÉTAPE 3 : Déployer le backend sur Hugging Face Spaces
45
+ ═══════════════════════════════════════════════════════════════
46
+
47
+ 3.1 Aller sur https://huggingface.co/new-space
48
+ → Space Name : sahel-api
49
+ → License : MIT
50
+ → SDK : Docker
51
+ → Cliquer "Create Space"
52
+
53
+ 3.2 Aller dans Settings → Repository secrets
54
+ → Ajouter ces 6 secrets (cliquer "New secret" pour chacun) :
55
+
56
+ GROQ_API_KEY = ta_clé_groq
57
+ APP_SECRET_KEY = un_secret_aléatoire
58
+ DATABASE_URL = sqlite:////data/sahel.db
59
+ AUTO_CREATE_TABLES = true
60
+ DEFAULT_BUSINESS_SLUG = sahel
61
+ CORS_ORIGINS = https://sahel-ai.vercel.app
62
+
63
+ 3.3 Pousser le code sur le Space (ouvrir un terminal) :
64
+
65
+ git remote add space https://huggingface.co/spaces/TON_NOM_UTILISATEUR/sahel-api
66
+ git push space main
67
+
68
+ REMPLACER "TON_NOM_UTILISATEUR" par ton nom Hugging Face
69
+
70
+ 3.4 Attendre 2-3 minutes que le build Docker termine
71
+ → Aller sur https://huggingface.co/spaces/TON_NOM_UTILISATEUR/sahel-api
72
+ → Voir les logs dans l'onglet "Builder"
73
+ → Une fois fini, l'URL sera : https://TON_NOM_UTILISATEUR-sahel-api.hf.space
74
+
75
+ 3.5 Tester :
76
+ https://TON_NOM_UTILISATEUR-sahel-api.hf.space/health
77
+ → Doit répondre {"status":"ok"}
78
+
79
+
80
+ ═══════════════════════════════════════════════════════════════
81
+ ÉTAPE 4 : Déployer le frontend sur Vercel
82
+ ═══════════════════════════════════════════════════════════════
83
+
84
+ 4.1 Aller sur https://vercel.com
85
+ → Cliquer "Add New..." → "Project"
86
+ → Importer le dépôt GitHub de Sahel.ai
87
+ → Root Directory : sélectionner website
88
+ → Framework Preset : Vite
89
+ → Cliquer "Deploy"
90
+
91
+ 4.2 Une fois déployé, aller dans Settings → Environment Variables
92
+ → Ajouter :
93
+
94
+ VITE_API_URL = https://TON_NOM_UTILISATEUR-sahel-api.hf.space
95
+ VITE_BUSINESS_SLUG = sahel
96
+
97
+ 4.3 Re-déployer (Deployments → ⋮ → Redeploy)
98
+ → L'URL sera : https://sahel-ai.vercel.app (ou nom-personnalisé)
99
+
100
+
101
+ ═════════��═════════════════════════════════════════════════════
102
+ ÉTAPE 5 : Garder le backend éveillé (optionnel mais recommandé)
103
+ ═══════════════════════════════════════════════════════════════
104
+
105
+ HF Spaces gratuits s'endorment après 48h d'inactivité.
106
+ Pour les réveiller automatiquement :
107
+
108
+ 5.1 Aller sur https://uptimerobot.com
109
+ → Créer un compte gratuit (aucune CB)
110
+ → Cliquer "Add New Monitor"
111
+ → Monitor Type : HTTP(s)
112
+ → URL : https://TON_NOM_UTILISATEUR-sahel-api.hf.space/health
113
+ → Interval : 5 minutes
114
+ → Créer
115
+
116
+ → Le backend restera éveillé gratuitement.
117
+
118
+
119
+ ═══════════════════════════════════════════════════════════════
120
+ ÉTAPE 6 : (OPTIONNEL) Notifications email gratuites
121
+ ═══════════════════════════════════════════════════════════════
122
+
123
+ Quand un client envoie une inquiry, Sahel.ai peut t'envoyer
124
+ un email gratuitement via Gmail.
125
+
126
+ 6.1 Aller sur https://myaccount.google.com/apppasswords
127
+ → Générer un mot de passe d'application
128
+
129
+ 6.2 Aller dans Settings → Repository secrets du Space HF
130
+ → Ajouter :
131
+
132
+ SMTP_HOST = smtp.gmail.com
133
+ SMTP_PORT = 587
134
+ SMTP_USER = ton.email@gmail.com
135
+ SMTP_PASS = le-mot-de-passe-genere
136
+ NOTIFICATION_EMAIL = ton.email@gmail.com (ou autre destinataire)
137
+
138
+
139
+ ═══════════════════════════════════════════════════════════════
140
+ LIENS UTILES
141
+ ═══════════════════════════════════════════════════════════════
142
+
143
+ Backend API → https://TON_USER-sahel-api.hf.space
144
+ Frontend chat → https://sahel-ai.vercel.app
145
+ Mini-site test → https://TON_USER-sahel-api.hf.space/b/sahel
146
+ QR code → https://TON_USER-sahel-api.hf.space/b/sahel/qr
147
+ Script embed → https://TON_USER-sahel-api.hf.space/embed/sahel.js
148
+ Dashboard → https://sahel-ai.vercel.app/#/dashboard
149
+ Login: demo@sahel.ai / demo123
150
+
151
+
152
+ ═══════════════════════════════════════════════════════════════
153
+ ÉTAPE 7 : (OPTIONNEL) Chatbot WhatsApp gratuit
154
+ ═══════════════════════════════════════════════════════════════
155
+
156
+ Un vrai chatbot IA sur WhatsApp qui répond automatiquement
157
+ avec les documents du commerce. Gratuit (sans carte bancaire).
158
+
159
+ 7.1 Aller dans le dossier whatsapp-bot :
160
+ cd whatsapp-bot
161
+
162
+ 7.2 Copier et éditer le fichier .env :
163
+ cp .env.example .env
164
+ → Mettre API_BASE = https://TON_USER-sahel-api.hf.space
165
+
166
+ 7.3 Installer les dépendances (une seule fois) :
167
+ npm install
168
+
169
+ 7.4 Lancer le bot :
170
+ npm start
171
+
172
+ 7.5 Scannez le QR code avec WhatsApp :
173
+ → Menu → Appareils liés → Lier un appareil
174
+ → Scanner le QR code affiché dans le terminal
175
+
176
+ 7.6 Envoyer /start au bot depuis WhatsApp
177
+
178
+ Commandes WhatsApp :
179
+ /start ou /menu → Afficher le menu
180
+ /business <slug> → Changer de commerce
181
+
182
+ 💡 Pour garder le bot allumé 24/7 gratuitement :
183
+ • Railway.app (500h/mois gratuites)
184
+ • Fly.io (always-on gratuit)
185
+ Voir whatsapp-bot/README.md pour les détails.
186
+
187
+
188
+ ═══════════════════════════════════════════════════════════════
189
+ PROCHAINES ÉTAPES (développement)
190
+ ═══════════════════════════════════════════════════════════════
191
+
192
+ Une fois déployé, on peut continuer sur :
193
+ → Ajouter de vraies données (commerces marocains avec PDF)
194
+ → Améliorer le CSS du mini-site
195
+ → Ajouter un vrai flow d'inscription commerçant
196
+
197
+ ═══════════════════════════════════════════════════════════════
Dockerfile ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.12-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && apt-get install -y --no-install-recommends \
6
+ build-essential && \
7
+ rm -rf /var/lib/apt/lists/*
8
+
9
+ COPY backend/requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY backend/ .
13
+
14
+ RUN mkdir -p /data /data/vector_stores /data/uploaded_documents /data/data
15
+
16
+ # Copy seed data to where DATA_ROOT expects it
17
+ COPY backend/data/ /data/data/
18
+
19
+ ENV DATA_ROOT=/data
20
+ ENV AUTO_CREATE_TABLES=true
21
+ ENV CORS_ORIGINS=https://sahel-ai.vercel.app,http://localhost:5173
22
+
23
+ EXPOSE 7860
24
+
25
+ CMD ["uvicorn", "backend_chatdoc:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Sahel.ai RAG API
3
+ emoji: 🏪
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Sahel.ai Backend
13
+
14
+ RAG chat API for Moroccan businesses.
Up.txt ADDED
@@ -0,0 +1,1000 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+
3
+ <html class="light" lang="en"><head>
4
+ <html class="light" lang="fr"><head>
5
+ <meta charset="utf-8"/>
6
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
7
+ <title>Site Settings | Sahel.ai</title>
8
+ <title>{{ business.name }} | {{ t('publicBusiness.gastronomy') }}</title>
9
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
10
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&amp;family=Plus+Jakarta+Sans:wght@300;400;500;600;700;800&amp;display=swap" rel="stylesheet"/>
11
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
12
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&amp;family=Plus+Jakarta+Sans:wght@200..800&amp;family=Inter:wght@300;400;500;600&amp;display=swap" rel="stylesheet"/>
13
+ <style>
14
+ .material-symbols-outlined {
15
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
16
+ }
17
+ .glass-card {
18
+ background: rgba(255, 255, 255, 0.8);
19
+ backdrop-filter: blur(8px);
20
+ -webkit-backdrop-filter: blur(8px);
21
+ }
22
+ .hairline {
23
+ border-width: 0.5px;
24
+ }
25
+ </style>
26
+ .material-symbols-outlined { font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24; }
27
+ .hero-gradient { background: linear-gradient(to top, rgba(13, 17, 23, 0.95) 0%, rgba(13, 17, 23, 0.4) 50%, rgba(13, 17, 23, 0.1) 100%); }
28
+ .reveal { animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
29
+ @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } }
30
+ </style>
31
+ <script id="tailwind-config">
32
+ tailwind.config = {
33
+ darkMode: "class",
34
+ theme: {
35
+ tailwind.config = {
36
+ theme: {
37
+ extend: {
38
+ "colors": {
39
+ "surface-variant": "#e6e1e1",
40
+ "surface-container-high": "#ece7e7",
41
+ "warm-bg": "#F1EFE8",
42
+ "tertiary": "#00457f",
43
+ "tertiary-fixed-dim": "#a4c9ff",
44
+ "on-tertiary-fixed-variant": "#004884",
45
+ "tertiary-container": "#145da3",
46
+ "on-tertiary-container": "#bcd6ff",
47
+ "on-secondary-container": "#0b7052",
48
+ "on-primary-fixed": "#001c38",
49
+ "secondary": "#006c4e",
50
+ "surface-container-low": "#f7f2f2",
51
+ "error-container": "#ffdad6",
52
+ "on-secondary-fixed-variant": "#00513a",
53
+ "on-error": "#ffffff",
54
+ "on-secondary-fixed": "#002115",
55
+ "tertiary-fixed": "#d4e3ff",
56
+ "hairline-border": "rgba(0, 0, 0, 0.08)",
57
+ "secondary-fixed-dim": "#81d7b2",
58
+ "outline": "#717782",
59
+ "on-primary-fixed-variant": "#004880",
60
+ "surface-blue": "#E6F1FB",
61
+ "on-secondary": "#ffffff",
62
+ "on-primary-container": "#bbd7ff",
63
+ "primary": "#00467c",
64
+ "surface-dim": "#ddd9d8",
65
+ "background": "#fdf8f8",
66
+ "surface-bright": "#fdf8f8",
67
+ "surface-container-highest": "#e6e1e1",
68
+ "on-surface": "#1c1b1b",
69
+ "secondary-fixed": "#9df4ce",
70
+ "primary-container": "#005ea4",
71
+ "secondary-container": "#9af1cb",
72
+ "inverse-surface": "#313030",
73
+ "on-primary": "#ffffff",
74
+ "primary-fixed-dim": "#a1c9ff",
75
+ "on-tertiary": "#ffffff",
76
+ "inverse-primary": "#a1c9ff",
77
+ "on-tertiary-fixed": "#001c39",
78
+ "inverse-on-surface": "#f4f0ef",
79
+ "on-error-container": "#93000a",
80
+ "on-surface-variant": "#414751",
81
+ "primary-fixed": "#d3e4ff",
82
+ "outline-variant": "#c1c7d2",
83
+ "on-background": "#1c1b1b",
84
+ "surface": "#fdf8f8",
85
+ "surface-container": "#f1edec",
86
+ "surface-tint": "#0760a6",
87
+ "deep-navy": "#0D1117",
88
+ "surface-container-lowest": "#ffffff",
89
+ "error": "#ba1a1a"
90
+ },
91
+ "borderRadius": {
92
+ "DEFAULT": "0.25rem",
93
+ "lg": "0.5rem",
94
+ "xl": "0.75rem",
95
+ "full": "9999px"
96
+ },
97
+ "spacing": {
98
+ "gutter": "24px",
99
+ "xl": "80px",
100
+ "margin": "32px",
101
+ "md": "24px",
102
+ "lg": "48px",
103
+ "base": "4px",
104
+ "sm": "16px",
105
+ "xs": "8px"
106
+ },
107
+ "fontFamily": {
108
+ "display-lg": ["Newsreader"],
109
+ "headline-md": ["Newsreader"],
110
+ "headline-sm": ["Newsreader"],
111
+ "body-md": ["Plus Jakarta Sans"],
112
+ "body-lg": ["Plus Jakarta Sans"],
113
+ "label-md": ["Plus Jakarta Sans"],
114
+ "label-sm": ["Plus Jakarta Sans"]
115
+ }
116
+ colors: { "deep-navy": "#0D1117", "primary": "#00467c", "warm-bg": "#F1EFE8" },
117
+ fontFamily: { "headline-md": ["Newsreader", "serif"], "body-md": ["Inter", "sans-serif"] }
118
+ }
119
+ }
120
+ }
121
+ </script>
122
+ <style>
123
+ body {
124
+ min-height: max(884px, 100dvh);
125
+ }
126
+ </style>
127
+ </head>
128
+ <body class="bg-background font-body-md text-on-background min-h-screen">
129
+ <!-- Top Navigation Bar (Shared Component Strategy) -->
130
+ <header class="fixed top-0 w-full z-50 bg-background/80 backdrop-blur-md border-b border-hairline-border h-16">
131
+ <div class="max-w-7xl mx-auto px-gutter flex justify-between items-center h-full">
132
+ <div class="flex items-center gap-4">
133
+ <button class="p-2 hover:bg-surface-container-low rounded-full transition-all active:scale-95">
134
+ <span class="material-symbols-outlined text-on-surface-variant">arrow_back</span>
135
+ </script>
136
+ </head>
137
+ <body class="bg-background text-on-background font-body-md overflow-x-hidden">
138
+ <header class="bg-white/70 backdrop-blur-xl border-b fixed top-0 w-full z-50">
139
+ <div class="flex justify-between items-center px-8 py-4 max-w-7xl mx-auto w-full">
140
+ <div class="flex items-center gap-2">
141
+ <span class="material-symbols-outlined text-primary text-2xl">hub</span>
142
+ <span class="font-bold text-deep-navy">Sahel<span class="text-primary">.ai</span></span>
143
+ </div>
144
+ <button class="bg-deep-navy text-white px-6 py-2 rounded-full text-sm">{{ t('nav.register') }}</button>
145
+ </div>
146
+ </header>
147
+
148
+ <main class="max-w-7xl mx-auto">
149
+ <section class="relative h-screen min-h-[700px] flex flex-col justify-end p-8 overflow-hidden">
150
+ <img class="absolute inset-0 w-full h-full object-cover z-0" src="{{ business.cover_image }}"/>
151
+ <div class="absolute inset-0 hero-gradient z-10"></div>
152
+ <div class="relative z-20 max-w-2xl mb-20 reveal">
153
+ <div class="flex items-center gap-3 mb-6">
154
+ <span class="bg-white/10 backdrop-blur-md text-white border px-4 py-1 rounded-full text-[10px] uppercase tracking-[0.2em]">{{ t('publicBusiness.category.gastronomy') }}</span>
155
+ <span class="text-white/80 text-xs">{{ business.city }}, {{ t('language.maroc') }}</span>
156
+ </div>
157
+ <h1 class="text-6xl md:text-8xl text-white italic leading-[0.9] mb-8">{{ business.name }}</h1>
158
+ <p class="text-white/80 text-lg md:text-xl leading-relaxed max-w-lg mb-10 font-light">{{ t('publicBusiness.hero.description') }}</p>
159
+ <button class="bg-white text-deep-navy px-8 py-4 rounded-full hover:bg-primary hover:text-white transition-all">{{ t('publicBusiness.bookingCta') }}</button>
160
+ </div>
161
+ </section>
162
+
163
+ <section class="px-8 py-20 space-y-8">
164
+ <div class="grid grid-cols-12 gap-6">
165
+ <div class="col-span-12 lg:col-span-8 group relative overflow-hidden rounded-3xl min-h-[450px] flex flex-col justify-end p-8 reveal">
166
+ <img src="{{ business.feature_image }}" class="absolute inset-0 w-full h-full object-cover"/>
167
+ <div class="absolute inset-0 bg-gradient-to-t from-black/80 to-transparent"></div>
168
+ <div class="relative z-10 text-white max-w-md">
169
+ <h3 class="text-4xl mb-4 italic">{{ t('publicBusiness.feature.terrace') }}</h3>
170
+ <p class="text-white/70">{{ t('publicBusiness.feature.terraceDesc') }}</p>
171
+ </div>
172
+ </div>
173
+ <div class="col-span-12 lg:col-span-4 flex flex-col gap-6">
174
+ <div class="bg-warm-bg rounded-3xl p-8 border reveal">
175
+ <h3 class="text-2xl text-deep-navy mb-3">{{ t('publicBusiness.feature.heritage') }}</h3>
176
+ <p class="text-gray-600">{{ t('publicBusiness.feature.heritageDesc') }}</p>
177
+ </div>
178
+ <div class="bg-blue-50 rounded-3xl p-8 border reveal">
179
+ <h3 class="text-2xl text-deep-navy mb-3">{{ t('publicBusiness.feature.terroir') }}</h3>
180
+ <p class="text-gray-600">{{ t('publicBusiness.feature.terroirDesc') }}</p>
181
+ </div>
182
+ </div>
183
+ </div>
184
+ </section>
185
+
186
+ <!-- Contact Section -->
187
+ <section id="contact" class="px-8 py-20 bg-white">
188
+ <div class="max-w-3xl mx-auto bg-warm-bg rounded-3xl p-10 shadow-xl reveal">
189
+ <h2 class="text-3xl font-headline-md mb-6 italic text-deep-navy">{{ t('contact.title') }}</h2>
190
+ <form action="mailto:{{ t('publicBusiness.contactEmail') }}" method="post" enctype="text/plain" class="space-y-4">
191
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
192
+ <input type="text" name="name" placeholder="{{ t('contact.fullName') }}" class="w-full p-4 rounded-xl border-none focus:ring-2 focus:ring-primary" required>
193
+ <input type="email" name="email" placeholder="{{ t('contact.email') }}" class="w-full p-4 rounded-xl border-none focus:ring-2 focus:ring-primary" required>
194
+ </div>
195
+ <textarea name="message" placeholder="{{ t('contact.message') }}" class="w-full p-4 rounded-xl border-none focus:ring-2 focus:ring-primary h-32" required></textarea>
196
+ <button type="submit" class="bg-primary text-white px-8 py-4 rounded-full w-full font-bold hover:opacity-90 transition-all">{{ t('contact.submit') }}</button>
197
+ </form>
198
+ </div>
199
+ </section>
200
+
201
+ <!-- Social Media Section -->
202
+ <section class="px-8 py-20 text-center">
203
+ <h2 class="text-4xl mb-12 italic text-deep-navy">{{ t('publicBusiness.followUs') }}</h2>
204
+ <div class="flex justify-center gap-8">
205
+ <a href="https://instagram.com/{{ business.slug }}" class="group flex flex-col items-center gap-2">
206
+ <div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-primary group-hover:text-white transition-all">
207
+ <span class="material-symbols-outlined text-3xl">photo_camera</span>
208
+ </div>
209
+ <span class="text-xs uppercase tracking-widest">{{ t('publicBusiness.instagram') }}</span>
210
+ </a>
211
+ <a href="https://facebook.com/{{ business.slug }}" class="group flex flex-col items-center gap-2">
212
+ <div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-blue-600 group-hover:text-white transition-all">
213
+ <span class="material-symbols-outlined text-3xl">facebook</span>
214
+ </div>
215
+ <span class="text-xs uppercase tracking-widest">{{ t('publicBusiness.facebook') }}</span>
216
+ </a>
217
+ <a href="https://tiktok.com/@{{ business.slug }}" class="group flex flex-col items-center gap-2">
218
+ <div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-black group-hover:text-white transition-all">
219
+ <span class="material-symbols-outlined text-3xl">movie</span>
220
+ </div>
221
+ <span class="text-xs uppercase tracking-widest">{{ t('publicBusiness.tiktok') }}</span>
222
+ </a>
223
+ <a href="https://wa.me/{{ business.phone }}" class="group flex flex-col items-center gap-2">
224
+ <div class="w-16 h-16 rounded-full bg-gray-100 flex items-center justify-center group-hover:bg-green-500 group-hover:text-white transition-all">
225
+ <span class="material-symbols-outlined text-3xl">chat</span>
226
+ </div>
227
+ <span class="text-xs uppercase tracking-widest">{{ t('publicBusiness.whatsapp') }}</span>
228
+ </a>
229
+ </div>
230
+ </section>
231
+ </main>
232
+
233
+ <footer class="bg-deep-navy py-20 text-white/40">
234
+ <div class="max-w-7xl mx-auto px-8 flex flex-col md:flex-row justify-between items-center gap-8 border-t border-white/10 pt-10">
235
+ <div class="flex items-center gap-2">
236
+ <span class="material-symbols-outlined text-white">hub</span>
237
+ <span class="font-bold text-white">Sahel<span class="text-primary">.ai</span></span>
238
+ </div>
239
+ <p class="text-sm">© 2026 {{ business.name }} — {{ t('footer.poweredBy') }}</p>
240
+ <div class="flex gap-6 text-xs uppercase">
241
+ <a href="#" class="hover:text-white">{{ t('footer.privacy') }}</a>
242
+ <a href="#" class="hover:text-white">{{ t('footer.terms') }}</a>
243
+ </div>
244
+ </div>
245
+ </footer>
246
+
247
+ <script>
248
+ const observer = new IntersectionObserver((entries) => {
249
+ entries.forEach(entry => {
250
+ if (entry.isIntersecting) entry.target.classList.add('reveal');
251
+ });
252
+ }, { threshold: 0.1 });
253
+ document.querySelectorAll('.reveal').forEach(el => observer.observe(el));
254
+ </script>
255
+ </body></html>
256
+ </button>
257
+ <div class="flex flex-col">
258
+ <span class="font-headline-sm text-headline-sm text-on-surface">Site Settings</span>
259
+ </div>
260
+ </div>
261
+ <div class="flex items-center gap-4">
262
+ <span class="hidden md:block font-label-md text-label-md text-on-surface-variant">Last saved: 2 mins ago</span>
263
+ <button class="bg-primary text-on-primary font-label-md text-label-md px-6 py-2 rounded-xl hover:opacity-90 active:scale-95 transition-all shadow-lg shadow-primary/10">
264
+ Save Changes
265
+ </button>
266
+ </div>
267
+ </div>
268
+ </header>
269
+ <main class="pt-24 pb-xl px-gutter max-w-7xl mx-auto grid grid-cols-1 md:grid-cols-12 gap-lg">
270
+ <!-- Sidebar Navigation (Drawer Simulation) -->
271
+ <aside class="md:col-span-3 space-y-2 hidden md:block">
272
+ <nav class="space-y-1">
273
+ <a class="bg-surface-blue text-primary border-l-2 border-primary py-3 px-4 flex items-center gap-3 font-label-md text-label-md" href="#">
274
+ <span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">settings</span>
275
+ General Info
276
+ </a>
277
+ <a class="text-on-surface-variant py-3 px-4 flex items-center gap-3 font-label-md text-label-md hover:bg-surface-container-low transition-colors" href="#">
278
+ <span class="material-symbols-outlined">share</span>
279
+ Social Presence
280
+ </a>
281
+ <a class="text-on-surface-variant py-3 px-4 flex items-center gap-3 font-label-md text-label-md hover:bg-surface-container-low transition-colors" href="#">
282
+ <span class="material-symbols-outlined">schedule</span>
283
+ Operating Hours
284
+ </a>
285
+ <a class="text-on-surface-variant py-3 px-4 flex items-center gap-3 font-label-md text-label-md hover:bg-surface-container-low transition-colors" href="#">
286
+ <span class="material-symbols-outlined">psychology</span>
287
+ Knowledge Base
288
+ </a>
289
+ </nav>
290
+ </aside>
291
+ <!-- Main Settings Content -->
292
+ <div class="md:col-span-9 space-y-lg">
293
+ <!-- Live Preview Card -->
294
+ <section class="glass-card hairline rounded-xl p-md flex flex-col md:flex-row md:items-center justify-between gap-md border-primary/20 bg-gradient-to-r from-surface-blue/50 to-background">
295
+ <div class="flex gap-4 items-center">
296
+ <div class="w-12 h-12 rounded-lg bg-primary/10 flex items-center justify-center text-primary">
297
+ <span class="material-symbols-outlined">language</span>
298
+ </div>
299
+ <div>
300
+ <h3 class="font-label-md text-label-md text-on-surface">Live Mini-Site</h3>
301
+ <p class="text-primary font-mono text-body-md">sahel.ai/biz/riad-shams-marrakech</p>
302
+ </div>
303
+ </div>
304
+ <div class="flex gap-2">
305
+ <button class="bg-white hairline text-on-surface font-label-md text-label-md px-4 py-2 rounded-lg hover:bg-surface-container-low transition-all flex items-center gap-2">
306
+ <span class="material-symbols-outlined text-[18px]">content_copy</span>
307
+ Copy Link
308
+ </button>
309
+ <button class="bg-primary/5 text-primary font-label-md text-label-md px-4 py-2 rounded-lg hover:bg-primary/10 transition-all flex items-center gap-2">
310
+ <span class="material-symbols-outlined text-[18px]">open_in_new</span>
311
+ View Site
312
+ </button>
313
+ </div>
314
+ </section>
315
+ <!-- General Information -->
316
+ <section class="space-y-sm">
317
+ <h2 class="font-headline-md text-headline-md text-on-surface border-b border-hairline-border pb-2">General Information</h2>
318
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-md">
319
+ <div class="space-y-base">
320
+ <label class="font-label-md text-label-md text-on-surface-variant">Business Name</label>
321
+ <input class="w-full bg-white hairline rounded-lg px-4 py-3 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all" type="text" value="Riad Shams Marrakech"/>
322
+ </div>
323
+ <div class="space-y-base">
324
+ <label class="font-label-md text-label-md text-on-surface-variant">Category</label>
325
+ <select class="w-full bg-white hairline rounded-lg px-4 py-3 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all appearance-none">
326
+ <option>Hospitality &amp; Tourism</option>
327
+ <option>Food &amp; Beverage</option>
328
+ <option>Artisan &amp; Craft</option>
329
+ </select>
330
+ </div>
331
+ <div class="md:col-span-2 space-y-base">
332
+ <label class="font-label-md text-label-md text-on-surface-variant">About Your Business</label>
333
+ <textarea class="w-full bg-white hairline rounded-lg px-4 py-3 focus:ring-2 focus:ring-primary/20 focus:border-primary outline-none transition-all" rows="4">Located in the heart of the Medina, Riad Shams offers an authentic Moroccan experience blended with modern luxury. Our kitchen serves traditional home-cooked tagines daily.</textarea>
334
+ </div>
335
+ </div>
336
+ </section>
337
+ <!-- Social Presence -->
338
+ <section class="space-y-sm">
339
+ <h2 class="font-headline-md text-headline-md text-on-surface border-b border-hairline-border pb-2">Social Presence</h2>
340
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-md">
341
+ <div class="flex items-center bg-white hairline rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-primary/20 transition-all">
342
+ <div class="w-12 h-12 flex items-center justify-center border-r border-hairline-border bg-surface-container-low">
343
+ <span class="material-symbols-outlined text-secondary" style="font-variation-settings: 'FILL' 1;">chat</span>
344
+ </div>
345
+ <input class="flex-1 px-4 py-3 outline-none border-none" placeholder="WhatsApp Number" type="text" value="+212 661-234567"/>
346
+ </div>
347
+ <div class="flex items-center bg-white hairline rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-primary/20 transition-all">
348
+ <div class="w-12 h-12 flex items-center justify-center border-r border-hairline-border bg-surface-container-low">
349
+ <span class="material-symbols-outlined text-[#E1306C]" style="font-variation-settings: 'FILL' 1;">photo_camera</span>
350
+ </div>
351
+ <input class="flex-1 px-4 py-3 outline-none border-none" placeholder="Instagram Username" type="text" value="@riadshams_marrakech"/>
352
+ </div>
353
+ <div class="flex items-center bg-white hairline rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-primary/20 transition-all">
354
+ <div class="w-12 h-12 flex items-center justify-center border-r border-hairline-border bg-surface-container-low">
355
+ <span class="material-symbols-outlined text-[#1877F2]" style="font-variation-settings: 'FILL' 1;">face_nod</span>
356
+ </div>
357
+ <input class="flex-1 px-4 py-3 outline-none border-none" placeholder="Facebook Page URL" type="text"/>
358
+ </div>
359
+ <div class="flex items-center bg-white hairline rounded-lg overflow-hidden focus-within:ring-2 focus-within:ring-primary/20 transition-all">
360
+ <div class="w-12 h-12 flex items-center justify-center border-r border-hairline-border bg-surface-container-low">
361
+ <span class="material-symbols-outlined text-black" style="font-variation-settings: 'FILL' 1;">music_note</span>
362
+ </div>
363
+ <input class="flex-1 px-4 py-3 outline-none border-none" placeholder="TikTok Username" type="text"/>
364
+ </div>
365
+ </div>
366
+ </section>
367
+ <!-- Operating Hours -->
368
+ <section class="space-y-sm">
369
+ <h2 class="font-headline-md text-headline-md text-on-surface border-b border-hairline-border pb-2">Operating Hours</h2>
370
+ <div class="bg-white hairline rounded-xl divide-y divide-hairline-border">
371
+ <div class="p-md flex flex-wrap items-center justify-between gap-4">
372
+ <div class="flex items-center gap-4 min-w-[120px]">
373
+ <input checked="" class="w-5 h-5 rounded text-primary focus:ring-primary" type="checkbox"/>
374
+ <span class="font-label-md text-label-md">Monday - Friday</span>
375
+ </div>
376
+ <div class="flex items-center gap-2">
377
+ <input class="hairline rounded-lg px-2 py-1 text-body-md outline-none" type="time" value="09:00"/>
378
+ <span class="text-on-surface-variant">—</span>
379
+ <input class="hairline rounded-lg px-2 py-1 text-body-md outline-none" type="time" value="22:00"/>
380
+ </div>
381
+ </div>
382
+ <div class="p-md flex flex-wrap items-center justify-between gap-4">
383
+ <div class="flex items-center gap-4 min-w-[120px]">
384
+ <input checked="" class="w-5 h-5 rounded text-primary focus:ring-primary" type="checkbox"/>
385
+ <span class="font-label-md text-label-md">Saturday</span>
386
+ </div>
387
+ <div class="flex items-center gap-2">
388
+ <input class="hairline rounded-lg px-2 py-1 text-body-md outline-none" type="time" value="10:00"/>
389
+ <span class="text-on-surface-variant">—</span>
390
+ <input class="hairline rounded-lg px-2 py-1 text-body-md outline-none" type="time" value="23:00"/>
391
+ </div>
392
+ </div>
393
+ <div class="p-md flex flex-wrap items-center justify-between gap-4">
394
+ <div class="flex items-center gap-4 min-w-[120px]">
395
+ <input class="w-5 h-5 rounded text-primary focus:ring-primary" type="checkbox"/>
396
+ <span class="font-label-md text-label-md text-outline">Sunday</span>
397
+ </div>
398
+ <span class="text-on-surface-variant italic text-body-md">Closed</span>
399
+ </div>
400
+ </div>
401
+ </section>
402
+ <!-- AI Knowledge Base -->
403
+ <section class="space-y-sm">
404
+ <h2 class="font-headline-md text-headline-md text-on-surface border-b border-hairline-border pb-2">AI Knowledge Base</h2>
405
+ <div class="bg-surface-container-low hairline rounded-xl p-md flex flex-col md:flex-row items-center justify-between gap-md">
406
+ <div class="flex items-center gap-4">
407
+ <div class="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center text-primary">
408
+ <span class="material-symbols-outlined">description</span>
409
+ </div>
410
+ <div>
411
+ <p class="font-label-md text-label-md text-on-surface">menu_v2.pdf</p>
412
+ <div class="flex items-center gap-2">
413
+ <span class="w-2 h-2 rounded-full bg-secondary"></span>
414
+ <p class="text-caption text-secondary">Knowledge base is up to date</p>
415
+ </div>
416
+ </div>
417
+ </div>
418
+ <button class="bg-white hairline text-on-surface font-label-md text-label-md px-4 py-2 rounded-lg hover:bg-surface-container-high transition-all flex items-center gap-2 active:scale-95">
419
+ <span class="material-symbols-outlined text-[18px]">upload</span>
420
+ Update Document
421
+ </button>
422
+ </div>
423
+ <p class="text-caption text-on-surface-variant">The Sahel AI uses this document to answer customer queries automatically. Upload menus, service lists, or FAQs.</p>
424
+ </section>
425
+ <!-- Danger Zone -->
426
+ <section class="mt-xl pt-lg border-t border-hairline-border">
427
+ <div class="p-md rounded-xl bg-error-container/10 border border-error/20">
428
+ <div class="flex flex-col md:flex-row md:items-center justify-between gap-md">
429
+ <div>
430
+ <h3 class="font-label-md text-label-md text-error">Danger Zone</h3>
431
+ <p class="text-body-md text-on-surface-variant">Permanently delete your business mini-site and all associated training data.</p>
432
+ </div>
433
+ <button class="border border-error text-error hover:bg-error hover:text-white font-label-md text-label-md px-6 py-2 rounded-lg transition-all duration-300">
434
+ Delete Site
435
+ </button>
436
+ </div>
437
+ </div>
438
+ </section>
439
+ </div>
440
+ </main>
441
+ <!-- Bottom Nav (Mobile Only) -->
442
+ <nav class="md:hidden fixed bottom-0 left-0 w-full bg-white/90 backdrop-blur-md border-t border-hairline-border flex justify-around items-center h-16 px-4 z-50">
443
+ <button class="flex flex-col items-center text-primary pt-2">
444
+ <span class="material-symbols-outlined" style="font-variation-settings: 'FILL' 1;">settings</span>
445
+ <span class="font-label-sm text-label-sm">General</span>
446
+ </button>
447
+ <button class="flex flex-col items-center text-on-surface-variant pt-2">
448
+ <span class="material-symbols-outlined">share</span>
449
+ <span class="font-label-sm text-label-sm">Social</span>
450
+ </button>
451
+ <button class="flex flex-col items-center text-on-surface-variant pt-2">
452
+ <span class="material-symbols-outlined">schedule</span>
453
+ <span class="font-label-sm text-label-sm">Hours</span>
454
+ </button>
455
+ <button class="flex flex-col items-center text-on-surface-variant pt-2">
456
+ <span class="material-symbols-outlined">psychology</span>
457
+ <span class="font-label-sm text-label-sm">AI</span>
458
+ </button>
459
+ </nav>
460
+ <!-- Footer (Shared Component) -->
461
+ <footer class="bg-surface border-t border-hairline-border mt-xl">
462
+ <div class="grid grid-cols-2 md:grid-cols-4 gap-gutter px-gutter py-lg max-w-7xl mx-auto">
463
+ <div class="col-span-2 md:col-span-1">
464
+ <span class="font-headline-sm italic text-on-surface">Sahel.ai</span>
465
+ <p class="mt-4 font-body-md text-body-md text-on-surface-variant">Empowering Moroccan SMEs with premium digital solutions.</p>
466
+ </div>
467
+ <div class="flex flex-col gap-2">
468
+ <span class="font-label-md text-label-md text-on-surface">Product</span>
469
+ <a class="font-label-sm text-label-sm text-on-surface-variant hover:text-primary underline" href="#">Features</a>
470
+ <a class="font-label-sm text-label-sm text-on-surface-variant hover:text-primary underline" href="#">Pricing</a>
471
+ </div>
472
+ <div class="flex flex-col gap-2">
473
+ <span class="font-label-md text-label-md text-on-surface">Support</span>
474
+ <a class="font-label-sm text-label-sm text-on-surface-variant hover:text-primary underline" href="#">Help Center</a>
475
+ <a class="font-label-sm text-label-sm text-on-surface-variant hover:text-primary underline" href="#">Contact Us</a>
476
+ </div>
477
+ <div class="col-span-2 md:col-span-4 mt-lg text-center font-label-sm text-label-sm text-on-surface-variant">
478
+ © 2026 Sahel.ai. Powered by Minnova.
479
+ </div>
480
+ </div>
481
+ </footer>
482
+ <script>
483
+ // Simple micro-interaction for input fields
484
+ document.querySelectorAll('input, textarea, select').forEach(el => {
485
+ el.addEventListener('focus', () => {
486
+ el.parentElement.classList.add('scale-[1.01]');
487
+ });
488
+ el.addEventListener('blur', () => {
489
+ el.parentElement.classList.remove('scale-[1.01]');
490
+ });
491
+ });
492
+
493
+ // Toggle state for operating hours
494
+ document.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
495
+ checkbox.addEventListener('change', (e) => {
496
+ const parent = e.target.closest('.p-md');
497
+ if(!e.target.checked) {
498
+ parent.classList.add('opacity-50');
499
+ } else {
500
+ parent.classList.remove('opacity-50');
501
+ }
502
+ });
503
+ });
504
+ </script>
505
+ </body></html>
506
+
507
+ whaat is above is the page of modifiyi,g info of the mini website ,it is there for inspo ; also what ever you add it should be functionnal not just uselles ui
508
+ (we have added the place for social media that you should too , and also in the other page where wwe creat a full ai businnes with the three steps page it should include a place for social media as well )
509
+
510
+
511
+ <!DOCTYPE html>
512
+
513
+ <html class="light" lang="fr"><head>
514
+ <meta charset="utf-8"/>
515
+ <meta content="width=device-width, initial-scale=1.0" name="viewport"/>
516
+ <title>Restaurant Amoudou | Gastronomie d'Exception à Marrakech</title>
517
+ <script src="https://cdn.tailwindcss.com?plugins=forms,container-queries"></script>
518
+ <link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&amp;display=swap" rel="stylesheet"/>
519
+ <link href="https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,200..800;1,6..72,200..800&amp;family=Plus+Jakarta+Sans:wght@200..800&amp;family=Inter:wght@300;400;500;600&amp;display=swap" rel="stylesheet"/>
520
+ <style>
521
+ .material-symbols-outlined {
522
+ font-variation-settings: 'FILL' 0, 'wght' 300, 'GRAD' 0, 'opsz' 24;
523
+ }
524
+ .glass-card {
525
+ background: rgba(255, 255, 255, 0.7);
526
+ backdrop-filter: blur(12px);
527
+ -webkit-backdrop-filter: blur(12px);
528
+ }
529
+ .no-scrollbar::-webkit-scrollbar { display: none; }
530
+ .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
531
+
532
+ .hero-gradient {
533
+ background: linear-gradient(to top, rgba(13, 17, 23, 0.95) 0%, rgba(13, 17, 23, 0.4) 50%, rgba(13, 17, 23, 0.1) 100%);
534
+ }
535
+
536
+ @keyframes fadeInUp {
537
+ from { opacity: 0; transform: translateY(20px); }
538
+ to { opacity: 1; transform: translateY(0); }
539
+ }
540
+ .reveal { animation: fadeInUp 0.8s cubic-bezier(0.16, 1, 0.3, 1) forwards; }
541
+
542
+ .chat-bubble-shadow {
543
+ box-shadow: 0 4px 15px -3px rgba(0, 0, 0, 0.1), 0 2px 6px -2px rgba(0, 0, 0, 0.05);
544
+ }
545
+ </style>
546
+ <script id="tailwind-config">
547
+ tailwind.config = {
548
+ darkMode: "class",
549
+ theme: {
550
+ extend: {
551
+ "colors": {
552
+ "on-background": "#1c1b1b",
553
+ "on-secondary": "#ffffff",
554
+ "secondary": "#006c4e",
555
+ "on-tertiary-container": "#bcd6ff",
556
+ "surface-bright": "#fdf8f8",
557
+ "warm-bg": "#F1EFE8",
558
+ "on-error": "#ffffff",
559
+ "on-secondary-fixed-variant": "#00513a",
560
+ "primary-fixed": "#d3e4ff",
561
+ "outline": "#717782",
562
+ "surface-container": "#f1edec",
563
+ "surface-blue": "#E6F1FB",
564
+ "on-surface-variant": "#414751",
565
+ "deep-navy": "#0D1117",
566
+ "surface-dim": "#ddd9d8",
567
+ "on-tertiary": "#ffffff",
568
+ "surface-tint": "#0760a6",
569
+ "surface-container-high": "#ece7e7",
570
+ "inverse-surface": "#313030",
571
+ "tertiary-fixed": "#d4e3ff",
572
+ "on-error-container": "#93000a",
573
+ "error-container": "#ffdad6",
574
+ "background": "#fdf8f8",
575
+ "on-secondary-container": "#0b7052",
576
+ "error": "#ba1a1a",
577
+ "surface-container-highest": "#e6e1e1",
578
+ "on-primary-fixed": "#001c38",
579
+ "primary-fixed-dim": "#a1c9ff",
580
+ "primary": "#00467c",
581
+ "inverse-primary": "#a1c9ff",
582
+ "on-primary": "#ffffff",
583
+ "on-tertiary-fixed": "#001c39",
584
+ "on-surface": "#1c1b1b",
585
+ "hairline-border": "rgba(0, 0, 0, 0.08)",
586
+ "tertiary-fixed-dim": "#a4c9ff",
587
+ "surface-variant": "#e6e1e1",
588
+ "outline-variant": "#c1c7d2",
589
+ "on-secondary-fixed": "#002115",
590
+ "primary-container": "#005ea4",
591
+ "on-primary-fixed-variant": "#004880",
592
+ "secondary-fixed": "#9df4ce",
593
+ "secondary-fixed-dim": "#81d7b2",
594
+ "on-primary-container": "#bbd7ff",
595
+ "inverse-on-surface": "#f4f0ef",
596
+ "on-tertiary-fixed-variant": "#004884",
597
+ "surface-container-lowest": "#ffffff",
598
+ "surface": "#fdf8f8",
599
+ "surface-container-low": "#f7f2f2",
600
+ "secondary-container": "#9af1cb",
601
+ "tertiary-container": "#145da3",
602
+ "tertiary": "#00457f"
603
+ },
604
+ "borderRadius": {
605
+ "DEFAULT": "0.25rem",
606
+ "lg": "0.5rem",
607
+ "xl": "1rem",
608
+ "2xl": "1.5rem",
609
+ "3xl": "2.5rem",
610
+ "full": "9999px"
611
+ },
612
+ "spacing": {
613
+ "xs": "8px",
614
+ "margin": "32px",
615
+ "xl": "80px",
616
+ "sm": "16px",
617
+ "md": "24px",
618
+ "gutter": "24px",
619
+ "base": "4px",
620
+ "lg": "48px"
621
+ },
622
+ "fontFamily": {
623
+ "label-sm": ["Inter", "sans-serif"],
624
+ "body-md": ["Inter", "sans-serif"],
625
+ "headline-sm": ["Newsreader", "serif"],
626
+ "headline-md": ["Newsreader", "serif"],
627
+ "label-md": ["Inter", "sans-serif"],
628
+ "display-lg-mobile": ["Newsreader", "serif"],
629
+ "display-lg": ["Newsreader", "serif"],
630
+ "caption": ["Inter", "sans-serif"],
631
+ "body-lg": ["Inter", "sans-serif"]
632
+ }
633
+ },
634
+ },
635
+ }
636
+ </script>
637
+ </head>
638
+ <body class="bg-background text-on-background font-body-md selection:bg-primary-fixed selection:text-on-primary-fixed overflow-x-hidden">
639
+ <header class="bg-white/70 backdrop-blur-xl border-b border-hairline-border fixed top-0 w-full z-50 transition-all duration-300">
640
+ <div class="flex justify-between items-center px-margin py-4 max-w-7xl mx-auto w-full">
641
+ <div class="flex items-center gap-xs">
642
+ <span class="material-symbols-outlined text-primary text-2xl" data-icon="hub">hub</span>
643
+ <span class="font-headline-sm text-xl font-bold tracking-tight text-deep-navy">Sahel<span class="text-primary">.ai</span></span>
644
+ </div>
645
+ <button class="bg-deep-navy text-white px-6 py-2.5 rounded-full font-label-md text-sm hover:bg-primary transition-all active:scale-95 shadow-lg shadow-deep-navy/10">
646
+ Get Started
647
+ </button>
648
+ </div>
649
+ </header>
650
+ <main class="max-w-7xl mx-auto">
651
+ <!-- Section 1: Immersive Full-Bleed Hero -->
652
+ <section class="relative h-screen min-h-[700px] flex flex-col justify-end p-margin overflow-hidden">
653
+ <img class="absolute inset-0 w-full h-full object-cover z-0" data-alt="A luxurious Moroccan restaurant terrace in Marrakech" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCRGo1K7dlROFNBV1GQmLdlx3groq2LcvwJ5gWtweZfvC36nzJVjhTq4pAOOxYttIrc2rQxGQge-ebsEauJb1vjkWtVAW03axS96P2MKfHCTvgMe76yD1_3-e5bvs7KWy7EOvAXsSP5ys2wQTOPCws4Md083CI7KHiqLiwpRpnx4b6uC9TLlRpTRHoqA3zGiV7mVIcxPGCi6767Fz5rEIMtbqBlbsX2KzhCVhHpdJz1MlXjsF0QGvo9AL509H0vqvpk7yh5mAErLlkD"/>
654
+ <div class="absolute inset-0 hero-gradient z-10"></div>
655
+ <div class="relative z-20 max-w-2xl mb-xl reveal">
656
+ <div class="flex items-center gap-3 mb-6">
657
+ <span class="bg-white/10 backdrop-blur-md text-white border border-white/20 px-4 py-1 rounded-full font-label-sm text-[10px] uppercase tracking-[0.2em]">{{ t('publicBusiness.category.gastronomy') }}</span>
658
+ <div class="h-px w-8 bg-white/30"></div>
659
+ <span class="text-white/80 font-label-sm text-xs tracking-wide">Marrakech, {{ t('language.maroc') }}</span>
660
+ </div>
661
+ <h1 class="font-display-lg text-6xl md:text-8xl text-white italic tracking-tighter leading-[0.9] mb-8">
662
+ Restaurant <br/>Amoudou
663
+ </h1>
664
+ <p class="text-white/80 font-body-lg text-lg md:text-xl leading-relaxed max-w-lg mb-10 font-light">{{ t('landing.hero.description') }}</p>
665
+ <div class="flex gap-4">
666
+ <button class="bg-white text-deep-navy px-8 py-4 rounded-full font-label-md hover:bg-primary hover:text-white transition-all shadow-xl active:scale-95">
667
+ {{ t('publicBusiness.bookingCta') }}
668
+ </button>
669
+ </div>
670
+ </div>
671
+ <!-- Scroll Indicator -->
672
+ <div class="absolute bottom-10 left-1/2 -translate-x-1/2 z-20 flex flex-col items-center gap-2 opacity-50">
673
+ <span class="font-label-sm text-[10px] text-white uppercase tracking-widest">Scroll</span>
674
+ <div class="w-px h-12 bg-gradient-to-b from-white to-transparent"></div>
675
+ </div>
676
+ </section>
677
+ <!-- Section 2: Intelligence Assistant 2.0 (Integrated Module) -->
678
+ <section class="px-margin -mt-20 relative z-30 reveal" style="animation-delay: 0.2s">
679
+ <div class="bg-deep-navy rounded-3xl overflow-hidden border border-white/10 shadow-2xl">
680
+ <div class="flex flex-col md:flex-row h-full">
681
+ <!-- Branding/Status Side -->
682
+ <div class="md:w-1/3 bg-white/[0.03] p-lg border-b md:border-b-0 md:border-r border-white/10 flex flex-col justify-between">
683
+ <div>
684
+ <div class="w-14 h-14 bg-primary rounded-2xl flex items-center justify-center mb-6 shadow-lg shadow-primary/20">
685
+ <span class="material-symbols-outlined text-white text-3xl" data-icon="smart_toy" style="font-variation-settings: 'FILL' 1;">smart_toy</span>
686
+ </div>
687
+ <h2 class="text-white font-headline-md text-3xl mb-2">{{ t('publicBusiness.concierge', { name: 'Sahel' }) }}</h2>
688
+ <p class="text-white/50 font-body-md leading-relaxed">{{ t('publicBusiness.conciergeDesc') }}</p>
689
+ </div>
690
+ <div class="mt-8 flex items-center gap-3">
691
+ <span class="relative flex h-3 w-3">
692
+ <span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-secondary opacity-75"></span>
693
+ <span class="relative inline-flex rounded-full h-3 w-3 bg-secondary"></span>
694
+ </span>
695
+ <span class="text-secondary font-label-sm text-xs uppercase tracking-widest">{{ t('publicBusiness.onlineReady') }}</span>
696
+ </div>
697
+ </div>
698
+ <!-- Chat Side -->
699
+ <div class="flex-1 p-lg flex flex-col h-[500px]">
700
+ <div class="flex-1 overflow-y-auto space-y-6 no-scrollbar pb-6">
701
+ <div class="flex gap-4 max-w-[85%]">
702
+ <div class="flex-1 bg-white/5 backdrop-blur-sm p-5 rounded-2xl rounded-tl-none border border-white/10 chat-bubble-shadow">
703
+ <p class="text-white/90 font-body-md text-base leading-relaxed">{{ t('chat.welcomeBusiness', { name: 'Amoudou' }) }}</p>
704
+ </div>
705
+ </div>
706
+ <!-- Suggestions -->
707
+ <div class="flex flex-wrap gap-3 pt-2">
708
+ <button class="bg-primary/20 border border-primary/40 text-primary-fixed-dim px-5 py-2.5 rounded-full font-label-md text-sm hover:bg-primary/40 transition-all flex items-center gap-2">
709
+ <span class="material-symbols-outlined text-sm" data-icon="restaurant_menu">restaurant_menu</span>
710
+ {{ t('publicBusiness.menu') }}
711
+ </button>
712
+ <button class="bg-white/5 border border-white/10 text-white/80 px-5 py-2.5 rounded-full font-label-md text-sm hover:bg-white/10 transition-all flex items-center gap-2">
713
+ <span class="material-symbols-outlined text-sm" data-icon="event_seat">event_seat</span>
714
+ {{ t('publicBusiness.booking') }}
715
+ </button>
716
+ <button class="bg-white/5 border border-white/10 text-white/80 px-5 py-2.5 rounded-full font-label-md text-sm hover:bg-white/10 transition-all flex items-center gap-2">
717
+ <span class="material-symbols-outlined text-sm" data-icon="directions">directions</span>
718
+ {{ t('publicBusiness.chatSuggestion.location') }}
719
+ </button>
720
+ </div>
721
+ </div>
722
+ <!-- Input Area -->
723
+ <div class="relative pt-6 border-t border-white/10">
724
+ <input class="w-full bg-white/5 border-none rounded-2xl py-5 px-6 text-white placeholder:text-white/30 focus:ring-2 focus:ring-primary/50 transition-all" placeholder="{{ t('publicBusiness.chatPlaceholder') }}" type="text"/>
725
+ <button class="absolute right-3 top-[calc(50%+12px)] -translate-y-1/2 w-12 h-12 bg-primary hover:bg-primary-container text-white rounded-xl flex items-center justify-center transition-all shadow-lg active:scale-95">
726
+ <span class="material-symbols-outlined" data-icon="send">send</span>
727
+ </button>
728
+ </div>
729
+ </div>
730
+ </div>
731
+ </div>
732
+ </section>
733
+ <!-- Section 3: Sophisticated Services Layout -->
734
+ <section class="px-margin py-xl space-y-gutter">
735
+ <div class="grid grid-cols-12 gap-gutter">
736
+ <!-- Large Featured Card -->
737
+ <div class="col-span-12 lg:col-span-8 group relative overflow-hidden rounded-3xl min-h-[450px] flex flex-col justify-end p-lg bg-surface-container-low transition-all hover:shadow-2xl reveal" style="animation-delay: 0.3s">
738
+ <img alt="Terrasse" class="absolute inset-0 w-full h-full object-cover transition-transform duration-1000 group-hover:scale-105" src="https://lh3.googleusercontent.com/aida-public/AB6AXuCRGo1K7dlROFNBV1GQmLdlx3groq2LcvwJ5gWtweZfvC36nzJVjhTq4pAOOxYttIrc2rQxGQge-ebsEauJb1vjkWtVAW03axS96P2MKfHCTvgMe76yD1_3-e5bvs7KWy7EOvAXsSP5ys2wQTOPCws4Md083CI7KHiqLiwpRpnx4b6uC9TLlRpTRHoqA3zGiV7mVIcxPGCi6767Fz5rEIMtbqBlbsX2KzhCVhHpdJz1MlXjsF0QGvo9AL509H0vqvpk7yh5mAErLlkD"/>
739
+ <div class="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent"></div>
740
+ <div class="relative z-10 text-white max-w-md">
741
+ <span class="bg-primary px-3 py-1 rounded-full font-label-sm text-[10px] uppercase tracking-widest mb-4 inline-block">Signature</span>
742
+ <h3 class="font-headline-md text-4xl mb-4 italic">Terrasse Panoramique</h3>
743
+ <p class="text-white/70 font-body-md text-lg leading-relaxed">Un écrin suspendu offrant une vue magistrale sur les jardins de la Médina. L'endroit idéal pour vos dîners sous les étoiles.</p>
744
+ </div>
745
+ </div>
746
+ <!-- Side Card Stack -->
747
+ <div class="col-span-12 lg:col-span-4 flex flex-col gap-gutter">
748
+ <div class="flex-1 bg-warm-bg rounded-3xl p-lg border border-hairline-border hover:border-primary/20 transition-all reveal" style="animation-delay: 0.4s">
749
+ <div class="w-12 h-12 bg-secondary/10 rounded-xl flex items-center justify-center mb-6">
750
+ <span class="material-symbols-outlined text-secondary text-3xl" data-icon="restaurant" style="font-variation-settings: 'FILL' 1;">restaurant</span>
751
+ </div>
752
+ <h3 class="font-headline-sm text-2xl text-deep-navy mb-3">Cuisine d'Héritage</h3>
753
+ <p class="text-on-surface-variant font-body-md leading-relaxed">Les recettes séculaires revisitées avec l'audace de la gastronomie contemporaine.</p>
754
+ </div>
755
+ <div class="flex-1 bg-surface-blue rounded-3xl p-lg border border-hairline-border hover:border-primary/20 transition-all reveal" style="animation-delay: 0.5s">
756
+ <div class="w-12 h-12 bg-primary/10 rounded-xl flex items-center justify-center mb-6">
757
+ <span class="material-symbols-outlined text-primary text-3xl" data-icon="eco" style="font-variation-settings: 'FILL' 1;">eco</span>
758
+ </div>
759
+ <h3 class="font-headline-sm text-2xl text-deep-navy mb-3">Produits du Terroir</h3>
760
+ <p class="text-on-surface-variant font-body-md leading-relaxed">Une sélection matinale rigoureuse auprès des producteurs locaux passionnés.</p>
761
+ </div>
762
+ </div>
763
+ </div>
764
+ <!-- Amenities Strip -->
765
+ <div class="bg-white rounded-3xl p-8 border border-hairline-border flex flex-wrap justify-center gap-xl md:gap-24 reveal" style="animation-delay: 0.6s">
766
+ <div class="flex flex-col items-center gap-3">
767
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-transform hover:scale-110">
768
+ <span class="material-symbols-outlined text-primary text-2xl" data-icon="wifi_calling_3">wifi_calling_3</span>
769
+ </div>
770
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline">Fibre Optique</span>
771
+ </div>
772
+ <div class="flex flex-col items-center gap-3">
773
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-transform hover:scale-110">
774
+ <span class="material-symbols-outlined text-primary text-2xl" data-icon="local_parking">local_parking</span>
775
+ </div>
776
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline">Valet Parking</span>
777
+ </div>
778
+ <div class="flex flex-col items-center gap-3">
779
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-transform hover:scale-110">
780
+ <span class="material-symbols-outlined text-primary text-2xl" data-icon="ac_unit">ac_unit</span>
781
+ </div>
782
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline">Climatisation</span>
783
+ </div>
784
+ <div class="flex flex-col items-center gap-3">
785
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-transform hover:scale-110">
786
+ <span class="material-symbols-outlined text-primary text-2xl" data-icon="payments">payments</span>
787
+ </div>
788
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline">Multi-Paiement</span>
789
+ </div>
790
+ </div>
791
+ </section>
792
+ <!-- Section 4: Practical Info & Location -->
793
+ <section class="px-margin py-xl bg-surface-container-lowest">
794
+ <div class="grid grid-cols-1 lg:grid-cols-2 gap-xl">
795
+ <div class="space-y-xl reveal" style="animation-delay: 0.7s">
796
+ <div>
797
+ <h2 class="font-headline-md text-4xl mb-2 italic">{{ t('publicBusiness.practicalInfo') }}</h2>
798
+ <div class="h-1 w-20 bg-primary/20 mb-8"></div>
799
+ <div class="space-y-0">
800
+ <div class="flex items-center justify-between py-6 border-b border-hairline-border group">
801
+ <span class="font-label-md text-on-surface-variant flex items-center gap-3">
802
+ <span class="material-symbols-outlined text-primary/40 group-hover:text-primary transition-colors" data-icon="schedule">schedule</span>
803
+ {{ t('publicBusiness.openingHours') }}
804
+ </span>
805
+ <span class="font-body-md font-semibold text-deep-navy">12h00 — 23h30</span>
806
+ </div>
807
+ <div class="flex items-center justify-between py-6 border-b border-hairline-border group">
808
+ <span class="font-label-md text-on-surface-variant flex items-center gap-3">
809
+ <span class="material-symbols-outlined text-primary/40 group-hover:text-primary transition-colors" data-icon="calendar_today">calendar_today</span>
810
+ {{ t('publicBusiness.serviceDays') }}
811
+ </span>
812
+ <span class="font-body-md font-semibold text-deep-navy">{{ t('publicBusiness.everyday') }}</span>
813
+ </div>
814
+ <div class="flex items-start justify-between py-6 border-b border-hairline-border group">
815
+ <span class="font-label-md text-on-surface-variant flex items-center gap-3">
816
+ <span class="material-symbols-outlined text-primary/40 group-hover:text-primary transition-colors" data-icon="location_on">location_on</span>
817
+ {{ t('publicBusiness.address') }}
818
+ </span>
819
+ <span class="font-body-md font-semibold text-deep-navy text-right max-w-[240px]">Rue de la Kasbah, Médina, Marrakech 40000</span>
820
+ </div>
821
+ </div>
822
+ </div>
823
+ <div class="flex flex-col sm:flex-row gap-4">
824
+ <a class="flex-1 bg-[#25D366] text-white py-5 rounded-2xl flex items-center justify-center gap-3 font-label-md text-sm shadow-xl shadow-[#25D366]/20 transition-all hover:brightness-110 active:scale-95" href="https://wa.me/2120000000">
825
+ <span class="material-symbols-outlined" data-icon="chat">chat</span>
826
+ {{ t('publicBusiness.whatsapp') }}
827
+ </a>
828
+ <a class="flex-1 bg-deep-navy text-white py-5 rounded-2xl flex items-center justify-center gap-3 font-label-md text-sm transition-all hover:bg-primary shadow-xl shadow-deep-navy/10 active:scale-95" href="tel:+2120000000">
829
+ <span class="material-symbols-outlined" data-icon="call">call</span>
830
+ {{ t('publicBusiness.call') }}
831
+ </a>
832
+ </div>
833
+ </div>
834
+ <!-- Map Integration -->
835
+ <div class="h-[500px] rounded-3xl overflow-hidden border border-hairline-border relative shadow-2xl reveal" style="animation-delay: 0.8s">
836
+ <img class="absolute inset-0 w-full h-full object-cover" data-alt="Minimalist digital map of Marrakech Medina" src="https://lh3.googleusercontent.com/aida-public/AB6AXuDELaf05Iw_LWW42blSPOdnyR6rdHaCeZckPnggfcrCFaVXfW0ItwB1yCKmDdyT11DNI71UN0uadWDS1yxqrRm-tdmDN4NnmO_bbU97xu12jaygih9QeNtBKyA2c2rUOoOgjjotOl3CIcpwHXBIQvZYEXP8f_cApNimu8twVkZJ0vtywIpAdWUJx2CJfm4f-n8xawRnyycC2y1EmYSvO1TcBWaRCf-FpH-V46TEnDRs6lv7rFCExKBu0mSqFf5zOVKuOyyAyxcKB7as"/>
837
+ <div class="absolute inset-0 bg-deep-navy/5"></div>
838
+ <div class="absolute bottom-6 left-6 right-6 bg-white/90 backdrop-blur-md p-6 rounded-2xl border border-white shadow-xl">
839
+ <div class="flex items-center justify-between">
840
+ <div>
841
+ <p class="font-label-sm text-[10px] text-primary uppercase tracking-[0.2em] mb-1">{{ t('miniSite.directions') }}</p>
842
+ <p class="font-headline-sm text-xl text-deep-navy">{{ t('miniSite.directions') }}</p>
843
+ </div>
844
+ <div class="w-14 h-14 bg-deep-navy rounded-full flex items-center justify-center text-white hover:bg-primary transition-colors cursor-pointer shadow-lg">
845
+ <span class="material-symbols-outlined" data-icon="near_me">near_me</span>
846
+ </div>
847
+ </div>
848
+ </div>
849
+ </div>
850
+ </div>
851
+ </section>
852
+ </main>
853
+ <section class="px-margin py-xl bg-white">
854
+ <div class="max-w-7xl mx-auto text-center">
855
+ <h2 class="font-headline-md text-4xl mb-2 italic text-deep-navy">{{ t('publicBusiness.followUs') }}</h2>
856
+ <div class="h-1 w-20 bg-primary/20 mb-12 mx-auto"></div>
857
+ <div class="flex flex-wrap justify-center gap-8 md:gap-12">
858
+ <a class="group flex flex-col items-center gap-3" href="#">
859
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-all duration-300 group-hover:bg-primary group-hover:text-white text-deep-navy shadow-sm">
860
+ <span class="material-symbols-outlined text-3xl">photo_camera</span>
861
+ </div>
862
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline group-hover:text-primary transition-colors">Instagram</span>
863
+ </a>
864
+ <a class="group flex flex-col items-center gap-3" href="#">
865
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-all duration-300 group-hover:bg-primary group-hover:text-white text-deep-navy shadow-sm">
866
+ <span class="material-symbols-outlined text-3xl">facebook</span>
867
+ </div>
868
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline group-hover:text-primary transition-colors">Facebook</span>
869
+ </a>
870
+ <a class="group flex flex-col items-center gap-3" href="#">
871
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-all duration-300 group-hover:bg-primary group-hover:text-white text-deep-navy shadow-sm">
872
+ <span class="material-symbols-outlined text-3xl">movie</span>
873
+ </div>
874
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline group-hover:text-primary transition-colors">TikTok</span>
875
+ </a>
876
+ <a class="group flex flex-col items-center gap-3" href="https://wa.me/2120000000">
877
+ <div class="w-16 h-16 rounded-full bg-surface-container-low flex items-center justify-center transition-all duration-300 group-hover:bg-[#25D366] group-hover:text-white text-deep-navy shadow-sm">
878
+ <span class="material-symbols-outlined text-3xl">chat</span>
879
+ </div>
880
+ <span class="font-label-sm text-[11px] uppercase tracking-widest text-outline group-hover:text-[#25D366] transition-colors">WhatsApp</span>
881
+ </a>
882
+ </div>
883
+ </div>
884
+ </section><footer class="bg-deep-navy py-xl">
885
+ <div class="max-w-7xl mx-auto px-margin">
886
+ <div class="flex flex-col md:flex-row justify-between items-start gap-xl pb-xl border-b border-white/10">
887
+ <div class="space-y-6">
888
+ <div class="flex items-center gap-xs">
889
+ <span class="material-symbols-outlined text-white text-3xl" data-icon="hub">hub</span>
890
+ <span class="font-headline-sm text-2xl font-bold tracking-tight text-white">Sahel<span class="text-primary">.ai</span></span>
891
+ </div>
892
+ <p class="text-white/40 font-body-md max-w-sm leading-relaxed">
893
+ Accompagner les fleurons de l'hôtellerie et de la restauration marocaine vers l'excellence numérique grâce à l'intelligence artificielle.
894
+ </p>
895
+ </div>
896
+ <div class="grid grid-cols-2 md:grid-cols-3 gap-xl">
897
+ <div class="space-y-4">
898
+ <h4 class="text-white font-label-md text-xs uppercase tracking-widest">Menu</h4>
899
+ <nav class="flex flex-col gap-3">
900
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Carte</a>
901
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Réservation</a>
902
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Notre Histoire</a>
903
+ </nav>
904
+ </div>
905
+ <div class="space-y-4">
906
+ <h4 class="text-white font-label-md text-xs uppercase tracking-widest">Intelligence</h4>
907
+ <nav class="flex flex-col gap-3">
908
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Conciergerie IA</a>
909
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Sahel Ecosystem</a>
910
+ </nav>
911
+ </div>
912
+ <div class="space-y-4">
913
+ <h4 class="text-white font-label-md text-xs uppercase tracking-widest">Légal</h4>
914
+ <nav class="flex flex-col gap-3">
915
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Privacy</a>
916
+ <a class="text-white/40 hover:text-white transition-colors text-sm" href="#">Terms</a>
917
+ </nav>
918
+ </div>
919
+ </div>
920
+ </div>
921
+ <div class="pt-8 flex flex-col md:flex-row justify-between items-center gap-4 text-white/30 text-xs font-label-sm uppercase tracking-widest">
922
+ <p>© 2026 Restaurant Amoudou — Powered by Sahel.ai</p>
923
+ <div class="flex gap-8">
924
+ <a class="hover:text-white transition-colors" href="#">Instagram</a>
925
+ <a class="hover:text-white transition-colors" href="#">LinkedIn</a>
926
+ </div>
927
+ </div>
928
+ </div>
929
+ </footer>
930
+ <script>
931
+ // Header transition on scroll
932
+ window.addEventListener('scroll', () => {
933
+ const header = document.querySelector('header');
934
+ if (window.scrollY > 100) {
935
+ header.classList.add('py-2', 'bg-white/95', 'shadow-sm');
936
+ header.classList.remove('py-4', 'bg-white/70');
937
+ } else {
938
+ header.classList.remove('py-2', 'bg-white/95', 'shadow-sm');
939
+ header.classList.add('py-4', 'bg-white/70');
940
+ }
941
+ });
942
+
943
+ // Simple Intersection Observer for reveal animations
944
+ const observerOptions = { threshold: 0.1, rootMargin: '0px 0px -100px 0px' };
945
+ const observer = new IntersectionObserver((entries) => {
946
+ entries.forEach(entry => {
947
+ if (entry.isIntersecting) {
948
+ entry.target.classList.add('reveal');
949
+ entry.target.style.opacity = "1";
950
+ }
951
+ });
952
+ }, observerOptions);
953
+
954
+ document.querySelectorAll('.reveal').forEach(el => {
955
+ el.style.opacity = "0";
956
+ observer.observe(el);
957
+ });
958
+
959
+ // Chat micro-interactions
960
+ const chatInput = document.querySelector('input[type="text"]');
961
+ const sendBtn = chatInput?.nextElementSibling;
962
+
963
+ chatInput?.addEventListener('keypress', (e) => {
964
+ if (e.key === 'Enter' && chatInput.value.trim() !== '') {
965
+ sendMessage();
966
+ }
967
+ });
968
+
969
+ sendBtn?.addEventListener('click', () => {
970
+ if (chatInput.value.trim() !== '') {
971
+ sendMessage();
972
+ }
973
+ });
974
+
975
+ function sendMessage() {
976
+ const text = chatInput.value;
977
+ chatInput.value = '';
978
+
979
+ const container = document.querySelector('.space-y-6');
980
+ const userMsgWrapper = document.createElement('div');
981
+ userMsgWrapper.className = 'flex justify-end opacity-0 translate-y-4 transition-all duration-500';
982
+ const bubble = document.createElement('div');
983
+ bubble.className = 'bg-primary p-5 rounded-2xl rounded-tr-none text-white font-body-md max-w-[85%] shadow-xl shadow-primary/20 border border-white/10';
984
+ bubble.textContent = text;
985
+ userMsgWrapper.appendChild(bubble);
986
+ container.appendChild(userMsgWrapper);
987
+
988
+ setTimeout(() => {
989
+ userMsgWrapper.classList.remove('opacity-0', 'translate-y-4');
990
+ container.scrollTo({ top: container.scrollHeight, behavior: 'smooth' });
991
+ }, 50);
992
+ }
993
+ </script>
994
+ </body></html>
995
+
996
+
997
+ and here is the page of the mini website ;it is the same as the one we have the only thing you gonna add is the social media stuff (again what ever you add should be functional)
998
+
999
+ also the page of contact for contacting us what ever is sent from there should be going to my mail inbox (muha772005@gamil.com)
1000
+ ::
backend-rag/faiss_vector_store/index.faiss ADDED
Binary file (40 kB). View file
 
backend-rag/faiss_vector_store/index.pkl ADDED
Binary file (13.4 kB). View file
 
backend-rag/faiss_vector_store_MINNOVA/index.faiss ADDED
Binary file (18.5 kB). View file
 
backend-rag/faiss_vector_store_MINNOVA/index.pkl ADDED
Binary file (6.76 kB). View file
 
backend-rag/vector_stores/6609ea99cc4c/index.faiss ADDED
Binary file (12.3 kB). View file
 
backend-rag/vector_stores/6609ea99cc4c/index.pkl ADDED
Binary file (2.97 kB). View file
 
backend-rag/vector_stores/6ae136762cd8/index.faiss ADDED
Binary file (12.3 kB). View file
 
backend-rag/vector_stores/6ae136762cd8/index.pkl ADDED
Binary file (2.97 kB). View file
 
backend-rag/vector_stores/6be4e86b2c8a/index.faiss ADDED
Binary file (18.5 kB). View file
 
backend-rag/vector_stores/6be4e86b2c8a/index.pkl ADDED
Binary file (6.81 kB). View file
 
backend-rag/vector_stores/6efe87b2e45a/index.faiss ADDED
Binary file (24.6 kB). View file
 
backend-rag/vector_stores/6efe87b2e45a/index.pkl ADDED
Binary file (5.64 kB). View file
 
backend-rag/vector_stores/dc3d1c1f39ef/index.faiss ADDED
Binary file (12.3 kB). View file
 
backend-rag/vector_stores/dc3d1c1f39ef/index.pkl ADDED
Binary file (2.97 kB). View file
 
backend/.env.example ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sahel.ai Backend — Environment Configuration
2
+ # Copy this file to .env and fill in your own values.
3
+ # NEVER commit the actual .env file to version control.
4
+
5
+ APP_SECRET_KEY=change-me-to-a-random-secret-key
6
+ GROQ_API_KEY=your-groq-api-key
7
+ SITE_URL=http://localhost:5173
8
+ GOOGLE_CLIENT_ID=your-google-client-id
9
+ DATABASE_URL=postgresql://user:password@host:5432/database
10
+ AUTO_CREATE_TABLES=true
11
+
12
+ # SMTP for email notifications (contact form, verifications)
13
+ # For Gmail: enable 2FA then create app password at https://myaccount.google.com/apppasswords
14
+ SMTP_HOST=smtp.gmail.com
15
+ SMTP_PORT=587
16
+ SMTP_USER=your-email@gmail.com
17
+ SMTP_PASS=your-app-password
18
+ NOTIFICATION_EMAIL=your-email@gmail.com
backend/.gitignore ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ __*
2
+ .env
3
+ *.db
4
+ *.log
5
+ node_*
6
+ uploaded_documents/*
7
+ vector_stores/*
8
+ faiss_vector_store/*
9
+ faiss_vector_store_MINNOVA/*
10
+
backend/README.md ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Sahel.ai RAG API
3
+ emoji: 🏪
4
+ colorFrom: "#E8604C"
5
+ colorTo: "#F5A623"
6
+ sdk: docker
7
+ app_port: 7860
8
+ pinned: false
9
+ license: mit
10
+ ---
11
+
12
+ # Sahel.ai Backend
13
+
14
+ RAG chat API for Moroccan businesses. Deploy on Hugging Face Spaces (free, no credit card).
15
+
16
+ ## Environment Variables
17
+
18
+ Set these in your HF Space settings:
19
+
20
+ | Variable | Description |
21
+ |----------|-------------|
22
+ | `GROQ_API_KEY` | Your Groq API key |
23
+ | `APP_SECRET_KEY` | Random secret for JWT signing |
24
+ | `DATABASE_URL` | `sqlite:////data/sahel.db` (default) |
25
+ | `AUTO_CREATE_TABLES` | `true` |
26
+ | `DEFAULT_BUSINESS_SLUG` | `sahel` |
27
+ | `CORS_ORIGINS` | Comma-separated allowed origins |
28
+ | `SMTP_HOST` | (optional) Free SMTP server |
29
+ | `SMTP_USER` | (optional) SMTP login |
30
+ | `SMTP_PASS` | (optional) SMTP password |
31
+ | `NOTIFICATION_EMAIL` | (optional) Notification recipient |
32
+
33
+ ## API Endpoints
34
+
35
+ - `GET /health` — Health check
36
+ - `GET /b/{slug}` — Mini-site
37
+ - `GET /b/{slug}/qr` — QR code PNG
38
+ - `GET /embed/{slug}.js` — Embed widget script
39
+ - `POST /businesses/{identifier}/chat` — RAG question
40
+ - `WS /chat/stream?business_slug={slug}` — Streaming chat
41
+ - `POST /owners/login` — Owner login
42
+ - `GET /businesses/{slug}/analytics` — Dashboard data
43
+ - `GET /businesses/{slug}/inquiries` — Customer inquiries
44
+ - `PATCH /businesses/{slug}/inquiries/{id}` — Update inquiry status
backend/THIRD_PARTY_NOTES.md ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Third-Party Notes
2
+
3
+ Sahel.ai currently adapts code from cloned starter projects and uses open-source
4
+ libraries. Before market delivery, keep license notices for every cloned source
5
+ that remains in the product.
6
+
7
+ ## Vector Store
8
+
9
+ The backend uses FAISS through LangChain for local vector search. FAISS is MIT
10
+ licensed, so it can be used in a commercial product when the license notice is
11
+ preserved. ChromaDB is also free/open-source under Apache 2.0, but switching is
12
+ not required for cost reasons.
13
+
14
+ ## Reference SaaS
15
+
16
+ The `reference-saas` project in the parent workspace is AGPL-3.0 licensed. Treat
17
+ it as a reference unless the business is ready to comply with AGPL obligations.
18
+ Do not copy substantial code from it into a closed-source product without legal
19
+ review.
backend/alembic.ini ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [alembic]
2
+ script_location = %(here)s/alembic
3
+ prepend_sys_path = .
4
+ path_separator = os
5
+ sqlalchemy.url = sqlite:///./sahel.db
6
+
7
+ [post_write_hooks]
8
+
9
+ [loggers]
10
+ keys = root,sqlalchemy,alembic
11
+
12
+ [handlers]
13
+ keys = console
14
+
15
+ [formatters]
16
+ keys = generic
17
+
18
+ [logger_root]
19
+ level = WARNING
20
+ handlers = console
21
+ qualname =
22
+
23
+ [logger_sqlalchemy]
24
+ level = WARNING
25
+ handlers =
26
+ qualname = sqlalchemy.engine
27
+
28
+ [logger_alembic]
29
+ level = INFO
30
+ handlers =
31
+ qualname = alembic
32
+
33
+ [handler_console]
34
+ class = StreamHandler
35
+ args = (sys.stderr,)
36
+ level = NOTSET
37
+ formatter = generic
38
+
39
+ [formatter_generic]
40
+ format = %(levelname)-5.5s [%(name)s] %(message)s
41
+ datefmt = %H:%M:%S
backend/alembic/README.md ADDED
@@ -0,0 +1 @@
 
 
1
+ Single-database Alembic migrations for the Sahel.ai RAG backend.
backend/alembic/env.py ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ from storage import Base, DATABASE_URL
7
+
8
+ config = context.config
9
+
10
+ if config.config_file_name is not None:
11
+ fileConfig(config.config_file_name)
12
+
13
+ target_metadata = Base.metadata
14
+
15
+
16
+ def run_migrations_offline() -> None:
17
+ context.configure(
18
+ url=DATABASE_URL,
19
+ target_metadata=target_metadata,
20
+ literal_binds=True,
21
+ dialect_opts={"paramstyle": "named"},
22
+ )
23
+
24
+ with context.begin_transaction():
25
+ context.run_migrations()
26
+
27
+
28
+ def run_migrations_online() -> None:
29
+ config.set_main_option("sqlalchemy.url", DATABASE_URL)
30
+ connectable = engine_from_config(
31
+ config.get_section(config.config_ini_section, {}),
32
+ prefix="sqlalchemy.",
33
+ poolclass=pool.NullPool,
34
+ )
35
+
36
+ with connectable.connect() as connection:
37
+ context.configure(connection=connection, target_metadata=target_metadata)
38
+
39
+ with context.begin_transaction():
40
+ context.run_migrations()
41
+
42
+
43
+ if context.is_offline_mode():
44
+ run_migrations_offline()
45
+ else:
46
+ run_migrations_online()
backend/alembic/script.py.mako ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = ${repr(up_revision)}
16
+ down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
17
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
18
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
19
+
20
+
21
+ def upgrade() -> None:
22
+ ${upgrades if upgrades else "pass"}
23
+
24
+
25
+ def downgrade() -> None:
26
+ ${downgrades if downgrades else "pass"}
backend/alembic/versions/2026_06_03_0001_initial_schema.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial schema
2
+
3
+ Revision ID: 202606030001
4
+ Revises:
5
+ Create Date: 2026-06-03 00:01:00.000000
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ revision: str = "202606030001"
15
+ down_revision: Union[str, Sequence[str], None] = None
16
+ branch_labels: Union[str, Sequence[str], None] = None
17
+ depends_on: Union[str, Sequence[str], None] = None
18
+
19
+
20
+ def upgrade() -> None:
21
+ op.create_table(
22
+ "owners",
23
+ sa.Column("id", sa.String(length=80), nullable=False),
24
+ sa.Column("full_name", sa.String(length=80), nullable=False),
25
+ sa.Column("email", sa.String(length=120), nullable=False),
26
+ sa.Column("password_hash", sa.String(length=200), nullable=False),
27
+ sa.Column("created_at", sa.String(length=40), nullable=False),
28
+ sa.PrimaryKeyConstraint("id"),
29
+ )
30
+ op.create_index(op.f("ix_owners_email"), "owners", ["email"], unique=True)
31
+
32
+ op.create_table(
33
+ "businesses",
34
+ sa.Column("id", sa.String(length=80), nullable=False),
35
+ sa.Column("owner_id", sa.String(length=80), nullable=False),
36
+ sa.Column("slug", sa.String(length=120), nullable=False),
37
+ sa.Column("name", sa.String(length=80), nullable=False),
38
+ sa.Column("business_type", sa.String(length=50), nullable=False),
39
+ sa.Column("description", sa.Text(), nullable=False),
40
+ sa.Column("owner_email", sa.String(length=120), nullable=True),
41
+ sa.Column("owner_phone", sa.String(length=40), nullable=True),
42
+ sa.Column("site_url", sa.String(length=160), nullable=False),
43
+ sa.Column("chat_url", sa.String(length=160), nullable=False),
44
+ sa.PrimaryKeyConstraint("id"),
45
+ )
46
+ op.create_index(op.f("ix_businesses_owner_id"), "businesses", ["owner_id"], unique=False)
47
+ op.create_index(op.f("ix_businesses_slug"), "businesses", ["slug"], unique=True)
48
+
49
+ op.create_table(
50
+ "documents",
51
+ sa.Column("id", sa.String(length=80), nullable=False),
52
+ sa.Column("business_id", sa.String(length=80), nullable=False),
53
+ sa.Column("file_name", sa.String(length=260), nullable=False),
54
+ sa.Column("file_type", sa.String(length=80), nullable=False),
55
+ sa.Column("file_url", sa.String(length=320), nullable=False),
56
+ sa.Column("chunks", sa.Integer(), nullable=False),
57
+ sa.Column("size_bytes", sa.BigInteger(), nullable=False),
58
+ sa.Column("uploaded_at", sa.String(length=40), nullable=False),
59
+ sa.PrimaryKeyConstraint("id"),
60
+ )
61
+ op.create_index(op.f("ix_documents_business_id"), "documents", ["business_id"], unique=False)
62
+
63
+ op.create_table(
64
+ "conversations",
65
+ sa.Column("id", sa.String(length=80), nullable=False),
66
+ sa.Column("business_id", sa.String(length=80), nullable=False),
67
+ sa.Column("language", sa.String(length=20), nullable=False),
68
+ sa.Column("visitor_ip", sa.String(length=80), nullable=True),
69
+ sa.Column("started_at", sa.String(length=40), nullable=False),
70
+ sa.PrimaryKeyConstraint("id"),
71
+ )
72
+ op.create_index(op.f("ix_conversations_business_id"), "conversations", ["business_id"], unique=False)
73
+
74
+ op.create_table(
75
+ "messages",
76
+ sa.Column("id", sa.String(length=80), nullable=False),
77
+ sa.Column("business_id", sa.String(length=80), nullable=False),
78
+ sa.Column("conversation_id", sa.String(length=80), nullable=False),
79
+ sa.Column("question", sa.Text(), nullable=False),
80
+ sa.Column("answer", sa.Text(), nullable=False),
81
+ sa.Column("sources", sa.Text(), nullable=False),
82
+ sa.Column("language", sa.String(length=20), nullable=False),
83
+ sa.Column("created_at", sa.String(length=40), nullable=False),
84
+ sa.PrimaryKeyConstraint("id"),
85
+ )
86
+ op.create_index(op.f("ix_messages_business_id"), "messages", ["business_id"], unique=False)
87
+ op.create_index(op.f("ix_messages_conversation_id"), "messages", ["conversation_id"], unique=False)
88
+
89
+ op.create_table(
90
+ "inquiries",
91
+ sa.Column("id", sa.String(length=80), nullable=False),
92
+ sa.Column("business_id", sa.String(length=80), nullable=False),
93
+ sa.Column("name", sa.String(length=80), nullable=False),
94
+ sa.Column("contact", sa.String(length=120), nullable=False),
95
+ sa.Column("message", sa.Text(), nullable=False),
96
+ sa.Column("conversation_id", sa.String(length=80), nullable=True),
97
+ sa.Column("status", sa.String(length=20), nullable=False),
98
+ sa.Column("created_at", sa.String(length=40), nullable=False),
99
+ sa.Column("updated_at", sa.String(length=40), nullable=True),
100
+ sa.PrimaryKeyConstraint("id"),
101
+ )
102
+ op.create_index(op.f("ix_inquiries_business_id"), "inquiries", ["business_id"], unique=False)
103
+
104
+
105
+ def downgrade() -> None:
106
+ op.drop_index(op.f("ix_inquiries_business_id"), table_name="inquiries")
107
+ op.drop_table("inquiries")
108
+ op.drop_index(op.f("ix_messages_conversation_id"), table_name="messages")
109
+ op.drop_index(op.f("ix_messages_business_id"), table_name="messages")
110
+ op.drop_table("messages")
111
+ op.drop_index(op.f("ix_conversations_business_id"), table_name="conversations")
112
+ op.drop_table("conversations")
113
+ op.drop_index(op.f("ix_documents_business_id"), table_name="documents")
114
+ op.drop_table("documents")
115
+ op.drop_index(op.f("ix_businesses_slug"), table_name="businesses")
116
+ op.drop_index(op.f("ix_businesses_owner_id"), table_name="businesses")
117
+ op.drop_table("businesses")
118
+ op.drop_index(op.f("ix_owners_email"), table_name="owners")
119
+ op.drop_table("owners")
backend/backend_chatdoc.py ADDED
@@ -0,0 +1,2453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import asyncio
2
+ import base64
3
+ import html
4
+ import json
5
+ import logging
6
+ import os
7
+ import re
8
+ import secrets
9
+ import shutil
10
+ import hashlib
11
+ import hmac
12
+ import random
13
+ import smtplib
14
+ import time
15
+ import unicodedata
16
+ from datetime import datetime, timedelta, timezone
17
+ from email.mime.text import MIMEText
18
+ from pathlib import Path
19
+ from collections import defaultdict
20
+ from typing import List
21
+ from uuid import uuid4
22
+
23
+ import faiss
24
+ import jwt
25
+ import numpy as np
26
+ from pypdf import PdfReader
27
+ import io
28
+ from fastapi import FastAPI, File, Header, HTTPException, Request, Response, UploadFile, WebSocket, WebSocketDisconnect, Query
29
+ from fastapi.middleware.cors import CORSMiddleware
30
+ from fastapi.responses import HTMLResponse, Response
31
+ from fastapi.staticfiles import StaticFiles
32
+ from jinja2 import Environment, FileSystemLoader, select_autoescape
33
+ import qrcode
34
+ from groq import Groq
35
+ from langchain_huggingface import HuggingFaceEmbeddings
36
+ from langchain_text_splitters import CharacterTextSplitter
37
+ from pydantic import BaseModel, Field
38
+ from slowapi import Limiter, _rate_limit_exceeded_handler
39
+ from slowapi.util import get_remote_address
40
+ from slowapi.errors import RateLimitExceeded
41
+
42
+ from storage import import_json_table, read_table, write_table, seed_missing_social_media
43
+
44
+ GROQ_API_KEY = os.getenv("GROQ_API_KEY")
45
+ if not GROQ_API_KEY:
46
+ raise RuntimeError("GROQ_API_KEY is missing. Add it to .env before starting the API.")
47
+
48
+ client = Groq(api_key=GROQ_API_KEY)
49
+ app = FastAPI(title="Sahel.ai RAG API")
50
+
51
+ limiter = Limiter(key_func=get_remote_address, default_limits=[os.getenv("RATE_LIMIT_DEFAULT", "30/minute")])
52
+ app.state.limiter = limiter
53
+ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
54
+
55
+ RATE_LIMIT_AUTH = os.getenv("RATE_LIMIT_AUTH", "5/minute")
56
+ RATE_LIMIT_CHAT = os.getenv("RATE_LIMIT_CHAT", "20/minute")
57
+ RATE_LIMIT_WS = int(os.getenv("RATE_LIMIT_WS", "30"))
58
+
59
+ _ws_requests: dict[str, list[float]] = defaultdict(list)
60
+ _WS_WINDOW = 60.0
61
+ _WS_CLEANUP_INTERVAL = 300.0
62
+ _last_ws_cleanup = time.time()
63
+
64
+ def _check_ws_rate_limit(ip: str) -> bool:
65
+ global _last_ws_cleanup
66
+ now = time.time()
67
+ if now - _last_ws_cleanup > _WS_CLEANUP_INTERVAL:
68
+ cutoff = now - _WS_WINDOW
69
+ stale = [k for k, v in _ws_requests.items() if not any(t > cutoff for t in v)]
70
+ for k in stale:
71
+ del _ws_requests[k]
72
+ _last_ws_cleanup = now
73
+ _ws_requests[ip] = [t for t in _ws_requests[ip] if now - t < _WS_WINDOW]
74
+ if len(_ws_requests[ip]) >= RATE_LIMIT_WS:
75
+ return False
76
+ _ws_requests[ip].append(now)
77
+ return True
78
+
79
+ origins = (
80
+ os.getenv("CORS_ORIGINS", "http://localhost:5173,http://127.0.0.1:5173")
81
+ .split(",")
82
+ )
83
+
84
+ app.add_middleware(
85
+ CORSMiddleware,
86
+ allow_origins=origins,
87
+ allow_credentials=True,
88
+ allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
89
+ allow_headers=["Content-Type", "Authorization", "Accept", "Origin"],
90
+ )
91
+
92
+
93
+ @app.middleware("http")
94
+ async def _session_cookie_to_auth(request: Request, call_next):
95
+ if "authorization" not in request.headers:
96
+ token = request.cookies.get("session")
97
+ if token:
98
+ scope = dict(request.scope)
99
+ scope["headers"] = [
100
+ (k, v) for k, v in request.scope["headers"] if k != b"authorization"
101
+ ] + [(b"authorization", f"Bearer {token}".encode())]
102
+ request = Request(scope)
103
+ return await call_next(request)
104
+
105
+
106
+ def _set_session_cookie(response: Response, token: str, expires_at: str) -> None:
107
+ try:
108
+ expires_dt = datetime.fromisoformat(expires_at)
109
+ max_age = int((expires_dt - datetime.now(timezone.utc)).total_seconds())
110
+ except (ValueError, TypeError):
111
+ max_age = 86400
112
+ response.set_cookie(
113
+ key="session",
114
+ value=token,
115
+ httponly=True,
116
+ samesite="lax",
117
+ max_age=max(max_age, 0),
118
+ path="/",
119
+ )
120
+
121
+
122
+ def _clear_session_cookie(response: Response) -> None:
123
+ response.set_cookie(
124
+ key="session",
125
+ value="",
126
+ httponly=True,
127
+ samesite="lax",
128
+ max_age=0,
129
+ path="/",
130
+ )
131
+
132
+
133
+ @app.middleware("http")
134
+ async def _security_headers(request: Request, call_next):
135
+ response = await call_next(request)
136
+ response.headers["X-Content-Type-Options"] = "nosniff"
137
+ response.headers["X-Frame-Options"] = "DENY"
138
+ response.headers["X-XSS-Protection"] = "0"
139
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
140
+ response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
141
+ response.headers["Strict-Transport-Security"] = "max-age=63072000; includeSubDomains; preload"
142
+ return response
143
+
144
+
145
+ @app.middleware("http")
146
+ async def _csrf_protection(request: Request, call_next):
147
+ if request.method in ("POST", "PUT", "PATCH", "DELETE"):
148
+ origin = request.headers.get("origin")
149
+ referer = request.headers.get("referer")
150
+ if not origin and not referer:
151
+ return await call_next(request)
152
+ allowed = False
153
+ if origin:
154
+ allowed = any(origin.rstrip("/") == o.rstrip("/") for o in origins)
155
+ if not allowed and referer:
156
+ allowed = any(
157
+ referer.rstrip("/").startswith(o.rstrip("/"))
158
+ for o in origins
159
+ )
160
+ if not allowed:
161
+ raise HTTPException(status_code=403, detail="CSRF check failed.")
162
+ return await call_next(request)
163
+
164
+
165
+ BASE_DIR = Path(__file__).resolve().parent
166
+ DATA_ROOT = Path(os.getenv("DATA_ROOT", str(BASE_DIR)))
167
+ VECTOR_STORE_ROOT = DATA_ROOT / "vector_stores"
168
+ UPLOAD_ROOT = DATA_ROOT / "uploaded_documents"
169
+ DATA_DIR = DATA_ROOT / "data"
170
+ BUSINESSES_FILE = DATA_DIR / "businesses.json"
171
+ MESSAGES_FILE = DATA_DIR / "messages.json"
172
+ INQUIRIES_FILE = DATA_DIR / "inquiries.json"
173
+ OWNERS_FILE = DATA_DIR / "owners.json"
174
+ DOCUMENTS_FILE = DATA_DIR / "documents.json"
175
+ CONVERSATIONS_FILE = DATA_DIR / "conversations.json"
176
+
177
+ TEMPLATES_DIR = BASE_DIR / "templates"
178
+ TEMPLATES_DIR.mkdir(exist_ok=True)
179
+ jinja_env = Environment(
180
+ loader=FileSystemLoader(str(TEMPLATES_DIR)),
181
+ autoescape=select_autoescape(["html"]),
182
+ )
183
+
184
+ for folder in (VECTOR_STORE_ROOT, UPLOAD_ROOT, DATA_DIR):
185
+ folder.mkdir(exist_ok=True)
186
+
187
+ for table_name, json_path in {
188
+ "owners": OWNERS_FILE,
189
+ "businesses": BUSINESSES_FILE,
190
+ "documents": DOCUMENTS_FILE,
191
+ "conversations": CONVERSATIONS_FILE,
192
+ "messages": MESSAGES_FILE,
193
+ "inquiries": INQUIRIES_FILE,
194
+ }.items():
195
+ import_json_table(table_name, json_path)
196
+
197
+ seed_missing_social_media(BUSINESSES_FILE)
198
+
199
+ embeddings = None
200
+
201
+ LANGUAGE_NAMES = {
202
+ "ar": "Arabic",
203
+ "fr": "French",
204
+ "en": "English",
205
+ }
206
+
207
+ LANGUAGE_FALLBACKS = {
208
+ "ar": "لا أتوفر على معلومات كافية في ملف هذا النشاط التجاري للإجابة بدقة. المرجو التواصل مباشرة مع صاحب النشاط.",
209
+ "fr": "Je n'ai pas assez d'informations dans les documents de ce commerce pour répondre avec précision. Veuillez contacter directement le propriétaire.",
210
+ "en": "I do not have enough information in this business's documents to answer accurately. Please contact the owner directly.",
211
+ }
212
+
213
+
214
+ def get_embeddings() -> HuggingFaceEmbeddings:
215
+ global embeddings
216
+ if embeddings is None:
217
+ embeddings = HuggingFaceEmbeddings(model_name="sentence-transformers/all-mpnet-base-v2")
218
+ return embeddings
219
+
220
+
221
+ class BusinessCreate(BaseModel):
222
+ name: str = Field(min_length=2, max_length=80)
223
+ business_type: str = Field(default="commerce", max_length=50)
224
+ description: str = Field(default="", max_length=500)
225
+ owner_email: str | None = Field(default=None, max_length=120)
226
+ owner_phone: str | None = Field(default=None, max_length=40)
227
+ owner_id: str | None = Field(default=None, max_length=80)
228
+ city: str | None = Field(default=None, max_length=100)
229
+ working_hours: str | None = Field(default=None, max_length=100)
230
+ primary_services: str | None = Field(default=None, max_length=500)
231
+ address: str | None = Field(default=None, max_length=300)
232
+ latitude: float | None = Field(default=None, ge=-90, le=90)
233
+ longitude: float | None = Field(default=None, ge=-180, le=180)
234
+ cover_image_url: str | None = Field(default=None, max_length=320)
235
+ highlights: str | None = Field(default=None, max_length=2000)
236
+ public_knowledge: str | None = Field(default=None, max_length=3000)
237
+ ice: str | None = Field(default=None, max_length=20)
238
+ rc: str | None = Field(default=None, max_length=30)
239
+ if_tax: str | None = Field(default=None, max_length=30)
240
+ patente: str | None = Field(default=None, max_length=30)
241
+ cnss: str | None = Field(default=None, max_length=30)
242
+ legal_form: str | None = Field(default=None, max_length=50)
243
+ social_media: str | None = Field(default=None, max_length=2000)
244
+
245
+
246
+ class Business(BusinessCreate):
247
+ id: str
248
+ slug: str
249
+ site_url: str
250
+ chat_url: str
251
+
252
+
253
+ class BusinessUpdate(BaseModel):
254
+ name: str = Field(min_length=2, max_length=80)
255
+ business_type: str = Field(default="commerce", max_length=50)
256
+ description: str = Field(default="", max_length=500)
257
+ owner_email: str | None = Field(default=None, max_length=120)
258
+ owner_phone: str | None = Field(default=None, max_length=40)
259
+ city: str | None = Field(default=None, max_length=100)
260
+ working_hours: str | None = Field(default=None, max_length=100)
261
+ primary_services: str | None = Field(default=None, max_length=500)
262
+ address: str | None = Field(default=None, max_length=300)
263
+ latitude: float | None = Field(default=None, ge=-90, le=90)
264
+ longitude: float | None = Field(default=None, ge=-180, le=180)
265
+ cover_image_url: str | None = Field(default=None, max_length=320)
266
+ highlights: str | None = Field(default=None, max_length=2000)
267
+ public_knowledge: str | None = Field(default=None, max_length=3000)
268
+ ice: str | None = Field(default=None, max_length=20)
269
+ rc: str | None = Field(default=None, max_length=30)
270
+ if_tax: str | None = Field(default=None, max_length=30)
271
+ patente: str | None = Field(default=None, max_length=30)
272
+ cnss: str | None = Field(default=None, max_length=30)
273
+ legal_form: str | None = Field(default=None, max_length=50)
274
+ social_media: str | None = Field(default=None, max_length=2000)
275
+
276
+
277
+ class DocumentMetadata(BaseModel):
278
+ id: str
279
+ business_id: str
280
+ file_name: str
281
+ file_type: str
282
+ file_url: str
283
+ chunks: int = 0
284
+ size_bytes: int = 0
285
+ uploaded_at: str
286
+
287
+
288
+ class QueryRequest(BaseModel):
289
+ question: str = Field(min_length=1, max_length=1000)
290
+ conversation_id: str | None = Field(default=None, max_length=80)
291
+
292
+
293
+ class OwnerRegister(BaseModel):
294
+ full_name: str = Field(min_length=2, max_length=80)
295
+ email: str = Field(min_length=5, max_length=120)
296
+ password: str = Field(min_length=6, max_length=120)
297
+
298
+
299
+ class OwnerLogin(BaseModel):
300
+ email: str = Field(min_length=5, max_length=120)
301
+ password: str = Field(min_length=6, max_length=120)
302
+
303
+
304
+ class ForgotPassword(BaseModel):
305
+ email: str = Field(min_length=5, max_length=120)
306
+
307
+
308
+ class ResetPassword(BaseModel):
309
+ email: str = Field(min_length=5, max_length=120)
310
+ code: str = Field(min_length=8, max_length=8)
311
+ new_password: str = Field(min_length=6, max_length=120)
312
+
313
+
314
+ class VerifyResetCode(BaseModel):
315
+ email: str = Field(min_length=5, max_length=120)
316
+ code: str = Field(min_length=8, max_length=8)
317
+
318
+
319
+ class GoogleLogin(BaseModel):
320
+ credential: str
321
+
322
+
323
+ class OwnerPublic(BaseModel):
324
+ id: str
325
+ full_name: str
326
+ email: str
327
+ created_at: str
328
+ picture: str | None = None
329
+
330
+
331
+ class ProfileUpdate(BaseModel):
332
+ full_name: str | None = None
333
+ email: str | None = None
334
+ current_password: str | None = None
335
+ new_password: str | None = None
336
+ profile_photo: str | None = None
337
+
338
+
339
+ class AuthResponse(BaseModel):
340
+ owner: OwnerPublic
341
+ token: str
342
+ expires_at: str
343
+
344
+
345
+ class VerifyToken(BaseModel):
346
+ token: str = Field(min_length=1)
347
+
348
+
349
+ class InquiryCreate(BaseModel):
350
+ name: str = Field(min_length=2, max_length=80)
351
+ contact: str = Field(min_length=3, max_length=120)
352
+ message: str = Field(min_length=2, max_length=1000)
353
+ conversation_id: str | None = Field(default=None, max_length=80)
354
+
355
+
356
+ class ContactCreate(BaseModel):
357
+ name: str = Field(min_length=2, max_length=80)
358
+ email: str = Field(min_length=5, max_length=120)
359
+ message: str = Field(min_length=2, max_length=2000)
360
+
361
+
362
+ class InquiryStatusUpdate(BaseModel):
363
+ status: str = Field(pattern="^(new|contacted|closed)$")
364
+
365
+
366
+ class RetrievedDocument:
367
+ def __init__(self, page_content: str, metadata: dict):
368
+ self.page_content = page_content
369
+ self.metadata = metadata
370
+
371
+
372
+ class LocalFaissStore:
373
+ INDEX_FILE = "index.faiss"
374
+ DOCSTORE_FILE = "docstore.json"
375
+
376
+ def __init__(self, index, documents: list[dict], embedding_model: HuggingFaceEmbeddings):
377
+ self.index = index
378
+ self.documents = documents
379
+ self.embedding_model = embedding_model
380
+
381
+ @classmethod
382
+ def from_texts(
383
+ cls,
384
+ texts: list[str],
385
+ embedding_model: HuggingFaceEmbeddings,
386
+ metadatas: list[dict],
387
+ ) -> "LocalFaissStore":
388
+ vectors = np.array(embedding_model.embed_documents(texts), dtype="float32")
389
+ if vectors.ndim != 2 or vectors.shape[0] == 0:
390
+ raise ValueError("Cannot build FAISS index without document vectors.")
391
+
392
+ index = faiss.IndexFlatL2(vectors.shape[1])
393
+ index.add(vectors)
394
+ documents = [
395
+ {"page_content": text, "metadata": metadata}
396
+ for text, metadata in zip(texts, metadatas, strict=True)
397
+ ]
398
+ return cls(index=index, documents=documents, embedding_model=embedding_model)
399
+
400
+ @classmethod
401
+ def load_local(cls, store_path: Path, embedding_model: HuggingFaceEmbeddings) -> "LocalFaissStore":
402
+ index_path = store_path / cls.INDEX_FILE
403
+ docstore_path = store_path / cls.DOCSTORE_FILE
404
+ if not index_path.exists() or not docstore_path.exists():
405
+ raise HTTPException(
406
+ status_code=404,
407
+ detail="No documents indexed for this business yet. Upload a PDF first.",
408
+ )
409
+
410
+ index = faiss.read_index(str(index_path))
411
+ documents = json.loads(docstore_path.read_text(encoding="utf-8"))
412
+ return cls(index=index, documents=documents, embedding_model=embedding_model)
413
+
414
+ def save_local(self, store_path: Path) -> None:
415
+ store_path.mkdir(parents=True, exist_ok=True)
416
+ faiss.write_index(self.index, str(store_path / self.INDEX_FILE))
417
+ (store_path / self.DOCSTORE_FILE).write_text(
418
+ json.dumps(self.documents, ensure_ascii=False, indent=2),
419
+ encoding="utf-8",
420
+ )
421
+
422
+ def similarity_search(self, question: str, k: int = 3) -> list[RetrievedDocument]:
423
+ if self.index.ntotal == 0:
424
+ return []
425
+
426
+ query_vector = np.array([self.embedding_model.embed_query(question)], dtype="float32")
427
+ result_count = min(k, self.index.ntotal)
428
+ _, indices = self.index.search(query_vector, result_count)
429
+ results: list[RetrievedDocument] = []
430
+ for index in indices[0]:
431
+ if index < 0 or index >= len(self.documents):
432
+ continue
433
+ document = self.documents[int(index)]
434
+ results.append(
435
+ RetrievedDocument(
436
+ page_content=document["page_content"],
437
+ metadata=document.get("metadata") or {},
438
+ )
439
+ )
440
+ return results
441
+
442
+
443
+ def slugify(value: str) -> str:
444
+ slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
445
+ return slug or f"business-{uuid4().hex[:8]}"
446
+
447
+
448
+ def safe_id(value: str) -> str:
449
+ cleaned = re.sub(r"[^a-zA-Z0-9_-]", "", value)
450
+ if not cleaned:
451
+ raise HTTPException(status_code=400, detail="Invalid business id.")
452
+ return cleaned
453
+
454
+
455
+ def read_json_file(path: Path) -> list[dict]:
456
+ if not path.exists():
457
+ return []
458
+ try:
459
+ return json.loads(path.read_text(encoding="utf-8"))
460
+ except UnicodeDecodeError:
461
+ return json.loads(path.read_text(encoding="cp1252"))
462
+
463
+
464
+ def normalize_email(email: str) -> str:
465
+ return email.strip().lower()
466
+
467
+
468
+ def hash_password(password: str, salt: str | None = None) -> str:
469
+ current_salt = salt or uuid4().hex
470
+ digest = hashlib.pbkdf2_hmac(
471
+ "sha256",
472
+ password.encode("utf-8"),
473
+ current_salt.encode("utf-8"),
474
+ 600_000,
475
+ ).hex()
476
+ return f"{current_salt}${digest}"
477
+
478
+
479
+ def verify_password(password: str, stored_hash: str) -> bool:
480
+ try:
481
+ salt, expected_digest = stored_hash.split("$", 1)
482
+ except ValueError:
483
+ return False
484
+ actual_digest = hash_password(password, salt).split("$", 1)[1]
485
+ return hmac.compare_digest(actual_digest, expected_digest)
486
+
487
+
488
+ def auth_secret() -> str:
489
+ secret = os.getenv("APP_SECRET_KEY")
490
+ if not secret:
491
+ raise HTTPException(status_code=500, detail="APP_SECRET_KEY is not configured.")
492
+ return secret
493
+
494
+
495
+ def owner_session_ttl_minutes() -> int:
496
+ try:
497
+ return max(1, int(os.getenv("OWNER_SESSION_TTL_MINUTES", "1440")))
498
+ except ValueError as exc:
499
+ raise HTTPException(status_code=500, detail="OWNER_SESSION_TTL_MINUTES must be an integer.") from exc
500
+
501
+
502
+ def create_owner_session(owner_id: str) -> dict:
503
+ now = datetime.now(timezone.utc)
504
+ expires_at = now + timedelta(minutes=owner_session_ttl_minutes())
505
+ payload = {
506
+ "sub": owner_id,
507
+ "iat": int(now.timestamp()),
508
+ "exp": int(expires_at.timestamp()),
509
+ }
510
+ return {
511
+ "token": jwt.encode(payload, auth_secret(), algorithm="HS256"),
512
+ "expires_at": expires_at.isoformat(),
513
+ }
514
+
515
+
516
+ def verify_owner_token(token: str) -> dict:
517
+ try:
518
+ payload = jwt.decode(token, auth_secret(), algorithms=["HS256"])
519
+ except jwt.ExpiredSignatureError as exc:
520
+ raise HTTPException(status_code=401, detail="Owner token has expired. Please log in again.") from exc
521
+ except jwt.InvalidTokenError as exc:
522
+ raise HTTPException(status_code=401, detail="Invalid owner token.") from exc
523
+
524
+ owner_id = payload.get("sub")
525
+ if not isinstance(owner_id, str) or not owner_id:
526
+ raise HTTPException(status_code=401, detail="Invalid owner token.")
527
+ return ensure_owner(owner_id)
528
+
529
+
530
+ def require_owner(authorization: str | None) -> dict:
531
+ if not authorization or not authorization.startswith("Bearer "):
532
+ raise HTTPException(status_code=401, detail="Owner authentication is required.")
533
+ return verify_owner_token(authorization.removeprefix("Bearer ").strip())
534
+
535
+
536
+ def default_owner() -> dict | None:
537
+ demo_email = os.getenv("DEMO_OWNER_EMAIL")
538
+ demo_password = os.getenv("DEMO_OWNER_PASSWORD")
539
+ if not demo_email or not demo_password:
540
+ return None
541
+ return {
542
+ "id": "demo-owner",
543
+ "full_name": "Demo Owner",
544
+ "email": demo_email,
545
+ "password_hash": hash_password(demo_password),
546
+ "verified": "true",
547
+ "verification_token": None,
548
+ "reset_code": None,
549
+ "reset_code_expires": None,
550
+ "created_at": datetime.now(timezone.utc).isoformat(),
551
+ }
552
+
553
+
554
+ def public_owner(owner: dict) -> dict:
555
+ return {
556
+ "id": owner["id"],
557
+ "full_name": owner["full_name"],
558
+ "email": owner["email"],
559
+ "verified": "true" if owner.get("verified") in (None, "true", True) else "false",
560
+ "created_at": owner["created_at"],
561
+ "picture": owner.get("picture"),
562
+ }
563
+
564
+
565
+ def owner_verified(owner: dict) -> bool:
566
+ val = owner.get("verified")
567
+ return val in (None, "true", True)
568
+
569
+
570
+ def generate_verification_token() -> str:
571
+ return uuid4().hex + uuid4().hex
572
+
573
+
574
+ def send_verification_email(owner: dict) -> None:
575
+ smtp_host = os.getenv("SMTP_HOST")
576
+ smtp_port = os.getenv("SMTP_PORT", "587")
577
+ smtp_user = os.getenv("SMTP_USER")
578
+ smtp_pass = os.getenv("SMTP_PASS")
579
+ site_url = os.getenv("SITE_URL", "http://localhost:5173")
580
+ token = owner.get("verification_token")
581
+ if not token:
582
+ return
583
+
584
+ verify_url = f"{site_url}/verify-email?token={token}"
585
+
586
+ subject = "Confirm your email — Sahel.ai"
587
+ body = (
588
+ f"Welcome to Sahel.ai, {owner['full_name']}!\n\n"
589
+ f"Please confirm your email address by clicking the link below:\n\n"
590
+ f"{verify_url}\n\n"
591
+ f"This link expires in 24 hours.\n\n"
592
+ f"If you didn't create an account, you can ignore this email.\n\n"
593
+ f"Sahel.ai — Digital presence for Moroccan businesses"
594
+ )
595
+
596
+ if all([smtp_host, smtp_user, smtp_pass]):
597
+ try:
598
+ msg = MIMEText(body, _charset="utf-8")
599
+ msg["Subject"] = subject
600
+ msg["From"] = smtp_user
601
+ msg["To"] = owner["email"]
602
+ with smtplib.SMTP(smtp_host, int(smtp_port), timeout=10) as server:
603
+ server.starttls()
604
+ server.login(smtp_user, smtp_pass)
605
+ server.send_message(msg)
606
+ return
607
+ except Exception as e:
608
+ logging.error("SMTP send_verification_email failed: %s", e)
609
+
610
+ logging.warning("\n=== VERIFICATION EMAIL (SMTP not configured) ===")
611
+ logging.warning("To: %s", owner['email'])
612
+ logging.warning("==================================================\n")
613
+
614
+
615
+ def send_reset_code_email(owner: dict, code: str) -> None:
616
+ smtp_host = os.getenv("SMTP_HOST")
617
+ smtp_port = os.getenv("SMTP_PORT", "587")
618
+ smtp_user = os.getenv("SMTP_USER")
619
+ smtp_pass = os.getenv("SMTP_PASS")
620
+
621
+ subject = "Password reset code — Sahel.ai"
622
+ body = (
623
+ f"Hello {owner['full_name']},\n\n"
624
+ f"Use the code below to reset your Sahel.ai password:\n\n"
625
+ f"{code}\n\n"
626
+ f"This code expires in 15 minutes.\n\n"
627
+ f"If you didn't request this, you can ignore this email.\n\n"
628
+ f"Sahel.ai — Digital presence for Moroccan businesses"
629
+ )
630
+
631
+ if all([smtp_host, smtp_user, smtp_pass]):
632
+ try:
633
+ msg = MIMEText(body, _charset="utf-8")
634
+ msg["Subject"] = subject
635
+ msg["From"] = smtp_user
636
+ msg["To"] = owner["email"]
637
+ with smtplib.SMTP(smtp_host, int(smtp_port), timeout=10) as server:
638
+ server.starttls()
639
+ server.login(smtp_user, smtp_pass)
640
+ server.send_message(msg)
641
+ return
642
+ except Exception as e:
643
+ logging.error("SMTP send_reset_code failed: %s", e)
644
+
645
+ logging.warning("\n=== RESET CODE (SMTP not configured) ===")
646
+ logging.warning("To: %s", owner['email'])
647
+ logging.warning("==========================================\n")
648
+
649
+
650
+ def read_owners() -> list[dict]:
651
+ owners = read_table("owners")
652
+ if owners:
653
+ return owners
654
+ demo = default_owner()
655
+ if demo:
656
+ owners = [demo]
657
+ write_owners(owners)
658
+ return owners
659
+
660
+
661
+ def write_owners(owners: list[dict]) -> None:
662
+ write_table("owners", owners)
663
+
664
+
665
+ def find_owner(owner_id: str) -> dict | None:
666
+ return next((owner for owner in read_owners() if owner["id"] == owner_id), None)
667
+
668
+
669
+ def find_owner_by_email(email: str) -> dict | None:
670
+ normalized = normalize_email(email)
671
+ return next((owner for owner in read_owners() if owner["email"] == normalized), None)
672
+
673
+
674
+ def read_documents() -> list[dict]:
675
+ return read_table("documents")
676
+
677
+
678
+ def write_documents(documents: list[dict]) -> None:
679
+ write_table("documents", documents)
680
+
681
+
682
+ def read_conversations() -> list[dict]:
683
+ return read_table("conversations")
684
+
685
+
686
+ def write_conversations(conversations: list[dict]) -> None:
687
+ write_table("conversations", conversations)
688
+
689
+
690
+ def ensure_owner(owner_id: str) -> dict:
691
+ owner = find_owner(owner_id)
692
+ if not owner:
693
+ raise HTTPException(status_code=404, detail="Owner not found.")
694
+ return owner
695
+
696
+
697
+ BUSINESS_OPTIONAL_DEFAULTS = {
698
+ "owner_phone": None,
699
+ "city": None,
700
+ "working_hours": None,
701
+ "primary_services": None,
702
+ "address": None,
703
+ "latitude": None,
704
+ "longitude": None,
705
+ "cover_image_url": None,
706
+ "highlights": None,
707
+ "public_knowledge": None,
708
+ "ice": None,
709
+ "rc": None,
710
+ "if_tax": None,
711
+ "patente": None,
712
+ "cnss": None,
713
+ "legal_form": None,
714
+ "social_media": None,
715
+ }
716
+
717
+ COVER_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp"}
718
+
719
+
720
+ def normalize_business_record(business: dict) -> dict:
721
+ for key, default in BUSINESS_OPTIONAL_DEFAULTS.items():
722
+ if key not in business:
723
+ business[key] = default
724
+ for coord in ("latitude", "longitude"):
725
+ value = business.get(coord)
726
+ if value is not None and value != "":
727
+ try:
728
+ business[coord] = float(value)
729
+ except (TypeError, ValueError):
730
+ business[coord] = None
731
+ else:
732
+ business[coord] = None
733
+ return business
734
+
735
+
736
+ def business_profile_fields(payload: BusinessCreate | BusinessUpdate) -> dict:
737
+ return {
738
+ "name": payload.name,
739
+ "business_type": payload.business_type,
740
+ "description": payload.description,
741
+ "owner_email": payload.owner_email,
742
+ "owner_phone": payload.owner_phone,
743
+ "city": payload.city,
744
+ "working_hours": payload.working_hours,
745
+ "primary_services": payload.primary_services,
746
+ "address": payload.address,
747
+ "latitude": payload.latitude,
748
+ "longitude": payload.longitude,
749
+ "cover_image_url": payload.cover_image_url,
750
+ "highlights": payload.highlights,
751
+ "public_knowledge": payload.public_knowledge,
752
+ "ice": payload.ice,
753
+ "rc": payload.rc,
754
+ "if_tax": payload.if_tax,
755
+ "patente": payload.patente,
756
+ "cnss": payload.cnss,
757
+ "legal_form": payload.legal_form,
758
+ "social_media": payload.social_media,
759
+ }
760
+
761
+
762
+ def read_businesses() -> list[dict]:
763
+ businesses = read_table("businesses")
764
+ changed = False
765
+ for business in businesses:
766
+ if not business.get("owner_id"):
767
+ business["owner_id"] = "demo-owner"
768
+ changed = True
769
+ before = dict(business)
770
+ normalize_business_record(business)
771
+ if before != business:
772
+ changed = True
773
+ if changed:
774
+ read_owners()
775
+ write_businesses(businesses)
776
+ return businesses
777
+
778
+
779
+ def write_businesses(businesses: list[dict]) -> None:
780
+ write_table("businesses", businesses)
781
+
782
+
783
+ def read_messages() -> list[dict]:
784
+ messages = read_table("messages")
785
+ changed = False
786
+ for message in messages:
787
+ if not message.get("language"):
788
+ message["language"] = detect_language(message.get("question", ""))
789
+ changed = True
790
+ if changed:
791
+ write_messages(messages)
792
+ return messages
793
+
794
+
795
+ def write_messages(messages: list[dict]) -> None:
796
+ write_table("messages", messages)
797
+
798
+
799
+ def read_inquiries() -> list[dict]:
800
+ inquiries = read_table("inquiries")
801
+ changed = False
802
+ for inquiry in inquiries:
803
+ if not inquiry.get("status"):
804
+ inquiry["status"] = "new"
805
+ changed = True
806
+ if changed:
807
+ write_inquiries(inquiries)
808
+ return inquiries
809
+
810
+
811
+ def write_inquiries(inquiries: list[dict]) -> None:
812
+ write_table("inquiries", inquiries)
813
+
814
+
815
+ def read_contacts() -> list[dict]:
816
+ return read_table("contacts")
817
+
818
+
819
+ def write_contacts(contacts: list[dict]) -> None:
820
+ write_table("contacts", contacts)
821
+
822
+
823
+ def send_contact_email(contact: dict) -> None:
824
+ smtp_host = os.getenv("SMTP_HOST")
825
+ smtp_port = os.getenv("SMTP_PORT", "587")
826
+ smtp_user = os.getenv("SMTP_USER")
827
+ smtp_pass = os.getenv("SMTP_PASS")
828
+ notify_to = os.getenv("NOTIFICATION_EMAIL")
829
+
830
+ if not notify_to:
831
+ return
832
+
833
+ if not all([smtp_host, smtp_user, smtp_pass, notify_to]):
834
+ return
835
+
836
+ subject = f"New contact from {contact['name']} — Sahel.ai"
837
+ body = (
838
+ f"New contact message received!\n\n"
839
+ f"Name: {contact['name']}\n"
840
+ f"Email: {contact['email']}\n"
841
+ f"Message: {contact['message']}\n\n"
842
+ f"Sahel.ai — Digital presence for Moroccan businesses"
843
+ )
844
+
845
+ try:
846
+ msg = MIMEText(body, _charset="utf-8")
847
+ msg["Subject"] = subject
848
+ msg["From"] = smtp_user
849
+ msg["To"] = notify_to
850
+ with smtplib.SMTP(smtp_host, int(smtp_port), timeout=10) as server:
851
+ server.starttls()
852
+ server.login(smtp_user, smtp_pass)
853
+ server.send_message(msg)
854
+ logging.info("Contact notification sent to %s", notify_to)
855
+ except Exception as e:
856
+ logging.error("Failed to send contact notification: %s", e)
857
+
858
+
859
+ def save_contact(payload: ContactCreate) -> dict:
860
+ contact = {
861
+ "id": uuid4().hex[:12],
862
+ "name": payload.name.strip(),
863
+ "email": payload.email.strip().lower(),
864
+ "message": payload.message.strip(),
865
+ "created_at": datetime.now(timezone.utc).isoformat(),
866
+ }
867
+ contacts = read_contacts()
868
+ contacts.append(contact)
869
+ write_contacts(contacts)
870
+ try:
871
+ send_contact_email(contact)
872
+ except Exception as e:
873
+ logging.error("send_contact_email failed: %s", e)
874
+ return contact
875
+
876
+
877
+ def normalize_language_text(value: str) -> str:
878
+ return "".join(
879
+ char
880
+ for char in unicodedata.normalize("NFKD", value.lower())
881
+ if not unicodedata.combining(char)
882
+ )
883
+
884
+
885
+ def keyword_score(text: str, keywords: set[str]) -> int:
886
+ searchable_text = " " + re.sub(r"[^a-z0-9'-]+", " ", text) + " "
887
+ return sum(1 for keyword in keywords if f" {keyword} " in searchable_text)
888
+
889
+
890
+ def detect_language(text: str) -> str:
891
+ if re.search(r"[\u0600-\u06FF]", text):
892
+ return "ar"
893
+
894
+ normalized_text = normalize_language_text(text)
895
+ french_keywords = {
896
+ "bonjour",
897
+ "salut",
898
+ "merci",
899
+ "reservation",
900
+ "reserver",
901
+ "rendez-vous",
902
+ "disponibilite",
903
+ "dispo",
904
+ "devis",
905
+ "prix",
906
+ "svp",
907
+ "je",
908
+ "vous",
909
+ "nous",
910
+ "est-ce",
911
+ "s'il",
912
+ "ouvert",
913
+ "ferme",
914
+ "adresse",
915
+ "telephone",
916
+ "contact",
917
+ "livraison",
918
+ "menu",
919
+ "chambre",
920
+ "pharmacie",
921
+ "clinique",
922
+ "combien",
923
+ "horaires",
924
+ }
925
+ english_keywords = {
926
+ "hello",
927
+ "hi",
928
+ "thanks",
929
+ "thank",
930
+ "booking",
931
+ "availability",
932
+ "quote",
933
+ "price",
934
+ "please",
935
+ "what",
936
+ "when",
937
+ "where",
938
+ "can",
939
+ "open",
940
+ "closed",
941
+ "address",
942
+ "phone",
943
+ "contact",
944
+ "delivery",
945
+ "menu",
946
+ "room",
947
+ "clinic",
948
+ "how",
949
+ }
950
+ arabic_latin_keywords = {
951
+ "salam",
952
+ "salaam",
953
+ "salamou",
954
+ "chhal",
955
+ "bchhal",
956
+ "wach",
957
+ "wash",
958
+ "fin",
959
+ "fayn",
960
+ "kayen",
961
+ "kayn",
962
+ "bghit",
963
+ "bghina",
964
+ "nheb",
965
+ "shukran",
966
+ "chokran",
967
+ "marhba",
968
+ "had",
969
+ "hadou",
970
+ "dyal",
971
+ "dial",
972
+ "dyali",
973
+ "dyalna",
974
+ "chnu",
975
+ "chnou",
976
+ "chno",
977
+ "ach",
978
+ "ash",
979
+ "ana",
980
+ "hna",
981
+ "mzyan",
982
+ "mzian",
983
+ "bzf",
984
+ "bezzaf",
985
+ "smiya",
986
+ "chi",
987
+ "bla",
988
+ "bhal",
989
+ "nta",
990
+ "nti",
991
+ "ntuma",
992
+ "wah",
993
+ "gal",
994
+ "lgal",
995
+ "drari",
996
+ "tnak",
997
+ "temma",
998
+ }
999
+ if keyword_score(normalized_text, arabic_latin_keywords) > 0:
1000
+ return "ar"
1001
+
1002
+ french_score = keyword_score(normalized_text, french_keywords)
1003
+ english_score = keyword_score(normalized_text, english_keywords)
1004
+
1005
+ if re.search(r"[àâçéèêëîïôùûüÿœ]", text.lower()):
1006
+ french_score += 1
1007
+
1008
+ if french_score > english_score:
1009
+ return "fr"
1010
+ if english_score > french_score:
1011
+ return "en"
1012
+ return "en"
1013
+
1014
+
1015
+ def language_instruction(language: str) -> str:
1016
+ language_name = LANGUAGE_NAMES.get(language, LANGUAGE_NAMES["en"])
1017
+ fallback = LANGUAGE_FALLBACKS.get(language, LANGUAGE_FALLBACKS["en"])
1018
+ return (
1019
+ f"The visitor language is {language_name}. Answer entirely in {language_name}. "
1020
+ "Do not switch language unless the visitor explicitly asks you to translate. "
1021
+ "For Moroccan Arabic/Darija, answer in clear Arabic script. "
1022
+ "If the profile and retrieved document context do not contain the answer, "
1023
+ f"use this fallback message exactly: {fallback}"
1024
+ )
1025
+
1026
+
1027
+ def save_chat_message(
1028
+ business_id: str,
1029
+ question: str,
1030
+ answer: str,
1031
+ sources: list[str],
1032
+ conversation_id: str | None,
1033
+ language: str,
1034
+ visitor_ip: str | None,
1035
+ ) -> str:
1036
+ current_conversation_id = conversation_id or uuid4().hex[:12]
1037
+ conversations = read_conversations()
1038
+ if not any(conversation["id"] == current_conversation_id for conversation in conversations):
1039
+ conversations.append(
1040
+ {
1041
+ "id": current_conversation_id,
1042
+ "business_id": business_id,
1043
+ "language": language,
1044
+ "visitor_ip": visitor_ip,
1045
+ "started_at": datetime.now(timezone.utc).isoformat(),
1046
+ }
1047
+ )
1048
+ write_conversations(conversations)
1049
+
1050
+ messages = read_messages()
1051
+ messages.append(
1052
+ {
1053
+ "id": uuid4().hex[:12],
1054
+ "business_id": business_id,
1055
+ "conversation_id": current_conversation_id,
1056
+ "question": question,
1057
+ "answer": answer,
1058
+ "sources": sources,
1059
+ "language": language,
1060
+ "created_at": datetime.now(timezone.utc).isoformat(),
1061
+ }
1062
+ )
1063
+ write_messages(messages)
1064
+ return current_conversation_id
1065
+
1066
+
1067
+ def send_notification_email(business: dict, inquiry: dict) -> None:
1068
+ smtp_host = os.getenv("SMTP_HOST")
1069
+ smtp_port = os.getenv("SMTP_PORT", "587")
1070
+ smtp_user = os.getenv("SMTP_USER")
1071
+ smtp_pass = os.getenv("SMTP_PASS")
1072
+ notify_to = os.getenv("NOTIFICATION_EMAIL") or business.get("owner_email")
1073
+
1074
+ if not all([smtp_host, smtp_user, smtp_pass, notify_to]):
1075
+ return
1076
+
1077
+ subject = f"New inquiry for {business['name']} — Sahel.ai"
1078
+ body = (
1079
+ f"New inquiry received for {business['name']}!\n\n"
1080
+ f"Name: {inquiry['name']}\n"
1081
+ f"Contact: {inquiry['contact']}\n"
1082
+ f"Message: {inquiry['message']}\n\n"
1083
+ f"View all inquiries: {os.getenv('SITE_URL', 'https://sahel.ai')}/#/dashboard\n\n"
1084
+ f"Sahel.ai — Digital presence for Moroccan businesses"
1085
+ )
1086
+
1087
+ try:
1088
+ msg = MIMEText(body, _charset="utf-8")
1089
+ msg["Subject"] = subject
1090
+ msg["From"] = smtp_user
1091
+ msg["To"] = notify_to
1092
+
1093
+ with smtplib.SMTP(smtp_host, int(smtp_port), timeout=10) as server:
1094
+ server.starttls()
1095
+ server.login(smtp_user, smtp_pass)
1096
+ server.send_message(msg)
1097
+ logging.info("Inquiry notification sent to %s", notify_to)
1098
+ except Exception as e:
1099
+ logging.error("Failed to send inquiry notification: %s", e)
1100
+
1101
+
1102
+ def save_inquiry(business: dict, payload: InquiryCreate) -> dict:
1103
+ inquiry = {
1104
+ "id": uuid4().hex[:12],
1105
+ "business_id": business["id"],
1106
+ "name": payload.name.strip(),
1107
+ "contact": payload.contact.strip(),
1108
+ "message": payload.message.strip(),
1109
+ "conversation_id": payload.conversation_id,
1110
+ "status": "new",
1111
+ "created_at": datetime.now(timezone.utc).isoformat(),
1112
+ }
1113
+ inquiries = read_inquiries()
1114
+ inquiries.append(inquiry)
1115
+ write_inquiries(inquiries)
1116
+ try:
1117
+ send_notification_email(business, inquiry)
1118
+ except Exception as e:
1119
+ logging.error("send_notification_email failed: %s", e)
1120
+ return inquiry
1121
+
1122
+
1123
+ def find_business(identifier: str) -> dict | None:
1124
+ return next(
1125
+ (
1126
+ business
1127
+ for business in read_businesses()
1128
+ if business["id"] == identifier or business["slug"] == identifier
1129
+ ),
1130
+ None,
1131
+ )
1132
+
1133
+
1134
+ def ensure_owner_business(identifier: str, owner: dict) -> dict:
1135
+ business = find_business(identifier)
1136
+ if not business:
1137
+ raise HTTPException(status_code=404, detail="Business not found.")
1138
+ if business.get("owner_id") != owner["id"]:
1139
+ raise HTTPException(status_code=403, detail="This business belongs to another owner.")
1140
+ return business
1141
+
1142
+
1143
+ def unique_slug(name: str) -> str:
1144
+ base_slug = slugify(name)
1145
+ existing = {business["slug"] for business in read_businesses()}
1146
+ slug = base_slug
1147
+ counter = 2
1148
+ while slug in existing:
1149
+ slug = f"{base_slug}-{counter}"
1150
+ counter += 1
1151
+ return slug
1152
+
1153
+
1154
+ def business_store_path(business_id: str) -> Path:
1155
+ return VECTOR_STORE_ROOT / safe_id(business_id)
1156
+
1157
+
1158
+ def business_upload_path(business_id: str) -> Path:
1159
+ path = UPLOAD_ROOT / safe_id(business_id)
1160
+ path.mkdir(exist_ok=True)
1161
+ return path
1162
+
1163
+
1164
+ def document_file_path(document: dict) -> Path:
1165
+ return business_upload_path(document["business_id"]) / Path(document["file_name"]).name
1166
+
1167
+
1168
+ def process_document(file_path: Path) -> List[str]:
1169
+ try:
1170
+ if file_path.suffix.lower() == ".txt":
1171
+ try:
1172
+ document_text = file_path.read_text(encoding="utf-8")
1173
+ except UnicodeDecodeError:
1174
+ document_text = file_path.read_text(encoding="cp1252")
1175
+ elif file_path.suffix.lower() == ".pdf":
1176
+ reader = PdfReader(str(file_path))
1177
+ document_text = "\n\n".join(
1178
+ page.extract_text() or ""
1179
+ for page in reader.pages
1180
+ )
1181
+ else:
1182
+ raise ValueError(f"Unsupported file format: {file_path.suffix}")
1183
+
1184
+ text_splitter = CharacterTextSplitter(chunk_size=1000, chunk_overlap=100)
1185
+ texts = text_splitter.split_text(document_text)
1186
+ return [text.strip() for text in texts if text.strip()]
1187
+ except Exception as exc:
1188
+ raise HTTPException(status_code=500, detail=f"Error processing document: {exc}") from exc
1189
+
1190
+
1191
+ def load_store(business_id: str) -> LocalFaissStore:
1192
+ store_path = business_store_path(business_id)
1193
+ if not store_path.exists():
1194
+ raise HTTPException(
1195
+ status_code=404,
1196
+ detail="No documents indexed for this business yet. Upload a PDF first.",
1197
+ )
1198
+ return LocalFaissStore.load_local(store_path, get_embeddings())
1199
+
1200
+
1201
+ def sync_documents_from_uploads() -> list[dict]:
1202
+ documents = read_documents()
1203
+ known_files = {
1204
+ (document["business_id"], document["file_name"])
1205
+ for document in documents
1206
+ }
1207
+ changed = False
1208
+
1209
+ for business in read_businesses():
1210
+ upload_path = business_upload_path(business["id"])
1211
+ for ext in ("*.pdf", "*.txt"):
1212
+ for file_path in upload_path.glob(ext):
1213
+ key = (business["id"], file_path.name)
1214
+ if key in known_files:
1215
+ continue
1216
+ file_type = "text/plain" if file_path.suffix.lower() == ".txt" else "application/pdf"
1217
+ documents.append(
1218
+ {
1219
+ "id": uuid4().hex[:12],
1220
+ "business_id": business["id"],
1221
+ "file_name": file_path.name,
1222
+ "file_type": file_type,
1223
+ "file_url": f"/uploaded_documents/{business['id']}/{file_path.name}",
1224
+ "chunks": 0,
1225
+ "size_bytes": file_path.stat().st_size,
1226
+ "uploaded_at": datetime.fromtimestamp(
1227
+ file_path.stat().st_mtime,
1228
+ timezone.utc,
1229
+ ).isoformat(),
1230
+ }
1231
+ )
1232
+ changed = True
1233
+
1234
+ if changed:
1235
+ write_documents(documents)
1236
+ return documents
1237
+
1238
+
1239
+ def documents_for_business(business_id: str) -> list[dict]:
1240
+ documents = [
1241
+ document
1242
+ for document in sync_documents_from_uploads()
1243
+ if document["business_id"] == business_id
1244
+ ]
1245
+ return sorted(documents, key=lambda item: item["uploaded_at"], reverse=True)
1246
+
1247
+
1248
+ def upsert_document_metadata(business_id: str, file_path: Path, chunks: int, original_name: str | None = None) -> dict:
1249
+ documents = sync_documents_from_uploads()
1250
+ file_name = file_path.name
1251
+ existing = next(
1252
+ (
1253
+ document
1254
+ for document in documents
1255
+ if document["business_id"] == business_id and document["file_name"] == file_name
1256
+ ),
1257
+ None,
1258
+ )
1259
+ file_type = "text/plain" if file_path.suffix.lower() == ".txt" else "application/pdf"
1260
+ metadata = {
1261
+ "id": existing["id"] if existing else uuid4().hex[:12],
1262
+ "business_id": business_id,
1263
+ "file_name": file_name,
1264
+ "original_name": original_name or file_name,
1265
+ "file_type": file_type,
1266
+ "file_url": f"/uploaded_documents/{business_id}/{file_name}",
1267
+ "chunks": chunks,
1268
+ "size_bytes": file_path.stat().st_size,
1269
+ "uploaded_at": datetime.now(timezone.utc).isoformat(),
1270
+ }
1271
+
1272
+ if existing:
1273
+ existing.update(metadata)
1274
+ else:
1275
+ documents.append(metadata)
1276
+
1277
+ write_documents(documents)
1278
+ return metadata
1279
+
1280
+
1281
+ def remove_business_store(business_id: str) -> None:
1282
+ store_path = business_store_path(business_id).resolve()
1283
+ root_path = VECTOR_STORE_ROOT.resolve()
1284
+ if store_path.exists() and root_path in store_path.parents:
1285
+ shutil.rmtree(store_path)
1286
+
1287
+
1288
+ def rebuild_business_store(business_id: str) -> int:
1289
+ documents = documents_for_business(business_id)
1290
+ all_texts: list[str] = []
1291
+ all_metadata: list[dict] = []
1292
+ changed = False
1293
+
1294
+ for document in documents:
1295
+ file_path = document_file_path(document)
1296
+ if not file_path.exists():
1297
+ continue
1298
+ texts = process_document(file_path)
1299
+ all_texts.extend(texts)
1300
+ all_metadata.extend(
1301
+ [{"source": document["file_name"], "document_id": document["id"]}] * len(texts)
1302
+ )
1303
+ if document.get("chunks") != len(texts):
1304
+ document["chunks"] = len(texts)
1305
+ changed = True
1306
+
1307
+ if changed:
1308
+ existing_documents = read_documents()
1309
+ chunk_counts = {document["id"]: document["chunks"] for document in documents}
1310
+ for document in existing_documents:
1311
+ if document["id"] in chunk_counts:
1312
+ document["chunks"] = chunk_counts[document["id"]]
1313
+ write_documents(existing_documents)
1314
+
1315
+ remove_business_store(business_id)
1316
+ if not all_texts:
1317
+ return 0
1318
+
1319
+ store_path = business_store_path(business_id)
1320
+ store = LocalFaissStore.from_texts(all_texts, get_embeddings(), all_metadata)
1321
+ store.save_local(store_path)
1322
+ return len(all_texts)
1323
+
1324
+
1325
+ def answer_for_business(business: dict, question: str, language: str | None = None) -> dict:
1326
+ detected_language = language or detect_language(question)
1327
+ relevant_docs = []
1328
+ try:
1329
+ store = load_store(business["id"])
1330
+ relevant_docs = store.similarity_search(question, k=3)
1331
+ except HTTPException as exc:
1332
+ if exc.status_code != 404:
1333
+ raise
1334
+ if documents_for_business(business["id"]):
1335
+ try:
1336
+ rebuild_business_store(business["id"])
1337
+ store = load_store(business["id"])
1338
+ relevant_docs = store.similarity_search(question, k=3)
1339
+ except HTTPException:
1340
+ pass
1341
+
1342
+ context = "\n".join(doc.page_content for doc in relevant_docs)
1343
+ owner_email = business.get("owner_email") or "Not provided"
1344
+ owner_phone = business.get("owner_phone") or "Not provided"
1345
+
1346
+ response = client.chat.completions.create(
1347
+ model="llama-3.3-70b-versatile",
1348
+ temperature=0.2,
1349
+ messages=[
1350
+ {
1351
+ "role": "system",
1352
+ "content": (
1353
+ "You are the multilingual assistant for this Moroccan business. "
1354
+ "Answer only from the provided business profile and document context. "
1355
+ "Never invent prices, opening hours, availability, medical advice, "
1356
+ "legal advice, or policies that are not present in the context. "
1357
+ f"{language_instruction(detected_language)}"
1358
+ ),
1359
+ },
1360
+ {
1361
+ "role": "user",
1362
+ "content": (
1363
+ f"Business name: {business['name']}\n"
1364
+ f"Business type: {business['business_type']}\n"
1365
+ f"Business description: {business.get('description', '')}\n"
1366
+ f"City: {business.get('city', 'Not specified')}\n"
1367
+ f"Working hours: {business.get('working_hours', 'Not specified')}\n"
1368
+ f"Services: {business.get('primary_services', 'Not specified')}\n"
1369
+ f"Highlights: {business.get('highlights', 'Not specified')}\n"
1370
+ f"General knowledge: {business.get('public_knowledge', 'Not specified')}\n\n"
1371
+ f"Owner email: {owner_email}\n"
1372
+ f"Owner phone: {owner_phone}\n\n"
1373
+ f"Question: {question}\n\n"
1374
+ f"Document context:\n{context or 'No uploaded document context available.'}"
1375
+ ),
1376
+ },
1377
+ ],
1378
+ )
1379
+
1380
+ return {
1381
+ "answer": response.choices[0].message.content,
1382
+ "sources": [doc.metadata.get("source", "Unknown") for doc in relevant_docs],
1383
+ }
1384
+
1385
+
1386
+ async def stream_answer_for_business(business: dict, question: str, language: str | None = None):
1387
+ detected_language = language or detect_language(question)
1388
+ relevant_docs = []
1389
+ try:
1390
+ store = load_store(business["id"])
1391
+ relevant_docs = store.similarity_search(question, k=3)
1392
+ except HTTPException as exc:
1393
+ if exc.status_code != 404:
1394
+ raise
1395
+ if documents_for_business(business["id"]):
1396
+ try:
1397
+ rebuild_business_store(business["id"])
1398
+ store = load_store(business["id"])
1399
+ relevant_docs = store.similarity_search(question, k=3)
1400
+ except HTTPException:
1401
+ pass
1402
+
1403
+ context = "\n".join(doc.page_content for doc in relevant_docs)
1404
+ owner_email = business.get("owner_email") or "Not provided"
1405
+ owner_phone = business.get("owner_phone") or "Not provided"
1406
+
1407
+ messages = [
1408
+ {
1409
+ "role": "system",
1410
+ "content": (
1411
+ "You are the multilingual assistant for this Moroccan business. "
1412
+ "Answer only from the provided business profile and document context. "
1413
+ "Never invent prices, opening hours, availability, medical advice, "
1414
+ "legal advice, or policies that are not present in the context. "
1415
+ f"{language_instruction(detected_language)}"
1416
+ ),
1417
+ },
1418
+ {
1419
+ "role": "user",
1420
+ "content": (
1421
+ f"Business name: {business['name']}\n"
1422
+ f"Business type: {business['business_type']}\n"
1423
+ f"Business description: {business.get('description', '')}\n"
1424
+ f"City: {business.get('city', 'Not specified')}\n"
1425
+ f"Working hours: {business.get('working_hours', 'Not specified')}\n"
1426
+ f"Services: {business.get('primary_services', 'Not specified')}\n"
1427
+ f"Highlights: {business.get('highlights', 'Not specified')}\n"
1428
+ f"General knowledge: {business.get('public_knowledge', 'Not specified')}\n\n"
1429
+ f"Owner email: {owner_email}\n"
1430
+ f"Owner phone: {owner_phone}\n\n"
1431
+ f"Question: {question}\n\n"
1432
+ f"Document context:\n{context or 'No uploaded document context available.'}"
1433
+ ),
1434
+ },
1435
+ ]
1436
+
1437
+ loop = asyncio.get_event_loop()
1438
+ stream = await loop.run_in_executor(
1439
+ None,
1440
+ lambda: client.chat.completions.create(
1441
+ model="llama-3.3-70b-versatile",
1442
+ temperature=0.2,
1443
+ stream=True,
1444
+ messages=messages,
1445
+ ),
1446
+ )
1447
+ for chunk in stream:
1448
+ token = chunk.choices[0].delta.content
1449
+ if token:
1450
+ yield token
1451
+
1452
+
1453
+ async def stream_llm_only(business: dict, question: str, language: str | None = None):
1454
+ detected_language = language or detect_language(question)
1455
+ owner_email = business.get("owner_email") or "Not provided"
1456
+ owner_phone = business.get("owner_phone") or "Not provided"
1457
+
1458
+ messages = [
1459
+ {
1460
+ "role": "system",
1461
+ "content": (
1462
+ "You are the multilingual assistant for this Moroccan business. "
1463
+ "Answer based on the business profile below. "
1464
+ "If you do not know the answer, say so honestly. "
1465
+ "Never invent prices, opening hours, or other details. "
1466
+ f"{language_instruction(detected_language)}"
1467
+ ),
1468
+ },
1469
+ {
1470
+ "role": "user",
1471
+ "content": (
1472
+ f"Business name: {business['name']}\n"
1473
+ f"Business type: {business['business_type']}\n"
1474
+ f"Business description: {business.get('description', '')}\n"
1475
+ f"City: {business.get('city', 'Not specified')}\n"
1476
+ f"Working hours: {business.get('working_hours', 'Not specified')}\n"
1477
+ f"Services: {business.get('primary_services', 'Not specified')}\n"
1478
+ f"Highlights: {business.get('highlights', 'Not specified')}\n"
1479
+ f"General knowledge: {business.get('public_knowledge', 'Not specified')}\n\n"
1480
+ f"Owner email: {owner_email}\n"
1481
+ f"Owner phone: {owner_phone}\n\n"
1482
+ f"Question: {question}"
1483
+ ),
1484
+ },
1485
+ ]
1486
+
1487
+ loop = asyncio.get_event_loop()
1488
+ stream = await loop.run_in_executor(
1489
+ None,
1490
+ lambda: client.chat.completions.create(
1491
+ model="llama-3.3-70b-versatile",
1492
+ temperature=0.2,
1493
+ stream=True,
1494
+ messages=messages,
1495
+ ),
1496
+ )
1497
+ for chunk in stream:
1498
+ token = chunk.choices[0].delta.content
1499
+ if token:
1500
+ yield token
1501
+
1502
+
1503
+ def analytics_for_business(business_id: str) -> dict:
1504
+ messages = [message for message in read_messages() if message["business_id"] == business_id]
1505
+ conversations = {message["conversation_id"] for message in messages}
1506
+ conversation_languages: dict[str, str] = {}
1507
+ question_counts: dict[str, int] = {}
1508
+
1509
+ for message in messages:
1510
+ conversation_languages.setdefault(
1511
+ message["conversation_id"],
1512
+ message.get("language") or detect_language(message.get("question", "")),
1513
+ )
1514
+ question = message["question"].strip()
1515
+ question_counts[question] = question_counts.get(question, 0) + 1
1516
+
1517
+ language_counts = {"ar": 0, "fr": 0, "en": 0, "unknown": 0}
1518
+ for language in conversation_languages.values():
1519
+ if language in language_counts:
1520
+ language_counts[language] += 1
1521
+ else:
1522
+ language_counts["unknown"] += 1
1523
+
1524
+ top_questions = [
1525
+ {"question": question, "count": count}
1526
+ for question, count in sorted(question_counts.items(), key=lambda item: item[1], reverse=True)[:5]
1527
+ ]
1528
+
1529
+ return {
1530
+ "total_messages": len(messages),
1531
+ "total_conversations": len(conversations),
1532
+ "language_counts": language_counts,
1533
+ "top_questions": top_questions,
1534
+ "recent_messages": list(reversed(messages[-8:])),
1535
+ }
1536
+
1537
+
1538
+ def inquiries_for_business(business_id: str) -> list[dict]:
1539
+ inquiries = [inquiry for inquiry in read_inquiries() if inquiry["business_id"] == business_id]
1540
+ return list(reversed(inquiries[-20:]))
1541
+
1542
+
1543
+ @app.get("/health")
1544
+ async def health_check():
1545
+ return {"status": "ok"}
1546
+
1547
+
1548
+ @app.post("/owners/register")
1549
+ @limiter.limit(RATE_LIMIT_AUTH)
1550
+ async def register_owner(request: Request, payload: OwnerRegister):
1551
+ owners = read_owners()
1552
+ email = normalize_email(payload.email)
1553
+ if any(owner["email"] == email for owner in owners):
1554
+ raise HTTPException(status_code=409, detail="An owner with this email already exists.")
1555
+
1556
+ verification_token = generate_verification_token()
1557
+ owner = {
1558
+ "id": uuid4().hex[:12],
1559
+ "full_name": payload.full_name.strip(),
1560
+ "email": email,
1561
+ "password_hash": hash_password(payload.password),
1562
+ "verified": "false",
1563
+ "verification_token": verification_token,
1564
+ "reset_code": None,
1565
+ "reset_code_expires": None,
1566
+ "created_at": datetime.now(timezone.utc).isoformat(),
1567
+ }
1568
+ owners.append(owner)
1569
+ write_owners(owners)
1570
+ send_verification_email(owner)
1571
+ return {
1572
+ "message": "Registration successful. Please check your email to verify your account.",
1573
+ "owner_id": owner["id"],
1574
+ }
1575
+
1576
+
1577
+ @app.post("/owners/verify", response_model=AuthResponse)
1578
+ @limiter.limit(RATE_LIMIT_AUTH)
1579
+ async def verify_owner_email(request: Request, payload: VerifyToken, response: Response):
1580
+ owners = read_owners()
1581
+ owner = next((o for o in owners if o.get("verification_token") == payload.token), None)
1582
+ if not owner:
1583
+ raise HTTPException(status_code=400, detail="Invalid or expired verification token.")
1584
+
1585
+ owner["verified"] = "true"
1586
+ owner["verification_token"] = None
1587
+ write_owners(owners)
1588
+ session = create_owner_session(owner["id"])
1589
+ _set_session_cookie(response, session["token"], session["expires_at"])
1590
+ return {"owner": public_owner(owner), **session}
1591
+
1592
+
1593
+ @app.post("/owners/resend-verification")
1594
+ @limiter.limit(RATE_LIMIT_AUTH)
1595
+ async def resend_verification(request: Request, payload: ForgotPassword):
1596
+ owner = find_owner_by_email(payload.email)
1597
+ if not owner:
1598
+ raise HTTPException(status_code=404, detail="No account found with this email.")
1599
+ if owner_verified(owner):
1600
+ raise HTTPException(status_code=400, detail="This account is already verified.")
1601
+
1602
+ owner["verification_token"] = generate_verification_token()
1603
+ owners = read_owners()
1604
+ for i, o in enumerate(owners):
1605
+ if o["id"] == owner["id"]:
1606
+ owners[i] = owner
1607
+ break
1608
+ write_owners(owners)
1609
+ send_verification_email(owner)
1610
+ return {"message": "Verification email resent. Please check your inbox."}
1611
+
1612
+
1613
+ @app.post("/owners/forgot-password")
1614
+ @limiter.limit(RATE_LIMIT_AUTH)
1615
+ async def forgot_password(request: Request, payload: ForgotPassword):
1616
+ owner = find_owner_by_email(payload.email)
1617
+ if not owner:
1618
+ return {"message": "If that email exists, a reset code has been sent."}
1619
+
1620
+ code = secrets.token_urlsafe(6)[:8]
1621
+ owner["reset_code"] = code
1622
+ owner["reset_code_expires"] = (datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat()
1623
+
1624
+ owners = read_owners()
1625
+ for i, o in enumerate(owners):
1626
+ if o["id"] == owner["id"]:
1627
+ owners[i] = owner
1628
+ break
1629
+ write_owners(owners)
1630
+ send_reset_code_email(owner, code)
1631
+ return {"message": "If that email exists, a reset code has been sent."}
1632
+
1633
+
1634
+ @app.post("/owners/verify-reset-code")
1635
+ @limiter.limit(RATE_LIMIT_AUTH)
1636
+ async def verify_reset_code(request: Request, payload: VerifyResetCode):
1637
+ owner = find_owner_by_email(payload.email)
1638
+ if not owner:
1639
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1640
+
1641
+ if owner.get("reset_code") != payload.code:
1642
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1643
+
1644
+ expires = owner.get("reset_code_expires")
1645
+ if expires:
1646
+ try:
1647
+ expires_dt = datetime.fromisoformat(expires)
1648
+ if expires_dt.tzinfo is None:
1649
+ expires_dt = expires_dt.replace(tzinfo=timezone.utc)
1650
+ if datetime.now(timezone.utc) > expires_dt:
1651
+ raise HTTPException(status_code=400, detail="Reset code has expired. Please request a new one.")
1652
+ except (ValueError, TypeError):
1653
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1654
+
1655
+ return {"valid": True}
1656
+
1657
+
1658
+ @app.post("/owners/reset-password")
1659
+ @limiter.limit(RATE_LIMIT_AUTH)
1660
+ async def reset_password(request: Request, payload: ResetPassword):
1661
+ owner = find_owner_by_email(payload.email)
1662
+ if not owner:
1663
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1664
+
1665
+ if owner.get("reset_code") != payload.code:
1666
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1667
+
1668
+ expires = owner.get("reset_code_expires")
1669
+ if not expires:
1670
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1671
+
1672
+ try:
1673
+ expires_dt = datetime.fromisoformat(expires)
1674
+ if expires_dt.tzinfo is None:
1675
+ expires_dt = expires_dt.replace(tzinfo=timezone.utc)
1676
+ except (ValueError, TypeError):
1677
+ raise HTTPException(status_code=400, detail="Invalid or expired reset code.")
1678
+
1679
+ if datetime.now(timezone.utc) > expires_dt:
1680
+ raise HTTPException(status_code=400, detail="Reset code has expired. Please request a new one.")
1681
+
1682
+ owner["password_hash"] = hash_password(payload.new_password)
1683
+ owner["reset_code"] = None
1684
+ owner["reset_code_expires"] = None
1685
+
1686
+ owners = read_owners()
1687
+ for i, o in enumerate(owners):
1688
+ if o["id"] == owner["id"]:
1689
+ owners[i] = owner
1690
+ break
1691
+ write_owners(owners)
1692
+ return {"message": "Password has been reset successfully."}
1693
+
1694
+
1695
+ @app.post("/owners/login", response_model=AuthResponse)
1696
+ @limiter.limit(RATE_LIMIT_AUTH)
1697
+ async def login_owner(request: Request, payload: OwnerLogin, response: Response):
1698
+ owner = find_owner_by_email(payload.email)
1699
+ if not owner or not verify_password(payload.password, owner.get("password_hash", "")):
1700
+ raise HTTPException(status_code=401, detail="Invalid email or password.")
1701
+ if not owner_verified(owner):
1702
+ raise HTTPException(
1703
+ status_code=403,
1704
+ detail="Please verify your email before logging in. Check your inbox or request a new verification link.",
1705
+ )
1706
+ session = create_owner_session(owner["id"])
1707
+ _set_session_cookie(response, session["token"], session["expires_at"])
1708
+ return {"owner": public_owner(owner), **session}
1709
+
1710
+
1711
+ @app.post("/owners/google-login", response_model=AuthResponse)
1712
+ @limiter.limit(RATE_LIMIT_AUTH)
1713
+ async def google_login(request: Request, payload: GoogleLogin, response: Response):
1714
+ import httpx
1715
+ client_id = os.getenv("GOOGLE_CLIENT_ID")
1716
+ if not client_id:
1717
+ raise HTTPException(status_code=500, detail="GOOGLE_CLIENT_ID not configured.")
1718
+
1719
+ try:
1720
+ async with httpx.AsyncClient(timeout=10) as client:
1721
+ resp = await client.get(
1722
+ "https://oauth2.googleapis.com/tokeninfo",
1723
+ params={"id_token": payload.credential},
1724
+ )
1725
+ if resp.status_code != 200:
1726
+ raise HTTPException(status_code=401, detail="Invalid Google token.")
1727
+ info = resp.json()
1728
+ except httpx.RequestError:
1729
+ raise HTTPException(status_code=502, detail="Failed to verify Google token.")
1730
+
1731
+ if info.get("aud") != client_id:
1732
+ raise HTTPException(status_code=401, detail="Google token audience mismatch.")
1733
+
1734
+ email = normalize_email(info.get("email", ""))
1735
+ if not email:
1736
+ raise HTTPException(status_code=400, detail="Google account has no email.")
1737
+
1738
+ name = info.get("name", "").strip() or email.split("@")[0]
1739
+ owners = read_owners()
1740
+ owner = find_owner_by_email(email)
1741
+
1742
+ if not owner:
1743
+ owner = {
1744
+ "id": uuid4().hex[:12],
1745
+ "full_name": name,
1746
+ "email": email,
1747
+ "password_hash": "",
1748
+ "verified": "true",
1749
+ "verification_token": None,
1750
+ "reset_code": None,
1751
+ "reset_code_expires": None,
1752
+ "created_at": datetime.now(timezone.utc).isoformat(),
1753
+ }
1754
+ owners.append(owner)
1755
+ write_owners(owners)
1756
+ elif not owner_verified(owner):
1757
+ owner["verified"] = "true"
1758
+ owner["verification_token"] = None
1759
+ for i, o in enumerate(owners):
1760
+ if o["id"] == owner["id"]:
1761
+ owners[i] = owner
1762
+ break
1763
+ write_owners(owners)
1764
+
1765
+ session = create_owner_session(owner["id"])
1766
+ _set_session_cookie(response, session["token"], session["expires_at"])
1767
+ return {"owner": public_owner(owner), **session}
1768
+
1769
+
1770
+ @app.post("/owners/logout")
1771
+ async def logout_owner(response: Response):
1772
+ _clear_session_cookie(response)
1773
+ return {"message": "Logged out."}
1774
+
1775
+
1776
+ @app.get("/owners/{owner_id}", response_model=OwnerPublic)
1777
+ async def get_owner(owner_id: str, authorization: str | None = Header(default=None)):
1778
+ owner = require_owner(authorization)
1779
+ if owner["id"] != owner_id:
1780
+ raise HTTPException(status_code=403, detail="You can only access your own owner profile.")
1781
+ return public_owner(owner)
1782
+
1783
+
1784
+ PROFILE_PHOTOS_DIR = DATA_ROOT / "profile_photos"
1785
+ PROFILE_PHOTOS_DIR.mkdir(exist_ok=True)
1786
+
1787
+
1788
+ @app.put("/owners/profile")
1789
+ async def update_owner_profile(payload: ProfileUpdate, authorization: str | None = Header(default=None)):
1790
+ owner = require_owner(authorization)
1791
+ owners = read_owners()
1792
+ idx = next((i for i, o in enumerate(owners) if o["id"] == owner["id"]), None)
1793
+ if idx is None:
1794
+ raise HTTPException(status_code=404, detail="Owner not found")
1795
+
1796
+ if payload.full_name is not None:
1797
+ owner["full_name"] = payload.full_name
1798
+ if payload.email is not None:
1799
+ owner["email"] = normalize_email(payload.email)
1800
+ if payload.new_password:
1801
+ if not payload.current_password:
1802
+ raise HTTPException(status_code=400, detail="Current password is required to change password.")
1803
+ if not verify_password(payload.current_password, owner["password_hash"]):
1804
+ raise HTTPException(status_code=400, detail="Current password is incorrect")
1805
+ if len(payload.new_password) < 6:
1806
+ raise HTTPException(status_code=400, detail="Password must be at least 6 characters")
1807
+ owner["password_hash"] = hash_password(payload.new_password)
1808
+ if payload.profile_photo:
1809
+ try:
1810
+ header, encoded = payload.profile_photo.split(",", 1)
1811
+ ext = "png"
1812
+ if "jpeg" in header or "jpg" in header:
1813
+ ext = "jpg"
1814
+ elif "webp" in header:
1815
+ ext = "webp"
1816
+ elif "gif" in header:
1817
+ ext = "gif"
1818
+ photo_data = base64.b64decode(encoded)
1819
+ photo_path = PROFILE_PHOTOS_DIR / f"{owner['id']}.{ext}"
1820
+ with open(photo_path, "wb") as f:
1821
+ f.write(photo_data)
1822
+ owner["picture"] = f"/profile_photos/{owner['id']}.{ext}"
1823
+ except Exception:
1824
+ raise HTTPException(status_code=400, detail="Invalid profile photo data")
1825
+
1826
+ owners[idx] = owner
1827
+ write_owners(owners)
1828
+ return {"owner": public_owner(owner)}
1829
+
1830
+
1831
+ @app.get("/businesses")
1832
+ async def list_businesses(owner_id: str | None = None, authorization: str | None = Header(default=None)):
1833
+ businesses = read_businesses()
1834
+ if owner_id:
1835
+ owner = require_owner(authorization)
1836
+ if owner["id"] != owner_id:
1837
+ raise HTTPException(status_code=403, detail="You can only list your own businesses.")
1838
+ businesses = [business for business in businesses if business.get("owner_id") == owner_id]
1839
+ return {"businesses": businesses}
1840
+
1841
+
1842
+ @app.post("/businesses", response_model=Business)
1843
+ async def create_business(payload: BusinessCreate, authorization: str | None = Header(default=None)):
1844
+ owner = require_owner(authorization)
1845
+ businesses = read_businesses()
1846
+ business_id = uuid4().hex[:12]
1847
+ slug = unique_slug(payload.name)
1848
+ business = {
1849
+ "id": business_id,
1850
+ "owner_id": owner["id"],
1851
+ "slug": slug,
1852
+ **business_profile_fields(payload),
1853
+ "site_url": f"/b/{slug}",
1854
+ "chat_url": f"/chat/{slug}",
1855
+ }
1856
+ normalize_business_record(business)
1857
+ businesses.append(business)
1858
+ write_businesses(businesses)
1859
+ return {**BUSINESS_OPTIONAL_DEFAULTS, **business}
1860
+
1861
+
1862
+ @app.put("/businesses/{identifier}", response_model=Business)
1863
+ async def update_business(identifier: str, payload: BusinessUpdate, authorization: str | None = Header(default=None)):
1864
+ owner = require_owner(authorization)
1865
+ businesses = read_businesses()
1866
+ business = next(
1867
+ (
1868
+ item
1869
+ for item in businesses
1870
+ if item["id"] == identifier or item["slug"] == identifier
1871
+ ),
1872
+ None,
1873
+ )
1874
+ if not business:
1875
+ raise HTTPException(status_code=404, detail="Business not found.")
1876
+ if business.get("owner_id") != owner["id"]:
1877
+ raise HTTPException(status_code=403, detail="This business belongs to another owner.")
1878
+
1879
+ business.update(business_profile_fields(payload))
1880
+ normalize_business_record(business)
1881
+ write_businesses(businesses)
1882
+ return business
1883
+
1884
+
1885
+ @app.delete("/businesses/{identifier}")
1886
+ async def delete_business(identifier: str, authorization: str | None = Header(default=None)):
1887
+ owner = require_owner(authorization)
1888
+ businesses = read_businesses()
1889
+ business = next((item for item in businesses if item["id"] == identifier or item["slug"] == identifier), None)
1890
+ if not business:
1891
+ raise HTTPException(status_code=404, detail="Business not found.")
1892
+ if business.get("owner_id") != owner["id"]:
1893
+ raise HTTPException(status_code=403, detail="This business belongs to another owner.")
1894
+ write_businesses([item for item in businesses if item["id"] != business["id"]])
1895
+ return {"message": "Business deleted.", "id": business["id"]}
1896
+
1897
+
1898
+ @app.post("/businesses/{identifier}/cover", response_model=Business)
1899
+ async def upload_business_cover(
1900
+ identifier: str,
1901
+ file: UploadFile = File(...),
1902
+ authorization: str | None = Header(default=None),
1903
+ ):
1904
+ owner = require_owner(authorization)
1905
+ business = ensure_owner_business(identifier, owner)
1906
+
1907
+ if not file.filename:
1908
+ raise HTTPException(status_code=400, detail="Missing cover image file.")
1909
+
1910
+ extension = Path(file.filename).suffix.lower()
1911
+ if extension not in COVER_EXTENSIONS:
1912
+ raise HTTPException(status_code=400, detail="Only JPG, PNG, and WEBP images are supported.")
1913
+
1914
+ upload_path = business_upload_path(business["id"])
1915
+ for old_cover in upload_path.glob("cover.*"):
1916
+ if old_cover.is_file():
1917
+ old_cover.unlink()
1918
+
1919
+ cover_name = f"cover{extension}"
1920
+ cover_path = upload_path / cover_name
1921
+ try:
1922
+ with cover_path.open("wb") as saved_file:
1923
+ shutil.copyfileobj(file.file, saved_file)
1924
+ except OSError as exc:
1925
+ raise HTTPException(status_code=500, detail=f"Could not save cover image: {exc}") from exc
1926
+
1927
+ cover_url = f"/uploaded_documents/{business['id']}/{cover_name}"
1928
+ businesses = read_businesses()
1929
+ updated = None
1930
+ for item in businesses:
1931
+ if item["id"] == business["id"]:
1932
+ item["cover_image_url"] = cover_url
1933
+ normalize_business_record(item)
1934
+ updated = item
1935
+ break
1936
+ if not updated:
1937
+ raise HTTPException(status_code=404, detail="Business not found.")
1938
+ write_businesses(businesses)
1939
+ return updated
1940
+
1941
+
1942
+ @app.get("/businesses/{identifier}", response_model=Business)
1943
+ async def get_business(identifier: str):
1944
+ business = find_business(identifier)
1945
+ if not business:
1946
+ raise HTTPException(status_code=404, detail="Business not found.")
1947
+ normalize_business_record(business)
1948
+ return business
1949
+
1950
+
1951
+ @app.get("/businesses/{identifier}/analytics")
1952
+ async def get_business_analytics(identifier: str, authorization: str | None = Header(default=None)):
1953
+ owner = require_owner(authorization)
1954
+ business = ensure_owner_business(identifier, owner)
1955
+ return analytics_for_business(business["id"])
1956
+
1957
+
1958
+ @app.get("/owner/analytics")
1959
+ async def get_owner_analytics(authorization: str | None = Header(default=None)):
1960
+ owner = require_owner(authorization)
1961
+ owner_businesses = [b for b in read_businesses() if b.get("owner_id") == owner["id"]]
1962
+ owner_business_ids = {b["id"] for b in owner_businesses}
1963
+ business_names = {b["id"]: b["name"] for b in owner_businesses}
1964
+
1965
+ all_messages = [m for m in read_messages() if m["business_id"] in owner_business_ids]
1966
+
1967
+ messages_by_business = {}
1968
+ for msg in all_messages:
1969
+ messages_by_business.setdefault(msg["business_id"], []).append(msg)
1970
+
1971
+ total_messages = len(all_messages)
1972
+ total_conversations = len({m["conversation_id"] for m in all_messages})
1973
+
1974
+ conversation_languages = {}
1975
+ for msg in all_messages:
1976
+ conversation_languages.setdefault(
1977
+ msg["conversation_id"],
1978
+ msg.get("language") or detect_language(msg.get("question", "")),
1979
+ )
1980
+
1981
+ language_counts = {"ar": 0, "fr": 0, "en": 0, "unknown": 0}
1982
+ for lang in conversation_languages.values():
1983
+ if lang in language_counts:
1984
+ language_counts[lang] += 1
1985
+ else:
1986
+ language_counts["unknown"] += 1
1987
+
1988
+ question_counts = {}
1989
+ for msg in all_messages:
1990
+ q = msg["question"].strip()
1991
+ question_counts[q] = question_counts.get(q, 0) + 1
1992
+
1993
+ top_questions = [
1994
+ {"question": q, "count": c}
1995
+ for q, c in sorted(question_counts.items(), key=lambda x: x[1], reverse=True)[:5]
1996
+ ]
1997
+
1998
+ all_messages.sort(key=lambda m: m.get("created_at", ""), reverse=True)
1999
+ recent = all_messages[:20]
2000
+
2001
+ businesses_data = []
2002
+ for b in owner_businesses:
2003
+ msgs = messages_by_business.get(b["id"], [])
2004
+ recent_for_biz = msgs[-8:]
2005
+ answered = sum(1 for m in recent_for_biz if m.get("answer", "").strip())
2006
+ response_rate = round((answered / len(recent_for_biz)) * 100) if recent_for_biz else 0
2007
+ businesses_data.append({
2008
+ "id": b["id"],
2009
+ "name": b["name"],
2010
+ "messages": len(msgs),
2011
+ "conversations": len({m["conversation_id"] for m in msgs}),
2012
+ "responseRate": response_rate,
2013
+ })
2014
+
2015
+ return {
2016
+ "total_messages": total_messages,
2017
+ "total_conversations": total_conversations,
2018
+ "language_counts": language_counts,
2019
+ "top_questions": top_questions,
2020
+ "recent_messages": [
2021
+ {
2022
+ "id": m.get("id"),
2023
+ "conversation_id": m["conversation_id"],
2024
+ "business_id": m["business_id"],
2025
+ "business_name": business_names.get(m["business_id"], "Unknown"),
2026
+ "language": m.get("language"),
2027
+ "question": m.get("question", ""),
2028
+ "answer": m.get("answer", ""),
2029
+ "created_at": m.get("created_at"),
2030
+ }
2031
+ for m in recent
2032
+ ],
2033
+ "businesses": businesses_data,
2034
+ }
2035
+
2036
+
2037
+ @app.get("/businesses/{identifier}/documents")
2038
+ async def get_business_documents(identifier: str, authorization: str | None = Header(default=None)):
2039
+ owner = require_owner(authorization)
2040
+ business = ensure_owner_business(identifier, owner)
2041
+ return {"documents": documents_for_business(business["id"])}
2042
+
2043
+
2044
+ @app.delete("/businesses/{identifier}/documents/{document_id}")
2045
+ async def delete_business_document(
2046
+ identifier: str,
2047
+ document_id: str,
2048
+ authorization: str | None = Header(default=None),
2049
+ ):
2050
+ owner = require_owner(authorization)
2051
+ business = ensure_owner_business(identifier, owner)
2052
+
2053
+ documents = sync_documents_from_uploads()
2054
+ document = next(
2055
+ (
2056
+ item
2057
+ for item in documents
2058
+ if item["id"] == document_id and item["business_id"] == business["id"]
2059
+ ),
2060
+ None,
2061
+ )
2062
+ if not document:
2063
+ raise HTTPException(status_code=404, detail="Document not found.")
2064
+
2065
+ file_path = document_file_path(document)
2066
+ if file_path.exists():
2067
+ file_path.unlink()
2068
+
2069
+ write_documents([item for item in documents if item["id"] != document_id])
2070
+ chunks = rebuild_business_store(business["id"])
2071
+ return {
2072
+ "business_id": business["id"],
2073
+ "document_id": document_id,
2074
+ "remaining_documents": len(documents_for_business(business["id"])),
2075
+ "chunks": chunks,
2076
+ "message": "Document deleted and vector store rebuilt.",
2077
+ }
2078
+
2079
+
2080
+ @app.get("/businesses/{identifier}/inquiries")
2081
+ async def get_business_inquiries(identifier: str, authorization: str | None = Header(default=None)):
2082
+ owner = require_owner(authorization)
2083
+ business = ensure_owner_business(identifier, owner)
2084
+ return {"inquiries": inquiries_for_business(business["id"])}
2085
+
2086
+
2087
+ @app.post("/businesses/{identifier}/inquiries")
2088
+ @limiter.limit(RATE_LIMIT_CHAT)
2089
+ async def create_business_inquiry(identifier: str, payload: InquiryCreate, request: Request):
2090
+ business = find_business(identifier)
2091
+ if not business:
2092
+ raise HTTPException(status_code=404, detail="Business not found.")
2093
+ return save_inquiry(business, payload)
2094
+
2095
+
2096
+ @app.patch("/businesses/{identifier}/inquiries/{inquiry_id}")
2097
+ async def update_business_inquiry_status(
2098
+ identifier: str,
2099
+ inquiry_id: str,
2100
+ payload: InquiryStatusUpdate,
2101
+ authorization: str | None = Header(default=None),
2102
+ ):
2103
+ owner = require_owner(authorization)
2104
+ business = ensure_owner_business(identifier, owner)
2105
+
2106
+ inquiries = read_inquiries()
2107
+ inquiry = next(
2108
+ (
2109
+ item
2110
+ for item in inquiries
2111
+ if item["id"] == inquiry_id and item["business_id"] == business["id"]
2112
+ ),
2113
+ None,
2114
+ )
2115
+ if not inquiry:
2116
+ raise HTTPException(status_code=404, detail="Inquiry not found.")
2117
+
2118
+ inquiry["status"] = payload.status
2119
+ inquiry["updated_at"] = datetime.now(timezone.utc).isoformat()
2120
+ write_inquiries(inquiries)
2121
+ return inquiry
2122
+
2123
+
2124
+ @app.post("/api/contact")
2125
+ @limiter.limit(RATE_LIMIT_AUTH)
2126
+ async def create_contact_message(request: Request, payload: ContactCreate):
2127
+ return save_contact(payload)
2128
+
2129
+
2130
+ @app.post("/businesses/{business_id}/documents")
2131
+ async def upload_business_document(
2132
+ business_id: str,
2133
+ file: UploadFile = File(...),
2134
+ authorization: str | None = Header(default=None),
2135
+ ):
2136
+ owner = require_owner(authorization)
2137
+ business = ensure_owner_business(business_id, owner)
2138
+ if not file.filename or not (file.filename.lower().endswith(".pdf") or file.filename.lower().endswith(".txt")):
2139
+ raise HTTPException(status_code=400, detail="Only PDF and TXT files are supported.")
2140
+
2141
+ original_name = Path(file.filename).name
2142
+ ext = Path(original_name).suffix
2143
+ safe_name = f"{uuid4().hex[:16]}{ext}"
2144
+ file_path = business_upload_path(business["id"]) / safe_name
2145
+
2146
+ try:
2147
+ with file_path.open("wb") as saved_file:
2148
+ shutil.copyfileobj(file.file, saved_file)
2149
+
2150
+ texts = process_document(file_path)
2151
+ if not texts:
2152
+ file_path.unlink(missing_ok=True)
2153
+ raise HTTPException(status_code=400, detail="No readable text found in this document.")
2154
+
2155
+ document = upsert_document_metadata(business["id"], file_path, len(texts), original_name)
2156
+ chunks = rebuild_business_store(business["id"])
2157
+ return {
2158
+ "business_id": business["id"],
2159
+ "document": document,
2160
+ "filename": original_name,
2161
+ "chunks": chunks,
2162
+ "message": "Document uploaded and indexed successfully.",
2163
+ }
2164
+ finally:
2165
+ file.file.close()
2166
+
2167
+
2168
+ @app.post("/businesses/{identifier}/chat")
2169
+ @limiter.limit(RATE_LIMIT_CHAT)
2170
+ async def ask_business_question(identifier: str, query: QueryRequest, request: Request):
2171
+ business = find_business(identifier)
2172
+ if not business:
2173
+ raise HTTPException(status_code=404, detail="Business not found.")
2174
+ language = detect_language(query.question)
2175
+ response = answer_for_business(business, query.question, language)
2176
+ conversation_id = save_chat_message(
2177
+ business_id=business["id"],
2178
+ question=query.question,
2179
+ answer=response["answer"],
2180
+ sources=response["sources"],
2181
+ conversation_id=query.conversation_id,
2182
+ language=language,
2183
+ visitor_ip=request.client.host if request.client else None,
2184
+ )
2185
+ return {**response, "conversation_id": conversation_id, "language": language}
2186
+
2187
+
2188
+ # ── WebSocket streaming chat ──────────────────────────────────────────────
2189
+ @app.websocket("/chat/stream")
2190
+ async def chat_stream(websocket: WebSocket, business_slug: str = Query(default="demo")):
2191
+ client_ip = websocket.client.host if websocket.client else "unknown"
2192
+ if not _check_ws_rate_limit(client_ip):
2193
+ await websocket.close(code=1008, reason="Rate limit exceeded")
2194
+ return
2195
+ await websocket.accept()
2196
+ business = find_business(business_slug)
2197
+ if not business:
2198
+ await websocket.send_json({"error": "Business not found."})
2199
+ await websocket.close()
2200
+ return
2201
+
2202
+ try:
2203
+ while True:
2204
+ data = await websocket.receive_json()
2205
+ text = data.get("text", "").strip()
2206
+ use_rag = data.get("rag", True)
2207
+ if not text:
2208
+ continue
2209
+ if len(text) > 1000:
2210
+ await websocket.send_json({"error": "Message too long (max 1000 characters)."})
2211
+ continue
2212
+
2213
+ if use_rag:
2214
+ async for token in stream_answer_for_business(business, text):
2215
+ await websocket.send_text(token)
2216
+ else:
2217
+ async for token in stream_llm_only(business, text):
2218
+ await websocket.send_text(token)
2219
+ except WebSocketDisconnect:
2220
+ logging.info("WebSocket disconnected")
2221
+ except Exception as exc:
2222
+ logging.error("WebSocket error: %s", exc)
2223
+ try:
2224
+ await websocket.send_text(f"Error: {exc}")
2225
+ except Exception as e2:
2226
+ logging.error("WebSocket send error: %s", e2)
2227
+ finally:
2228
+ try:
2229
+ await websocket.close()
2230
+ except Exception as e3:
2231
+ logging.error("WebSocket close error: %s", e3)
2232
+
2233
+
2234
+ # ── Frontend-aligned REST endpoints (global, uses default/demo business) ──
2235
+ DEFAULT_BUSINESS_SLUG = os.getenv("DEFAULT_BUSINESS_SLUG", "sahel")
2236
+
2237
+ def _resolve_business_for_owner(authorization: str | None) -> dict:
2238
+ owner = require_owner(authorization) if authorization else None
2239
+ business = find_business(DEFAULT_BUSINESS_SLUG)
2240
+ if not business:
2241
+ raise HTTPException(status_code=404, detail="No default business configured.")
2242
+ if owner and business.get("owner_id") != owner["id"]:
2243
+ raise HTTPException(status_code=403, detail="Access denied.")
2244
+ return business
2245
+
2246
+
2247
+ @app.post("/documents")
2248
+ async def upload_document_frontend(
2249
+ file: UploadFile = File(...),
2250
+ authorization: str | None = Header(default=None),
2251
+ ):
2252
+ business = _resolve_business_for_owner(authorization)
2253
+ return await upload_business_document(business["id"], file, authorization)
2254
+
2255
+
2256
+ @app.get("/documents")
2257
+ async def list_documents_frontend(authorization: str | None = Header(default=None)):
2258
+ business = _resolve_business_for_owner(authorization)
2259
+ business_docs = documents_for_business(business["id"])
2260
+ return {
2261
+ "documents": [
2262
+ {
2263
+ "document_id": doc["id"],
2264
+ "filename": doc["file_name"],
2265
+ "size": doc["size_bytes"],
2266
+ "content_type": doc["file_type"],
2267
+ }
2268
+ for doc in business_docs
2269
+ ]
2270
+ }
2271
+
2272
+
2273
+ @app.delete("/documents/{document_id}")
2274
+ async def delete_document_frontend(
2275
+ document_id: str,
2276
+ authorization: str | None = Header(default=None),
2277
+ ):
2278
+ business = _resolve_business_for_owner(authorization)
2279
+ return await delete_business_document(business["id"], document_id, authorization)
2280
+
2281
+
2282
+ @app.delete("/chat/history")
2283
+ async def reset_chat_history_frontend(authorization: str | None = Header(default=None)):
2284
+ business = _resolve_business_for_owner(authorization)
2285
+ business_id = business["id"]
2286
+ conversations = read_conversations()
2287
+ messages = read_messages()
2288
+ write_conversations(
2289
+ [c for c in conversations if c.get("business_id") != business_id]
2290
+ )
2291
+ write_messages([m for m in messages if m.get("business_id") != business_id])
2292
+ return {"message": "Chat history cleared."}
2293
+
2294
+
2295
+ # Backward-compatible endpoints for the current frontend while it is being migrated.
2296
+ @app.post("/upload/{business_id}")
2297
+ async def upload_document_legacy(
2298
+ business_id: str,
2299
+ file: UploadFile = File(...),
2300
+ authorization: str | None = Header(default=None),
2301
+ ):
2302
+ return await upload_business_document(business_id, file, authorization)
2303
+
2304
+
2305
+ @app.post("/query/{business_id}")
2306
+ @limiter.limit(RATE_LIMIT_CHAT)
2307
+ async def ask_question_legacy(business_id: str, query: QueryRequest, request: Request):
2308
+ return await ask_business_question(business_id, query, request)
2309
+
2310
+
2311
+ # ── Embed script ─────────────────────────────────────────────────────────
2312
+
2313
+ EMBED_LANG = {
2314
+ "ar": {"greeting": "مرحباً! كيف يمكنني مساعدتك؟", "placeholder": "اكتب رسالتك..."},
2315
+ "fr": {"greeting": "Bonjour ! Comment puis-je vous aider ?", "placeholder": "Écrivez votre message..."},
2316
+ "en": {"greeting": "Hello! How can I help you?", "placeholder": "Type your message..."},
2317
+ }
2318
+
2319
+ @app.get("/embed/{slug}.js", response_class=Response)
2320
+ async def embed_script(slug: str, request: Request):
2321
+ business = find_business(slug)
2322
+ if not business:
2323
+ raise HTTPException(status_code=404, detail="Business not found.")
2324
+
2325
+ lang_code = SITE_LANG_MAP.get(business.get("language"), "en")
2326
+ labels = EMBED_LANG.get(lang_code, EMBED_LANG["en"])
2327
+ base_url = str(request.base_url).rstrip("/")
2328
+ ws_base = base_url.replace("http://", "ws://").replace("https://", "wss://")
2329
+
2330
+ js = jinja_env.get_template("embed_widget.js").render(
2331
+ slug=slug,
2332
+ business_name=html.escape(business["name"]),
2333
+ greeting=html.escape(labels["greeting"]),
2334
+ placeholder=html.escape(labels["placeholder"]),
2335
+ ws_url=f"{ws_base}/chat/stream",
2336
+ api_url=base_url,
2337
+ )
2338
+ return Response(content=js, media_type="application/javascript")
2339
+
2340
+
2341
+ # ── Mini-site and QR code ────────────────────────────────────────────────
2342
+
2343
+ SITE_TRANSLATIONS = {
2344
+ "ar": {
2345
+ "chat": "تحدث معنا",
2346
+ "qr": "تحميل QR code",
2347
+ "highlights": "أبرز الميزات",
2348
+ "info": "معلومات الاتصال",
2349
+ "phone": "الهاتف",
2350
+ "email": "البريد الإلكتروني",
2351
+ "city": "المدينة",
2352
+ "address": "العنوان",
2353
+ "hours": "ساعات العمل",
2354
+ "services": "الخدمات",
2355
+ "about": "من نحن",
2356
+ "social": "وسائل التواصل الاجتماعي",
2357
+ "powered_by": "مدعوم من",
2358
+ },
2359
+ "fr": {
2360
+ "chat": "Discuter avec nous",
2361
+ "qr": "Télécharger QR code",
2362
+ "highlights": "Points forts",
2363
+ "info": "Coordonnées",
2364
+ "phone": "Téléphone",
2365
+ "email": "Email",
2366
+ "city": "Ville",
2367
+ "address": "Adresse",
2368
+ "hours": "Horaires",
2369
+ "services": "Services",
2370
+ "about": "À propos",
2371
+ "social": "Réseaux sociaux",
2372
+ "powered_by": "Propulsé par",
2373
+ },
2374
+ "en": {
2375
+ "chat": "Chat with us",
2376
+ "qr": "Download QR code",
2377
+ "highlights": "Highlights",
2378
+ "info": "Contact info",
2379
+ "phone": "Phone",
2380
+ "email": "Email",
2381
+ "city": "City",
2382
+ "address": "Address",
2383
+ "hours": "Working hours",
2384
+ "services": "Services",
2385
+ "about": "About",
2386
+ "social": "Social media",
2387
+ "powered_by": "Powered by",
2388
+ },
2389
+ }
2390
+
2391
+ SITE_LANG_MAP = {"ar": "ar", "fr": "fr", "en": "en"}
2392
+
2393
+
2394
+ def _site_context(business: dict, request: Request) -> dict:
2395
+ slug = business["slug"]
2396
+ lang_code = SITE_LANG_MAP.get(business.get("language"), "en")
2397
+ base_url = str(request.base_url).rstrip("/")
2398
+ social_links = {}
2399
+ raw = business.get("social_media")
2400
+ if raw and isinstance(raw, str):
2401
+ try:
2402
+ social_links = json.loads(raw)
2403
+ except (json.JSONDecodeError, TypeError):
2404
+ pass
2405
+ return {
2406
+ "business": business,
2407
+ "lang": lang_code,
2408
+ "t": SITE_TRANSLATIONS.get(lang_code, SITE_TRANSLATIONS["en"]),
2409
+ "chat_url": f"{base_url}/chat/{slug}",
2410
+ "qr_url": f"{base_url}/b/{slug}/qr",
2411
+ "social_links": social_links,
2412
+ }
2413
+
2414
+
2415
+ @app.get("/b/{slug}", response_class=HTMLResponse)
2416
+ async def mini_site(slug: str, request: Request):
2417
+ business = find_business(slug)
2418
+ if not business:
2419
+ raise HTTPException(status_code=404, detail="Business not found.")
2420
+ normalize_business_record(business)
2421
+ ctx = _site_context(business, request)
2422
+ html = jinja_env.get_template("mini_site.html").render(**ctx)
2423
+ return HTMLResponse(content=html)
2424
+
2425
+
2426
+ @app.get("/b/{slug}/qr")
2427
+ async def business_qr_code(slug: str, request: Request):
2428
+ business = find_business(slug)
2429
+ if not business:
2430
+ raise HTTPException(status_code=404, detail="Business not found.")
2431
+
2432
+ mini_site_url = str(request.url_for("mini_site", slug=slug))
2433
+ qr = qrcode.make(mini_site_url)
2434
+ buf = io.BytesIO()
2435
+ qr.save(buf, format="PNG")
2436
+ buf.seek(0)
2437
+ return Response(
2438
+ content=buf.getvalue(),
2439
+ media_type="image/png",
2440
+ headers={"Content-Disposition": f'attachment; filename="{slug}-sahel-qr.png"'},
2441
+ )
2442
+
2443
+
2444
+ app.mount("/uploaded_documents", StaticFiles(directory=str(UPLOAD_ROOT)), name="uploaded_documents")
2445
+ app.mount("/profile_photos", StaticFiles(directory=str(PROFILE_PHOTOS_DIR)), name="profile_photos")
2446
+
2447
+
2448
+ if __name__ == "__main__":
2449
+ import uvicorn
2450
+
2451
+ if not os.getenv("SITE_URL"):
2452
+ logging.warning("SITE_URL is not set — using localhost fallback for email links")
2453
+ uvicorn.run(app, host="0.0.0.0", port=8000)
backend/data/businesses.json ADDED
@@ -0,0 +1,249 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "a10000000001",
4
+ "owner_id": "demo-owner",
5
+ "slug": "amoudou",
6
+ "name": "Restaurant Amoudou",
7
+ "business_type": "restaurant",
8
+ "description": "Restaurant marocain traditionnel situé au cœur de Marrakech. Spécialisé dans les tajines, couscous et pâtisseries orientales préparés selon les recettes ancestrales. Terrasse panoramique avec vue sur la médina.",
9
+ "owner_email": "contact@amoudou.ma",
10
+ "owner_phone": "+212 6 61 23 45 67",
11
+ "city": "Marrakech",
12
+ "address": "15 Derb Sidi Ben Slimane, Médina",
13
+ "working_hours": "Lun-Dim 12h-22h",
14
+ "primary_services": "Tajine, Couscous, Pâtisserie, Thé à la menthe",
15
+ "site_url": "/b/amoudou",
16
+ "chat_url": "/chat/amoudou",
17
+ "highlights": "Cuisine traditionnelle, Terrasse panoramique, Produits frais du souk",
18
+ "public_knowledge": "Le Restaurant Amoudou est ouvert tous les jours de 12h à 22h. Nous servons des tajines (15-25€), couscous (18€), et pâtisseries orientales. Réservation recommandée pour le dîner. Terrasse disponible pour les groupes jusqu'à 20 personnes.",
19
+ "social_media": "{\"instagram\":\"https://instagram.com/amoudou.marrakech\",\"facebook\":\"https://facebook.com/amoudou.marrakech\"}"
20
+ },
21
+ {
22
+ "id": "a10000000002",
23
+ "owner_id": "demo-owner",
24
+ "slug": "ajami",
25
+ "name": "Poissonnerie Ajami",
26
+ "business_type": "restaurant",
27
+ "description": "Restaurant de fruits de mer frais situé sur le port de Casablanca. Poissons pêchés quotidiennement, grillades marocaines et plats de fruits de mer traditionnels. Vue imprenable sur l'océan Atlantique.",
28
+ "owner_email": "info@ajami.ma",
29
+ "owner_phone": "+212 6 62 34 56 78",
30
+ "city": "Casablanca",
31
+ "address": "32 Boulevard de la Corniche, Ain Diab",
32
+ "working_hours": "Mar-Dim 11h-23h",
33
+ "primary_services": "Fruits de mer, Poisson grillé, Paella marocaine, Tagines de poisson",
34
+ "site_url": "/b/ajami",
35
+ "chat_url": "/chat/ajami",
36
+ "highlights": "Produits frais du jour, Vue océan, Spécialités méditerranéennes",
37
+ "public_knowledge": "Poissonnerie Ajami est ouverte du mardi au dimanche de 11h à 23h. Notre menu change selon la pêche du jour. Prix moyens : 20-35€ par personne. Réservation conseillée le week-end. Possibilité d'organiser des événements privés jusqu'à 40 personnes.",
38
+ "social_media": "{\"instagram\":\"https://instagram.com/ajami.casablanca\",\"facebook\":\"https://facebook.com/ajami.casablanca\"}"
39
+ },
40
+ {
41
+ "id": "a10000000003",
42
+ "owner_id": "demo-owner",
43
+ "slug": "snan",
44
+ "name": "Riad Snan",
45
+ "business_type": "restaurant",
46
+ "description": "Restaurant gastronomique marocain dans un riad magnifiquement restauré à Fès. Dîners à la lueur des bougies dans un patio andalou. Cuisine fassie raffinée avec des produits du terroir.",
47
+ "owner_email": "reservation@snan.ma",
48
+ "owner_phone": "+212 6 63 45 67 89",
49
+ "city": "Fès",
50
+ "address": "7 Derb Bennis, Fès El Bali",
51
+ "working_hours": "Lun-Dim 19h-23h (sur réservation)",
52
+ "primary_services": "Cuisine fassie, Dîner traditionnel, Menu dégustation, Cours de cuisine",
53
+ "site_url": "/b/snan",
54
+ "chat_url": "/chat/snan",
55
+ "highlights": "Riad historique, Ambiance intime, Cuisine fassie authentique",
56
+ "public_knowledge": "Riad Snan propose un menu unique chaque soir. Menu dégustation : 45€ par personne (entrée, plat, dessert, thé). Réservation obligatoire au moins 24h à l'avance. Capacité : 25 couverts. Cuisine végétarienne disponible sur demande.",
57
+ "social_media": "{\"instagram\":\"https://instagram.com/riad_snan\",\"facebook\":\"https://facebook.com/riad_snan\"}"
58
+ },
59
+ {
60
+ "id": "a10000000004",
61
+ "owner_id": "demo-owner",
62
+ "slug": "tichka",
63
+ "name": "Hôtel Tichka",
64
+ "business_type": "hotel",
65
+ "description": "Hôtel de charme avec vue imprenable sur les montagnes de l'Atlas. Piscine chauffée, spa traditionnel, et restaurant gastronomique. Parfait pour les escapades nature et les séjours bien-être.",
66
+ "owner_email": "contact@tichka.ma",
67
+ "owner_phone": "+212 6 64 56 78 90",
68
+ "city": "Marrakech",
69
+ "address": "Route de l'Ourika, Km 12",
70
+ "working_hours": "Réception 24h/24",
71
+ "primary_services": "Hébergement, Spa, Piscine, Restaurant, Excursions Atlas",
72
+ "site_url": "/b/tichka",
73
+ "chat_url": "/chat/tichka",
74
+ "highlights": "Vue Atlas, Spa traditionnel, Piscine chauffée, Excursions organisées",
75
+ "public_knowledge": "Hôtel Tichka propose 30 chambres et 5 suites. Tarifs : chambre double à partir de 80€/nuit, suite à partir de 150€/nuit. Petit-déjeuner inclus. Spa : hammam, massage, soins à l'huile d'argan. Excursions : randonnées dans l'Atlas, vallée de l'Ourika, désert d'Agafay.",
76
+ "social_media": "{\"instagram\":\"https://instagram.com/hotel_tichka\",\"facebook\":\"https://facebook.com/hotel_tichka\"}"
77
+ },
78
+ {
79
+ "id": "a10000000005",
80
+ "owner_id": "demo-owner",
81
+ "slug": "el-fenn",
82
+ "name": "Riad El Fenn",
83
+ "business_type": "hotel",
84
+ "description": "Boutique-hôtel de luxe au cœur de Fès El Bali. Riads traditionnels reliés par des jardins andalous. Chambres décorées avec des zelliges et des pièces d'artisanat local.",
85
+ "owner_email": "info@elfenn.ma",
86
+ "owner_phone": "+212 6 65 67 89 01",
87
+ "city": "Fès",
88
+ "address": "22 Derb Moulay Ismail, Fès El Bali",
89
+ "working_hours": "Réception 24h/24",
90
+ "primary_services": "Hébergement de luxe, Spa, Restaurant gastronomique, Visites guidées",
91
+ "site_url": "/b/el-fenn",
92
+ "chat_url": "/chat/el-fenn",
93
+ "highlights": "Luxe traditionnel, Zelliges authentiques, Jardin andalou, Spa de luxe",
94
+ "public_knowledge": "Riad El Fenn dispose de 12 suites de luxe. Tarifs : suites de 200€ à 500€/nuit. Services inclus : petit-déjeuner, wifi, service de conciergerie. Visites guidées de Fès El Bali disponibles. Spa ouvert de 10h à 20h.",
95
+ "social_media": "{\"instagram\":\"https://instagram.com/riad_el_fenn\",\"facebook\":\"https://facebook.com/riad_el_fenn\"}"
96
+ },
97
+ {
98
+ "id": "a10000000006",
99
+ "owner_id": "demo-owner",
100
+ "slug": "toubkal",
101
+ "name": "Kasbah du Toubkal",
102
+ "business_type": "hotel",
103
+ "description": "Refuge d'altitude unique perché à 1800m d'altitude face au Mont Toubkal. Point de départ idéal pour les randonneurs et amateurs de nature. Ambiance chaleureuse et authentique.",
104
+ "owner_email": "reservation@toubkal.ma",
105
+ "owner_phone": "+212 6 66 78 90 12",
106
+ "city": "Imlil",
107
+ "address": "Douar Imlil, Vallée de l'Ourika",
108
+ "working_hours": "Réception 7h-22h",
109
+ "primary_services": "Hébergement, Randonnées, Cuisine berbère, Guides de montagne",
110
+ "site_url": "/b/toubkal",
111
+ "chat_url": "/chat/toubkal",
112
+ "highlights": "Vue Mont Toubkal, Randonnées guidées, Cuisine berbère, Éco-responsable",
113
+ "public_knowledge": "Kasbah du Toubkal propose 15 chambres et 3 dortoirs. Tarifs : chambre double 60€/nuit, dortoir 20€/nuit/personne. Repas : 15€ (dîner traditionnel berbère). Réservation obligatoire en haute saison. Guides de montagne disponibles pour l'ascension du Toubkal (3 jours).",
114
+ "social_media": "{\"instagram\":\"https://instagram.com/kasbah_toubkal\",\"facebook\":\"https://facebook.com/kasbah_toubkal\"}"
115
+ },
116
+ {
117
+ "id": "a10000000007",
118
+ "owner_id": "demo-owner",
119
+ "slug": "auto-hall",
120
+ "name": "Auto-Hall Mécanique",
121
+ "business_type": "service",
122
+ "description": "Garage multimarque spécialisé dans la réparation et l'entretien de véhicules particuliers et utilitaires. Diagnostic électronique, mécanique générale, carrosserie et climatisation.",
123
+ "owner_email": "contact@autohall.ma",
124
+ "owner_phone": "+212 6 67 89 01 23",
125
+ "city": "Casablanca",
126
+ "address": "45 Boulevard Zerktouni, Quartier Palmiers",
127
+ "working_hours": "Lun-Ven 8h-18h, Sam 8h-13h",
128
+ "primary_services": "Diagnostic, Mécanique, Carrosserie, Climatisation, Vidange, Pneus",
129
+ "site_url": "/b/auto-hall",
130
+ "chat_url": "/chat/auto-hall",
131
+ "highlights": "Garage agréé, Diagnostic électronique, Véhicules de courtoisie, Devis gratuit",
132
+ "public_knowledge": "Auto-Hall Mécanique est ouvert du lundi au vendredi 8h-18h et le samedi 8h-13h. Devis gratuit sous 24h. Prix : vidange à partir de 35€, révision complète à partir de 80€. Véhicule de courtoisie disponible pour les réparations de plus de 24h. Paiement accepté : espèces, carte bancaire, chèque.",
133
+ "social_media": "{\"facebook\":\"https://facebook.com/autohall.casablanca\"}"
134
+ },
135
+ {
136
+ "id": "a10000000008",
137
+ "owner_id": "demo-owner",
138
+ "slug": "hicham-coiffure",
139
+ "name": "Salon Hicham Coiffure",
140
+ "business_type": "service",
141
+ "description": "Salon de coiffure mixte haut de gamme en plein centre de Rabat. Coupe, couleur, soins et coiffure de mariage. Équipe formée aux dernières tendances internationales.",
142
+ "owner_email": "hicham@coiffure.ma",
143
+ "owner_phone": "+212 6 68 90 12 34",
144
+ "city": "Rabat",
145
+ "address": "12 Rue Oukaimeden, Agdal",
146
+ "working_hours": "Mar-Sam 9h-20h, Dim 9h-14h",
147
+ "primary_services": "Coupe homme/femme, Coloration, Soins capillaires, Coiffure mariage, Barbe",
148
+ "site_url": "/b/hicham-coiffure",
149
+ "chat_url": "/chat/hicham-coiffure",
150
+ "highlights": "Produits professionnels, Ambiance détente, Coiffure mariage, Fidélité",
151
+ "public_knowledge": "Salon Hicham Coiffure propose des prestations pour hommes et femmes. Tarifs : coupe femme à partir de 25€, coupe homme à partir de 15€, coloration à partir de 35€. Forfait mariage sur devis. Produits L'Oréal Professionnel et Kérastase. Carte de fidélité : 10e coupe offerte.",
152
+ "social_media": "{\"instagram\":\"https://instagram.com/salon_hicham\",\"facebook\":\"https://facebook.com/salon_hicham\"}"
153
+ },
154
+ {
155
+ "id": "a10000000009",
156
+ "owner_id": "demo-owner",
157
+ "slug": "el-amrani",
158
+ "name": "Cabinet Avocats El Amrani",
159
+ "business_type": "service",
160
+ "description": "Cabinet d'avocats spécialisé en droit des affaires, droit immobilier et droit de la famille. Accompagnement juridique complet pour particuliers et entreprises à Marrakech.",
161
+ "owner_email": "contact@elamrani-avocats.ma",
162
+ "owner_phone": "+212 6 69 01 23 45",
163
+ "city": "Marrakech",
164
+ "address": "Immeuble Al Manar, Boulevard Mohammed VI, Guéliz",
165
+ "working_hours": "Lun-Ven 9h-18h30",
166
+ "primary_services": "Droit des affaires, Droit immobilier, Droit de la famille, Contentieux, Conseil juridique",
167
+ "site_url": "/b/el-amrani",
168
+ "chat_url": "/chat/el-amrani",
169
+ "highlights": "Cabinet pluridisciplinaire, Première consultation offerte, Honoraires transparents",
170
+ "public_knowledge": "Cabinet El Amrani propose une première consultation gratuite de 30 minutes. Spécialités : création d'entreprise, rédaction de contrats, divorce et garde d'enfants, litiges immobiliers. Honoraires : à partir de 80€/heure ou forfait selon dossier. Permanence téléphonique du lundi au vendredi 9h-18h30.",
171
+ "social_media": "{\"linkedin\":\"https://linkedin.com/company/el-amrani-law\",\"facebook\":\"https://facebook.com/elamrani.avocats\"}"
172
+ },
173
+ {
174
+ "id": "a10000000010",
175
+ "owner_id": "demo-owner",
176
+ "slug": "tinfou",
177
+ "name": "Boutique Artisanale Tinfou",
178
+ "business_type": "retail",
179
+ "description": "Boutique d'artisanat marocain authentique située à Tinghir. Tapis berbères, poteries, bijoux en argent, et articles en cuir fabriqués par les artisans locaux de la région du Todgha.",
180
+ "owner_email": "contact@tinfou.ma",
181
+ "owner_phone": "+212 6 70 12 34 56",
182
+ "city": "Tinghir",
183
+ "address": "Avenue Hassan II, Centre Ville",
184
+ "working_hours": "Lun-Sam 9h-20h, Dim 10h-13h",
185
+ "primary_services": "Tapis berbères, Poterie, Bijouterie, Cuir, Articles de décoration",
186
+ "site_url": "/b/tinfou",
187
+ "chat_url": "/chat/tinfou",
188
+ "highlights": "Artisanat local authentique, Prix directs de l'artisan, Livraison Maroc",
189
+ "public_knowledge": "Boutique Tinfou travaille directement avec plus de 50 artisans de la région de Tinghir. Produits : tapis berbères (100-500€), poterie traditionnelle (10-80€), bijoux en argent (15-200€). Livraison possible dans tout le Maroc sous 3-5 jours. Paiement par carte bancaire accepté.",
190
+ "social_media": "{\"instagram\":\"https://instagram.com/tinfou.artisanat\",\"facebook\":\"https://facebook.com/tinfou.artisanat\"}"
191
+ },
192
+ {
193
+ "id": "a10000000011",
194
+ "owner_id": "demo-owner",
195
+ "slug": "bab-el-hadid",
196
+ "name": "Épicerie Bab El Hadid",
197
+ "business_type": "retail",
198
+ "description": "Épicerie fine et traiteur proposant des produits du terroir marocain : huile d'argan, épices rares, dattes, olives, et pâtisseries traditionnelles. Livraison à domicile à Fès.",
199
+ "owner_email": "bonjour@bab-el-hadid.ma",
200
+ "owner_phone": "+212 6 71 23 45 67",
201
+ "city": "Fès",
202
+ "address": "3 Place Bab El Hadid, Ville Nouvelle",
203
+ "working_hours": "Lun-Sam 8h30-21h, Dim 9h-13h",
204
+ "primary_services": "Épicerie fine, Traiteur, Panier cadeau, Livraison à domicile",
205
+ "site_url": "/b/bab-el-hadid",
206
+ "chat_url": "/chat/bab-el-hadid",
207
+ "highlights": "Produits du terroir, Panier cadeau, Livraison gratuite à Fès, Conseils personnalisés",
208
+ "public_knowledge": "Épicerie Bab El Hadid propose plus de 200 produits du terroir marocain. Huile d'argan certifiée biologique à partir de 12€/250ml. Panier cadeau à partir de 25€. Livraison gratuite à Fès pour toute commande de plus de 20€. Commande minimum 10€.",
209
+ "social_media": "{\"facebook\":\"https://facebook.com/bab.elhadid.fes\",\"instagram\":\"https://instagram.com/bab_el_hadid\"}"
210
+ },
211
+ {
212
+ "id": "a10000000012",
213
+ "owner_id": "demo-owner",
214
+ "slug": "kenza-studio",
215
+ "name": "Studio Créatif Kenza",
216
+ "business_type": "service",
217
+ "description": "Agence de design graphique et marketing digital basée à Casablanca. Création de logos, chartes graphiques, sites web, et stratégies de communication pour PME marocaines.",
218
+ "owner_email": "bonjour@kenza-studio.ma",
219
+ "owner_phone": "+212 6 72 34 56 78",
220
+ "city": "Casablanca",
221
+ "address": "88 Rue Abou Baker El Kadiri, Maârif",
222
+ "working_hours": "Lun-Ven 9h-18h",
223
+ "primary_services": "Design graphique, identité visuelle, sites web, social media, packaging",
224
+ "site_url": "/b/kenza-studio",
225
+ "chat_url": "/chat/kenza-studio",
226
+ "highlights": "Création sur mesure, Délais rapides, Forfaits PME, Portfolio varié",
227
+ "public_knowledge": "Studio Kenza accompagne les PME marocaines dans leur stratégie digitale. Forfaits : identité visuelle complète à partir de 350€, site web vitrine à partir de 500€, social media pack à partir de 200€/mois. Délai moyen de livraison : 7-15 jours. Portfolio disponible sur demande.",
228
+ "social_media": "{\"instagram\":\"https://instagram.com/studio_kenza\",\"linkedin\":\"https://linkedin.com/company/kenza-studio\"}"
229
+ },
230
+ {
231
+ "id": "a10000000013",
232
+ "owner_id": "demo-owner",
233
+ "slug": "saghro",
234
+ "name": "Hotel Saghro",
235
+ "business_type": "hotel",
236
+ "description": "Hôtel 4**** situé en plein centre-ville de Tinghir offrant une vue panoramique sur la vallée de Toudgha et les montagnes de l'Atlas. 65 chambres avec balcon privé, piscine extérieure, restaurant, bar et services hôteliers complets. Une oasis de tranquillité au cœur du sud marocain.",
237
+ "owner_email": "saghrotinghir@gmail.com",
238
+ "owner_phone": "+212 524 834 179",
239
+ "city": "Tinghir",
240
+ "address": "Av. des FAR, Centre Tinghir, 45800, Tinghir, Maroc",
241
+ "working_hours": "Réception 24h/24",
242
+ "primary_services": "Hébergement, Piscine extérieure, Restaurant, Bar, Petit-déjeuner buffet, Room service, Excursions, Laverie, Transfert aéroport, Organisation d'événements",
243
+ "site_url": "/b/saghro",
244
+ "chat_url": "/chat/saghro",
245
+ "highlights": "Vue panoramique vallée Toudgha, Piscine extérieure, 65 chambres climatisées, Restaurant cuisine marocaine et internationale, Proche Gorges du Todgha, Parking gratuit, Wi-Fi gratuit, Personnel multilingue",
246
+ "public_knowledge": "Hotel Saghro est un hôtel 4 étoiles situé au centre de Tinghir. Il dispose de 65 chambres (simples, doubles, triples) toutes avec salle de bain privée, climatisation, téléphone, coffre-fort et balcon privé. Tarifs : chambre simple à partir de 50€, double à partir de 70€, triple à partir de 90€/nuit. Petit-déjeuner buffet inclus. Check-out avant 12h. Piscine extérieure ouverte de mai à octobre. Restaurant ouvert midi et soir (cuisine marocaine et internationale). Bar ouvert de 10h à 23h. Services : room service 24h/24, laverie, transfert aéroport, excursions vers les Gorges du Todgha (à 9 km) et la Vallée du Dadès. Langues parlées : arabe, français, anglais, espagnol. Parking gratuit. Wi-Fi gratuit dans tout l'hôtel. Animaux acceptés sur demande. Réception ouverte 24h/24.",
247
+ "social_media": "{\"instagram\":\"https://instagram.com/hotel_saghro\",\"facebook\":\"https://facebook.com/hotel.saghro.tinghir\"}"
248
+ }
249
+ ]
backend/data/conversations.json ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "saghro-conv-001",
4
+ "business_id": "a10000000013",
5
+ "language": "fr",
6
+ "visitor_ip": null,
7
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
8
+ },
9
+ {
10
+ "id": "saghro-conv-002",
11
+ "business_id": "a10000000013",
12
+ "language": "en",
13
+ "visitor_ip": null,
14
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
15
+ },
16
+ {
17
+ "id": "saghro-conv-003",
18
+ "business_id": "a10000000013",
19
+ "language": "ar",
20
+ "visitor_ip": null,
21
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
22
+ },
23
+ {
24
+ "id": "saghro-conv-004",
25
+ "business_id": "a10000000013",
26
+ "language": "fr",
27
+ "visitor_ip": null,
28
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
29
+ },
30
+ {
31
+ "id": "saghro-conv-005",
32
+ "business_id": "a10000000013",
33
+ "language": "en",
34
+ "visitor_ip": null,
35
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
36
+ },
37
+ {
38
+ "id": "saghro-conv-006",
39
+ "business_id": "a10000000013",
40
+ "language": "fr",
41
+ "visitor_ip": null,
42
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
43
+ },
44
+ {
45
+ "id": "saghro-conv-007",
46
+ "business_id": "a10000000013",
47
+ "language": "ar",
48
+ "visitor_ip": null,
49
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
50
+ },
51
+ {
52
+ "id": "saghro-conv-008",
53
+ "business_id": "a10000000013",
54
+ "language": "en",
55
+ "visitor_ip": null,
56
+ "started_at": "2026-06-05T14:35:06.808685+00:00"
57
+ }
58
+ ]
backend/data/documents.json ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "d000000000001",
4
+ "business_id": "a10000000001",
5
+ "file_name": "Menu Restaurant Amoudou.pdf",
6
+ "file_type": "application/pdf",
7
+ "file_url": "/uploaded_documents/a10000000001/Menu Restaurant Amoudou.pdf",
8
+ "chunks": 0,
9
+ "size_bytes": 0,
10
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
11
+ },
12
+ {
13
+ "id": "d000000000002",
14
+ "business_id": "a10000000002",
15
+ "file_name": "Carte Poissonnerie Ajami.pdf",
16
+ "file_type": "application/pdf",
17
+ "file_url": "/uploaded_documents/a10000000002/Carte Poissonnerie Ajami.pdf",
18
+ "chunks": 0,
19
+ "size_bytes": 0,
20
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
21
+ },
22
+ {
23
+ "id": "d000000000003",
24
+ "business_id": "a10000000003",
25
+ "file_name": "Menu Dégustation Riad Snan.pdf",
26
+ "file_type": "application/pdf",
27
+ "file_url": "/uploaded_documents/a10000000003/Menu Dégustation Riad Snan.pdf",
28
+ "chunks": 0,
29
+ "size_bytes": 0,
30
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
31
+ },
32
+ {
33
+ "id": "d000000000004",
34
+ "business_id": "a10000000004",
35
+ "file_name": "Brochure Hôtel Tichka.pdf",
36
+ "file_type": "application/pdf",
37
+ "file_url": "/uploaded_documents/a10000000004/Brochure Hôtel Tichka.pdf",
38
+ "chunks": 0,
39
+ "size_bytes": 0,
40
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
41
+ },
42
+ {
43
+ "id": "d000000000005",
44
+ "business_id": "a10000000005",
45
+ "file_name": "Tarifs Suites El Fenn.pdf",
46
+ "file_type": "application/pdf",
47
+ "file_url": "/uploaded_documents/a10000000005/Tarifs Suites El Fenn.pdf",
48
+ "chunks": 0,
49
+ "size_bytes": 0,
50
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
51
+ },
52
+ {
53
+ "id": "d000000000006",
54
+ "business_id": "a10000000006",
55
+ "file_name": "Services Kasbah Toubkal.pdf",
56
+ "file_type": "application/pdf",
57
+ "file_url": "/uploaded_documents/a10000000006/Services Kasbah Toubkal.pdf",
58
+ "chunks": 0,
59
+ "size_bytes": 0,
60
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
61
+ },
62
+ {
63
+ "id": "d000000000007",
64
+ "business_id": "a10000000007",
65
+ "file_name": "Tarifs Auto-Hall 2026.pdf",
66
+ "file_type": "application/pdf",
67
+ "file_url": "/uploaded_documents/a10000000007/Tarifs Auto-Hall 2026.pdf",
68
+ "chunks": 0,
69
+ "size_bytes": 0,
70
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
71
+ },
72
+ {
73
+ "id": "d000000000008",
74
+ "business_id": "a10000000008",
75
+ "file_name": "Menu Prestations Salon Hicham.pdf",
76
+ "file_type": "application/pdf",
77
+ "file_url": "/uploaded_documents/a10000000008/Menu Prestations Salon Hicham.pdf",
78
+ "chunks": 0,
79
+ "size_bytes": 0,
80
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
81
+ },
82
+ {
83
+ "id": "d000000000009",
84
+ "business_id": "a10000000009",
85
+ "file_name": "Brochure Cabinet El Amrani.pdf",
86
+ "file_type": "application/pdf",
87
+ "file_url": "/uploaded_documents/a10000000009/Brochure Cabinet El Amrani.pdf",
88
+ "chunks": 0,
89
+ "size_bytes": 0,
90
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
91
+ },
92
+ {
93
+ "id": "d000000000010",
94
+ "business_id": "a10000000010",
95
+ "file_name": "Catalogue Tinfou.pdf",
96
+ "file_type": "application/pdf",
97
+ "file_url": "/uploaded_documents/a10000000010/Catalogue Tinfou.pdf",
98
+ "chunks": 0,
99
+ "size_bytes": 0,
100
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
101
+ },
102
+ {
103
+ "id": "d000000000011",
104
+ "business_id": "a10000000011",
105
+ "file_name": "Catalogue Épicerie Bab El Hadid.pdf",
106
+ "file_type": "application/pdf",
107
+ "file_url": "/uploaded_documents/a10000000011/Catalogue Épicerie Bab El Hadid.pdf",
108
+ "chunks": 0,
109
+ "size_bytes": 0,
110
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
111
+ },
112
+ {
113
+ "id": "d000000000012",
114
+ "business_id": "a10000000012",
115
+ "file_name": "Portfolio Studio Kenza.pdf",
116
+ "file_type": "application/pdf",
117
+ "file_url": "/uploaded_documents/a10000000012/Portfolio Studio Kenza.pdf",
118
+ "chunks": 0,
119
+ "size_bytes": 0,
120
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
121
+ },
122
+ {
123
+ "id": "d000000000013",
124
+ "business_id": "a10000000013",
125
+ "file_name": "Brochure Hotel Saghro.pdf",
126
+ "file_type": "application/pdf",
127
+ "file_url": "/uploaded_documents/a10000000013/Brochure Hotel Saghro.pdf",
128
+ "chunks": 0,
129
+ "size_bytes": 0,
130
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
131
+ },
132
+ {
133
+ "id": "d000000000014",
134
+ "business_id": "a10000000013",
135
+ "file_name": "Menu Restaurant Saghro.pdf",
136
+ "file_type": "application/pdf",
137
+ "file_url": "/uploaded_documents/a10000000013/Menu Restaurant Saghro.pdf",
138
+ "chunks": 0,
139
+ "size_bytes": 0,
140
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
141
+ },
142
+ {
143
+ "id": "d000000000015",
144
+ "business_id": "a10000000013",
145
+ "file_name": "Tarifs Chambres Saghro.pdf",
146
+ "file_type": "application/pdf",
147
+ "file_url": "/uploaded_documents/a10000000013/Tarifs Chambres Saghro.pdf",
148
+ "chunks": 0,
149
+ "size_bytes": 0,
150
+ "uploaded_at": "2026-06-05T00:00:00+00:00"
151
+ }
152
+ ]
backend/data/inquiries.json ADDED
@@ -0,0 +1 @@
 
 
1
+ []
backend/data/messages.json ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "e61f7a691a9e",
4
+ "business_id": "a10000000013",
5
+ "conversation_id": "saghro-conv-001",
6
+ "question": "Bonjour, quels sont les tarifs des chambres ?",
7
+ "answer": "Nos chambres simples sont à partir de 50€, doubles à partir de 70€ et triples à partir de 90€ par nuit. Le petit-déjeuner buffet est inclus.",
8
+ "sources": "[]",
9
+ "language": "fr",
10
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
11
+ },
12
+ {
13
+ "id": "245c71dc3e53",
14
+ "business_id": "a10000000013",
15
+ "conversation_id": "saghro-conv-001",
16
+ "question": "Est-ce que vous avez une piscine ?",
17
+ "answer": "Oui, nous avons une piscine extérieure ouverte de mai à octobre.",
18
+ "sources": "[]",
19
+ "language": "fr",
20
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
21
+ },
22
+ {
23
+ "id": "bc08b9c4953c",
24
+ "business_id": "a10000000013",
25
+ "conversation_id": "saghro-conv-001",
26
+ "question": "Quels sont les horaires du restaurant ?",
27
+ "answer": "Notre restaurant est ouvert le midi de 12h à 14h30 et le soir de 19h à 22h30.",
28
+ "sources": "[]",
29
+ "language": "fr",
30
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
31
+ },
32
+ {
33
+ "id": "bfedcb14b165",
34
+ "business_id": "a10000000013",
35
+ "conversation_id": "saghro-conv-001",
36
+ "question": "Proposez-vous le petit-déjeuner ?",
37
+ "answer": "Oui, le petit-déjeuner buffet est inclus dans le prix de la chambre.",
38
+ "sources": "[]",
39
+ "language": "fr",
40
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
41
+ },
42
+ {
43
+ "id": "cc16076a72a7",
44
+ "business_id": "a10000000013",
45
+ "conversation_id": "saghro-conv-002",
46
+ "question": "Hello, how far is the hotel from Todgha Gorge?",
47
+ "answer": "The Todgha Gorge is approximately 9 km from Hotel Saghro.",
48
+ "sources": "[]",
49
+ "language": "en",
50
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
51
+ },
52
+ {
53
+ "id": "d2aaeffe2fa8",
54
+ "business_id": "a10000000013",
55
+ "conversation_id": "saghro-conv-002",
56
+ "question": "Do you have free parking?",
57
+ "answer": "Yes, we offer free parking for all our guests.",
58
+ "sources": "[]",
59
+ "language": "en",
60
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
61
+ },
62
+ {
63
+ "id": "c57562956438",
64
+ "business_id": "a10000000013",
65
+ "conversation_id": "saghro-conv-002",
66
+ "question": "Is Wi-Fi available?",
67
+ "answer": "Yes, Wi-Fi is available free of charge throughout the hotel.",
68
+ "sources": "[]",
69
+ "language": "en",
70
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
71
+ },
72
+ {
73
+ "id": "c1ce1419168e",
74
+ "business_id": "a10000000013",
75
+ "conversation_id": "saghro-conv-003",
76
+ "question": "السلام عليكم، واش عندكم غرف فاضية هاد السيمانة؟",
77
+ "answer": "وعليكم السلام. نعم، تتوفر لدينا غرف متاحة هذا الأسبوع.",
78
+ "sources": "[]",
79
+ "language": "ar",
80
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
81
+ },
82
+ {
83
+ "id": "9ed78d39d6fc",
84
+ "business_id": "a10000000013",
85
+ "conversation_id": "saghro-conv-003",
86
+ "question": "شحال الثمن ف الليلة؟",
87
+ "answer": "أسعار الغرف: الغرفة الفردية ابتداء من 50 يورو.",
88
+ "sources": "[]",
89
+ "language": "ar",
90
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
91
+ },
92
+ {
93
+ "id": "2cfa12365668",
94
+ "business_id": "a10000000013",
95
+ "conversation_id": "saghro-conv-003",
96
+ "question": "فين كاين الفندق بالضبط؟",
97
+ "answer": "الفندق يقع في وسط مدينة تنغير.",
98
+ "sources": "[]",
99
+ "language": "ar",
100
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
101
+ },
102
+ {
103
+ "id": "62597a01e67f",
104
+ "business_id": "a10000000013",
105
+ "conversation_id": "saghro-conv-004",
106
+ "question": "Bonjour, est-ce que vous avez des chambres avec vue sur la vallée ?",
107
+ "answer": "Oui, toutes nos chambres disposent d'un balcon privé avec vue panoramique.",
108
+ "sources": "[]",
109
+ "language": "fr",
110
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
111
+ },
112
+ {
113
+ "id": "a2292d49d79a",
114
+ "business_id": "a10000000013",
115
+ "conversation_id": "saghro-conv-004",
116
+ "question": "Acceptez-vous les animaux ?",
117
+ "answer": "Oui, les animaux sont acceptés sur demande préalable.",
118
+ "sources": "[]",
119
+ "language": "fr",
120
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
121
+ },
122
+ {
123
+ "id": "35e4c28bd881",
124
+ "business_id": "a10000000013",
125
+ "conversation_id": "saghro-conv-004",
126
+ "question": "Quels sont les moyens de paiement acceptés ?",
127
+ "answer": "Nous acceptons les paiements en espèces, par carte bancaire.",
128
+ "sources": "[]",
129
+ "language": "fr",
130
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
131
+ },
132
+ {
133
+ "id": "8feb58d0a7b0",
134
+ "business_id": "a10000000013",
135
+ "conversation_id": "saghro-conv-005",
136
+ "question": "What excursions do you offer?",
137
+ "answer": "We offer excursions to Todgha Gorge, Dades Valley, and the Rose Valley.",
138
+ "sources": "[]",
139
+ "language": "en",
140
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
141
+ },
142
+ {
143
+ "id": "78859e6802e5",
144
+ "business_id": "a10000000013",
145
+ "conversation_id": "saghro-conv-005",
146
+ "question": "Do you have airport transfer service?",
147
+ "answer": "Yes, we offer airport transfer service.",
148
+ "sources": "[]",
149
+ "language": "en",
150
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
151
+ },
152
+ {
153
+ "id": "dbbe4832aff3",
154
+ "business_id": "a10000000013",
155
+ "conversation_id": "saghro-conv-005",
156
+ "question": "What languages do your staff speak?",
157
+ "answer": "Our staff speaks Arabic, French, English, and Spanish.",
158
+ "sources": "[]",
159
+ "language": "en",
160
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
161
+ },
162
+ {
163
+ "id": "094419ac4490",
164
+ "business_id": "a10000000013",
165
+ "conversation_id": "saghro-conv-006",
166
+ "question": "Avez-vous des salles de réunion ?",
167
+ "answer": "Oui, nous disposons d'une salle de réunion équipée.",
168
+ "sources": "[]",
169
+ "language": "fr",
170
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
171
+ },
172
+ {
173
+ "id": "778e77d94f18",
174
+ "business_id": "a10000000013",
175
+ "conversation_id": "saghro-conv-006",
176
+ "question": "Proposez-vous des formules pour séminaires ?",
177
+ "answer": "Oui, nous proposons des forfaits séminaires.",
178
+ "sources": "[]",
179
+ "language": "fr",
180
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
181
+ },
182
+ {
183
+ "id": "c2c80bb8a525",
184
+ "business_id": "a10000000013",
185
+ "conversation_id": "saghro-conv-007",
186
+ "question": "واش كاين مطعم فالفندق؟",
187
+ "answer": "نعم، يوجد مطعم في الفندق يقدم المأكولات المغربية.",
188
+ "sources": "[]",
189
+ "language": "ar",
190
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
191
+ },
192
+ {
193
+ "id": "95c6c8fba701",
194
+ "business_id": "a10000000013",
195
+ "conversation_id": "saghro-conv-007",
196
+ "question": "بغيت نحجز غرفة لعائلة، شحال الثمن؟",
197
+ "answer": "الغرفة الثلاثية مناسبة للعائلة بسعر 90 يورو.",
198
+ "sources": "[]",
199
+ "language": "ar",
200
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
201
+ },
202
+ {
203
+ "id": "70b4f15512a7",
204
+ "business_id": "a10000000013",
205
+ "conversation_id": "saghro-conv-008",
206
+ "question": "Is there a gym or spa at the hotel?",
207
+ "answer": "We do not have a gym or spa on site.",
208
+ "sources": "[]",
209
+ "language": "en",
210
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
211
+ },
212
+ {
213
+ "id": "8436b1ecea78",
214
+ "business_id": "a10000000013",
215
+ "conversation_id": "saghro-conv-008",
216
+ "question": "Do you have room service?",
217
+ "answer": "Yes, room service is available 24 hours a day.",
218
+ "sources": "[]",
219
+ "language": "en",
220
+ "created_at": "2026-06-05T14:35:06.808685+00:00"
221
+ }
222
+ ]
backend/data/owners.json ADDED
@@ -0,0 +1,44 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "id": "demo-owner",
4
+ "full_name": "Demo Owner",
5
+ "email": "demo@sahel.ai",
6
+ "password_hash": "",
7
+ "created_at": "2026-06-03T15:37:27.623565+00:00"
8
+ },
9
+ {
10
+ "id": "ad76dd581e22",
11
+ "full_name": "Mohamed h",
12
+ "email": "demo@exemple.com",
13
+ "password_hash": "",
14
+ "created_at": "2026-06-03T21:23:56.479668+00:00"
15
+ },
16
+ {
17
+ "id": "dcbdae79f052",
18
+ "full_name": "moha",
19
+ "email": "m@m.com",
20
+ "password_hash": "",
21
+ "created_at": "2026-06-03T23:31:12.977809+00:00"
22
+ },
23
+ {
24
+ "id": "92fd352a0deb",
25
+ "full_name": "moha",
26
+ "email": "mo@m.com",
27
+ "password_hash": "",
28
+ "created_at": "2026-06-03T23:34:21.769155+00:00"
29
+ },
30
+ {
31
+ "id": "6fb00317f3d7",
32
+ "full_name": "hamid",
33
+ "email": "ham@hdh",
34
+ "password_hash": "",
35
+ "created_at": "2026-06-04T00:08:50.225763+00:00"
36
+ },
37
+ {
38
+ "id": "8ffae9d05aa7",
39
+ "full_name": "mohy",
40
+ "email": "moh@yy",
41
+ "password_hash": "",
42
+ "created_at": "2026-06-04T00:35:00.558785+00:00"
43
+ }
44
+ ]
backend/requirements.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi>=0.115.0
2
+ slowapi>=0.1.9
3
+ uvicorn[standard]>=0.34.0
4
+ python-dotenv>=1.0.1
5
+ PyJWT>=2.10.0
6
+ groq>=0.14.0
7
+ langchain-huggingface>=0.1.2
8
+ langchain-text-splitters>=0.3.6
9
+ sentence-transformers>=3.4.0
10
+ faiss-cpu>=1.10.0
11
+ pypdf>=5.3.0
12
+ numpy>=2.2.0
13
+ sqlalchemy>=2.0.38
14
+ psycopg[binary]>=3.2.6
15
+ alembic>=1.14.0
16
+ pytest>=8.3.0
17
+ httpx>=0.28.0
18
+ python-multipart>=0.0.20
19
+ qrcode[pil]>=8.0.0
20
+ Pillow>=11.1.0
backend/scripts/check_biz.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3
2
+ conn = sqlite3.connect('sahel.db')
3
+ cursor = conn.cursor()
4
+ cursor.execute("SELECT name FROM sqlite_master WHERE type='table'")
5
+ tables = cursor.fetchall()
6
+ print('Tables:', tables)
7
+ try:
8
+ cursor.execute("SELECT id, name, business_type, city, slug FROM businesses LIMIT 20")
9
+ rows = cursor.fetchall()
10
+ print(f'Businesses ({len(rows)}):')
11
+ for r in rows:
12
+ print(r)
13
+ except Exception as e:
14
+ print('Error:', e)
15
+ cursor.execute("PRAGMA table_info(businesses)")
16
+ cols = cursor.fetchall()
17
+ print('Columns:', cols)
18
+ conn.close()
backend/scripts/check_db.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3, json, os
2
+
3
+ DB = os.path.join(os.path.dirname(__file__), "..", "sahel.db")
4
+ conn = sqlite3.connect(DB)
5
+ cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
6
+ tables = [row[0] for row in cursor.fetchall()]
7
+ print("Tables:", tables)
8
+
9
+ if "businesses" in tables:
10
+ cursor = conn.execute("SELECT id, name, slug, social_media FROM businesses")
11
+ rows = cursor.fetchall()
12
+ print(f"Businesses: {len(rows)}")
13
+ for row in rows:
14
+ print(f" {row[0]}: {row[1]} ({row[2]}) social_media={row[3]}")
15
+ conn.close()
backend/scripts/check_db2.py ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3, os
2
+
3
+ DB = os.path.join(os.path.dirname(__file__), "..", "sahel.db")
4
+ conn = sqlite3.connect(DB)
5
+ cursor = conn.execute("SELECT id, name, slug, social_media FROM businesses")
6
+ rows = cursor.fetchall()
7
+ print(f"Businesses: {len(rows)}")
8
+ for row in rows:
9
+ print(f" {row[0]}: {row[1]} ({row[2]}) social_media={row[3]}")
10
+ conn.close()
backend/scripts/check_frontend.py ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request
2
+ import re
3
+ r = urllib.request.urlopen('http://localhost:5173')
4
+ html = r.read().decode('utf-8', errors='replace')
5
+ scripts = re.findall(r'src="([^"]+\.(?:js|ts|jsx|tsx))"', html)
6
+ for s in scripts:
7
+ print(s)
8
+ idx = html.lower().find('error')
9
+ if idx >= 0:
10
+ print('\nError context:', html[max(0,idx-50):idx+100])
11
+ else:
12
+ print('No error found')
backend/scripts/check_saghro.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(__file__))
3
+ from storage import read_table
4
+
5
+ print("=== Businesses in DB ===")
6
+ for b in read_table("businesses"):
7
+ print(f" {b['id']:20s} owner={b['owner_id']:15s} slug={b['slug']:25s} {b['name']}")
8
+
9
+ print("\n=== Messages for saghro ===")
10
+ msgs = [m for m in read_table("messages") if m.get("business_id") == "a10000000013"]
11
+ print(f" Total messages: {len(msgs)}")
backend/scripts/check_social.py ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import urllib.request, json
2
+ r = urllib.request.urlopen('http://localhost:8000/businesses')
3
+ data = json.loads(r.read())
4
+ for b in data.get('businesses', []):
5
+ sm = b.get('social_media', '')
6
+ sm_preview = repr(sm[:80]) if sm else '(empty)'
7
+ print(f'{b["name"]:35s} social_media={sm_preview}')
backend/scripts/create_flyer.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate Hotel Saghro PDF flyer."""
2
+ from fpdf import FPDF
3
+ import os
4
+
5
+ PDF_PATH = os.path.join(os.path.dirname(__file__), "..", "website", "public", "flyer-hotel-saghro.pdf")
6
+
7
+
8
+ class SaghroFlyer(FPDF):
9
+ pass
10
+
11
+
12
+ pdf = SaghroFlyer("P", "mm", "A4")
13
+ pdf.set_auto_page_break(auto=False)
14
+
15
+ # --- Front face ---
16
+ pdf.add_page()
17
+
18
+ # Background color (deep navy)
19
+ pdf.set_fill_color(15, 23, 42)
20
+ pdf.rect(0, 0, 210, 297, "F")
21
+
22
+ # Gold accent line
23
+ pdf.set_fill_color(212, 175, 55)
24
+ pdf.rect(0, 0, 210, 6, "F")
25
+
26
+ # Hotel name
27
+ pdf.set_text_color(255, 255, 255)
28
+ pdf.set_font("Helvetica", "B", 36)
29
+ pdf.set_xy(20, 30)
30
+ pdf.cell(170, 14, "Hotel Saghro", align="C")
31
+
32
+ # Stars
33
+ pdf.set_font("Helvetica", "", 28)
34
+ pdf.set_xy(20, 46)
35
+ pdf.cell(170, 12, "\xE2\x98\x85 \xE2\x98\x85 \xE2\x98\x85 \xE2\x98\x85", align="C")
36
+
37
+ # Tagline
38
+ pdf.set_font("Helvetica", "I", 14)
39
+ pdf.set_text_color(212, 175, 55)
40
+ pdf.set_xy(20, 60)
41
+ pdf.cell(170, 8, '"L\'esprit du haut Atlas marocain"', align="C")
42
+
43
+ # Subtitle
44
+ pdf.set_font("Helvetica", "", 11)
45
+ pdf.set_text_color(200, 200, 200)
46
+ pdf.set_xy(20, 75)
47
+ pdf.cell(170, 10, "Tinghir \xE2\x80\x93 Vall\xE9e de Toudgha \xE2\x80\x93 Maroc", align="C")
48
+
49
+ # Divider
50
+ pdf.set_draw_color(212, 175, 55)
51
+ pdf.set_line_width(0.5)
52
+ pdf.line(60, 92, 150, 92)
53
+
54
+ # Key features
55
+ features = [
56
+ ("65 chambres", "Climatis\xE9es, balcon priv\xE9, coffre-fort, t\xE9l\xE9phone"),
57
+ ("Piscine ext\xE9rieure", "Ouverte de mai \xE0 octobre \xE2\x80\x93 Jardin et solarium"),
58
+ ("Restaurant", "Cuisine marocaine et internationale \xE2\x80\x93 Midi et soir"),
59
+ ("Bar", "Ouvert de 10h \xE0 23h \xE2\x80\x93 Vue panoramique"),
60
+ ("Room service", "24h/24 \xE2\x80\x93 Service en chambre"),
61
+ ("Wi-Fi gratuit", "Dans tout l\'h\xF4tel \xE2\x80\x93 Parking gratuit"),
62
+ ]
63
+
64
+ pdf.set_text_color(255, 255, 255)
65
+ pdf.set_font("Helvetica", "B", 12)
66
+ y = 110
67
+ for title, desc in features:
68
+ pdf.set_xy(30, y)
69
+ pdf.set_font("Helvetica", "B", 12)
70
+ pdf.set_text_color(212, 175, 55)
71
+ pdf.cell(80, 7, f"\xE2\x96\xB8 {title}")
72
+ pdf.set_xy(30, y + 7)
73
+ pdf.set_font("Helvetica", "", 10)
74
+ pdf.set_text_color(200, 200, 200)
75
+ pdf.cell(150, 6, desc)
76
+ y += 18
77
+
78
+ # Location
79
+ pdf.set_font("Helvetica", "B", 11)
80
+ pdf.set_text_color(212, 175, 55)
81
+ pdf.set_xy(20, y + 10)
82
+ pdf.cell(170, 7, "Emplacement", align="C")
83
+ pdf.set_font("Helvetica", "", 10)
84
+ pdf.set_text_color(200, 200, 200)
85
+ pdf.set_xy(20, y + 18)
86
+ pdf.cell(170, 6, "Av. des FAR, Centre Tinghir, 45800, Maroc", align="C")
87
+ pdf.set_xy(20, y + 25)
88
+ pdf.cell(170, 6, "\xC0 9 km des Gorges du Todgha", align="C")
89
+
90
+ # Contact
91
+ y2 = y + 45
92
+ pdf.set_draw_color(212, 175, 55)
93
+ pdf.line(60, y2, 150, y2)
94
+
95
+ pdf.set_font("Helvetica", "B", 14)
96
+ pdf.set_text_color(212, 175, 55)
97
+ pdf.set_xy(20, y2 + 8)
98
+ pdf.cell(170, 8, "Contact & R\xE9servation", align="C")
99
+
100
+ pdf.set_font("Helvetica", "", 12)
101
+ pdf.set_text_color(255, 255, 255)
102
+
103
+ pdf.set_xy(20, y2 + 20)
104
+ pdf.cell(170, 7, "T\xE9l : +212 524 834 179 / +212 661 572 679", align="C")
105
+ pdf.set_xy(20, y2 + 28)
106
+ pdf.cell(170, 7, "Email : saghrotinghir@gmail.com", align="C")
107
+ pdf.set_xy(20, y2 + 36)
108
+ pdf.cell(170, 7, "Web : hotelsaghro.ma / saghrohotel.ma", align="C")
109
+
110
+ # Back face - rates
111
+ pdf.add_page()
112
+ pdf.set_fill_color(15, 23, 42)
113
+ pdf.rect(0, 0, 210, 297, "F")
114
+ pdf.set_fill_color(212, 175, 55)
115
+ pdf.rect(0, 0, 210, 6, "F")
116
+
117
+ pdf.set_font("Helvetica", "B", 24)
118
+ pdf.set_text_color(212, 175, 55)
119
+ pdf.set_xy(20, 30)
120
+ pdf.cell(170, 12, "Tarifs 2026", align="C")
121
+
122
+ pdf.set_draw_color(212, 175, 55)
123
+ pdf.line(60, 48, 150, 48)
124
+
125
+ rates = [
126
+ ("Chambre simple", "\xE0 partir de 50 \x80 / nuit"),
127
+ ("Chambre double", "\xE0 partir de 70 \x80 / nuit"),
128
+ ("Chambre triple", "\xE0 partir de 90 \x80 / nuit"),
129
+ ("Petit-d\xE9jeuner", "Inclus (buffet 7h-10h30)"),
130
+ ("Lit suppl\xE9mentaire", "Disponible \xE2\x80\x93 tarif sur demande"),
131
+ ]
132
+
133
+ pdf.set_font("Helvetica", "", 12)
134
+ pdf.set_text_color(255, 255, 255)
135
+ y = 65
136
+ for title, price in rates:
137
+ pdf.set_xy(35, y)
138
+ pdf.set_font("Helvetica", "B", 12)
139
+ pdf.set_text_color(212, 175, 55)
140
+ pdf.cell(70, 8, title)
141
+ pdf.set_text_color(200, 200, 200)
142
+ pdf.set_font("Helvetica", "", 12)
143
+ pdf.cell(70, 8, price, align="R")
144
+ y += 14
145
+
146
+ # Services
147
+ y3 = y + 15
148
+ pdf.set_draw_color(212, 175, 55)
149
+ pdf.line(60, y3, 150, y3)
150
+
151
+ pdf.set_font("Helvetica", "B", 16)
152
+ pdf.set_text_color(212, 175, 55)
153
+ pdf.set_xy(20, y3 + 8)
154
+ pdf.cell(170, 10, "Services inclus", align="C")
155
+
156
+ services_list = [
157
+ "R\xE9ception 24h/24",
158
+ "Connexion Wi-Fi gratuite",
159
+ "Parking gratuit s\xE9curis\xE9",
160
+ "Service en chambre 24h/24",
161
+ "Petit-d\xE9jeuner buffet inclus",
162
+ "Animaux accept\xE9s sur demande",
163
+ "Transfert a\xE9roport",
164
+ "Salle de r\xE9union (30 pers.)",
165
+ "Excursions : Gorges du Todgha, Vall\xE9e du Dad\xE8s, D\xE9sert",
166
+ "Personnel multilingue : FR, AR, EN, ES",
167
+ ]
168
+
169
+ pdf.set_font("Helvetica", "", 10)
170
+ pdf.set_text_color(200, 200, 200)
171
+ y = y3 + 25
172
+ for service in services_list:
173
+ pdf.set_xy(35, y)
174
+ pdf.cell(160, 6, f"\xE2\x80\xA2 {service}")
175
+ y += 6.5
176
+
177
+ # Checkout
178
+ y4 = y + 15
179
+ pdf.set_draw_color(212, 175, 55)
180
+ pdf.line(60, y4, 150, y4)
181
+ pdf.set_font("Helvetica", "B", 14)
182
+ pdf.set_text_color(212, 175, 55)
183
+ pdf.set_xy(20, y4 + 8)
184
+ pdf.cell(170, 8, "Check-out : 12h00", align="C")
185
+ pdf.set_font("Helvetica", "", 10)
186
+ pdf.set_text_color(200, 200, 200)
187
+ pdf.set_xy(20, y4 + 18)
188
+ pdf.cell(170, 6, "R\xE9servation : +212 524 834 179 | saghrotinghir@gmail.com", align="C")
189
+ pdf.set_xy(20, y4 + 25)
190
+ pdf.cell(170, 6, "hotelsaghro.ma \xE2\x80\x93 saghrohotel.ma", align="C")
191
+
192
+ os.makedirs(os.path.dirname(PDF_PATH), exist_ok=True)
193
+ pdf.output(PDF_PATH)
194
+ print(f"PDF created: {PDF_PATH}")
backend/scripts/gen_seed_json.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Generate messages.json and conversations.json from seed data."""
2
+ import json, uuid
3
+ from datetime import datetime, timezone
4
+
5
+ BUSINESS_ID = "a10000000013"
6
+
7
+ SAMPLE_CONVERSATIONS = [
8
+ {
9
+ "conversation_id": "saghro-conv-001",
10
+ "language": "fr",
11
+ "messages": [
12
+ {"q": "Bonjour, quels sont les tarifs des chambres ?", "a": "Nos chambres simples sont à partir de 50€, doubles à partir de 70€ et triples à partir de 90€ par nuit. Le petit-déjeuner buffet est inclus."},
13
+ {"q": "Est-ce que vous avez une piscine ?", "a": "Oui, nous avons une piscine extérieure ouverte de mai à octobre."},
14
+ {"q": "Quels sont les horaires du restaurant ?", "a": "Notre restaurant est ouvert le midi de 12h à 14h30 et le soir de 19h à 22h30."},
15
+ {"q": "Proposez-vous le petit-déjeuner ?", "a": "Oui, le petit-déjeuner buffet est inclus dans le prix de la chambre."},
16
+ ],
17
+ },
18
+ {
19
+ "conversation_id": "saghro-conv-002",
20
+ "language": "en",
21
+ "messages": [
22
+ {"q": "Hello, how far is the hotel from Todgha Gorge?", "a": "The Todgha Gorge is approximately 9 km from Hotel Saghro."},
23
+ {"q": "Do you have free parking?", "a": "Yes, we offer free parking for all our guests."},
24
+ {"q": "Is Wi-Fi available?", "a": "Yes, Wi-Fi is available free of charge throughout the hotel."},
25
+ ],
26
+ },
27
+ {
28
+ "conversation_id": "saghro-conv-003",
29
+ "language": "ar",
30
+ "messages": [
31
+ {"q": "السلام عليكم، واش عندكم غرف فاضية هاد السيمانة؟", "a": "وعليكم السلام. نعم، تتوفر لدينا غرف متاحة هذا الأسبوع."},
32
+ {"q": "شحال الثمن ف الليلة؟", "a": "أسعار الغرف: الغرفة الفردية ابتداء من 50 يورو."},
33
+ {"q": "فين كاين الفندق بالضبط؟", "a": "الفندق يقع في وسط مدينة تنغير."},
34
+ ],
35
+ },
36
+ {
37
+ "conversation_id": "saghro-conv-004",
38
+ "language": "fr",
39
+ "messages": [
40
+ {"q": "Bonjour, est-ce que vous avez des chambres avec vue sur la vallée ?", "a": "Oui, toutes nos chambres disposent d'un balcon privé avec vue panoramique."},
41
+ {"q": "Acceptez-vous les animaux ?", "a": "Oui, les animaux sont acceptés sur demande préalable."},
42
+ {"q": "Quels sont les moyens de paiement acceptés ?", "a": "Nous acceptons les paiements en espèces, par carte bancaire."},
43
+ ],
44
+ },
45
+ {
46
+ "conversation_id": "saghro-conv-005",
47
+ "language": "en",
48
+ "messages": [
49
+ {"q": "What excursions do you offer?", "a": "We offer excursions to Todgha Gorge, Dades Valley, and the Rose Valley."},
50
+ {"q": "Do you have airport transfer service?", "a": "Yes, we offer airport transfer service."},
51
+ {"q": "What languages do your staff speak?", "a": "Our staff speaks Arabic, French, English, and Spanish."},
52
+ ],
53
+ },
54
+ {
55
+ "conversation_id": "saghro-conv-006",
56
+ "language": "fr",
57
+ "messages": [
58
+ {"q": "Avez-vous des salles de réunion ?", "a": "Oui, nous disposons d'une salle de réunion équipée."},
59
+ {"q": "Proposez-vous des formules pour séminaires ?", "a": "Oui, nous proposons des forfaits séminaires."},
60
+ ],
61
+ },
62
+ {
63
+ "conversation_id": "saghro-conv-007",
64
+ "language": "ar",
65
+ "messages": [
66
+ {"q": "واش كاين مطعم فالفندق؟", "a": "نعم، يوجد مطعم في الفندق يقدم المأكولات المغربية."},
67
+ {"q": "بغيت نحجز غرفة لعائلة، شحال الثمن؟", "a": "الغرفة الثلاثية مناسبة للعائلة بسعر 90 يورو."},
68
+ ],
69
+ },
70
+ {
71
+ "conversation_id": "saghro-conv-008",
72
+ "language": "en",
73
+ "messages": [
74
+ {"q": "Is there a gym or spa at the hotel?", "a": "We do not have a gym or spa on site."},
75
+ {"q": "Do you have room service?", "a": "Yes, room service is available 24 hours a day."},
76
+ ],
77
+ },
78
+ ]
79
+
80
+ now = datetime.now(timezone.utc).isoformat()
81
+ conversations = []
82
+ messages = []
83
+
84
+ for conv in SAMPLE_CONVERSATIONS:
85
+ cid = conv["conversation_id"]
86
+ conversations.append({
87
+ "id": cid,
88
+ "business_id": BUSINESS_ID,
89
+ "language": conv["language"],
90
+ "visitor_ip": None,
91
+ "started_at": now,
92
+ })
93
+ for m in conv["messages"]:
94
+ messages.append({
95
+ "id": uuid.uuid4().hex[:12],
96
+ "business_id": BUSINESS_ID,
97
+ "conversation_id": cid,
98
+ "question": m["q"],
99
+ "answer": m["a"],
100
+ "sources": json.dumps([]),
101
+ "language": conv["language"],
102
+ "created_at": now,
103
+ })
104
+
105
+ import os
106
+ data_dir = os.path.join(os.path.dirname(__file__), "data")
107
+ os.makedirs(data_dir, exist_ok=True)
108
+
109
+ with open(os.path.join(data_dir, "messages.json"), "w", encoding="utf-8") as f:
110
+ json.dump(messages, f, ensure_ascii=False, indent=2)
111
+
112
+ with open(os.path.join(data_dir, "conversations.json"), "w", encoding="utf-8") as f:
113
+ json.dump(conversations, f, ensure_ascii=False, indent=2)
114
+
115
+ print(f"Generated {len(messages)} messages, {len(conversations)} conversations")
backend/scripts/migrate_social.py ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sqlite3, os
2
+
3
+ DB = os.path.join(os.path.dirname(__file__), "..", "sahel.db")
4
+ conn = sqlite3.connect(DB)
5
+ cursor = conn.execute("PRAGMA table_info(businesses)")
6
+ cols = [row[1] for row in cursor.fetchall()]
7
+ print("Existing columns:", cols)
8
+
9
+ if "social_media" not in cols:
10
+ conn.execute("ALTER TABLE businesses ADD COLUMN social_media TEXT")
11
+ conn.commit()
12
+ print("Added social_media column")
13
+ else:
14
+ print("social_media column already exists")
15
+
16
+ conn.close()
17
+ print("Done")
backend/scripts/seed_saghro.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seed Hotel Saghro business + sample conversations into PostgreSQL."""
2
+ import json
3
+ import os
4
+ import sys
5
+ from datetime import datetime, timezone
6
+ from uuid import uuid4
7
+
8
+ # Ensure we can import from the backend directory
9
+ sys.path.insert(0, os.path.dirname(__file__))
10
+ from storage import (
11
+ read_table,
12
+ write_table,
13
+ SessionLocal,
14
+ BusinessModel,
15
+ DocumentModel,
16
+ MessageModel,
17
+ ConversationModel,
18
+ )
19
+
20
+ BUSINESS_ID = "a10000000013"
21
+
22
+ BUSINESS = {
23
+ "id": BUSINESS_ID,
24
+ "owner_id": "demo-owner",
25
+ "slug": "saghro",
26
+ "name": "Hotel Saghro",
27
+ "business_type": "hotel",
28
+ "description": "H\u00f4tel 4**** situ\u00e9 en plein centre-ville de Tinghir offrant une vue panoramique sur la vall\u00e9e de Toudgha et les montagnes de l'Atlas. 65 chambres avec balcon priv\u00e9, piscine ext\u00e9rieure, restaurant, bar et services h\u00f4teliers complets.",
29
+ "owner_email": "saghrotinghir@gmail.com",
30
+ "owner_phone": "+212 524 834 179",
31
+ "city": "Tinghir",
32
+ "address": "Av. des FAR, Centre Tinghir, 45800, Maroc",
33
+ "working_hours": "R\u00e9ception 24h/24",
34
+ "primary_services": "H\u00e9bergement, Piscine ext\u00e9rieure, Restaurant, Bar, Petit-d\u00e9jeuner buffet, Room service, Excursions Gorges Todgha, Laverie, Transfert a\u00e9roport",
35
+ "site_url": "/b/saghro",
36
+ "chat_url": "/chat/saghro",
37
+ "highlights": "Vue panoramique vall\u00e9e Toudgha, Piscine ext\u00e9rieure, 65 chambres climatis\u00e9es, Proche Gorges du Todgha, Parking gratuit, Wi-Fi gratuit, Personnel multilingue",
38
+ "public_knowledge": "Hotel Saghro est un h\u00f4tel 4 \u00e9toiles situ\u00e9 au centre de Tinghir. 65 chambres avec salle de bain, clim, coffre-fort, balcon. Tarifs: simple 50\u20ac, double 70\u20ac, triple 90\u20ac/nuit. Petit-d\u00e9jeuner buffet inclus. Check-out 12h. Piscine ext\u00e9rieure. Restaurant cuisine marocaine et internationale. Bar 10h-23h. Room service 24h. Excursions aux Gorges du Todgha (9 km). Parking gratuit. Wi-Fi gratuit. Animaux accept\u00e9s. R\u00e9ception 24h.",
39
+ "latitude": None,
40
+ "longitude": None,
41
+ "cover_image_url": None,
42
+ "ice": None,
43
+ "rc": None,
44
+ "if_tax": None,
45
+ "patente": None,
46
+ "cnss": None,
47
+ "legal_form": "SARL",
48
+ }
49
+
50
+ DOCUMENTS = [
51
+ {
52
+ "id": "d000000000013",
53
+ "business_id": BUSINESS_ID,
54
+ "file_name": "Brochure Hotel Saghro.pdf",
55
+ "file_type": "application/pdf",
56
+ "file_url": "/uploaded_documents/a10000000013/Brochure Hotel Saghro.pdf",
57
+ "chunks": 0,
58
+ "size_bytes": 0,
59
+ "uploaded_at": "2026-06-05T00:00:00+00:00",
60
+ },
61
+ {
62
+ "id": "d000000000014",
63
+ "business_id": BUSINESS_ID,
64
+ "file_name": "Menu Restaurant Saghro.pdf",
65
+ "file_type": "application/pdf",
66
+ "file_url": "/uploaded_documents/a10000000013/Menu Restaurant Saghro.pdf",
67
+ "chunks": 0,
68
+ "size_bytes": 0,
69
+ "uploaded_at": "2026-06-05T00:00:00+00:00",
70
+ },
71
+ ]
72
+
73
+ SAMPLE_CONVERSATIONS = [
74
+ # Conversation 1 — French
75
+ {
76
+ "conversation_id": "saghro-conv-001",
77
+ "language": "fr",
78
+ "messages": [
79
+ {"q": "Bonjour, quels sont les tarifs des chambres ?", "a": "Nos chambres simples sont à partir de 50€, doubles à partir de 70€ et triples à partir de 90€ par nuit. Le petit-déjeuner buffet est inclus."},
80
+ {"q": "Est-ce que vous avez une piscine ?", "a": "Oui, nous avons une piscine extérieure ouverte de mai à octobre. Elle est située dans notre jardin avec des chaises longues et des parasols."},
81
+ {"q": "Quels sont les horaires du restaurant ?", "a": "Notre restaurant est ouvert le midi de 12h à 14h30 et le soir de 19h à 22h30. Nous servons une cuisine marocaine traditionnelle et des plats internationaux."},
82
+ {"q": "Proposez-vous le petit-déjeuner ?", "a": "Oui, le petit-déjeuner buffet est inclus dans le prix de la chambre. Il est servi de 7h à 10h30 et comprend des viennoiseries, pains, fruits, jus, café et thé."},
83
+ ],
84
+ },
85
+ # Conversation 2 — English
86
+ {
87
+ "conversation_id": "saghro-conv-002",
88
+ "language": "en",
89
+ "messages": [
90
+ {"q": "Hello, how far is the hotel from Todgha Gorge?", "a": "The Todgha Gorge is approximately 9 km (5.8 miles) from Hotel Saghro, about a 15-minute drive. We can arrange a taxi or excursions for you at the reception."},
91
+ {"q": "Do you have free parking?", "a": "Yes, we offer free parking for all our guests. The parking is secure and located right next to the hotel."},
92
+ {"q": "Is Wi-Fi available?", "a": "Yes, Wi-Fi is available free of charge throughout the hotel including in all rooms and common areas."},
93
+ ],
94
+ },
95
+ # Conversation 3 — Darija
96
+ {
97
+ "conversation_id": "saghro-conv-003",
98
+ "language": "ar",
99
+ "messages": [
100
+ {"q": "السلام عليكم، واش عندكم غرف فاضية هاد السيمانة؟", "a": "وعليكم السلام. نعم، تتوفر لدينا غرف متاحة هذا الأسبوع. يرجى الاتصال بنا على +212 524 834 179 للحجز والتأكد من التوفر."},
101
+ {"q": "شحال الثمن ف الليلة؟", "a": "أسعار الغرف: الغرفة الفردية ابتداء من 50 يورو، الغرفة المزدوجة ابتداء من 70 يورو، والغرفة الثلاثية ابتداء من 90 يورو لليلة الواحدة. الإفطار مشمول في السعر."},
102
+ {"q": "فين كاين الفندق بالضبط؟", "a": "الفندق يقع في وسط مدينة تنغير، شارع القوات المسلحة الملكية. إطلالة رائعة على وادي تودغا وجبال الأطلس."},
103
+ ],
104
+ },
105
+ # Conversation 4 — French (couple asking about stay)
106
+ {
107
+ "conversation_id": "saghro-conv-004",
108
+ "language": "fr",
109
+ "messages": [
110
+ {"q": "Bonjour, est-ce que vous avez des chambres avec vue sur la vallée ?", "a": "Oui, toutes nos chambres disposent d'un balcon privé avec vue panoramique sur la vallée de Toudgha et les montagnes de l'Atlas."},
111
+ {"q": "Acceptez-vous les animaux ?", "a": "Oui, les animaux sont acceptés sur demande préalable. Veuillez nous contacter avant votre réservation pour confirmer."},
112
+ {"q": "Quels sont les moyens de paiement acceptés ?", "a": "Nous acceptons les paiements en espèces, par carte bancaire (Visa, Mastercard) et par virement bancaire."},
113
+ ],
114
+ },
115
+ # Conversation 5 — English (tourist planning trip)
116
+ {
117
+ "conversation_id": "saghro-conv-005",
118
+ "language": "en",
119
+ "messages": [
120
+ {"q": "What excursions do you offer?", "a": "We offer excursions to Todgha Gorge, Dades Valley, and the Rose Valley in Kelaat Mgouna. We can also arrange 4x4 desert trips and guided hikes in the Atlas Mountains. Please ask at reception for prices and schedules."},
121
+ {"q": "Do you have airport transfer service?", "a": "Yes, we offer airport transfer service. Please contact us in advance to arrange pick-up from Sale Airport or Errachidia Airport. Rates depend on the distance and number of passengers."},
122
+ {"q": "What languages do your staff speak?", "a": "Our staff speaks Arabic, French, English, and Spanish. We also have guides who speak German and Italian for excursions."},
123
+ ],
124
+ },
125
+ # Conversation 6 — French (business client)
126
+ {
127
+ "conversation_id": "saghro-conv-006",
128
+ "language": "fr",
129
+ "messages": [
130
+ {"q": "Avez-vous des salles de réunion ?", "a": "Oui, nous disposons d'une salle de réunion équipée pouvant accueillir jusqu'à 30 personnes. Elle est équipée de Wi-Fi, vidéoprojecteur et paperboard."},
131
+ {"q": "Proposez-vous des formules pour séminaires ?", "a": "Oui, nous proposons des forfaits séminaires incluant salle de réunion, pause-café, déjeuner et hébergement. Contactez-nous pour un devis personnalisé."},
132
+ ],
133
+ },
134
+ # Conversation 7 — Darija (family)
135
+ {
136
+ "conversation_id": "saghro-conv-007",
137
+ "language": "ar",
138
+ "messages": [
139
+ {"q": "واش كاين مطعم فالفندق؟", "a": "نعم، يوجد مطعم في الفندق يقدم المأكولات المغربية التقليدية والعالمية. مفتوح على الغداء من 12h إلى 14h30 والعشاء من 19h إلى 22h30."},
140
+ {"q": "بغيت نحجز غرفة لعائلة، شحال الثمن؟", "a": "الغرفة الثلاثية مناسبة للعائلة بسعر 90 يورو لليلة الواحدة مع الإفطار. يمكن إضافة سرير إضافي للأطفال بتكلفة إضافية."},
141
+ ],
142
+ },
143
+ # Conversation 8 — English (checking facilities)
144
+ {
145
+ "conversation_id": "saghro-conv-008",
146
+ "language": "en",
147
+ "messages": [
148
+ {"q": "Is there a gym or spa at the hotel?", "a": "We do not have a gym or spa on site, but we can arrange hammam and massage treatments at a nearby partner facility. Please ask at reception for more information."},
149
+ {"q": "Do you have room service?", "a": "Yes, room service is available 24 hours a day. You can order from our restaurant menu directly to your room."},
150
+ ],
151
+ },
152
+ ]
153
+
154
+
155
+ def seed():
156
+ # 1. Seed business
157
+ businesses = read_table("businesses")
158
+ existing = [b for b in businesses if b["id"] == BUSINESS_ID]
159
+ if not existing:
160
+ businesses.append(BUSINESS)
161
+ write_table("businesses", businesses)
162
+ print(f"✓ Business '{BUSINESS['name']}' added")
163
+ else:
164
+ print(f"→ Business '{BUSINESS['name']}' already exists")
165
+
166
+ # 2. Seed documents
167
+ documents = read_table("documents")
168
+ existing_doc_ids = {d["id"] for d in documents}
169
+ new_docs = [d for d in DOCUMENTS if d["id"] not in existing_doc_ids]
170
+ if new_docs:
171
+ documents.extend(new_docs)
172
+ write_table("documents", documents)
173
+ print(f"✓ Added {len(new_docs)} document(s)")
174
+ else:
175
+ print("→ Documents already exist")
176
+
177
+ # 3. Seed sample conversations & messages
178
+ messages = read_table("messages")
179
+ conversations = read_table("conversations")
180
+ existing_conv_ids = {c["id"] for c in conversations}
181
+
182
+ now = datetime.now(timezone.utc)
183
+ msg_count = 0
184
+ conv_count = 0
185
+
186
+ for conv in SAMPLE_CONVERSATIONS:
187
+ cid = conv["conversation_id"]
188
+ lang = conv["language"]
189
+ if cid in existing_conv_ids:
190
+ continue
191
+
192
+ conversations.append({
193
+ "id": cid,
194
+ "business_id": BUSINESS_ID,
195
+ "language": lang,
196
+ "visitor_ip": None,
197
+ "started_at": now.isoformat(),
198
+ })
199
+ conv_count += 1
200
+
201
+ for i, m in enumerate(conv["messages"]):
202
+ messages.append({
203
+ "id": uuid4().hex[:12],
204
+ "business_id": BUSINESS_ID,
205
+ "conversation_id": cid,
206
+ "question": m["q"],
207
+ "answer": m["a"],
208
+ "sources": json.dumps([]),
209
+ "language": lang,
210
+ "created_at": now.isoformat(),
211
+ })
212
+ msg_count += 1
213
+
214
+ if conv_count > 0:
215
+ write_table("conversations", conversations)
216
+ write_table("messages", messages)
217
+ print(f"✓ Added {conv_count} conversation(s) with {msg_count} message(s)")
218
+ else:
219
+ print("→ Sample conversations already exist")
220
+
221
+ total_messages = len(read_table("messages"))
222
+ total_convs = len([c for c in read_table("conversations") if c["business_id"] == BUSINESS_ID])
223
+ print(f"\n📊 Hotel Saghro stats: {total_convs} conversations, {total_messages} messages")
224
+
225
+
226
+ if __name__ == "__main__":
227
+ seed()
backend/scripts/seed_social.py ADDED
@@ -0,0 +1,36 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request, json
2
+
3
+ SOCIAL_PRESETS = {
4
+ "Restaurant Amoudou": {"instagram": "https://instagram.com/amoudou.marrakech", "facebook": "https://facebook.com/amoudou.marrakech"},
5
+ "Poissonnerie Ajami": {"instagram": "https://instagram.com/ajami.casablanca", "facebook": "https://facebook.com/ajami.casablanca"},
6
+ "Riad Snan": {"instagram": "https://instagram.com/riad_snan", "facebook": "https://facebook.com/riad_snan"},
7
+ "H\u00f4tel Tichka": {"instagram": "https://instagram.com/hotel_tichka", "facebook": "https://facebook.com/hotel_tichka"},
8
+ "Riad El Fenn": {"instagram": "https://instagram.com/riad_el_fenn", "facebook": "https://facebook.com/riad_el_fenn"},
9
+ "Kasbah du Toubkal": {"instagram": "https://instagram.com/kasbah_toubkal", "facebook": "https://facebook.com/kasbah_toubkal"},
10
+ "Auto-Hall M\u00e9canique": {"facebook": "https://facebook.com/autohall.casablanca"},
11
+ "Salon Hicham Coiffure": {"instagram": "https://instagram.com/salon_hicham", "facebook": "https://facebook.com/salon_hicham"},
12
+ "Cabinet Avocats El Amrani": {"linkedin": "https://linkedin.com/company/el-amrani-law"},
13
+ "Boutique Artisanale Tinfou": {"instagram": "https://instagram.com/tinfou.artisanat", "facebook": "https://facebook.com/tinfou.artisanat"},
14
+ "\u00c9picerie Bab El Hadid": {"facebook": "https://facebook.com/bab.elhadid.fes"},
15
+ "Studio Cr\u00e9atif Kenza": {"instagram": "https://instagram.com/studio_kenza", "linkedin": "https://linkedin.com/company/kenza-studio"},
16
+ }
17
+
18
+ r = urllib.request.urlopen('http://localhost:8000/businesses')
19
+ data = json.loads(r.read())
20
+ businesses = data.get('businesses', [])
21
+
22
+ for b in businesses:
23
+ name = b['name']
24
+ if name in SOCIAL_PRESETS:
25
+ sm = json.dumps(SOCIAL_PRESETS[name])
26
+ payload = json.dumps({"name": b['name'], "business_type": b.get('business_type', ''), "description": b.get('description', ''), "social_media": sm}).encode()
27
+ req = urllib.request.Request(f'http://localhost:8000/businesses/{b["id"]}', data=payload, method='PUT', headers={'Content-Type': 'application/json'})
28
+ try:
29
+ resp = urllib.request.urlopen(req)
30
+ print(f'OK: {name} -> social_media set')
31
+ except Exception as e:
32
+ print(f'FAIL: {name}: {e}')
33
+ else:
34
+ print(f'SKIP: {name} (no preset)')
35
+
36
+ print('Done')
backend/scripts/seed_social2.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, os
2
+ sys.path.insert(0, os.path.dirname(__file__))
3
+ from storage import engine, BusinessModel
4
+ from sqlalchemy.orm import sessionmaker
5
+ import json
6
+
7
+ SOCIAL_PRESETS = {
8
+ "Restaurant Amoudou": {"instagram": "https://instagram.com/amoudou.marrakech", "facebook": "https://facebook.com/amoudou.marrakech"},
9
+ "Poissonnerie Ajami": {"instagram": "https://instagram.com/ajami.casablanca", "facebook": "https://facebook.com/ajami.casablanca"},
10
+ "Riad Snan": {"instagram": "https://instagram.com/riad_snan", "facebook": "https://facebook.com/riad_snan"},
11
+ "H\u00f4tel Tichka": {"instagram": "https://instagram.com/hotel_tichka", "facebook": "https://facebook.com/hotel_tichka"},
12
+ "Riad El Fenn": {"instagram": "https://instagram.com/riad_el_fenn", "facebook": "https://facebook.com/riad_el_fenn"},
13
+ "Kasbah du Toubkal": {"instagram": "https://instagram.com/kasbah_toubkal", "facebook": "https://facebook.com/kasbah_toubkal"},
14
+ "Auto-Hall M\u00e9canique": {"facebook": "https://facebook.com/autohall.casablanca"},
15
+ "Salon Hicham Coiffure": {"instagram": "https://instagram.com/salon_hicham", "facebook": "https://facebook.com/salon_hicham"},
16
+ "Cabinet Avocats El Amrani": {"linkedin": "https://linkedin.com/company/el-amrani-law"},
17
+ "Boutique Artisanale Tinfou": {"instagram": "https://instagram.com/tinfou.artisanat", "facebook": "https://facebook.com/tinfou.artisanat"},
18
+ "\u00c9picerie Bab El Hadid": {"facebook": "https://facebook.com/bab.elhadid.fes"},
19
+ "Studio Cr\u00e9atif Kenza": {"instagram": "https://instagram.com/studio_kenza", "linkedin": "https://linkedin.com/company/kenza-studio"},
20
+ }
21
+
22
+ Session = sessionmaker(bind=engine)
23
+ session = Session()
24
+ count = 0
25
+ for b in session.query(BusinessModel).all():
26
+ name = b.name
27
+ if name in SOCIAL_PRESETS:
28
+ b.social_media = json.dumps(SOCIAL_PRESETS[name])
29
+ count += 1
30
+ print(f'OK: {name}')
31
+ else:
32
+ print(f'SKIP: {name}')
33
+ session.commit()
34
+ session.close()
35
+ print(f'\nDone: {count} businesses updated')
backend/scripts/test_api.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import urllib.request, json
2
+
3
+ # Test fetching specific business by slug
4
+ print("=== Fetch amoudou ===")
5
+ req = urllib.request.Request("http://localhost:8000/businesses/amoudou")
6
+ resp = urllib.request.urlopen(req)
7
+ data = json.loads(resp.read())
8
+ print(f"Name: {data.get('name')}")
9
+ print(f"Slug: {data.get('slug')}")
10
+ print(f"Social media: {data.get('social_media')}")
11
+
12
+ # Parse and show
13
+ sm = data.get("social_media")
14
+ if sm:
15
+ try:
16
+ parsed = json.loads(sm)
17
+ print(f"Parsed: {parsed}")
18
+ except json.JSONDecodeError:
19
+ print(f"Failed to parse: {sm}")
20
+ else:
21
+ print("No social media")
22
+
23
+ print()
24
+ print("=== Fetch ajami ===")
25
+ req = urllib.request.Request("http://localhost:8000/businesses/ajami")
26
+ resp = urllib.request.urlopen(req)
27
+ data = json.loads(resp.read())
28
+ print(f"Social media: {data.get('social_media')}")
29
+
30
+ print()
31
+ print("=== Fetch tinfou ===")
32
+ req = urllib.request.Request("http://localhost:8000/businesses/tinfou")
33
+ resp = urllib.request.urlopen(req)
34
+ data = json.loads(resp.read())
35
+ print(f"Social media: {data.get('social_media')}")