Update app.py
Browse files
app.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
import os
|
| 4 |
import time
|
| 5 |
import requests
|
|
@@ -46,8 +45,8 @@ class Order(Base):
|
|
| 46 |
user_id = Column(String, index=True)
|
| 47 |
dish = Column(String)
|
| 48 |
quantity = Column(String)
|
| 49 |
-
price = Column(String, default="0")
|
| 50 |
-
status = Column(String, default="Pending Payment")
|
| 51 |
payment_reference = Column(String, nullable=True)
|
| 52 |
timestamp = Column(DateTime, default=datetime.utcnow)
|
| 53 |
|
|
@@ -55,10 +54,10 @@ class UserProfile(Base):
|
|
| 55 |
__tablename__ = "user_profiles"
|
| 56 |
id = Column(Integer, primary_key=True, index=True)
|
| 57 |
user_id = Column(String, unique=True, index=True)
|
| 58 |
-
phone_number = Column(String, unique=True, index=True, nullable=True)
|
| 59 |
name = Column(String, default="Valued Customer")
|
| 60 |
email = Column(String, default="unknown@example.com")
|
| 61 |
-
preferences = Column(Text, default="")
|
| 62 |
last_interaction = Column(DateTime, default=datetime.utcnow)
|
| 63 |
|
| 64 |
class SentimentLog(Base):
|
|
@@ -69,7 +68,6 @@ class SentimentLog(Base):
|
|
| 69 |
sentiment_score = Column(Float)
|
| 70 |
message = Column(Text)
|
| 71 |
|
| 72 |
-
# Create the asynchronous engine.
|
| 73 |
engine = create_async_engine(DATABASE_URL, echo=True)
|
| 74 |
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 75 |
|
|
@@ -78,11 +76,10 @@ async def init_db():
|
|
| 78 |
await conn.run_sync(Base.metadata.create_all)
|
| 79 |
|
| 80 |
# --- Global In-Memory Stores ---
|
| 81 |
-
user_state = {} #
|
| 82 |
-
conversation_context = {}
|
| 83 |
-
proactive_timer = {}
|
| 84 |
|
| 85 |
-
# Local menu with nutritional details
|
| 86 |
menu_items = [
|
| 87 |
{"name": "Jollof Rice", "description": "A spicy and flavorful rice dish", "price": 1500, "nutrition": "Calories: 300 kcal, Carbs: 50g, Protein: 10g, Fat: 5g"},
|
| 88 |
{"name": "Fried Rice", "description": "A savory rice dish with vegetables and meat", "price": 1200, "nutrition": "Calories: 350 kcal, Carbs: 55g, Protein: 12g, Fat: 8g"},
|
|
@@ -91,28 +88,23 @@ menu_items = [
|
|
| 91 |
]
|
| 92 |
|
| 93 |
# --- Utility Functions ---
|
| 94 |
-
|
| 95 |
async def log_chat_to_db(user_id: str, direction: str, message: str):
|
| 96 |
-
"""Store chat messages into the database asynchronously."""
|
| 97 |
async with async_session() as session:
|
| 98 |
entry = ChatHistory(user_id=user_id, direction=direction, message=message)
|
| 99 |
session.add(entry)
|
| 100 |
await session.commit()
|
| 101 |
|
| 102 |
async def log_sentiment(user_id: str, message: str, score: float):
|
| 103 |
-
"""Store sentiment analysis results in the database."""
|
| 104 |
async with async_session() as session:
|
| 105 |
entry = SentimentLog(user_id=user_id, sentiment_score=score, message=message)
|
| 106 |
session.add(entry)
|
| 107 |
await session.commit()
|
| 108 |
|
| 109 |
def analyze_sentiment(text: str) -> float:
|
| 110 |
-
"""Analyze text sentiment using TextBlob. Returns polarity between -1 and 1."""
|
| 111 |
blob = TextBlob(text)
|
| 112 |
return blob.sentiment.polarity
|
| 113 |
|
| 114 |
def google_image_scrape(query: str) -> str:
|
| 115 |
-
"""Scrape Google Images using BeautifulSoup to get an image URL."""
|
| 116 |
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
| 117 |
search_url = f"https://www.google.com/search?tbm=isch&q={query}"
|
| 118 |
try:
|
|
@@ -129,7 +121,6 @@ def google_image_scrape(query: str) -> str:
|
|
| 129 |
return ""
|
| 130 |
|
| 131 |
def create_paystack_payment_link(email: str, amount: int, reference: str) -> dict:
|
| 132 |
-
"""Initialize a transaction via Paystack."""
|
| 133 |
url = "https://api.paystack.co/transaction/initialize"
|
| 134 |
headers = {
|
| 135 |
"Authorization": f"Bearer {PAYSTACK_SECRET_KEY}",
|
|
@@ -139,7 +130,7 @@ def create_paystack_payment_link(email: str, amount: int, reference: str) -> dic
|
|
| 139 |
"email": email,
|
| 140 |
"amount": amount,
|
| 141 |
"reference": reference,
|
| 142 |
-
"callback_url": "https://yourdomain.com/payment_callback"
|
| 143 |
}
|
| 144 |
try:
|
| 145 |
response = requests.post(url, json=data, headers=headers, timeout=10)
|
|
@@ -152,11 +143,7 @@ def create_paystack_payment_link(email: str, amount: int, reference: str) -> dic
|
|
| 152 |
|
| 153 |
# --- NVIDIA LLM Streaming Functions ---
|
| 154 |
def stream_text_completion(prompt: str):
|
| 155 |
-
|
| 156 |
-
Stream text completion using NVIDIA's text-only model.
|
| 157 |
-
Uses the OpenAI client interface pointed to NVIDIA's endpoint.
|
| 158 |
-
"""
|
| 159 |
-
from openai import OpenAI # Using OpenAI client library
|
| 160 |
client = OpenAI(
|
| 161 |
base_url="https://integrate.api.nvidia.com/v1",
|
| 162 |
api_key=NVIDIA_API_KEY
|
|
@@ -174,10 +161,6 @@ def stream_text_completion(prompt: str):
|
|
| 174 |
yield chunk.choices[0].delta.content
|
| 175 |
|
| 176 |
def stream_image_completion(image_b64: str):
|
| 177 |
-
"""
|
| 178 |
-
Stream image-based query using NVIDIA's vision model.
|
| 179 |
-
The image (in base64) is embedded in an HTML <img> tag.
|
| 180 |
-
"""
|
| 181 |
invoke_url = "https://ai.api.nvidia.com/v1/gr/meta/llama-3.2-90b-vision-instruct/chat/completions"
|
| 182 |
headers = {
|
| 183 |
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
|
@@ -203,12 +186,16 @@ def stream_image_completion(image_b64: str):
|
|
| 203 |
|
| 204 |
# --- Advanced Internal Flow: Order Processing & Payment Integration ---
|
| 205 |
def process_order_flow(user_id: str, message: str) -> str:
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
if user_id in user_state:
|
| 213 |
state = user_state[user_id]
|
| 214 |
flow = state.get("flow")
|
|
@@ -216,7 +203,7 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 216 |
data = state.get("data", {})
|
| 217 |
if flow == "order":
|
| 218 |
if step == 1:
|
| 219 |
-
# Validate dish name
|
| 220 |
dish_name = message.title()
|
| 221 |
dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
|
| 222 |
if dish:
|
|
@@ -225,59 +212,57 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 225 |
return f"You selected {data['dish']}. How many servings would you like?"
|
| 226 |
else:
|
| 227 |
return f"Sorry, we don't have {dish_name} on the menu. Please choose another dish."
|
| 228 |
-
|
| 229 |
elif step == 2:
|
| 230 |
-
#
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
if quantity <= 0:
|
| 234 |
-
return "Please enter a valid quantity (e.g., 1, 2, 3)."
|
| 235 |
-
data["quantity"] = quantity
|
| 236 |
-
order_id = f"ORD-{int(time.time())}"
|
| 237 |
-
data["order_id"] = order_id
|
| 238 |
-
price_per_serving = 1500 # ₦1500 per serving
|
| 239 |
-
total_price = quantity * price_per_serving
|
| 240 |
-
data["price"] = str(total_price)
|
| 241 |
-
|
| 242 |
-
# Save order asynchronously
|
| 243 |
-
import asyncio
|
| 244 |
-
async def save_order():
|
| 245 |
-
async with async_session() as session:
|
| 246 |
-
order = Order(
|
| 247 |
-
order_id=order_id,
|
| 248 |
-
user_id=user_id,
|
| 249 |
-
dish=data["dish"],
|
| 250 |
-
quantity=data["quantity"],
|
| 251 |
-
price=str(total_price),
|
| 252 |
-
status="Pending Payment"
|
| 253 |
-
)
|
| 254 |
-
session.add(order)
|
| 255 |
-
await session.commit()
|
| 256 |
-
asyncio.create_task(save_order())
|
| 257 |
-
|
| 258 |
-
# Clear conversation state for order flow.
|
| 259 |
-
del user_state[user_id]
|
| 260 |
-
|
| 261 |
-
# Retrieve email from user profile if available (here using a placeholder)
|
| 262 |
-
email = "customer@example.com"
|
| 263 |
-
payment_data = create_paystack_payment_link(email, total_price * 100, order_id)
|
| 264 |
-
if payment_data.get("status"):
|
| 265 |
-
payment_link = payment_data["data"]["authorization_url"]
|
| 266 |
-
return (f"Thank you for your order of {data['quantity']} serving(s) of {data['dish']}! "
|
| 267 |
-
f"Your Order ID is {order_id}.\nPlease complete payment here: {payment_link}")
|
| 268 |
-
else:
|
| 269 |
-
return f"Your order has been placed with Order ID {order_id}, but we could not initialize payment. Please try again later."
|
| 270 |
-
except ValueError:
|
| 271 |
return "Please enter a valid number for the quantity (e.g., 1, 2, 3)."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
else:
|
| 273 |
if "order" in message.lower():
|
| 274 |
user_state[user_id] = {"flow": "order", "step": 1, "data": {}, "last_active": datetime.utcnow()}
|
| 275 |
return "Sure! What dish would you like to order?"
|
| 276 |
return ""
|
| 277 |
-
# --- User Profile Functions ---
|
| 278 |
|
|
|
|
| 279 |
async def get_or_create_user_profile(user_id: str, phone_number: str = None) -> UserProfile:
|
| 280 |
-
"""Retrieve an existing profile or create a new one with user_id and phone_number."""
|
| 281 |
async with async_session() as session:
|
| 282 |
result = await session.execute(
|
| 283 |
select(UserProfile).where(UserProfile.user_id == user_id)
|
|
@@ -294,7 +279,6 @@ async def get_or_create_user_profile(user_id: str, phone_number: str = None) ->
|
|
| 294 |
return profile
|
| 295 |
|
| 296 |
async def update_user_last_interaction(user_id: str):
|
| 297 |
-
"""Update the user's last interaction timestamp."""
|
| 298 |
async with async_session() as session:
|
| 299 |
result = await session.execute(
|
| 300 |
select(UserProfile).where(UserProfile.user_id == user_id)
|
|
@@ -306,7 +290,6 @@ async def update_user_last_interaction(user_id: str):
|
|
| 306 |
|
| 307 |
# --- Proactive Engagement: Warm Greetings ---
|
| 308 |
async def send_proactive_greeting(user_id: str):
|
| 309 |
-
"""Simulate sending a proactive greeting if the user has been inactive."""
|
| 310 |
greeting = "Hi again! We miss you. Would you like to see our new menu items or get personalized recommendations?"
|
| 311 |
await log_chat_to_db(user_id, "outbound", greeting)
|
| 312 |
return greeting
|
|
@@ -318,21 +301,11 @@ app = FastAPI()
|
|
| 318 |
async def on_startup():
|
| 319 |
await init_db()
|
| 320 |
|
| 321 |
-
# --- Chatbot Endpoint ---
|
| 322 |
@app.post("/chatbot")
|
| 323 |
async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
| 324 |
-
"""
|
| 325 |
-
Main chatbot endpoint.
|
| 326 |
-
Supports text queries, image queries, and advanced logic.
|
| 327 |
-
Expects JSON payload with:
|
| 328 |
-
- 'user_id'
|
| 329 |
-
- 'phone_number'
|
| 330 |
-
- 'message'
|
| 331 |
-
- Optionally, 'is_image': true and 'image_base64'
|
| 332 |
-
"""
|
| 333 |
data = await request.json()
|
| 334 |
user_id = data.get("user_id")
|
| 335 |
-
phone_number = data.get("phone_number")
|
| 336 |
user_message = data.get("message", "").strip()
|
| 337 |
is_image = data.get("is_image", False)
|
| 338 |
image_b64 = data.get("image_base64", None)
|
|
@@ -340,9 +313,7 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 340 |
if not user_id:
|
| 341 |
raise HTTPException(status_code=400, detail="Missing user_id in payload.")
|
| 342 |
|
| 343 |
-
# Log inbound message
|
| 344 |
background_tasks.add_task(log_chat_to_db, user_id, "inbound", user_message)
|
| 345 |
-
# Update user last interaction and create profile with phone number
|
| 346 |
await update_user_last_interaction(user_id)
|
| 347 |
await get_or_create_user_profile(user_id, phone_number)
|
| 348 |
|
|
@@ -352,33 +323,30 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 352 |
raise HTTPException(status_code=400, detail="Image too large.")
|
| 353 |
return StreamingResponse(stream_image_completion(image_b64), media_type="text/plain")
|
| 354 |
|
| 355 |
-
# --- Advanced Textual Processing ---
|
| 356 |
-
# Analyze sentiment and log it
|
| 357 |
sentiment_score = analyze_sentiment(user_message)
|
| 358 |
background_tasks.add_task(log_sentiment, user_id, user_message, sentiment_score)
|
| 359 |
-
|
| 360 |
-
# Adjust response tone based on sentiment
|
| 361 |
sentiment_modifier = ""
|
| 362 |
if sentiment_score < -0.3:
|
| 363 |
sentiment_modifier = "I'm sorry if you're having a tough time. "
|
| 364 |
elif sentiment_score > 0.3:
|
| 365 |
sentiment_modifier = "Great to hear from you! "
|
| 366 |
|
| 367 |
-
#
|
| 368 |
-
# 1. Check if the user is already in an order flow
|
| 369 |
order_response = process_order_flow(user_id, user_message)
|
| 370 |
if order_response:
|
| 371 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", order_response)
|
| 372 |
return JSONResponse(content={"response": sentiment_modifier + order_response})
|
| 373 |
|
| 374 |
-
#
|
| 375 |
if "menu" in user_message.lower():
|
| 376 |
-
#
|
|
|
|
|
|
|
| 377 |
menu_with_images = []
|
| 378 |
for index, item in enumerate(menu_items, start=1):
|
| 379 |
image_url = google_image_scrape(item["name"])
|
| 380 |
menu_with_images.append({
|
| 381 |
-
"number": index,
|
| 382 |
"name": item["name"],
|
| 383 |
"description": item["description"],
|
| 384 |
"price": item["price"],
|
|
@@ -396,72 +364,21 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 396 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", str(response_payload))
|
| 397 |
return JSONResponse(content=response_payload)
|
| 398 |
|
| 399 |
-
#
|
| 400 |
-
if any(item["name"].lower() in user_message.lower() for item in menu_items) or \
|
| 401 |
-
any(str(index) == user_message.strip() for index, item in enumerate(menu_items, start=1)):
|
| 402 |
-
# Extract the selected dish
|
| 403 |
-
selected_dish = None
|
| 404 |
-
if user_message.strip().isdigit():
|
| 405 |
-
# User selected by number
|
| 406 |
-
dish_number = int(user_message.strip())
|
| 407 |
-
if 1 <= dish_number <= len(menu_items):
|
| 408 |
-
selected_dish = menu_items[dish_number - 1]["name"]
|
| 409 |
-
else:
|
| 410 |
-
# User selected by name
|
| 411 |
-
for item in menu_items:
|
| 412 |
-
if item["name"].lower() in user_message.lower():
|
| 413 |
-
selected_dish = item["name"]
|
| 414 |
-
break
|
| 415 |
-
|
| 416 |
-
if selected_dish:
|
| 417 |
-
# Trigger the order flow
|
| 418 |
-
user_state[user_id] = {"flow": "order", "step": 1, "data": {"dish": selected_dish}, "last_active": datetime.utcnow()}
|
| 419 |
-
response_text = f"You selected {selected_dish}. How many servings would you like?"
|
| 420 |
-
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 421 |
-
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 422 |
-
else:
|
| 423 |
-
response_text = "Sorry, I couldn't find that dish in the menu. Please try again."
|
| 424 |
-
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 425 |
-
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 426 |
-
|
| 427 |
-
# 4. Handle nutritional facts
|
| 428 |
-
if "nutritional facts for" in user_message.lower():
|
| 429 |
-
dish_name = user_message.lower().replace("nutritional facts for", "").strip().title()
|
| 430 |
-
dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
|
| 431 |
-
if dish:
|
| 432 |
-
response_text = f"Nutritional facts for {dish['name']}:\n{dish['nutrition']}"
|
| 433 |
-
else:
|
| 434 |
-
response_text = f"Sorry, I couldn't find nutritional facts for {dish_name}."
|
| 435 |
-
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 436 |
-
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 437 |
-
|
| 438 |
-
# 5. Fallback: use NVIDIA text LLM streaming for a response
|
| 439 |
-
prompt = f"User query: {user_message}\nGenerate a helpful, personalized response for a restaurant chatbot."
|
| 440 |
-
def stream_response():
|
| 441 |
-
for chunk in stream_text_completion(prompt):
|
| 442 |
-
yield chunk
|
| 443 |
-
background_tasks.add_task(log_chat_to_db, user_id, "outbound", f"LLM fallback response for prompt: {prompt}")
|
| 444 |
-
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
# Handle dish selection for ordering
|
| 448 |
if any(item["name"].lower() in user_message.lower() for item in menu_items) or \
|
| 449 |
any(str(index) == user_message.strip() for index, item in enumerate(menu_items, start=1)):
|
| 450 |
selected_dish = None
|
| 451 |
if user_message.strip().isdigit():
|
| 452 |
-
# User selected by number
|
| 453 |
dish_number = int(user_message.strip())
|
| 454 |
if 1 <= dish_number <= len(menu_items):
|
| 455 |
selected_dish = menu_items[dish_number - 1]["name"]
|
| 456 |
else:
|
| 457 |
-
# User selected by name
|
| 458 |
for item in menu_items:
|
| 459 |
if item["name"].lower() in user_message.lower():
|
| 460 |
selected_dish = item["name"]
|
| 461 |
break
|
| 462 |
-
|
| 463 |
if selected_dish:
|
| 464 |
-
# Trigger
|
| 465 |
user_state[user_id] = {"flow": "order", "step": 1, "data": {"dish": selected_dish}, "last_active": datetime.utcnow()}
|
| 466 |
response_text = f"You selected {selected_dish}. How many servings would you like?"
|
| 467 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
|
@@ -471,7 +388,7 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 471 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 472 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 473 |
|
| 474 |
-
#
|
| 475 |
if "nutritional facts for" in user_message.lower():
|
| 476 |
dish_name = user_message.lower().replace("nutritional facts for", "").strip().title()
|
| 477 |
dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
|
|
@@ -482,124 +399,16 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 482 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 483 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 484 |
|
| 485 |
-
|
| 486 |
-
# For context-aware conversation: store conversation context
|
| 487 |
-
conversation_context.setdefault(user_id, []).append({"timestamp": datetime.utcnow().isoformat(), "message": user_message})
|
| 488 |
-
|
| 489 |
-
# Fallback: use NVIDIA text LLM streaming for a response
|
| 490 |
prompt = f"User query: {user_message}\nGenerate a helpful, personalized response for a restaurant chatbot."
|
| 491 |
def stream_response():
|
| 492 |
for chunk in stream_text_completion(prompt):
|
| 493 |
yield chunk
|
| 494 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", f"LLM fallback response for prompt: {prompt}")
|
| 495 |
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 496 |
-
|
| 497 |
-
# --- Chat History
|
| 498 |
-
|
| 499 |
-
async def get_chat_history(user_id: str):
|
| 500 |
-
"""
|
| 501 |
-
Retrieve chat history for a user.
|
| 502 |
-
"""
|
| 503 |
-
async with async_session() as session:
|
| 504 |
-
result = await session.execute(
|
| 505 |
-
ChatHistory.__table__.select().where(ChatHistory.user_id == user_id)
|
| 506 |
-
)
|
| 507 |
-
history = result.fetchall()
|
| 508 |
-
return [dict(row) for row in history]
|
| 509 |
-
|
| 510 |
-
# --- Order Details Endpoint ---
|
| 511 |
-
@app.get("/order/{order_id}")
|
| 512 |
-
async def get_order(order_id: str):
|
| 513 |
-
"""
|
| 514 |
-
Retrieve details for a specific order.
|
| 515 |
-
"""
|
| 516 |
-
async with async_session() as session:
|
| 517 |
-
result = await session.execute(
|
| 518 |
-
Order.__table__.select().where(Order.order_id == order_id)
|
| 519 |
-
)
|
| 520 |
-
order = result.fetchone()
|
| 521 |
-
if order:
|
| 522 |
-
return dict(order)
|
| 523 |
-
else:
|
| 524 |
-
raise HTTPException(status_code=404, detail="Order not found.")
|
| 525 |
-
|
| 526 |
-
# --- User Profile Endpoint ---
|
| 527 |
-
@app.get("/user_profile/{user_id}")
|
| 528 |
-
async def get_user_profile(user_id: str):
|
| 529 |
-
"""
|
| 530 |
-
Retrieve the user profile.
|
| 531 |
-
"""
|
| 532 |
-
profile = await get_or_create_user_profile(user_id)
|
| 533 |
-
return {
|
| 534 |
-
"user_id": profile.user_id,
|
| 535 |
-
"phone_number": profile.phone_number,
|
| 536 |
-
"name": profile.name,
|
| 537 |
-
"email": profile.email,
|
| 538 |
-
"preferences": profile.preferences,
|
| 539 |
-
"last_interaction": profile.last_interaction.isoformat()
|
| 540 |
-
}
|
| 541 |
-
|
| 542 |
-
# --- Analytics Endpoint ---
|
| 543 |
-
@app.get("/analytics")
|
| 544 |
-
async def get_analytics():
|
| 545 |
-
"""
|
| 546 |
-
Simple analytics dashboard endpoint.
|
| 547 |
-
Returns counts of messages, orders, and average sentiment.
|
| 548 |
-
"""
|
| 549 |
-
async with async_session() as session:
|
| 550 |
-
# Total messages count
|
| 551 |
-
msg_result = await session.execute(ChatHistory.__table__.count())
|
| 552 |
-
total_messages = msg_result.scalar() or 0
|
| 553 |
-
|
| 554 |
-
# Total orders count
|
| 555 |
-
order_result = await session.execute(Order.__table__.count())
|
| 556 |
-
total_orders = order_result.scalar() or 0
|
| 557 |
-
|
| 558 |
-
# Average sentiment score
|
| 559 |
-
sentiment_result = await session.execute("SELECT AVG(sentiment_score) FROM sentiment_logs")
|
| 560 |
-
avg_sentiment = sentiment_result.scalar() or 0
|
| 561 |
-
|
| 562 |
-
return {
|
| 563 |
-
"total_messages": total_messages,
|
| 564 |
-
"total_orders": total_orders,
|
| 565 |
-
"average_sentiment": avg_sentiment
|
| 566 |
-
}
|
| 567 |
-
|
| 568 |
-
# --- Voice Integration Endpoint ---
|
| 569 |
-
@app.post("/voice")
|
| 570 |
-
async def process_voice(file: UploadFile = File(...)):
|
| 571 |
-
"""
|
| 572 |
-
Accept a voice file upload, perform speech-to-text (simulated), and process the resulting text.
|
| 573 |
-
In production, integrate with a real STT service.
|
| 574 |
-
"""
|
| 575 |
-
contents = await file.read()
|
| 576 |
-
# Simulated Speech-to-Text: In real implementation, send `contents` to an STT service.
|
| 577 |
-
simulated_text = "Simulated speech-to-text conversion result."
|
| 578 |
-
return {"transcription": simulated_text}
|
| 579 |
-
|
| 580 |
-
# --- Payment Callback Endpoint (Stub) ---
|
| 581 |
-
@app.post("/payment_callback")
|
| 582 |
-
async def payment_callback(request: Request):
|
| 583 |
-
"""
|
| 584 |
-
Endpoint to handle payment callbacks from Paystack.
|
| 585 |
-
Update order status based on callback data.
|
| 586 |
-
"""
|
| 587 |
-
data = await request.json()
|
| 588 |
-
# Extract order reference and update order status accordingly.
|
| 589 |
-
# In production, verify callback signature and extract data.
|
| 590 |
-
order_id = data.get("reference")
|
| 591 |
-
new_status = data.get("status", "Paid")
|
| 592 |
-
async with async_session() as session:
|
| 593 |
-
result = await session.execute(
|
| 594 |
-
Order.__table__.select().where(Order.order_id == order_id)
|
| 595 |
-
)
|
| 596 |
-
order = result.scalar_one_or_none()
|
| 597 |
-
if order:
|
| 598 |
-
order.status = new_status
|
| 599 |
-
await session.commit()
|
| 600 |
-
return JSONResponse(content={"message": "Order updated successfully."})
|
| 601 |
-
else:
|
| 602 |
-
raise HTTPException(status_code=404, detail="Order not found.")
|
| 603 |
|
| 604 |
if __name__ == "__main__":
|
| 605 |
import uvicorn
|
|
|
|
| 1 |
+
import re
|
|
|
|
| 2 |
import os
|
| 3 |
import time
|
| 4 |
import requests
|
|
|
|
| 45 |
user_id = Column(String, index=True)
|
| 46 |
dish = Column(String)
|
| 47 |
quantity = Column(String)
|
| 48 |
+
price = Column(String, default="0")
|
| 49 |
+
status = Column(String, default="Pending Payment")
|
| 50 |
payment_reference = Column(String, nullable=True)
|
| 51 |
timestamp = Column(DateTime, default=datetime.utcnow)
|
| 52 |
|
|
|
|
| 54 |
__tablename__ = "user_profiles"
|
| 55 |
id = Column(Integer, primary_key=True, index=True)
|
| 56 |
user_id = Column(String, unique=True, index=True)
|
| 57 |
+
phone_number = Column(String, unique=True, index=True, nullable=True)
|
| 58 |
name = Column(String, default="Valued Customer")
|
| 59 |
email = Column(String, default="unknown@example.com")
|
| 60 |
+
preferences = Column(Text, default="")
|
| 61 |
last_interaction = Column(DateTime, default=datetime.utcnow)
|
| 62 |
|
| 63 |
class SentimentLog(Base):
|
|
|
|
| 68 |
sentiment_score = Column(Float)
|
| 69 |
message = Column(Text)
|
| 70 |
|
|
|
|
| 71 |
engine = create_async_engine(DATABASE_URL, echo=True)
|
| 72 |
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
| 73 |
|
|
|
|
| 76 |
await conn.run_sync(Base.metadata.create_all)
|
| 77 |
|
| 78 |
# --- Global In-Memory Stores ---
|
| 79 |
+
user_state = {} # e.g., { user_id: { "flow": str, "step": int, "data": dict, "last_active": datetime } }
|
| 80 |
+
conversation_context = {}
|
| 81 |
+
proactive_timer = {}
|
| 82 |
|
|
|
|
| 83 |
menu_items = [
|
| 84 |
{"name": "Jollof Rice", "description": "A spicy and flavorful rice dish", "price": 1500, "nutrition": "Calories: 300 kcal, Carbs: 50g, Protein: 10g, Fat: 5g"},
|
| 85 |
{"name": "Fried Rice", "description": "A savory rice dish with vegetables and meat", "price": 1200, "nutrition": "Calories: 350 kcal, Carbs: 55g, Protein: 12g, Fat: 8g"},
|
|
|
|
| 88 |
]
|
| 89 |
|
| 90 |
# --- Utility Functions ---
|
|
|
|
| 91 |
async def log_chat_to_db(user_id: str, direction: str, message: str):
|
|
|
|
| 92 |
async with async_session() as session:
|
| 93 |
entry = ChatHistory(user_id=user_id, direction=direction, message=message)
|
| 94 |
session.add(entry)
|
| 95 |
await session.commit()
|
| 96 |
|
| 97 |
async def log_sentiment(user_id: str, message: str, score: float):
|
|
|
|
| 98 |
async with async_session() as session:
|
| 99 |
entry = SentimentLog(user_id=user_id, sentiment_score=score, message=message)
|
| 100 |
session.add(entry)
|
| 101 |
await session.commit()
|
| 102 |
|
| 103 |
def analyze_sentiment(text: str) -> float:
|
|
|
|
| 104 |
blob = TextBlob(text)
|
| 105 |
return blob.sentiment.polarity
|
| 106 |
|
| 107 |
def google_image_scrape(query: str) -> str:
|
|
|
|
| 108 |
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)"}
|
| 109 |
search_url = f"https://www.google.com/search?tbm=isch&q={query}"
|
| 110 |
try:
|
|
|
|
| 121 |
return ""
|
| 122 |
|
| 123 |
def create_paystack_payment_link(email: str, amount: int, reference: str) -> dict:
|
|
|
|
| 124 |
url = "https://api.paystack.co/transaction/initialize"
|
| 125 |
headers = {
|
| 126 |
"Authorization": f"Bearer {PAYSTACK_SECRET_KEY}",
|
|
|
|
| 130 |
"email": email,
|
| 131 |
"amount": amount,
|
| 132 |
"reference": reference,
|
| 133 |
+
"callback_url": "https://yourdomain.com/payment_callback"
|
| 134 |
}
|
| 135 |
try:
|
| 136 |
response = requests.post(url, json=data, headers=headers, timeout=10)
|
|
|
|
| 143 |
|
| 144 |
# --- NVIDIA LLM Streaming Functions ---
|
| 145 |
def stream_text_completion(prompt: str):
|
| 146 |
+
from openai import OpenAI
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
client = OpenAI(
|
| 148 |
base_url="https://integrate.api.nvidia.com/v1",
|
| 149 |
api_key=NVIDIA_API_KEY
|
|
|
|
| 161 |
yield chunk.choices[0].delta.content
|
| 162 |
|
| 163 |
def stream_image_completion(image_b64: str):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
invoke_url = "https://ai.api.nvidia.com/v1/gr/meta/llama-3.2-90b-vision-instruct/chat/completions"
|
| 165 |
headers = {
|
| 166 |
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
|
|
|
| 186 |
|
| 187 |
# --- Advanced Internal Flow: Order Processing & Payment Integration ---
|
| 188 |
def process_order_flow(user_id: str, message: str) -> str:
|
| 189 |
+
# If user types a command that should override any active order flow:
|
| 190 |
+
if message.lower() in ["order", "menu"]:
|
| 191 |
+
if user_id in user_state:
|
| 192 |
+
del user_state[user_id]
|
| 193 |
+
if message.lower() == "order":
|
| 194 |
+
user_state[user_id] = {"flow": "order", "step": 1, "data": {}, "last_active": datetime.utcnow()}
|
| 195 |
+
return "Sure! What dish would you like to order?"
|
| 196 |
+
# For "menu", we return empty to allow the menu-handling code to run.
|
| 197 |
+
return ""
|
| 198 |
+
|
| 199 |
if user_id in user_state:
|
| 200 |
state = user_state[user_id]
|
| 201 |
flow = state.get("flow")
|
|
|
|
| 203 |
data = state.get("data", {})
|
| 204 |
if flow == "order":
|
| 205 |
if step == 1:
|
| 206 |
+
# Validate dish name (exact match is used)
|
| 207 |
dish_name = message.title()
|
| 208 |
dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
|
| 209 |
if dish:
|
|
|
|
| 212 |
return f"You selected {data['dish']}. How many servings would you like?"
|
| 213 |
else:
|
| 214 |
return f"Sorry, we don't have {dish_name} on the menu. Please choose another dish."
|
|
|
|
| 215 |
elif step == 2:
|
| 216 |
+
# Extract the first number from the message (e.g., '1 servings' becomes 1)
|
| 217 |
+
numbers = re.findall(r'\d+', message)
|
| 218 |
+
if not numbers:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 219 |
return "Please enter a valid number for the quantity (e.g., 1, 2, 3)."
|
| 220 |
+
quantity = int(numbers[0])
|
| 221 |
+
if quantity <= 0:
|
| 222 |
+
return "Please enter a valid quantity (e.g., 1, 2, 3)."
|
| 223 |
+
data["quantity"] = quantity
|
| 224 |
+
order_id = f"ORD-{int(time.time())}"
|
| 225 |
+
data["order_id"] = order_id
|
| 226 |
+
price_per_serving = 1500 # ₦1500 per serving
|
| 227 |
+
total_price = quantity * price_per_serving
|
| 228 |
+
data["price"] = str(total_price)
|
| 229 |
+
|
| 230 |
+
# Save order asynchronously
|
| 231 |
+
import asyncio
|
| 232 |
+
async def save_order():
|
| 233 |
+
async with async_session() as session:
|
| 234 |
+
order = Order(
|
| 235 |
+
order_id=order_id,
|
| 236 |
+
user_id=user_id,
|
| 237 |
+
dish=data["dish"],
|
| 238 |
+
quantity=str(quantity),
|
| 239 |
+
price=str(total_price),
|
| 240 |
+
status="Pending Payment"
|
| 241 |
+
)
|
| 242 |
+
session.add(order)
|
| 243 |
+
await session.commit()
|
| 244 |
+
asyncio.create_task(save_order())
|
| 245 |
+
|
| 246 |
+
# Clear conversation state for order flow.
|
| 247 |
+
del user_state[user_id]
|
| 248 |
+
|
| 249 |
+
# Retrieve email from user profile if available (using a placeholder here)
|
| 250 |
+
email = "customer@example.com"
|
| 251 |
+
payment_data = create_paystack_payment_link(email, total_price * 100, order_id)
|
| 252 |
+
if payment_data.get("status"):
|
| 253 |
+
payment_link = payment_data["data"]["authorization_url"]
|
| 254 |
+
return (f"Thank you for your order of {data['quantity']} serving(s) of {data['dish']}! "
|
| 255 |
+
f"Your Order ID is {order_id}.\nPlease complete payment here: {payment_link}")
|
| 256 |
+
else:
|
| 257 |
+
return f"Your order has been placed with Order ID {order_id}, but we could not initialize payment. Please try again later."
|
| 258 |
else:
|
| 259 |
if "order" in message.lower():
|
| 260 |
user_state[user_id] = {"flow": "order", "step": 1, "data": {}, "last_active": datetime.utcnow()}
|
| 261 |
return "Sure! What dish would you like to order?"
|
| 262 |
return ""
|
|
|
|
| 263 |
|
| 264 |
+
# --- User Profile Functions ---
|
| 265 |
async def get_or_create_user_profile(user_id: str, phone_number: str = None) -> UserProfile:
|
|
|
|
| 266 |
async with async_session() as session:
|
| 267 |
result = await session.execute(
|
| 268 |
select(UserProfile).where(UserProfile.user_id == user_id)
|
|
|
|
| 279 |
return profile
|
| 280 |
|
| 281 |
async def update_user_last_interaction(user_id: str):
|
|
|
|
| 282 |
async with async_session() as session:
|
| 283 |
result = await session.execute(
|
| 284 |
select(UserProfile).where(UserProfile.user_id == user_id)
|
|
|
|
| 290 |
|
| 291 |
# --- Proactive Engagement: Warm Greetings ---
|
| 292 |
async def send_proactive_greeting(user_id: str):
|
|
|
|
| 293 |
greeting = "Hi again! We miss you. Would you like to see our new menu items or get personalized recommendations?"
|
| 294 |
await log_chat_to_db(user_id, "outbound", greeting)
|
| 295 |
return greeting
|
|
|
|
| 301 |
async def on_startup():
|
| 302 |
await init_db()
|
| 303 |
|
|
|
|
| 304 |
@app.post("/chatbot")
|
| 305 |
async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
data = await request.json()
|
| 307 |
user_id = data.get("user_id")
|
| 308 |
+
phone_number = data.get("phone_number")
|
| 309 |
user_message = data.get("message", "").strip()
|
| 310 |
is_image = data.get("is_image", False)
|
| 311 |
image_b64 = data.get("image_base64", None)
|
|
|
|
| 313 |
if not user_id:
|
| 314 |
raise HTTPException(status_code=400, detail="Missing user_id in payload.")
|
| 315 |
|
|
|
|
| 316 |
background_tasks.add_task(log_chat_to_db, user_id, "inbound", user_message)
|
|
|
|
| 317 |
await update_user_last_interaction(user_id)
|
| 318 |
await get_or_create_user_profile(user_id, phone_number)
|
| 319 |
|
|
|
|
| 323 |
raise HTTPException(status_code=400, detail="Image too large.")
|
| 324 |
return StreamingResponse(stream_image_completion(image_b64), media_type="text/plain")
|
| 325 |
|
|
|
|
|
|
|
| 326 |
sentiment_score = analyze_sentiment(user_message)
|
| 327 |
background_tasks.add_task(log_sentiment, user_id, user_message, sentiment_score)
|
|
|
|
|
|
|
| 328 |
sentiment_modifier = ""
|
| 329 |
if sentiment_score < -0.3:
|
| 330 |
sentiment_modifier = "I'm sorry if you're having a tough time. "
|
| 331 |
elif sentiment_score > 0.3:
|
| 332 |
sentiment_modifier = "Great to hear from you! "
|
| 333 |
|
| 334 |
+
# --- Order Flow Handling ---
|
|
|
|
| 335 |
order_response = process_order_flow(user_id, user_message)
|
| 336 |
if order_response:
|
| 337 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", order_response)
|
| 338 |
return JSONResponse(content={"response": sentiment_modifier + order_response})
|
| 339 |
|
| 340 |
+
# --- Menu Display ---
|
| 341 |
if "menu" in user_message.lower():
|
| 342 |
+
# Clear any active order flow if user explicitly asks for menu.
|
| 343 |
+
if user_id in user_state:
|
| 344 |
+
del user_state[user_id]
|
| 345 |
menu_with_images = []
|
| 346 |
for index, item in enumerate(menu_items, start=1):
|
| 347 |
image_url = google_image_scrape(item["name"])
|
| 348 |
menu_with_images.append({
|
| 349 |
+
"number": index,
|
| 350 |
"name": item["name"],
|
| 351 |
"description": item["description"],
|
| 352 |
"price": item["price"],
|
|
|
|
| 364 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", str(response_payload))
|
| 365 |
return JSONResponse(content=response_payload)
|
| 366 |
|
| 367 |
+
# --- Dish Selection via Menu ---
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
if any(item["name"].lower() in user_message.lower() for item in menu_items) or \
|
| 369 |
any(str(index) == user_message.strip() for index, item in enumerate(menu_items, start=1)):
|
| 370 |
selected_dish = None
|
| 371 |
if user_message.strip().isdigit():
|
|
|
|
| 372 |
dish_number = int(user_message.strip())
|
| 373 |
if 1 <= dish_number <= len(menu_items):
|
| 374 |
selected_dish = menu_items[dish_number - 1]["name"]
|
| 375 |
else:
|
|
|
|
| 376 |
for item in menu_items:
|
| 377 |
if item["name"].lower() in user_message.lower():
|
| 378 |
selected_dish = item["name"]
|
| 379 |
break
|
|
|
|
| 380 |
if selected_dish:
|
| 381 |
+
# Trigger a new order flow
|
| 382 |
user_state[user_id] = {"flow": "order", "step": 1, "data": {"dish": selected_dish}, "last_active": datetime.utcnow()}
|
| 383 |
response_text = f"You selected {selected_dish}. How many servings would you like?"
|
| 384 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
|
|
|
| 388 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 389 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 390 |
|
| 391 |
+
# --- Nutritional Facts ---
|
| 392 |
if "nutritional facts for" in user_message.lower():
|
| 393 |
dish_name = user_message.lower().replace("nutritional facts for", "").strip().title()
|
| 394 |
dish = next((item for item in menu_items if item["name"].lower() == dish_name.lower()), None)
|
|
|
|
| 399 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 400 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 401 |
|
| 402 |
+
# --- Fallback: LLM Response Streaming ---
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
prompt = f"User query: {user_message}\nGenerate a helpful, personalized response for a restaurant chatbot."
|
| 404 |
def stream_response():
|
| 405 |
for chunk in stream_text_completion(prompt):
|
| 406 |
yield chunk
|
| 407 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", f"LLM fallback response for prompt: {prompt}")
|
| 408 |
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 409 |
+
|
| 410 |
+
# --- Other Endpoints (Chat History, Order Details, User Profile, Analytics, Voice, Payment Callback) ---
|
| 411 |
+
# ... (unchanged for brevity) ...
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 412 |
|
| 413 |
if __name__ == "__main__":
|
| 414 |
import uvicorn
|