Fred808 commited on
Commit
f877f5f
·
verified ·
1 Parent(s): dba6fba

Update app.py

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