Fred808 commited on
Commit
5bf7442
·
verified ·
1 Parent(s): f1c467f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +977 -0
app.py ADDED
@@ -0,0 +1,977 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ import os
3
+ import time
4
+ import requests
5
+ import base64
6
+ import asyncio
7
+ from datetime import datetime, timedelta
8
+ from bs4 import BeautifulSoup
9
+ from sqlalchemy import select
10
+ from pydantic import BaseModel
11
+
12
+ from fastapi import FastAPI, Request, HTTPException, BackgroundTasks, UploadFile, File, Form
13
+ from fastapi.responses import JSONResponse, StreamingResponse, RedirectResponse
14
+
15
+ import openai
16
+
17
+ # For sentiment analysis using TextBlob
18
+ from textblob import TextBlob
19
+
20
+ # SQLAlchemy Imports (Async)
21
+ from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
22
+ from sqlalchemy.orm import sessionmaker, declarative_base
23
+ from sqlalchemy import Column, Integer, String, DateTime, Text, Float
24
+
25
+ # --- Environment Variables and API Keys ---
26
+ SPOONACULAR_API_KEY = os.getenv("SPOONACULAR_API_KEY", "default_fallback_value")
27
+ PAYSTACK_SECRET_KEY = os.getenv("PAYSTACK_SECRET_KEY", "default_fallback_value")
28
+ DATABASE_URL = os.getenv("DATABASE_URL", "default_fallback_value")  # Example using SQLite
29
+ NVIDIA_API_KEY = os.getenv("NVIDIA_API_KEY", "default_fallback_value")
30
+ openai.api_key = os.getenv("OPENAI_API_KEY", "default_fallback_value")
31
+
32
+ # WhatsApp Business API credentials (Cloud API)
33
+ WHATSAPP_PHONE_NUMBER_ID = os.getenv("WHATSAPP_PHONE_NUMBER_ID", "default_value")
34
+ WHATSAPP_ACCESS_TOKEN = os.getenv("WHATSAPP_ACCESS_TOKEN", "default_value")
35
+ MANAGEMENT_WHATSAPP_NUMBER = os.getenv("MANAGEMENT_WHATSAPP_NUMBER", "default_value")
36
+
37
+ # Synthetic town prices for shipping costs
38
+ TOWN_SHIPPING_COSTS = {
39
+     "lasu gate": 1000,  # N1,000 for LASU Gate
40
+     "ojo": 800,         # N800 for Ojo
41
+     "ajangbadi": 1200,  # N1,200 for Ajangbadi
42
+     "iba": 900,         # N900 for Iba
43
+     "okokomaiko": 1500, # N1,500 for Okokomaiko
44
+     "default": 1000     # Default shipping cost for unknown areas
45
+ }
46
+
47
+ # --- Database Setup ---
48
+ Base = declarative_base()
49
+
50
+ class ChatHistory(Base):
51
+     __tablename__ = "chat_history"
52
+     id = Column(Integer, primary_key=True, index=True)
53
+     user_id = Column(String, index=True)
54
+     timestamp = Column(DateTime, default=datetime.utcnow)
55
+     direction = Column(String)  # 'inbound' or 'outbound'
56
+     message = Column(Text)
57
+
58
+ class Order(Base):
59
+     __tablename__ = "orders"
60
+     id = Column(Integer, primary_key=True, index=True)
61
+     order_id = Column(String, unique=True, index=True)
62
+     user_id = Column(String, index=True)
63
+     dish = Column(String)
64
+     quantity = Column(String)
65
+     price = Column(String, default="0")
66
+     status = Column(String, default="Pending Payment")
67
+     payment_reference = Column(String, nullable=True)
68
+     delivery_address = Column(String, default="")  # New field for address
69
+     timestamp = Column(DateTime, default=datetime.utcnow)
70
+
71
+ class UserProfile(Base):
72
+     __tablename__ = "user_profiles"
73
+     id = Column(Integer, primary_key=True, index=True)
74
+     user_id = Column(String, unique=True, index=True)
75
+     phone_number = Column(String, unique=True, index=True, nullable=True)
76
+     name = Column(String, default="Valued Customer")
77
+     email = Column(String, default="unknown@example.com")
78
+     preferences = Column(Text, default="")
79
+     last_interaction = Column(DateTime, default=datetime.utcnow)
80
+
81
+ class SentimentLog(Base):
82
+     __tablename__ = "sentiment_logs"
83
+     id = Column(Integer, primary_key=True, index=True)
84
+     user_id = Column(String, index=True)
85
+     timestamp = Column(DateTime, default=datetime.utcnow)
86
+     sentiment_score = Column(Float)
87
+     message = Column(Text)
88
+
89
+ # --- New Model: Order Tracking ---
90
+ class OrderTracking(Base):
91
+     __tablename__ = "order_tracking"
92
+     id = Column(Integer, primary_key=True, index=True)
93
+     order_id = Column(String, index=True)
94
+     status = Column(String)  # e.g., "Order Placed", "Payment Confirmed", etc.
95
+     message = Column(Text, nullable=True)  # Optional additional details
96
+     timestamp = Column(DateTime, default=datetime.utcnow)
97
+
98
+ # --- Create Engine and Session ---
99
+ engine = create_async_engine(DATABASE_URL, echo=True)
100
+ async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
101
+
102
+ async def init_db():
103
+     async with engine.begin() as conn:
104
+         await conn.run_sync(Base.metadata.create_all)
105
+
106
+ # --- Global In-Memory Stores ---
107
+ user_state = {}       # e.g., { user_id: ConversationState }
108
+ conversation_context = {}  # { user_id: [ { "timestamp": ..., "role": "user"/"bot", "message": ... }, ... ] }
109
+ proactive_timer = {}
110
+
111
+ menu_items = [
112
+     {"name": "Jollof Rice", "description": "A spicy and flavorful rice dish", "price": 1500, "nutrition": "Calories: 300 kcal, Carbs: 50g, Protein: 10g, Fat: 5g"},
113
+     {"name": "Fried Rice", "description": "A savory rice dish with vegetables and meat", "price": 1200, "nutrition": "Calories: 350 kcal, Carbs: 55g, Protein: 12g, Fat: 8g"},
114
+     {"name": "Chicken Wings", "description": "Crispy fried chicken wings", "price": 2000, "nutrition": "Calories: 400 kcal, Carbs: 20g, Protein: 25g, Fat: 15g"},
115
+     {"name": "Egusi Soup", "description": "A rich and hearty soup made with melon seeds", "price": 1000, "nutrition": "Calories: 250 kcal, Carbs: 15g, Protein: 8g, Fat: 10g"}
116
+ ]
117
+
118
+ # --- Conversation State Management ---
119
+ SESSION_TIMEOUT = timedelta(minutes=5)
120
+
121
+ class ConversationState:
122
+     def __init__(self):
123
+         self.flow = None  # e.g., "order"
124
+         self.step = 0
125
+         self.data = {}
126
+         self.last_active = datetime.utcnow()
127
+
128
+     def update_last_active(self):
129
+         self.last_active = datetime.utcnow()
130
+
131
+     def is_expired(self):
132
+         return datetime.utcnow() - self.last_active > SESSION_TIMEOUT
133
+
134
+     def reset(self):
135
+         self.flow = None
136
+         self.step = 0
137
+         self.data = {}
138
+         self.last_active = datetime.utcnow()
139
+
140
+ # --- Utility Functions ---
141
+ async def log_chat_to_db(user_id: str, direction: str, message: str):
142
+     async with async_session() as session:
143
+         entry = ChatHistory(user_id=user_id, direction=direction, message=message)
144
+         session.add(entry)
145
+         await session.commit()
146
+
147
+ async def log_sentiment(user_id: str, message: str, score: float):
148
+     async with async_session() as session:
149
+         entry = SentimentLog(user_id=user_id, sentiment_score=score, message=message)
150
+         session.add(entry)
151
+         await session.commit()
152
+
153
+ def analyze_sentiment(text: str) -> float:
154
+     blob = TextBlob(text)
155
+     return blob.sentiment.polarity
156
+
157
+ def google_image_scrape(query: str) -> str:
158
+     headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
159
+     search_url = f"https://www.google.com/search?tbm=isch&q={query}"
160
+     try:
161
+         response = requests.get(search_url, headers=headers, timeout=5)
162
+     except Exception:
163
+         return ""
164
+     if response.status_code == 200:
165
+         soup = BeautifulSoup(response.text, "html.parser")
166
+         img_tags = soup.find_all("img")
167
+         for img in img_tags:
168
+             src = img.get("src")
169
+             if src and src.startswith("http"):
170
+                 return src
171
+     return ""
172
+
173
+ def create_paystack_payment_link(email: str, amount: int, reference: str) -> dict:
174
+     url = "https://api.paystack.co/transaction/initialize"
175
+     headers = {
176
+         "Authorization": f"Bearer {PAYSTACK_SECRET_KEY}",
177
+         "Content-Type": "application/json",
178
+     }
179
+     data = {
180
+         "email": email,
181
+         "amount": amount,
182
+         "reference": reference,
183
+         "callback_url": "https://custy-bot.vercel.app/payment_callback"
184
+     }
185
+     try:
186
+         response = requests.post(url, json=data, headers=headers, timeout=10)
187
+         if response.status_code == 200:
188
+             return response.json()
189
+         else:
190
+             return {"status": False, "message": "Failed to initialize payment."}
191
+     except Exception as e:
192
+         return {"status": False, "message": str(e)}
193
+
194
+ # --- WhatsApp Business API Helper ---
195
+ def send_whatsapp_message(recipient: str, message_body: str) -> dict:
196
+     """
197
+     Sends a WhatsApp text message using the WhatsApp Cloud API.
198
+     `recipient` should be in international format, e.g., "15551234567".
199
+     """
200
+     url = f"https://graph.facebook.com/v15.0/{WHATSAPP_PHONE_NUMBER_ID}/messages"
201
+     headers = {
202
+         "Authorization": f"Bearer {WHATSAPP_ACCESS_TOKEN}",
203
+         "Content-Type": "application/json"
204
+     }
205
+     payload = {
206
+         "messaging_product": "whatsapp",
207
+         "to": recipient,
208
+         "type": "text",
209
+         "text": {"body": message_body}
210
+     }
211
+     response = requests.post(url, headers=headers, json=payload)
212
+     return response.json()
213
+
214
+ # --- NVIDIA LLM Streaming Functions ---
215
+ def stream_text_completion(prompt: str):
216
+     from openai import OpenAI
217
+     client = OpenAI(
218
+         base_url="https://integrate.api.nvidia.com/v1",
219
+         api_key=NVIDIA_API_KEY
220
+     )
221
+     completion = client.chat.completions.create(
222
+         model="meta/llama-3.1-405b-instruct",
223
+         messages=[{"role": "user", "content": prompt}],
224
+         temperature=0.2,
225
+         top_p=0.7,
226
+         max_tokens=1024,
227
+         stream=True
228
+     )
229
+     for chunk in completion:
230
+         if chunk.choices[0].delta.content is not None:
231
+             yield chunk.choices[0].delta.content
232
+
233
+ def stream_image_completion(image_b64: str):
234
+     invoke_url = "https://ai.api.nvidia.com/v1/gr/meta/llama-3.2-90b-vision-instruct/chat/completions"
235
+     headers = {
236
+         "Authorization": f"Bearer {NVIDIA_API_KEY}",
237
+         "Accept": "text/event-stream"
238
+     }
239
+     payload = {
240
+         "model": "meta/llama-3.2-90b-vision-instruct",
241
+         "messages": [
242
+             {
243
+                 "role": "user",
244
+                 "content": f'What is in this image? <img src="data:image/png;base64,{image_b64}" />'
245
+             }
246
+         ],
247
+         "max_tokens": 512,
248
+         "temperature": 1.00,
249
+         "top_p": 1.00,
250
+         "stream": True
251
+     }
252
+     response = requests.post(invoke_url, headers=headers, json=payload, stream=True)
253
+     for line in response.iter_lines():
254
+         if line:
255
+             yield line.decode("utf-8") + "\n"
256
+
257
+ # --- Helper Function for Order Tracking ---
258
+ async def log_order_tracking(order_id: str, status: str, message: str = None):
259
+     async with async_session() as session:
260
+         tracking_entry = OrderTracking(
261
+             order_id=order_id,
262
+             status=status,
263
+             message=message
264
+         )
265
+         session.add(tracking_entry)
266
+         await session.commit()
267
+
268
+ # --- Advanced Internal Flow: Order Processing & Payment Integration ---
269
+ def calculate_shipping_cost(address: str) -> int:
270
+     """
271
+     Calculate shipping cost based on the user's address.
272
+     """
273
+     address_lower = address.lower()
274
+     for area, cost in TOWN_SHIPPING_COSTS.items():
275
+         if area in address_lower:
276
+             return cost
277
+     return TOWN_SHIPPING_COSTS["default"]  # Default shipping cost for unknown areas
278
+
279
+ # ... (rest of your imports and setup)
280
+
281
+ async def update_user_profile(user_id: str, phone_number: str = None, address: str = None) -> UserProfile:
282
+     """
283
+     Update or create a user profile with the provided phone number and address.
284
+     """
285
+     async with async_session() as session:
286
+         result = await session.execute(
287
+             select(UserProfile).where(UserProfile.user_id == user_id)
288
+         )
289
+         profile = result.scalars().first()
290
+         if profile is None:
291
+             profile = UserProfile(
292
+                 user_id=user_id,
293
+                 phone_number=phone_number,
294
+                 address=address,
295
+                 last_interaction=datetime.utcnow()
296
+             )
297
+         else:
298
+             if phone_number:
299
+                 profile.phone_number = phone_number
300
+             if address:
301
+                 profile.address = address
302
+             profile.last_interaction = datetime.utcnow()
303
+         session.add(profile)
304
+         await session.commit()
305
+         return profile
306
+
307
+ # ... (rest of your imports and setup)
308
+
309
+ def process_order_flow(user_id: str, message: str) -> str:
310
+     """
311
+     Implements an FSM-based order flow with shipping cost calculation.
312
+     """
313
+            
314
+     # Retrieve or initialize conversation state
315
+     state = user_state.get(user_id)
316
+     if state and state.is_expired():
317
+         state.reset()
318
+         del user_state[user_id]
319
+         state = None
320
+
321
+     # Start a new order flow if the user explicitly types "order" or "menu"
322
+     if message.lower() in ["order", "menu"]:
323
+         state = ConversationState()
324
+         state.flow = "order"
325
+         state.step = 1
326
+         state.update_last_active()
327
+         user_state[user_id] = state
328
+         if message.lower() == "order":
329
+             return "Sure! What dish would you like to order?"
330
+         return ""
331
+
332
+     # If no state exists but the message includes "order", start the order flow.
333
+     if not state and "order" in message.lower():
334
+         state = ConversationState()
335
+         state.flow = "order"
336
+         state.step = 1
337
+         state.update_last_active()
338
+         user_state[user_id] = state
339
+         return "Sure! What dish would you like to order?"
340
+
341
+     # --- New Logic: Parse Dish, Quantity, Phone Number, and Address in a Single Message ---
342
+     if not state or state.flow != "order":
343
+         # Check if the message contains a dish and quantity
344
+         dish_candidates = [item["name"] for item in menu_items]
345
+         found_dish = None
346
+         for dish in dish_candidates:
347
+             if dish.lower() in message.lower():
348
+                 found_dish = dish
349
+                 break
350
+         numbers = re.findall(r'\d+', message)
351
+         if found_dish and numbers:
352
+             quantity = int(numbers[0])
353
+             if quantity <= 0:
354
+                 return "Please enter a valid quantity (e.g., 1, 2, 3)."
355
+             # Initialize state and skip to step 3 (phone number)
356
+             state = ConversationState()
357
+             state.flow = "order"
358
+             state.step = 3
359
+             state.data["dish"] = found_dish
360
+             state.data["quantity"] = quantity
361
+             state.update_last_active()
362
+             user_state[user_id] = state
363
+
364
+             # Extract phone number and address from the message
365
+             phone_pattern = r'(\+?\d{10,15})'
366
+             phone_match = re.search(phone_pattern, message)
367
+             address = None
368
+             if phone_match:
369
+                 phone_number = phone_match.group(1)
370
+                 # Assume the address starts after the phone number
371
+                 address_start = phone_match.end()
372
+                 address = message[address_start:].strip()
373
+                 # Remove any leading/trailing commas or spaces
374
+                 address = re.sub(r'^[,\s]+|[,\s]+$', '', address)
375
+            
376
+             if phone_match and address:
377
+                 state.data["phone_number"] = phone_number
378
+                 state.data["address"] = address
379
+                 # Save phone number and address to the user's profile
380
+                 asyncio.create_task(update_user_profile(user_id, phone_number, address))
381
+                 # Calculate shipping cost based on the address
382
+                 shipping_cost = calculate_shipping_cost(address)
383
+                 state.data["shipping_cost"] = shipping_cost
384
+                 state.step = 5
385
+                 return (f"Thanks! Your phone number is recorded as: {phone_number}.\n"
386
+                         f"Your delivery address is: {address}.\n"
387
+                         f"Your delivery cost is N{shipping_cost}. Would you like to add any extras such as sides or drinks? (yes/no)")
388
+             elif phone_match:
389
+                 state.data["phone_number"] = phone_match.group(1)
390
+                 # Save phone number to the user's profile
391
+                 asyncio.create_task(update_user_profile(user_id, phone_number))
392
+                 return "Thank you. Please provide your delivery address."
393
+             else:
394
+                 return "Please provide both your phone number and delivery address. For example: '09162409591, 1, Iyana Isashi, Isashi, Ojo, Lagos'."
395
+
396
+     if state and state.flow == "order":
397
+         state.update_last_active()
398
+         # --- Step 1: Expecting Dish Selection (and optionally quantity) ---
399
+         if state.step == 1:
400
+             dish_candidates = [item["name"] for item in menu_items]
401
+             found_dish = None
402
+             for dish in dish_candidates:
403
+                 if dish.lower() in message.lower():
404
+                     found_dish = dish
405
+                     break
406
+             numbers = re.findall(r'\d+', message)
407
+             if found_dish:
408
+                 state.data["dish"] = found_dish
409
+                 if numbers:
410
+                     quantity = int(numbers[0])
411
+                     if quantity <= 0:
412
+                         return "Please enter a valid quantity (e.g., 1, 2, 3)."
413
+                     state.data["quantity"] = quantity
414
+                     state.step = 3  # Move to phone number step
415
+                     return f"You selected {found_dish} with {quantity} serving(s). Please provide your phone number and delivery address."
416
+                 else:
417
+                     state.step = 2  # Ask for quantity
418
+                     return f"You selected {found_dish}. How many servings would you like?"
419
+             else:
420
+                 return "I couldn't identify the dish. Please type the dish name from our menu."
421
+
422
+         # --- Step 2: Asking for Quantity ---
423
+         if state.step == 2:
424
+             numbers = re.findall(r'\d+', message)
425
+             if not numbers:
426
+                 return "Please enter a valid number for the quantity (e.g., 1, 2, 3)."
427
+             quantity = int(numbers[0])
428
+             if quantity <= 0:
429
+                 return "Please enter a valid quantity (e.g., 1, 2, 3)."
430
+             state.data["quantity"] = quantity
431
+             state.step = 3
432
+             return f"Got it. {quantity} serving(s) of {state.data.get('dish')}. Please provide your phone number and delivery address."
433
+
434
+         # --- Step 3: Parse Phone Number and Address from Message ---
435
+         if state.step == 3:
436
+             # Extract phone number using regex
437
+             phone_pattern = r'(\+?\d{10,15})'
438
+             phone_match = re.search(phone_pattern, message)
439
+            
440
+             # Extract address (assume it follows the phone number, separated by a comma)
441
+             address = None
442
+             if phone_match:
443
+                 phone_number = phone_match.group(1)
444
+                 # Assume the address starts after the phone number
445
+                 address_start = phone_match.end()
446
+                 address = message[address_start:].strip()
447
+                 # Remove any leading/trailing commas or spaces
448
+                 address = re.sub(r'^[,\s]+|[,\s]+$', '', address)
449
+            
450
+             if phone_match and address:
451
+                 state.data["phone_number"] = phone_number
452
+                 state.data["address"] = address
453
+                 # Save phone number and address to the user's profile
454
+                 asyncio.create_task(update_user_profile(user_id, phone_number, address))
455
+                 # Calculate shipping cost based on the address
456
+                 shipping_cost = calculate_shipping_cost(address)
457
+                 state.data["shipping_cost"] = shipping_cost
458
+                 state.step = 5
459
+                 return (f"Thanks! Your phone number is recorded as: {phone_number}.\n"
460
+                         f"Your delivery address is: {address}.\n"
461
+                         f"Your delivery cost is N{shipping_cost}. Would you like to add any extras such as sides or drinks? (yes/no)")
462
+             elif phone_match:
463
+                 state.data["phone_number"] = phone_match.group(1)
464
+                 # Save phone number to the user's profile
465
+                 asyncio.create_task(update_user_profile(user_id, phone_number))
466
+                 return "Thank you. Please provide your delivery address."
467
+             else:
468
+                 return "Please provide both your phone number and delivery address. For example: '09162409591, 1, Iyana Isashi, Isashi, Ojo, Lagos'."
469
+
470
+         # --- Step 4: Requesting Delivery Address ---
471
+         if state.step == 4:
472
+             state.data["address"] = message
473
+             # Save address to the user's profile
474
+             asyncio.create_task(update_user_profile(user_id, address=message))
475
+             # Calculate shipping cost based on the address
476
+             shipping_cost = calculate_shipping_cost(message)
477
+             state.data["shipping_cost"] = shipping_cost
478
+             state.step = 5
479
+             return (f"Thanks. Your delivery address is recorded as: {message}.\n"
480
+                     f"Your delivery cost is N{shipping_cost}. Would you like to add any extras such as sides or drinks? (yes/no)")
481
+
482
+         # --- Step 5: Asking if They Want Extras ---
483
+         if state.step == 5:
484
+             if message.lower() in ["yes", "y"]:
485
+                 state.step = 6
486
+                 return "Please list the extras you would like to add (e.g., drinks, sides, etc.)."
487
+             elif message.lower() in ["no", "n"]:
488
+                 state.data["extras"] = ""
489
+                 state.step = 7
490
+                 dish = state.data.get("dish", "")
491
+                 quantity = state.data.get("quantity", 1)
492
+                 phone = state.data.get("phone_number", "")
493
+                 address = state.data.get("address", "")
494
+                 shipping_cost = state.data.get("shipping_cost", 0)
495
+                 price_per_serving = 1500  # Fixed price per serving
496
+                 total_price = (quantity * price_per_serving) + shipping_cost
497
+                 summary = (f"Order Summary:\nDish: {dish}\nQuantity: {quantity}\n"
498
+                            f"Phone: {phone}\nAddress: {address}\n"
499
+                            f"Shipping Cost: N{shipping_cost}\n"
500
+                            f"Total Price: N{total_price}\n"
501
+                            f"Extras: None\nConfirm order? (yes/no)")
502
+                 return summary
503
+             else:
504
+                 return "Please respond with 'yes' or 'no'. Would you like to add any extras to your order? (yes/no)"
505
+
506
+         # --- Step 6: Capturing Extras Details ---
507
+         if state.step == 6:
508
+             state.data["extras"] = message
509
+             state.step = 7
510
+             dish = state.data.get("dish", "")
511
+             quantity = state.data.get("quantity", 1)
512
+             phone = state.data.get("phone_number", "")
513
+             address = state.data.get("address", "")
514
+             shipping_cost = state.data.get("shipping_cost", 0)
515
+             extras = state.data.get("extras", "")
516
+             price_per_serving = 1500  # Fixed price per serving
517
+             total_price = (quantity * price_per_serving) + shipping_cost
518
+             summary = (f"Order Summary:\nDish: {dish}\nQuantity: {quantity}\n"
519
+                        f"Phone: {phone}\nAddress: {address}\n"
520
+                        f"Shipping Cost: N{shipping_cost}\n"
521
+                        f"Total Price: N{total_price}\n"
522
+                        f"Extras: {extras}\nConfirm order? (yes/no)")
523
+             return summary
524
+
525
+         # --- Step 7: Order Confirmation & Finalization ---
526
+         if state.step == 7:
527
+             if message.lower() in ["yes", "y"]:
528
+                 order_id = f"ORD-{int(time.time())}"
529
+                 state.data["order_id"] = order_id
530
+                 price_per_serving = 1500  # Fixed price per serving
531
+                 quantity = state.data.get("quantity", 1)
532
+                 shipping_cost = state.data.get("shipping_cost", 0)
533
+                 total_price = (quantity * price_per_serving) + shipping_cost
534
+                 state.data["price"] = str(total_price)
535
+
536
+                 # Save the order asynchronously (including delivery_address, phone, extras, shipping_cost)
537
+                 async def save_order():
538
+                     async with async_session() as session:
539
+                         order = Order(
540
+                             order_id=order_id,
541
+                             user_id=user_id,
542
+                             dish=state.data["dish"],
543
+                             quantity=str(quantity),
544
+                             price=str(total_price),
545
+                             status="Pending Payment",
546
+                             delivery_address=state.data.get("address", ""),
547
+                             shipping_cost=str(shipping_cost)  # Save shipping cost
548
+                         )
549
+                         session.add(order)
550
+                         await session.commit()
551
+                 asyncio.create_task(save_order())
552
+
553
+                 # Record the initial tracking update: Order Placed
554
+                 asyncio.create_task(log_order_tracking(order_id, "Order Placed", "Order placed and awaiting payment."))
555
+
556
+                 # Notify management of the new order via WhatsApp
557
+                 async def notify_management_order(order_details: dict):
558
+                     message_body = (
559
+                         f"New Order Received:\n"
560
+                         f"Order ID: {order_details['order_id']}\n"
561
+                         f"Dish: {order_details['dish']}\n"
562
+                         f"Quantity: {order_details['quantity']}\n"
563
+                         f"Total Price: {order_details['price']}\n"
564
+                         f"Phone: {state.data.get('phone_number', '')}\n"
565
+                         f"Delivery Address: {order_details.get('address', 'Not Provided')}\n"
566
+                         f"Extras: {state.data.get('extras', 'None')}\n"
567
+                         f"Status: Pending Payment"
568
+                     )
569
+                     await asyncio.to_thread(send_whatsapp_message, MANAGEMENT_WHATSAPP_NUMBER, message_body)
570
+                 order_details = {
571
+                     "order_id": order_id,
572
+                     "dish": state.data["dish"],
573
+                     "quantity": state.data["quantity"],
574
+                     "price": state.data["price"],
575
+                     "address": state.data.get("address", "")
576
+                 }
577
+                 asyncio.create_task(notify_management_order(order_details))
578
+
579
+                 email = "customer@example.com"  # Placeholder; retrieve from profile if available
580
+                 payment_data = create_paystack_payment_link(email, total_price * 100, order_id)
581
+                 dish_name = state.data.get("dish", "")
582
+                 state.reset()
583
+                 if user_id in user_state:
584
+                     del user_state[user_id]
585
+                 if payment_data.get("status"):
586
+                     payment_link = payment_data["data"]["authorization_url"]
587
+                     return (f"Thank you for your order of {quantity} serving(s) of {dish_name}! "
588
+                             f"Your Order ID is {order_id}.\nPlease complete payment here: {payment_link}\n"
589
+                             "You can track your order status using your Order ID.\n"
590
+                             "Is there anything else you'd like to order?")
591
+                 else:
592
+                     return f"Your order has been placed with Order ID {order_id}, but we could not initialize payment. Please try again later."
593
+             else:
594
+                 state.reset()
595
+                 if user_id in user_state:
596
+                     del user_state[user_id]
597
+                 return "Order canceled. Let me know if you'd like to try again."
598
+
599
+     return ""
600
+    
601
+ # --- User Profile Functions ---
602
+ async def get_or_create_user_profile(user_id: str, phone_number: str = None) -> UserProfile:
603
+     async with async_session() as session:
604
+         result = await session.execute(
605
+             select(UserProfile).where(UserProfile.user_id == user_id)
606
+         )
607
+         profile = result.scalars().first()
608
+         if profile is None:
609
+             profile = UserProfile(
610
+                 user_id=user_id,
611
+                 phone_number=phone_number,
612
+                 last_interaction=datetime.utcnow()
613
+             )
614
+             session.add(profile)
615
+             await session.commit()
616
+         return profile
617
+
618
+ async def update_user_last_interaction(user_id: str):
619
+     async with async_session() as session:
620
+         result = await session.execute(
621
+             select(UserProfile).where(UserProfile.user_id == user_id)
622
+         )
623
+         profile = result.scalars().first()
624
+         if profile:
625
+             profile.last_interaction = datetime.utcnow()
626
+             await session.commit()
627
+
628
+ # --- Proactive Engagement: Warm Greetings ---
629
+ async def send_proactive_greeting(user_id: str):
630
+     greeting = "Hi again! We miss you. Would you like to see our new menu items or get personalized recommendations?"
631
+     await log_chat_to_db(user_id, "outbound", greeting)
632
+     return greeting
633
+
634
+ # --- FastAPI Setup & Endpoints ---
635
+ app = FastAPI()
636
+
637
+ @app.on_event("startup")
638
+ async def on_startup():
639
+     await init_db()
640
+
641
+ @app.post("/chatbot")
642
+ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
643
+     data = await request.json()
644
+     user_id = data.get("user_id")
645
+     phone_number = data.get("phone_number")
646
+     user_message = data.get("message", "").strip()
647
+     is_image = data.get("is_image", False)
648
+     image_b64 = data.get("image_base64", None)
649
+
650
+     if not user_id:
651
+         raise HTTPException(status_code=400, detail="Missing user_id in payload.")
652
+
653
+     # Initialize conversation context for the user if not present.
654
+     if user_id not in conversation_context:
655
+         conversation_context[user_id] = []
656
+     # Append the inbound message to the conversation context.
657
+     conversation_context[user_id].append({
658
+         "timestamp": datetime.utcnow().isoformat(),
659
+         "role": "user",
660
+         "message": user_message
661
+     })
662
+
663
+     background_tasks.add_task(log_chat_to_db, user_id, "inbound", user_message)
664
+     await update_user_last_interaction(user_id)
665
+     await get_or_create_user_profile(user_id, phone_number)
666
+
667
+     # Handle image queries
668
+     if is_image and image_b64:
669
+         if len(image_b64) >= 180_000:
670
+             raise HTTPException(status_code=400, detail="Image too large.")
671
+         return StreamingResponse(stream_image_completion(image_b64), media_type="text/plain")
672
+
673
+     sentiment_score = analyze_sentiment(user_message)
674
+     background_tasks.add_task(log_sentiment, user_id, user_message, sentiment_score)
675
+     sentiment_modifier = ""
676
+     if sentiment_score < -0.3:
677
+         sentiment_modifier = "I'm sorry if you're having a tough time. "
678
+     elif sentiment_score > 0.3:
679
+         sentiment_modifier = "Great to hear from you! "
680
+
681
+           # --- Order Tracking Handling ---
682
+     order_id_match = re.search(r"ord-\d+", user_message.lower())
683
+     if order_id_match:
684
+         order_id = order_id_match.group(0)
685
+         try:
686
+             # Call the /track_order endpoint
687
+             tracking_response = await track_order(order_id)
688
+             return JSONResponse(content={"response": tracking_response})
689
+         except HTTPException as e:
690
+             return JSONResponse(content={"response": f" {e.detail}"})
691
+
692
+     # --- Order Flow Handling ---
693
+     order_response = process_order_flow(user_id, user_message)
694
+     if order_response:
695
+         background_tasks.add_task(log_chat_to_db, user_id, "outbound", order_response)
696
+         conversation_context[user_id].append({
697
+             "timestamp": datetime.utcnow().isoformat(),
698
+             "role": "bot",
699
+             "message": order_response
700
+         })
701
+         return JSONResponse(content={"response": sentiment_modifier + order_response})
702
+
703
+     # --- Menu Display ---
704
+     if "menu" in user_message.lower():
705
+         if user_id in user_state:
706
+             del user_state[user_id]
707
+         menu_with_images = []
708
+         for index, item in enumerate(menu_items, start=1):
709
+             image_url = google_image_scrape(item["name"])
710
+             menu_with_images.append({
711
+                 "number": index,
712
+                 "name": item["name"],
713
+                 "description": item["description"],
714
+                 "price": item["price"],
715
+                 "image_url": image_url
716
+             })
717
+         response_payload = {
718
+             "response": sentiment_modifier + "Here’s our delicious menu:",
719
+             "menu": menu_with_images,
720
+             "follow_up": (
721
+                 "To order, type the *number* or *name* of the dish you'd like. "
722
+                 "For example, type '1' or 'Jollof Rice' to order Jollof Rice.\n\n"
723
+                 "You can also ask for nutritional facts by typing, for example, 'Nutritional facts for Jollof Rice'."
724
+             )
725
+         }
726
+         background_tasks.add_task(log_chat_to_db, user_id, "outbound", str(response_payload))
727
+         conversation_context[user_id].append({
728
+             "timestamp": datetime.utcnow().isoformat(),
729
+             "role": "bot",
730
+             "message": response_payload["response"]
731
+         })
732
+         return JSONResponse(content=response_payload)
733
+
734
+     # --- Dish Selection via Menu ---
735
+     if any(item["name"].lower() in user_message.lower() for item in menu_items) or \
736
+        any(str(index) == user_message.strip() for index, item in enumerate(menu_items, start=1)):
737
+         selected_dish = None
738
+         if user_message.strip().isdigit():
739
+             dish_number = int(user_message.strip())
740
+             if 1 <= dish_number <= len(menu_items):
741
+                 selected_dish = menu_items[dish_number - 1]["name"]
742
+         else:
743
+             for item in menu_items:
744
+                 if item["name"].lower() in user_message.lower():
745
+                     selected_dish = item["name"]
746
+                     break
747
+         if selected_dish:
748
+             state = ConversationState()
749
+             state.flow = "order"
750
+             # Set step to 2 since the dish is already selected
751
+             state.step = 2  
752
+             state.data["dish"] = selected_dish
753
+             state.update_last_active()
754
+             user_state[user_id] = state
755
+             response_text = f"You selected {selected_dish}. How many servings would you like?"
756
+             background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
757
+             conversation_context[user_id].append({
758
+                 "timestamp": datetime.utcnow().isoformat(),
759
+                 "role": "bot",
760
+                 "message": response_text
761
+             })
762
+             return JSONResponse(content={"response": sentiment_modifier + response_text})
763
+         else:
764
+             response_text = "Sorry, I couldn't find that dish in the menu. Please try again."
765
+             background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
766
+             conversation_context[user_id].append({
767
+                 "timestamp": datetime.utcnow().isoformat(),
768
+                 "role": "bot",
769
+                 "message": response_text
770
+             })
771
+             return JSONResponse(content={"response": sentiment_modifier + response_text})
772
+
773
+     # --- Nutritional Facts ---
774
+     if "nutritional facts for" in user_message.lower():
775
+         dish_name = user_message.lower().replace("nutritional facts for", "").strip().title()
776
+         dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
777
+         if dish:
778
+             response_text = f"Nutritional facts for {dish['name']}:\n{dish['nutrition']}"
779
+         else:
780
+             response_text = f"Sorry, I couldn't find nutritional facts for {dish_name}."
781
+         background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
782
+         conversation_context[user_id].append({
783
+             "timestamp": datetime.utcnow().isoformat(),
784
+             "role": "bot",
785
+             "message": response_text
786
+         })
787
+         return JSONResponse(content={"response": sentiment_modifier + response_text})
788
+
789
+     # --- Fallback: LLM Response Streaming with Conversation Context ---
790
+     recent_context = conversation_context.get(user_id, [])[-5:]
791
+     context_str = "\n".join([f"{entry['role'].capitalize()}: {entry['message']}" for entry in recent_context])
792
+     prompt = f"Conversation context:\n{context_str}\nUser query: {user_message}\nGenerate a helpful, personalized response for a restaurant chatbot."
793
+     def stream_response():
794
+         for chunk in stream_text_completion(prompt):
795
+             yield chunk
796
+     fallback_log = f"LLM fallback response for prompt: {prompt}"
797
+     background_tasks.add_task(log_chat_to_db, user_id, "outbound", fallback_log)
798
+     return StreamingResponse(stream_response(), media_type="text/plain")
799
+
800
+ # --- Other Endpoints (Chat History, Order Details, User Profile, Analytics, Voice, Payment Callback) ---
801
+ @app.get("/chat_history/{user_id}")
802
+ async def get_chat_history(user_id: str):
803
+     async with async_session() as session:
804
+         result = await session.execute(
805
+             ChatHistory.__table__.select().where(ChatHistory.user_id == user_id)
806
+         )
807
+         history = result.fetchall()
808
+         return [dict(row) for row in history]
809
+
810
+ @app.get("/order/{order_id}")
811
+ async def get_order(order_id: str):
812
+     async with async_session() as session:
813
+         result = await session.execute(
814
+             Order.__table__.select().where(Order.order_id == order_id)
815
+         )
816
+         order = result.fetchone()
817
+         if order:
818
+             return dict(order)
819
+         else:
820
+             raise HTTPException(status_code=404, detail="Order not found.")
821
+
822
+ @app.get("/user_profile/{user_id}")
823
+ async def get_user_profile(user_id: str):
824
+     profile = await get_or_create_user_profile(user_id)
825
+     return {
826
+         "user_id": profile.user_id,
827
+         "phone_number": profile.phone_number,
828
+         "name": profile.name,
829
+         "email": profile.email,
830
+         "preferences": profile.preferences,
831
+         "last_interaction": profile.last_interaction.isoformat()
832
+     }
833
+
834
+ @app.get("/analytics")
835
+ async def get_analytics():
836
+     async with async_session() as session:
837
+         msg_result = await session.execute(ChatHistory.__table__.count())
838
+         total_messages = msg_result.scalar() or 0
839
+         order_result = await session.execute(Order.__table__.count())
840
+         total_orders = order_result.scalar() or 0
841
+         sentiment_result = await session.execute("SELECT AVG(sentiment_score) FROM sentiment_logs")
842
+         avg_sentiment = sentiment_result.scalar() or 0
843
+     return {
844
+         "total_messages": total_messages,
845
+         "total_orders": total_orders,
846
+         "average_sentiment": avg_sentiment
847
+     }
848
+
849
+ # Load the Hugging Face API token from environment variables
850
+ HUGGING_FACE_API_TOKEN = os.getenv("HUGGING_FACE_API_TOKEN")
851
+ if not HUGGING_FACE_API_TOKEN:
852
+     raise ValueError("Hugging Face API token not found in environment variables.")
853
+
854
+ # Hugging Face Whisper API configuration
855
+ WHISPER_API_URL = "https://router.huggingface.co/fal-ai"
856
+ WHISPER_API_HEADERS = {"Authorization": f"Bearer {HUGGING_FACE_API_TOKEN}"}
857
+
858
+ class TranscriptionResponse(BaseModel):
859
+     transcription: str
860
+
861
+ @app.post("/voice", response_model=TranscriptionResponse)
862
+ async def process_voice(file: UploadFile = File(...)):
863
+     """
864
+     Endpoint to process voice notes and transcribe them using OpenAI Whisper via Hugging Face.
865
+     """
866
+     try:
867
+         # Read the uploaded file
868
+         contents = await file.read()
869
+
870
+         # Save the file temporarily (optional, depending on how the API expects the input)
871
+         temp_file_path = f"temp_{file.filename}"
872
+         with open(temp_file_path, "wb") as temp_file:
873
+             temp_file.write(contents)
874
+
875
+         # Call the Hugging Face Whisper API
876
+         with open(temp_file_path, "rb") as audio_file:
877
+             response = requests.post(
878
+                 WHISPER_API_URL,
879
+                 headers=WHISPER_API_HEADERS,
880
+                 files={"file": audio_file}
881
+             )
882
+
883
+         # Clean up the temporary file
884
+         os.remove(temp_file_path)
885
+
886
+         # Check if the API call was successful
887
+         if response.status_code != 200:
888
+             raise HTTPException(status_code=response.status_code, detail="Failed to transcribe audio.")
889
+
890
+         # Extract the transcription from the API response
891
+         transcription = response.json().get("text", "")
892
+
893
+         return {"transcription": transcription}
894
+
895
+     except Exception as e:
896
+         raise HTTPException(status_code=500, detail=f"An error occurred: {str(e)}")
897
+
898
+ # --- Payment Callback Endpoint with Payment Tracking and Redirection ---
899
+ @app.api_route("/payment_callback", methods=["GET", "POST"])
900
+
901
+ async def payment_callback(request: Request):
902
+     # GET: User redirection after payment
903
+     if request.method == "GET":
904
+         params = request.query_params
905
+         order_id = params.get("reference")
906
+         status = params.get("status", "Paid")
907
+         if not order_id:
908
+             raise HTTPException(status_code=400, detail="Missing order reference in callback.")
909
+         async with async_session() as session:
910
+             result = await session.execute(
911
+                 Order.__table__.select().where(Order.order_id == order_id)
912
+             )
913
+             order = result.scalar_one_or_none()
914
+             if order:
915
+                 order.status = status
916
+                 await session.commit()
917
+             else:
918
+                 raise HTTPException(status_code=404, detail="Order not found.")
919
+         # Record payment confirmation tracking update
920
+         await log_order_tracking(order_id, "Payment Confirmed", f"Payment status updated to {status}.")
921
+         # Notify management via WhatsApp about the payment update
922
+         await asyncio.to_thread(send_whatsapp_message, MANAGEMENT_WHATSAPP_NUMBER,
923
+             f"Payment Update:\nOrder ID: {order_id} is now {status}."
924
+         )
925
+         # Redirect user back to the chat interface (adjust URL as needed)
926
+         redirect_url = f"https://wa.link/am87s2"
927
+         return RedirectResponse(url=redirect_url)
928
+     # POST: Server-to-server callback from Paystack
929
+     else:
930
+         data = await request.json()
931
+         order_id = data.get("reference")
932
+         new_status = data.get("status", "Paid")
933
+         if not order_id:
934
+             raise HTTPException(status_code=400, detail="Missing order reference in callback.")
935
+         async with async_session() as session:
936
+             result = await session.execute(
937
+                 Order.__table__.select().where(Order.order_id == order_id)
938
+             )
939
+             order = result.scalar_one_or_none()
940
+             if order:
941
+                 order.status = new_status
942
+                 await session.commit()
943
+                 await log_order_tracking(order_id, "Payment Confirmed", f"Payment status updated to {new_status}.")
944
+                 await asyncio.to_thread(send_whatsapp_message, MANAGEMENT_WHATSAPP_NUMBER,
945
+                     f"Payment Update:\nOrder ID: {order_id} is now {new_status}."
946
+                 )
947
+                 return JSONResponse(content={"message": "Order updated successfully."})
948
+             else:
949
+                 raise HTTPException(status_code=404, detail="Order not found.")
950
+                
951
+ @app.get("/track_order/{order_id}")
952
+ async def track_order(order_id: str):
953
+     """
954
+     Fetch order tracking details for a given order ID.
955
+     """
956
+     async with async_session() as session:
957
+         result = await session.execute(
958
+             select(OrderTracking)
959
+             .where(OrderTracking.order_id == order_id)
960
+             .order_by(OrderTracking.timestamp)
961
+         )
962
+         tracking_updates = result.scalars().all()
963
+         if tracking_updates:
964
+             response = []
965
+             for update in tracking_updates:
966
+                 response.append({
967
+                     "status": update.status,
968
+                     "message": update.message,
969
+                     "timestamp": update.timestamp.isoformat(),
970
+                 })
971
+             return JSONResponse(content=response)
972
+         else:
973
+             raise HTTPException(status_code=404, detail="No tracking information found for this order.")
974
+
975
+ if __name__ == "__main__":
976
+     import uvicorn
977
+     uvicorn.run(app, host="0.0.0.0", port=8000)