Update app.py
Browse files
app.py
CHANGED
|
@@ -79,8 +79,8 @@ async def init_db():
|
|
| 79 |
# --- Global In-Memory Stores ---
|
| 80 |
# Instead of a plain dict for conversation context, we use a dedicated class below.
|
| 81 |
user_state = {} # e.g., { user_id: ConversationState }
|
| 82 |
-
conversation_context = {}
|
| 83 |
-
proactive_timer = {}
|
| 84 |
|
| 85 |
menu_items = [
|
| 86 |
{"name": "Jollof Rice", "description": "A spicy and flavorful rice dish", "price": 1500, "nutrition": "Calories: 300 kcal, Carbs: 50g, Protein: 10g, Fat: 5g"},
|
|
@@ -213,7 +213,7 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 213 |
"""
|
| 214 |
Implements an FSM-based order flow that:
|
| 215 |
- In step 1, expects the user to mention a dish name (optionally with quantity)
|
| 216 |
-
- In step 2, asks for quantity if
|
| 217 |
- In step 3, finalizes the order and creates a payment link
|
| 218 |
"""
|
| 219 |
# Retrieve or initialize conversation state
|
|
@@ -232,7 +232,6 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 232 |
user_state[user_id] = state
|
| 233 |
if message.lower() == "order":
|
| 234 |
return "Sure! What dish would you like to order?"
|
| 235 |
-
# For "menu", let the menu handler run by returning empty.
|
| 236 |
return ""
|
| 237 |
|
| 238 |
# If no state exists but the message includes "order", start the order flow.
|
|
@@ -248,7 +247,6 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 248 |
state.update_last_active()
|
| 249 |
# --- Step 1: Expecting Dish Selection (and optionally quantity) ---
|
| 250 |
if state.step == 1:
|
| 251 |
-
# Look for a dish name in the message
|
| 252 |
dish_candidates = [item["name"] for item in menu_items]
|
| 253 |
found_dish = None
|
| 254 |
for dish in dish_candidates:
|
|
@@ -259,7 +257,6 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 259 |
numbers = re.findall(r'\d+', message)
|
| 260 |
if found_dish:
|
| 261 |
state.data["dish"] = found_dish
|
| 262 |
-
# If a quantity is provided, move to finalizing the order directly.
|
| 263 |
if numbers:
|
| 264 |
quantity = int(numbers[0])
|
| 265 |
if quantity <= 0:
|
|
@@ -286,8 +283,7 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 286 |
if state.step == 3:
|
| 287 |
order_id = f"ORD-{int(time.time())}"
|
| 288 |
state.data["order_id"] = order_id
|
| 289 |
-
|
| 290 |
-
price_per_serving = 1500
|
| 291 |
quantity = state.data.get("quantity", 1)
|
| 292 |
total_price = quantity * price_per_serving
|
| 293 |
state.data["price"] = str(total_price)
|
|
@@ -307,13 +303,9 @@ def process_order_flow(user_id: str, message: str) -> str:
|
|
| 307 |
await session.commit()
|
| 308 |
asyncio.create_task(save_order())
|
| 309 |
|
| 310 |
-
#
|
| 311 |
-
email = "customer@example.com"
|
| 312 |
payment_data = create_paystack_payment_link(email, total_price * 100, order_id)
|
| 313 |
-
|
| 314 |
-
# Before returning, capture the dish name for the response
|
| 315 |
dish_name = state.data.get("dish", "")
|
| 316 |
-
# Clear the conversation state after finalizing the order.
|
| 317 |
state.reset()
|
| 318 |
if user_id in user_state:
|
| 319 |
del user_state[user_id]
|
|
@@ -379,6 +371,16 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 379 |
if not user_id:
|
| 380 |
raise HTTPException(status_code=400, detail="Missing user_id in payload.")
|
| 381 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
background_tasks.add_task(log_chat_to_db, user_id, "inbound", user_message)
|
| 383 |
await update_user_last_interaction(user_id)
|
| 384 |
await get_or_create_user_profile(user_id, phone_number)
|
|
@@ -401,11 +403,15 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 401 |
order_response = process_order_flow(user_id, user_message)
|
| 402 |
if order_response:
|
| 403 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", order_response)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
return JSONResponse(content={"response": sentiment_modifier + order_response})
|
| 405 |
|
| 406 |
# --- Menu Display ---
|
| 407 |
if "menu" in user_message.lower():
|
| 408 |
-
# Clear any active order flow if user explicitly asks for the menu.
|
| 409 |
if user_id in user_state:
|
| 410 |
del user_state[user_id]
|
| 411 |
menu_with_images = []
|
|
@@ -428,6 +434,11 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 428 |
)
|
| 429 |
}
|
| 430 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", str(response_payload))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 431 |
return JSONResponse(content=response_payload)
|
| 432 |
|
| 433 |
# --- Dish Selection via Menu ---
|
|
@@ -444,7 +455,6 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 444 |
selected_dish = item["name"]
|
| 445 |
break
|
| 446 |
if selected_dish:
|
| 447 |
-
# Start a new order flow with the selected dish.
|
| 448 |
state = ConversationState()
|
| 449 |
state.flow = "order"
|
| 450 |
state.step = 1
|
|
@@ -453,10 +463,20 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 453 |
user_state[user_id] = state
|
| 454 |
response_text = f"You selected {selected_dish}. How many servings would you like?"
|
| 455 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 456 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 457 |
else:
|
| 458 |
response_text = "Sorry, I couldn't find that dish in the menu. Please try again."
|
| 459 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 460 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 461 |
|
| 462 |
# --- Nutritional Facts ---
|
|
@@ -468,19 +488,133 @@ async def chatbot_response(request: Request, background_tasks: BackgroundTasks):
|
|
| 468 |
else:
|
| 469 |
response_text = f"Sorry, I couldn't find nutritional facts for {dish_name}."
|
| 470 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 472 |
|
| 473 |
-
# --- Fallback: LLM Response Streaming ---
|
| 474 |
-
|
|
|
|
|
|
|
|
|
|
| 475 |
def stream_response():
|
| 476 |
for chunk in stream_text_completion(prompt):
|
| 477 |
yield chunk
|
| 478 |
-
|
|
|
|
|
|
|
| 479 |
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 480 |
|
| 481 |
-
# ---
|
| 482 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
|
| 484 |
if __name__ == "__main__":
|
| 485 |
import uvicorn
|
| 486 |
-
uvicorn.run(app, host="0.0.0.0", port=8000)
|
|
|
|
| 79 |
# --- Global In-Memory Stores ---
|
| 80 |
# Instead of a plain dict for conversation context, we use a dedicated class below.
|
| 81 |
user_state = {} # e.g., { user_id: ConversationState }
|
| 82 |
+
conversation_context = {} # { user_id: [ { "timestamp": ..., "role": "user"/"bot", "message": ... }, ... ] }
|
| 83 |
+
proactive_timer = {}
|
| 84 |
|
| 85 |
menu_items = [
|
| 86 |
{"name": "Jollof Rice", "description": "A spicy and flavorful rice dish", "price": 1500, "nutrition": "Calories: 300 kcal, Carbs: 50g, Protein: 10g, Fat: 5g"},
|
|
|
|
| 213 |
"""
|
| 214 |
Implements an FSM-based order flow that:
|
| 215 |
- In step 1, expects the user to mention a dish name (optionally with quantity)
|
| 216 |
+
- In step 2, asks for quantity if not provided
|
| 217 |
- In step 3, finalizes the order and creates a payment link
|
| 218 |
"""
|
| 219 |
# Retrieve or initialize conversation state
|
|
|
|
| 232 |
user_state[user_id] = state
|
| 233 |
if message.lower() == "order":
|
| 234 |
return "Sure! What dish would you like to order?"
|
|
|
|
| 235 |
return ""
|
| 236 |
|
| 237 |
# If no state exists but the message includes "order", start the order flow.
|
|
|
|
| 247 |
state.update_last_active()
|
| 248 |
# --- Step 1: Expecting Dish Selection (and optionally quantity) ---
|
| 249 |
if state.step == 1:
|
|
|
|
| 250 |
dish_candidates = [item["name"] for item in menu_items]
|
| 251 |
found_dish = None
|
| 252 |
for dish in dish_candidates:
|
|
|
|
| 257 |
numbers = re.findall(r'\d+', message)
|
| 258 |
if found_dish:
|
| 259 |
state.data["dish"] = found_dish
|
|
|
|
| 260 |
if numbers:
|
| 261 |
quantity = int(numbers[0])
|
| 262 |
if quantity <= 0:
|
|
|
|
| 283 |
if state.step == 3:
|
| 284 |
order_id = f"ORD-{int(time.time())}"
|
| 285 |
state.data["order_id"] = order_id
|
| 286 |
+
price_per_serving = 1500 # fixed price per serving for demonstration
|
|
|
|
| 287 |
quantity = state.data.get("quantity", 1)
|
| 288 |
total_price = quantity * price_per_serving
|
| 289 |
state.data["price"] = str(total_price)
|
|
|
|
| 303 |
await session.commit()
|
| 304 |
asyncio.create_task(save_order())
|
| 305 |
|
| 306 |
+
email = "customer@example.com" # Placeholder; retrieve from profile if available
|
|
|
|
| 307 |
payment_data = create_paystack_payment_link(email, total_price * 100, order_id)
|
|
|
|
|
|
|
| 308 |
dish_name = state.data.get("dish", "")
|
|
|
|
| 309 |
state.reset()
|
| 310 |
if user_id in user_state:
|
| 311 |
del user_state[user_id]
|
|
|
|
| 371 |
if not user_id:
|
| 372 |
raise HTTPException(status_code=400, detail="Missing user_id in payload.")
|
| 373 |
|
| 374 |
+
# Initialize conversation context for the user if not present.
|
| 375 |
+
if user_id not in conversation_context:
|
| 376 |
+
conversation_context[user_id] = []
|
| 377 |
+
# Append the inbound message to the conversation context.
|
| 378 |
+
conversation_context[user_id].append({
|
| 379 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 380 |
+
"role": "user",
|
| 381 |
+
"message": user_message
|
| 382 |
+
})
|
| 383 |
+
|
| 384 |
background_tasks.add_task(log_chat_to_db, user_id, "inbound", user_message)
|
| 385 |
await update_user_last_interaction(user_id)
|
| 386 |
await get_or_create_user_profile(user_id, phone_number)
|
|
|
|
| 403 |
order_response = process_order_flow(user_id, user_message)
|
| 404 |
if order_response:
|
| 405 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", order_response)
|
| 406 |
+
conversation_context[user_id].append({
|
| 407 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 408 |
+
"role": "bot",
|
| 409 |
+
"message": order_response
|
| 410 |
+
})
|
| 411 |
return JSONResponse(content={"response": sentiment_modifier + order_response})
|
| 412 |
|
| 413 |
# --- Menu Display ---
|
| 414 |
if "menu" in user_message.lower():
|
|
|
|
| 415 |
if user_id in user_state:
|
| 416 |
del user_state[user_id]
|
| 417 |
menu_with_images = []
|
|
|
|
| 434 |
)
|
| 435 |
}
|
| 436 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", str(response_payload))
|
| 437 |
+
conversation_context[user_id].append({
|
| 438 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 439 |
+
"role": "bot",
|
| 440 |
+
"message": response_payload["response"]
|
| 441 |
+
})
|
| 442 |
return JSONResponse(content=response_payload)
|
| 443 |
|
| 444 |
# --- Dish Selection via Menu ---
|
|
|
|
| 455 |
selected_dish = item["name"]
|
| 456 |
break
|
| 457 |
if selected_dish:
|
|
|
|
| 458 |
state = ConversationState()
|
| 459 |
state.flow = "order"
|
| 460 |
state.step = 1
|
|
|
|
| 463 |
user_state[user_id] = state
|
| 464 |
response_text = f"You selected {selected_dish}. How many servings would you like?"
|
| 465 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 466 |
+
conversation_context[user_id].append({
|
| 467 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 468 |
+
"role": "bot",
|
| 469 |
+
"message": response_text
|
| 470 |
+
})
|
| 471 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 472 |
else:
|
| 473 |
response_text = "Sorry, I couldn't find that dish in the menu. Please try again."
|
| 474 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 475 |
+
conversation_context[user_id].append({
|
| 476 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 477 |
+
"role": "bot",
|
| 478 |
+
"message": response_text
|
| 479 |
+
})
|
| 480 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 481 |
|
| 482 |
# --- Nutritional Facts ---
|
|
|
|
| 488 |
else:
|
| 489 |
response_text = f"Sorry, I couldn't find nutritional facts for {dish_name}."
|
| 490 |
background_tasks.add_task(log_chat_to_db, user_id, "outbound", response_text)
|
| 491 |
+
conversation_context[user_id].append({
|
| 492 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 493 |
+
"role": "bot",
|
| 494 |
+
"message": response_text
|
| 495 |
+
})
|
| 496 |
return JSONResponse(content={"response": sentiment_modifier + response_text})
|
| 497 |
|
| 498 |
+
# --- Fallback: LLM Response Streaming with Conversation Context ---
|
| 499 |
+
# Build recent conversation history (e.g., last 5 messages)
|
| 500 |
+
recent_context = conversation_context.get(user_id, [])[-5:]
|
| 501 |
+
context_str = "\n".join([f"{entry['role'].capitalize()}: {entry['message']}" for entry in recent_context])
|
| 502 |
+
prompt = f"Conversation context:\n{context_str}\nUser query: {user_message}\nGenerate a helpful, personalized response for a restaurant chatbot."
|
| 503 |
def stream_response():
|
| 504 |
for chunk in stream_text_completion(prompt):
|
| 505 |
yield chunk
|
| 506 |
+
fallback_log = f"LLM fallback response for prompt: {prompt}"
|
| 507 |
+
background_tasks.add_task(log_chat_to_db, user_id, "outbound", fallback_log)
|
| 508 |
+
# (Optionally, you might want to capture and add the streamed response to conversation_context)
|
| 509 |
return StreamingResponse(stream_response(), media_type="text/plain")
|
| 510 |
|
| 511 |
+
# --- Chat History Endpoint ---
|
| 512 |
+
@app.get("/chat_history/{user_id}")
|
| 513 |
+
async def get_chat_history(user_id: str):
|
| 514 |
+
"""
|
| 515 |
+
Retrieve chat history for a user.
|
| 516 |
+
"""
|
| 517 |
+
async with async_session() as session:
|
| 518 |
+
result = await session.execute(
|
| 519 |
+
ChatHistory.__table__.select().where(ChatHistory.user_id == user_id)
|
| 520 |
+
)
|
| 521 |
+
history = result.fetchall()
|
| 522 |
+
return [dict(row) for row in history]
|
| 523 |
+
|
| 524 |
+
# --- Order Details Endpoint ---
|
| 525 |
+
@app.get("/order/{order_id}")
|
| 526 |
+
async def get_order(order_id: str):
|
| 527 |
+
"""
|
| 528 |
+
Retrieve details for a specific order.
|
| 529 |
+
"""
|
| 530 |
+
async with async_session() as session:
|
| 531 |
+
result = await session.execute(
|
| 532 |
+
Order.__table__.select().where(Order.order_id == order_id)
|
| 533 |
+
)
|
| 534 |
+
order = result.fetchone()
|
| 535 |
+
if order:
|
| 536 |
+
return dict(order)
|
| 537 |
+
else:
|
| 538 |
+
raise HTTPException(status_code=404, detail="Order not found.")
|
| 539 |
+
|
| 540 |
+
# --- User Profile Endpoint ---
|
| 541 |
+
@app.get("/user_profile/{user_id}")
|
| 542 |
+
async def get_user_profile(user_id: str):
|
| 543 |
+
"""
|
| 544 |
+
Retrieve the user profile.
|
| 545 |
+
"""
|
| 546 |
+
profile = await get_or_create_user_profile(user_id)
|
| 547 |
+
return {
|
| 548 |
+
"user_id": profile.user_id,
|
| 549 |
+
"phone_number": profile.phone_number,
|
| 550 |
+
"name": profile.name,
|
| 551 |
+
"email": profile.email,
|
| 552 |
+
"preferences": profile.preferences,
|
| 553 |
+
"last_interaction": profile.last_interaction.isoformat()
|
| 554 |
+
}
|
| 555 |
+
|
| 556 |
+
# --- Analytics Endpoint ---
|
| 557 |
+
@app.get("/analytics")
|
| 558 |
+
async def get_analytics():
|
| 559 |
+
"""
|
| 560 |
+
Simple analytics dashboard endpoint.
|
| 561 |
+
Returns counts of messages, orders, and average sentiment.
|
| 562 |
+
"""
|
| 563 |
+
async with async_session() as session:
|
| 564 |
+
# Total messages count
|
| 565 |
+
msg_result = await session.execute(ChatHistory.__table__.count())
|
| 566 |
+
total_messages = msg_result.scalar() or 0
|
| 567 |
+
|
| 568 |
+
# Total orders count
|
| 569 |
+
order_result = await session.execute(Order.__table__.count())
|
| 570 |
+
total_orders = order_result.scalar() or 0
|
| 571 |
+
|
| 572 |
+
# Average sentiment score
|
| 573 |
+
sentiment_result = await session.execute("SELECT AVG(sentiment_score) FROM sentiment_logs")
|
| 574 |
+
avg_sentiment = sentiment_result.scalar() or 0
|
| 575 |
+
|
| 576 |
+
return {
|
| 577 |
+
"total_messages": total_messages,
|
| 578 |
+
"total_orders": total_orders,
|
| 579 |
+
"average_sentiment": avg_sentiment
|
| 580 |
+
}
|
| 581 |
+
|
| 582 |
+
# --- Voice Integration Endpoint ---
|
| 583 |
+
@app.post("/voice")
|
| 584 |
+
async def process_voice(file: UploadFile = File(...)):
|
| 585 |
+
"""
|
| 586 |
+
Accept a voice file upload, perform speech-to-text (simulated), and process the resulting text.
|
| 587 |
+
In production, integrate with a real STT service.
|
| 588 |
+
"""
|
| 589 |
+
contents = await file.read()
|
| 590 |
+
# Simulated Speech-to-Text: In real implementation, send `contents` to an STT service.
|
| 591 |
+
simulated_text = "Simulated speech-to-text conversion result."
|
| 592 |
+
return {"transcription": simulated_text}
|
| 593 |
+
|
| 594 |
+
# --- Payment Callback Endpoint (Stub) ---
|
| 595 |
+
@app.post("/payment_callback")
|
| 596 |
+
async def payment_callback(request: Request):
|
| 597 |
+
"""
|
| 598 |
+
Endpoint to handle payment callbacks from Paystack.
|
| 599 |
+
Update order status based on callback data.
|
| 600 |
+
"""
|
| 601 |
+
data = await request.json()
|
| 602 |
+
# Extract order reference and update order status accordingly.
|
| 603 |
+
# In production, verify callback signature and extract data.
|
| 604 |
+
order_id = data.get("reference")
|
| 605 |
+
new_status = data.get("status", "Paid")
|
| 606 |
+
async with async_session() as session:
|
| 607 |
+
result = await session.execute(
|
| 608 |
+
Order.__table__.select().where(Order.order_id == order_id)
|
| 609 |
+
)
|
| 610 |
+
order = result.scalar_one_or_none()
|
| 611 |
+
if order:
|
| 612 |
+
order.status = new_status
|
| 613 |
+
await session.commit()
|
| 614 |
+
return JSONResponse(content={"message": "Order updated successfully."})
|
| 615 |
+
else:
|
| 616 |
+
raise HTTPException(status_code=404, detail="Order not found.")
|
| 617 |
|
| 618 |
if __name__ == "__main__":
|
| 619 |
import uvicorn
|
| 620 |
+
uvicorn.run(app, host="0.0.0.0", port=8000)
|