Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +239 -121
src/streamlit_app.py
CHANGED
|
@@ -18,78 +18,90 @@ st.set_page_config(page_title="FoodHub Support", page_icon="π", layout="cente
|
|
| 18 |
st.markdown(
|
| 19 |
"""
|
| 20 |
<style>
|
| 21 |
-
/* Keep app compact and centered */
|
| 22 |
.main { max-width: 980px; margin: 0 auto; }
|
| 23 |
|
| 24 |
-
/* Banner */
|
| 25 |
.foodhub-card {
|
| 26 |
background: linear-gradient(135deg, #fff3e0, #ffe0cc);
|
| 27 |
-
padding: 12px
|
| 28 |
border-radius: 16px;
|
| 29 |
border: 1px solid #f5c28c;
|
| 30 |
margin-bottom: 10px;
|
| 31 |
}
|
| 32 |
.foodhub-header-title {
|
| 33 |
-
margin
|
| 34 |
color: #e65c2b;
|
| 35 |
-
font-size:
|
| 36 |
font-weight: 800;
|
| 37 |
line-height: 1.1;
|
| 38 |
}
|
| 39 |
-
.foodhub-header-subtitle {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
.foodhub-assistant-pill {
|
| 41 |
display: inline-flex; align-items: center; gap: 6px;
|
| 42 |
background: #ffffff; border-radius: 999px;
|
| 43 |
-
padding: 4px 8px;
|
|
|
|
| 44 |
border: 1px solid #ffd3a3; margin-top: 6px;
|
| 45 |
}
|
| 46 |
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2ecc71; }
|
| 47 |
|
| 48 |
.quick-actions-title {
|
| 49 |
-
font-size:
|
| 50 |
font-weight: 700;
|
| 51 |
color: #555;
|
| 52 |
margin-top: 6px;
|
| 53 |
margin-bottom: 6px;
|
| 54 |
}
|
| 55 |
|
| 56 |
-
/* Make Streamlit buttons
|
| 57 |
div.stButton > button {
|
| 58 |
-
padding: 0.
|
| 59 |
-
font-size: 0.
|
| 60 |
-
height: 34px !important;
|
| 61 |
border-radius: 12px !important;
|
|
|
|
| 62 |
white-space: nowrap !important;
|
| 63 |
}
|
| 64 |
|
| 65 |
-
/*
|
| 66 |
-
.chat-
|
| 67 |
-
|
| 68 |
-
border-radius: 14px;
|
| 69 |
-
background: #ffffff;
|
| 70 |
-
padding: 8px;
|
| 71 |
-
margin-top: 10px;
|
| 72 |
-
margin-bottom: 10px;
|
| 73 |
-
}
|
| 74 |
-
.chat-window {
|
| 75 |
-
max-height: 280px; /* adjust if you want taller/shorter */
|
| 76 |
overflow-y: auto;
|
| 77 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
-
/* Bubbles */
|
| 81 |
.bot-bubble {
|
| 82 |
-
background: #eef7f2;
|
| 83 |
-
|
| 84 |
-
|
|
|
|
|
|
|
|
|
|
| 85 |
}
|
| 86 |
.user-bubble {
|
| 87 |
-
background: #ffffff;
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
}
|
| 92 |
-
.user-label, .bot-label { font-size: 12px; color: #777; margin-top: 10px; margin-bottom: 2px; }
|
| 93 |
|
| 94 |
code {
|
| 95 |
background: rgba(0,0,0,0.06);
|
|
@@ -97,12 +109,6 @@ st.markdown(
|
|
| 97 |
border-radius: 8px;
|
| 98 |
font-size: 13px;
|
| 99 |
}
|
| 100 |
-
|
| 101 |
-
/* Mobile-friendly: reduce chat height a bit */
|
| 102 |
-
@media (max-width: 720px) {
|
| 103 |
-
.chat-window { max-height: 240px; }
|
| 104 |
-
div.stButton > button { font-size: 0.76rem !important; height: 32px !important; }
|
| 105 |
-
}
|
| 106 |
</style>
|
| 107 |
""",
|
| 108 |
unsafe_allow_html=True,
|
|
@@ -116,7 +122,7 @@ ASK_ORDER_ID_LINE = "Please share your **Order ID** (for example: `O12488`) so I
|
|
| 116 |
ORDER_ID_REGEX = r"\b[oO]\d{3,}\b"
|
| 117 |
|
| 118 |
def format_for_bubble(text: str) -> str:
|
| 119 |
-
"""Safely render markdown-like **bold** and `code` inside HTML bubbles."""
|
| 120 |
t = html.escape(str(text or ""))
|
| 121 |
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t) # **bold**
|
| 122 |
t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t) # `code`
|
|
@@ -129,21 +135,27 @@ def extract_order_id(text: str) -> Optional[str]:
|
|
| 129 |
|
| 130 |
def needs_order_id(message: str) -> bool:
|
| 131 |
t = (message or "").lower().strip()
|
|
|
|
| 132 |
greetings = {"hi", "hello", "hey", "good morning", "good afternoon", "good evening"}
|
| 133 |
thanks = {"thanks", "thank you", "thx", "thankyou"}
|
| 134 |
if t in greetings or t in thanks:
|
| 135 |
return False
|
|
|
|
| 136 |
if extract_order_id(message):
|
| 137 |
return False
|
| 138 |
|
| 139 |
phrases = [
|
| 140 |
"where is my order", "track my order", "track order", "order status", "delivery status",
|
| 141 |
-
"status of my order", "when will my order arrive", "eta",
|
| 142 |
"order delayed", "delay in delivery", "order not delivered", "late delivery",
|
| 143 |
-
"order details", "show me the details",
|
|
|
|
|
|
|
| 144 |
"cancel my order", "cancel order",
|
| 145 |
"refund", "payment issue", "payment problem", "payment failed", "charged", "transaction",
|
|
|
|
| 146 |
"modify my order", "change my order", "postpone my order", "deliver at different address",
|
|
|
|
| 147 |
"order never arrived", "wrong or missing items", "food damage", "quality issue",
|
| 148 |
"food safety", "delivery safety", "charged more than once",
|
| 149 |
"another order issue"
|
|
@@ -152,8 +164,8 @@ def needs_order_id(message: str) -> bool:
|
|
| 152 |
return True
|
| 153 |
|
| 154 |
if "order" in t and any(k in t for k in [
|
| 155 |
-
"status", "track", "details", "cancel", "refund", "payment", "where", "when",
|
| 156 |
-
"modify", "change", "postpone", "address", "deliver",
|
| 157 |
"missing", "wrong", "quality", "damage", "safety", "arrived"
|
| 158 |
]):
|
| 159 |
return True
|
|
@@ -193,6 +205,8 @@ def _is_escalation_intent(message: str) -> bool:
|
|
| 193 |
"multiple follow-ups",
|
| 194 |
"i have followed multiple times",
|
| 195 |
"i have followed up multiple times",
|
|
|
|
|
|
|
| 196 |
"i want an update on my refund",
|
| 197 |
"refund update",
|
| 198 |
"update on my refund",
|
|
@@ -259,17 +273,23 @@ QUICK_BUTTON_EXACT_REPLIES = {
|
|
| 259 |
),
|
| 260 |
}
|
| 261 |
|
| 262 |
-
# ================== DB CONFIG
|
| 263 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 264 |
CLEAN_DB_PATH = os.path.join(BASE_DIR, "orders_clean.db")
|
| 265 |
|
|
|
|
| 266 |
try:
|
| 267 |
with sqlite3.connect(CLEAN_DB_PATH) as _conn:
|
| 268 |
_cur = _conn.cursor()
|
| 269 |
_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
| 270 |
_tables = [r[0] for r in _cur.fetchall()]
|
| 271 |
if "orders_clean" not in _tables:
|
| 272 |
-
st.error(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
st.stop()
|
| 274 |
except Exception as e:
|
| 275 |
st.error(f"Could not open database at {CLEAN_DB_PATH!r}: {e}")
|
|
@@ -306,37 +326,55 @@ Columns:
|
|
| 306 |
- is_canceled (INTEGER 0/1)
|
| 307 |
"""
|
| 308 |
|
|
|
|
|
|
|
|
|
|
| 309 |
def is_safe_sql(sql: str) -> bool:
|
| 310 |
if not isinstance(sql, str):
|
| 311 |
return False
|
| 312 |
s = sql.lower().strip()
|
|
|
|
| 313 |
if not s.startswith("select"):
|
| 314 |
return False
|
|
|
|
| 315 |
if re.search(r";\s*\S", s):
|
| 316 |
return False
|
|
|
|
| 317 |
forbidden = ["drop", "delete", "update", "insert", "alter", "truncate", "create"]
|
| 318 |
if any(k in s for k in forbidden):
|
| 319 |
return False
|
|
|
|
| 320 |
if any(tok in s for tok in ["--", "/*", "*/"]):
|
| 321 |
return False
|
|
|
|
| 322 |
return True
|
| 323 |
|
|
|
|
|
|
|
|
|
|
| 324 |
def run_sql_query(sql: str) -> pd.DataFrame:
|
| 325 |
if not isinstance(sql, str):
|
| 326 |
return pd.DataFrame([{"message": "π« Invalid SQL type (expected string)."}])
|
| 327 |
sql = sql.strip()
|
|
|
|
| 328 |
if not is_safe_sql(sql):
|
| 329 |
return pd.DataFrame([{"message": "π« Blocked unsafe or unsupported SQL."}])
|
|
|
|
| 330 |
try:
|
| 331 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 332 |
df = pd.read_sql_query(sql, conn)
|
|
|
|
| 333 |
for col in ["order_time", "preparing_eta", "prepared_time", "delivery_eta", "delivery_time"]:
|
| 334 |
if col in df.columns:
|
| 335 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
|
|
|
| 336 |
return df
|
| 337 |
except Exception as e:
|
| 338 |
return pd.DataFrame([{"message": f"β οΈ SQL execution error: {str(e)}"}])
|
| 339 |
|
|
|
|
|
|
|
|
|
|
| 340 |
def llm_to_sql(user_message: str) -> str:
|
| 341 |
msg = user_message or ""
|
| 342 |
text = msg.lower().strip()
|
|
@@ -351,7 +389,9 @@ def llm_to_sql(user_message: str) -> str:
|
|
| 351 |
|
| 352 |
system_prompt = f"""
|
| 353 |
You are an expert SQLite assistant for a food delivery company.
|
|
|
|
| 354 |
You ONLY generate valid SQLite SELECT queries using this schema:
|
|
|
|
| 355 |
{ORDERS_SCHEMA}
|
| 356 |
|
| 357 |
RULES:
|
|
@@ -364,48 +404,84 @@ RULES:
|
|
| 364 |
- If unsure:
|
| 365 |
SELECT 'Unable to answer with available data.' AS message;
|
| 366 |
"""
|
|
|
|
| 367 |
response = client.chat.completions.create(
|
| 368 |
model="llama-3.3-70b-versatile",
|
| 369 |
temperature=0.1,
|
| 370 |
-
messages=[
|
|
|
|
|
|
|
|
|
|
| 371 |
)
|
|
|
|
| 372 |
sql = (response.choices[0].message.content or "").strip()
|
|
|
|
| 373 |
if sql.startswith("```"):
|
| 374 |
sql = re.sub(r"^```sql", "", sql, flags=re.IGNORECASE).strip()
|
| 375 |
sql = re.sub(r"^```", "", sql).strip()
|
| 376 |
sql = sql.replace("```", "").strip()
|
|
|
|
| 377 |
sql = re.sub(r"\bfrom\s+\w+\b", "FROM orders_clean", sql, flags=re.IGNORECASE)
|
|
|
|
| 378 |
sql = re.sub(
|
| 379 |
r"where\s+order_id\s*=\s*'([^']+)'",
|
| 380 |
r"WHERE LOWER(order_id) = LOWER('\1')",
|
| 381 |
sql,
|
| 382 |
flags=re.IGNORECASE,
|
| 383 |
)
|
|
|
|
| 384 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 385 |
|
|
|
|
|
|
|
|
|
|
| 386 |
def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
| 387 |
user_message = "" if user_message is None else str(user_message).strip()
|
|
|
|
| 388 |
system_prompt = """
|
| 389 |
-
|
| 390 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
"""
|
|
|
|
| 392 |
response = client.chat.completions.create(
|
| 393 |
model="llama-3.3-70b-versatile",
|
| 394 |
temperature=0.0,
|
| 395 |
-
messages=[
|
|
|
|
|
|
|
|
|
|
| 396 |
)
|
|
|
|
| 397 |
raw = (response.choices[0].message.content or "").strip()
|
| 398 |
try:
|
| 399 |
info = json.loads(raw)
|
| 400 |
if not isinstance(info, dict):
|
| 401 |
-
raise ValueError
|
| 402 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 403 |
except Exception:
|
| 404 |
return {"sentiment": "neutral", "escalate": False}
|
| 405 |
|
|
|
|
|
|
|
|
|
|
| 406 |
def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general", order_id: Optional[str] = None) -> str:
|
| 407 |
ticket_id = "TKT-" + uuid.uuid4().hex[:8].upper()
|
| 408 |
created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
|
|
|
| 409 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 410 |
cur = conn.cursor()
|
| 411 |
cur.execute("""
|
|
@@ -424,8 +500,12 @@ def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general
|
|
| 424 |
VALUES (?, ?, ?, ?, ?, 'Open', ?);
|
| 425 |
""", (ticket_id, ticket_type, sentiment, user_message, order_id, created_at))
|
| 426 |
conn.commit()
|
|
|
|
| 427 |
return ticket_id
|
| 428 |
|
|
|
|
|
|
|
|
|
|
| 429 |
def sql_result_to_response(user_message: str, df: pd.DataFrame, max_rows_to_list: int = 5) -> str:
|
| 430 |
if isinstance(df, pd.DataFrame) and "message" in df.columns and len(df) == 1:
|
| 431 |
msg = str(df.iloc[0]["message"])
|
|
@@ -434,32 +514,72 @@ def sql_result_to_response(user_message: str, df: pd.DataFrame, max_rows_to_list
|
|
| 434 |
return msg
|
| 435 |
|
| 436 |
if df.empty:
|
| 437 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
df_for_llm = df.head(max_rows_to_list).copy()
|
| 440 |
result_data = df_for_llm.to_dict(orient="records")
|
| 441 |
|
| 442 |
system_prompt = f"""
|
| 443 |
You are a polite and professional customer support chatbot for FoodHub.
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
"""
|
| 448 |
-
|
|
|
|
|
|
|
| 449 |
resp = client.chat.completions.create(
|
| 450 |
model="llama-3.3-70b-versatile",
|
| 451 |
temperature=0.2,
|
| 452 |
-
messages=[
|
|
|
|
|
|
|
|
|
|
| 453 |
)
|
| 454 |
return (resp.choices[0].message.content or "").strip()
|
| 455 |
|
|
|
|
|
|
|
|
|
|
| 456 |
def answer_user_question(user_message: str) -> str:
|
| 457 |
if needs_order_id(user_message) and not extract_order_id(user_message):
|
| 458 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
|
|
|
| 459 |
sql = llm_to_sql(user_message)
|
| 460 |
df = run_sql_query(sql)
|
| 461 |
-
return sql_result_to_response(user_message, df)
|
| 462 |
|
|
|
|
|
|
|
|
|
|
| 463 |
def chatbot_response(user_message: str) -> str:
|
| 464 |
text = (user_message or "").strip()
|
| 465 |
t = text.lower().strip()
|
|
@@ -472,17 +592,33 @@ def chatbot_response(user_message: str) -> str:
|
|
| 472 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 473 |
|
| 474 |
if any(w in t for w in thanks_words):
|
| 475 |
-
return
|
|
|
|
|
|
|
|
|
|
| 476 |
|
| 477 |
if is_third_party_order_request(text):
|
| 478 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 479 |
|
| 480 |
if "hack" in t or "hacker" in t or "all orders" in t or "dump" in t or "database" in t:
|
| 481 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
|
| 483 |
if _is_escalation_intent(text):
|
| 484 |
senti = analyze_sentiment_and_escalation(text)
|
| 485 |
-
ticket_id = create_ticket(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 486 |
return (
|
| 487 |
f"{WELCOME_LINE}\n"
|
| 488 |
"π Iβm really sorry youβve had to follow up multiple times.\n\n"
|
|
@@ -491,6 +627,7 @@ def chatbot_response(user_message: str) -> str:
|
|
| 491 |
f"{ASK_ORDER_ID_LINE if not order_id else 'If you need anything else, Iβm here to help! π'}"
|
| 492 |
)
|
| 493 |
|
|
|
|
| 494 |
if _is_payment_refund_intent(text) and not order_id:
|
| 495 |
return (
|
| 496 |
"π Iβm sorry youβre facing an issue with the payment or refund. Iβll help you with this right away.\n\n"
|
|
@@ -499,6 +636,7 @@ def chatbot_response(user_message: str) -> str:
|
|
| 499 |
"If the payment was received, the refund will be processed within 48 hours."
|
| 500 |
)
|
| 501 |
|
|
|
|
| 502 |
if _is_cancel_intent(text) and not order_id:
|
| 503 |
return (
|
| 504 |
"π Iβm sorry to hear youβd like to cancel your order β I completely understand and Iβm here to help. "
|
|
@@ -514,10 +652,13 @@ def chatbot_response(user_message: str) -> str:
|
|
| 514 |
# ================== SESSION STATE ==================
|
| 515 |
if "welcome_shown" not in st.session_state:
|
| 516 |
st.session_state["welcome_shown"] = False
|
|
|
|
| 517 |
if "messages" not in st.session_state:
|
| 518 |
-
st.session_state["messages"] = []
|
|
|
|
| 519 |
if "pending_message" not in st.session_state:
|
| 520 |
st.session_state["pending_message"] = None
|
|
|
|
| 521 |
if "pending_quick_exact" not in st.session_state:
|
| 522 |
st.session_state["pending_quick_exact"] = None
|
| 523 |
|
|
@@ -525,7 +666,7 @@ if "pending_quick_exact" not in st.session_state:
|
|
| 525 |
st.markdown(
|
| 526 |
"""
|
| 527 |
<div class="foodhub-card">
|
| 528 |
-
<div style="display:flex; align-items:flex-start; gap:
|
| 529 |
<div style="font-size:28px; line-height:1;">π</div>
|
| 530 |
<div style="flex:1;">
|
| 531 |
<div class="foodhub-header-title">FoodHub AI Support</div>
|
|
@@ -548,79 +689,58 @@ if not st.session_state["welcome_shown"]:
|
|
| 548 |
st.caption("Because great food deserves great service π")
|
| 549 |
st.session_state["welcome_shown"] = True
|
| 550 |
|
| 551 |
-
# ================== QUICK BUTTONS (3 x 4
|
| 552 |
st.markdown('<div class="quick-actions-title">Quick help</div>', unsafe_allow_html=True)
|
| 553 |
|
| 554 |
-
|
| 555 |
-
if
|
| 556 |
st.session_state["pending_message"] = "Where is my order?"
|
| 557 |
-
if
|
| 558 |
st.session_state["pending_message"] = "I have an issue with my payment / refund."
|
| 559 |
-
if
|
| 560 |
st.session_state["pending_message"] = "I want to cancel my order."
|
| 561 |
-
if
|
| 562 |
st.session_state["pending_message"] = "Show me the details for my order."
|
| 563 |
|
| 564 |
-
|
| 565 |
-
if
|
| 566 |
st.session_state["pending_quick_exact"] = "Order never arrived."
|
| 567 |
-
if
|
| 568 |
st.session_state["pending_quick_exact"] = "Wrong or missing items."
|
| 569 |
-
if
|
| 570 |
st.session_state["pending_quick_exact"] = "Food damage or quality issue."
|
| 571 |
-
if
|
| 572 |
st.session_state["pending_quick_exact"] = "Delivery or food safety issue."
|
| 573 |
|
| 574 |
-
|
| 575 |
-
if
|
| 576 |
st.session_state["pending_quick_exact"] = "My card was charged more than once."
|
| 577 |
-
if
|
| 578 |
st.session_state["pending_quick_exact"] = "I want an update on my refund."
|
| 579 |
-
if
|
| 580 |
-
st.session_state["pending_message"] = "I have followed multiple times about my order."
|
| 581 |
-
if r3[3].button("β Other", use_container_width=True):
|
| 582 |
st.session_state["pending_quick_exact"] = "Another order issue."
|
|
|
|
|
|
|
| 583 |
|
| 584 |
st.markdown("---")
|
| 585 |
|
| 586 |
-
# ================== CHAT HISTORY (
|
| 587 |
-
def
|
| 588 |
-
parts = []
|
| 589 |
for m in st.session_state["messages"]:
|
| 590 |
if m["role"] == "user":
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
)
|
| 595 |
else:
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
)
|
| 600 |
-
|
| 601 |
-
# auto-scroll to bottom (best-effort)
|
| 602 |
-
parts.append(
|
| 603 |
-
"""
|
| 604 |
-
<script>
|
| 605 |
-
const shells = window.parent.document.querySelectorAll('.chat-window');
|
| 606 |
-
if (shells && shells.length) {
|
| 607 |
-
const w = shells[shells.length - 1];
|
| 608 |
-
w.scrollTop = w.scrollHeight;
|
| 609 |
-
}
|
| 610 |
-
</script>
|
| 611 |
-
"""
|
| 612 |
-
)
|
| 613 |
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
</div>
|
| 620 |
-
</div>
|
| 621 |
-
""",
|
| 622 |
-
unsafe_allow_html=True,
|
| 623 |
-
)
|
| 624 |
|
| 625 |
# ================== SEND LOGIC ==================
|
| 626 |
def append_user_and_bot(user_text: str, bot_text: str):
|
|
@@ -636,7 +756,7 @@ def send_message(user_text: str):
|
|
| 636 |
reply = chatbot_response(user_text)
|
| 637 |
st.session_state["messages"].append({"role": "assistant", "content": reply})
|
| 638 |
|
| 639 |
-
#
|
| 640 |
if st.session_state.get("pending_quick_exact"):
|
| 641 |
q = st.session_state["pending_quick_exact"]
|
| 642 |
st.session_state["pending_quick_exact"] = None
|
|
@@ -644,16 +764,14 @@ if st.session_state.get("pending_quick_exact"):
|
|
| 644 |
append_user_and_bot(q, exact)
|
| 645 |
st.rerun()
|
| 646 |
|
|
|
|
| 647 |
if st.session_state.get("pending_message"):
|
| 648 |
pm = st.session_state["pending_message"]
|
| 649 |
st.session_state["pending_message"] = None
|
| 650 |
send_message(pm)
|
| 651 |
st.rerun()
|
| 652 |
|
| 653 |
-
#
|
| 654 |
-
render_chat_history_inside_scroll()
|
| 655 |
-
|
| 656 |
-
# ================== INPUT (FIXED BELOW CHAT) ==================
|
| 657 |
with st.form("chat_form", clear_on_submit=True):
|
| 658 |
query = st.text_input(
|
| 659 |
"π¬ Type your question here:",
|
|
|
|
| 18 |
st.markdown(
|
| 19 |
"""
|
| 20 |
<style>
|
|
|
|
| 21 |
.main { max-width: 980px; margin: 0 auto; }
|
| 22 |
|
|
|
|
| 23 |
.foodhub-card {
|
| 24 |
background: linear-gradient(135deg, #fff3e0, #ffe0cc);
|
| 25 |
+
padding: 12px 16px;
|
| 26 |
border-radius: 16px;
|
| 27 |
border: 1px solid #f5c28c;
|
| 28 |
margin-bottom: 10px;
|
| 29 |
}
|
| 30 |
.foodhub-header-title {
|
| 31 |
+
margin: 0 0 2px 0;
|
| 32 |
color: #e65c2b;
|
| 33 |
+
font-size: 22px;
|
| 34 |
font-weight: 800;
|
| 35 |
line-height: 1.1;
|
| 36 |
}
|
| 37 |
+
.foodhub-header-subtitle {
|
| 38 |
+
margin: 0;
|
| 39 |
+
color: #444;
|
| 40 |
+
font-size: 13px;
|
| 41 |
+
line-height: 1.25;
|
| 42 |
+
}
|
| 43 |
.foodhub-assistant-pill {
|
| 44 |
display: inline-flex; align-items: center; gap: 6px;
|
| 45 |
background: #ffffff; border-radius: 999px;
|
| 46 |
+
padding: 4px 8px;
|
| 47 |
+
font-size: 11px; color: #555;
|
| 48 |
border: 1px solid #ffd3a3; margin-top: 6px;
|
| 49 |
}
|
| 50 |
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2ecc71; }
|
| 51 |
|
| 52 |
.quick-actions-title {
|
| 53 |
+
font-size: 13px;
|
| 54 |
font-weight: 700;
|
| 55 |
color: #555;
|
| 56 |
margin-top: 6px;
|
| 57 |
margin-bottom: 6px;
|
| 58 |
}
|
| 59 |
|
| 60 |
+
/* Make Streamlit buttons smaller */
|
| 61 |
div.stButton > button {
|
| 62 |
+
padding: 0.45rem 0.6rem !important;
|
| 63 |
+
font-size: 0.90rem !important;
|
|
|
|
| 64 |
border-radius: 12px !important;
|
| 65 |
+
height: 44px !important;
|
| 66 |
white-space: nowrap !important;
|
| 67 |
}
|
| 68 |
|
| 69 |
+
/* Scrollable chat container */
|
| 70 |
+
.chat-scroll{
|
| 71 |
+
height: 280px; /* adjust if you want more/less */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
overflow-y: auto;
|
| 73 |
+
border: 1px solid #e6e6e6;
|
| 74 |
+
border-radius: 16px;
|
| 75 |
+
padding: 10px 12px;
|
| 76 |
+
background: #fff;
|
| 77 |
+
margin-top: 6px;
|
| 78 |
+
margin-bottom: 10px;
|
| 79 |
}
|
| 80 |
|
|
|
|
| 81 |
.bot-bubble {
|
| 82 |
+
background: #eef7f2;
|
| 83 |
+
border-radius: 12px;
|
| 84 |
+
padding: 10px 12px;
|
| 85 |
+
font-size: 14px;
|
| 86 |
+
margin-top: 8px;
|
| 87 |
+
line-height: 1.35;
|
| 88 |
}
|
| 89 |
.user-bubble {
|
| 90 |
+
background: #ffffff;
|
| 91 |
+
border-radius: 12px;
|
| 92 |
+
padding: 10px 12px;
|
| 93 |
+
font-size: 14px;
|
| 94 |
+
margin-top: 8px;
|
| 95 |
+
line-height: 1.35;
|
| 96 |
+
border: 1px solid #f0f0f0;
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
.user-label, .bot-label {
|
| 100 |
+
font-size: 12px;
|
| 101 |
+
color: #777;
|
| 102 |
+
margin-top: 10px;
|
| 103 |
+
margin-bottom: 2px;
|
| 104 |
}
|
|
|
|
| 105 |
|
| 106 |
code {
|
| 107 |
background: rgba(0,0,0,0.06);
|
|
|
|
| 109 |
border-radius: 8px;
|
| 110 |
font-size: 13px;
|
| 111 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 112 |
</style>
|
| 113 |
""",
|
| 114 |
unsafe_allow_html=True,
|
|
|
|
| 122 |
ORDER_ID_REGEX = r"\b[oO]\d{3,}\b"
|
| 123 |
|
| 124 |
def format_for_bubble(text: str) -> str:
|
| 125 |
+
"""Safely render markdown-like **bold** and `code` inside HTML chat bubbles."""
|
| 126 |
t = html.escape(str(text or ""))
|
| 127 |
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t) # **bold**
|
| 128 |
t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t) # `code`
|
|
|
|
| 135 |
|
| 136 |
def needs_order_id(message: str) -> bool:
|
| 137 |
t = (message or "").lower().strip()
|
| 138 |
+
|
| 139 |
greetings = {"hi", "hello", "hey", "good morning", "good afternoon", "good evening"}
|
| 140 |
thanks = {"thanks", "thank you", "thx", "thankyou"}
|
| 141 |
if t in greetings or t in thanks:
|
| 142 |
return False
|
| 143 |
+
|
| 144 |
if extract_order_id(message):
|
| 145 |
return False
|
| 146 |
|
| 147 |
phrases = [
|
| 148 |
"where is my order", "track my order", "track order", "order status", "delivery status",
|
| 149 |
+
"status of my order", "when will my order arrive", "when will it arrive", "eta",
|
| 150 |
"order delayed", "delay in delivery", "order not delivered", "late delivery",
|
| 151 |
+
"order details", "show me the details", "details of my order", "details for my order",
|
| 152 |
+
"what is in my order", "what's in my order", "items in my order", "my order items",
|
| 153 |
+
"what did i order", "what have i ordered",
|
| 154 |
"cancel my order", "cancel order",
|
| 155 |
"refund", "payment issue", "payment problem", "payment failed", "charged", "transaction",
|
| 156 |
+
"money back", "return my money",
|
| 157 |
"modify my order", "change my order", "postpone my order", "deliver at different address",
|
| 158 |
+
"change address", "different address", "deliver to", "reschedule delivery",
|
| 159 |
"order never arrived", "wrong or missing items", "food damage", "quality issue",
|
| 160 |
"food safety", "delivery safety", "charged more than once",
|
| 161 |
"another order issue"
|
|
|
|
| 164 |
return True
|
| 165 |
|
| 166 |
if "order" in t and any(k in t for k in [
|
| 167 |
+
"status", "track", "details", "item", "cancel", "refund", "payment", "where", "when",
|
| 168 |
+
"modify", "change", "postpone", "address", "deliver", "reschedule",
|
| 169 |
"missing", "wrong", "quality", "damage", "safety", "arrived"
|
| 170 |
]):
|
| 171 |
return True
|
|
|
|
| 205 |
"multiple follow-ups",
|
| 206 |
"i have followed multiple times",
|
| 207 |
"i have followed up multiple times",
|
| 208 |
+
"i have followed multiple times about my order",
|
| 209 |
+
"i have followed up multiple times about my order",
|
| 210 |
"i want an update on my refund",
|
| 211 |
"refund update",
|
| 212 |
"update on my refund",
|
|
|
|
| 273 |
),
|
| 274 |
}
|
| 275 |
|
| 276 |
+
# ================== DB CONFIG ==================
|
| 277 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 278 |
CLEAN_DB_PATH = os.path.join(BASE_DIR, "orders_clean.db")
|
| 279 |
|
| 280 |
+
# Startup sanity check
|
| 281 |
try:
|
| 282 |
with sqlite3.connect(CLEAN_DB_PATH) as _conn:
|
| 283 |
_cur = _conn.cursor()
|
| 284 |
_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
| 285 |
_tables = [r[0] for r in _cur.fetchall()]
|
| 286 |
if "orders_clean" not in _tables:
|
| 287 |
+
st.error(
|
| 288 |
+
f"Connected to {CLEAN_DB_PATH!r}, but table 'orders_clean' was not found.\n\n"
|
| 289 |
+
"Ensure your notebook saved df_eng like:\n"
|
| 290 |
+
"df_eng.to_sql('orders_clean', conn, if_exists='replace', index=False)\n\n"
|
| 291 |
+
"and that orders_clean.db is present next to app.py."
|
| 292 |
+
)
|
| 293 |
st.stop()
|
| 294 |
except Exception as e:
|
| 295 |
st.error(f"Could not open database at {CLEAN_DB_PATH!r}: {e}")
|
|
|
|
| 326 |
- is_canceled (INTEGER 0/1)
|
| 327 |
"""
|
| 328 |
|
| 329 |
+
# =====================================================================
|
| 330 |
+
# SQL SAFETY FIREWALL
|
| 331 |
+
# =====================================================================
|
| 332 |
def is_safe_sql(sql: str) -> bool:
|
| 333 |
if not isinstance(sql, str):
|
| 334 |
return False
|
| 335 |
s = sql.lower().strip()
|
| 336 |
+
|
| 337 |
if not s.startswith("select"):
|
| 338 |
return False
|
| 339 |
+
|
| 340 |
if re.search(r";\s*\S", s):
|
| 341 |
return False
|
| 342 |
+
|
| 343 |
forbidden = ["drop", "delete", "update", "insert", "alter", "truncate", "create"]
|
| 344 |
if any(k in s for k in forbidden):
|
| 345 |
return False
|
| 346 |
+
|
| 347 |
if any(tok in s for tok in ["--", "/*", "*/"]):
|
| 348 |
return False
|
| 349 |
+
|
| 350 |
return True
|
| 351 |
|
| 352 |
+
# =====================================================================
|
| 353 |
+
# run_sql_query()
|
| 354 |
+
# =====================================================================
|
| 355 |
def run_sql_query(sql: str) -> pd.DataFrame:
|
| 356 |
if not isinstance(sql, str):
|
| 357 |
return pd.DataFrame([{"message": "π« Invalid SQL type (expected string)."}])
|
| 358 |
sql = sql.strip()
|
| 359 |
+
|
| 360 |
if not is_safe_sql(sql):
|
| 361 |
return pd.DataFrame([{"message": "π« Blocked unsafe or unsupported SQL."}])
|
| 362 |
+
|
| 363 |
try:
|
| 364 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 365 |
df = pd.read_sql_query(sql, conn)
|
| 366 |
+
|
| 367 |
for col in ["order_time", "preparing_eta", "prepared_time", "delivery_eta", "delivery_time"]:
|
| 368 |
if col in df.columns:
|
| 369 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 370 |
+
|
| 371 |
return df
|
| 372 |
except Exception as e:
|
| 373 |
return pd.DataFrame([{"message": f"β οΈ SQL execution error: {str(e)}"}])
|
| 374 |
|
| 375 |
+
# =====================================================================
|
| 376 |
+
# llm_to_sql()
|
| 377 |
+
# =====================================================================
|
| 378 |
def llm_to_sql(user_message: str) -> str:
|
| 379 |
msg = user_message or ""
|
| 380 |
text = msg.lower().strip()
|
|
|
|
| 389 |
|
| 390 |
system_prompt = f"""
|
| 391 |
You are an expert SQLite assistant for a food delivery company.
|
| 392 |
+
|
| 393 |
You ONLY generate valid SQLite SELECT queries using this schema:
|
| 394 |
+
|
| 395 |
{ORDERS_SCHEMA}
|
| 396 |
|
| 397 |
RULES:
|
|
|
|
| 404 |
- If unsure:
|
| 405 |
SELECT 'Unable to answer with available data.' AS message;
|
| 406 |
"""
|
| 407 |
+
|
| 408 |
response = client.chat.completions.create(
|
| 409 |
model="llama-3.3-70b-versatile",
|
| 410 |
temperature=0.1,
|
| 411 |
+
messages=[
|
| 412 |
+
{"role": "system", "content": system_prompt},
|
| 413 |
+
{"role": "user", "content": msg},
|
| 414 |
+
],
|
| 415 |
)
|
| 416 |
+
|
| 417 |
sql = (response.choices[0].message.content or "").strip()
|
| 418 |
+
|
| 419 |
if sql.startswith("```"):
|
| 420 |
sql = re.sub(r"^```sql", "", sql, flags=re.IGNORECASE).strip()
|
| 421 |
sql = re.sub(r"^```", "", sql).strip()
|
| 422 |
sql = sql.replace("```", "").strip()
|
| 423 |
+
|
| 424 |
sql = re.sub(r"\bfrom\s+\w+\b", "FROM orders_clean", sql, flags=re.IGNORECASE)
|
| 425 |
+
|
| 426 |
sql = re.sub(
|
| 427 |
r"where\s+order_id\s*=\s*'([^']+)'",
|
| 428 |
r"WHERE LOWER(order_id) = LOWER('\1')",
|
| 429 |
sql,
|
| 430 |
flags=re.IGNORECASE,
|
| 431 |
)
|
| 432 |
+
|
| 433 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 434 |
|
| 435 |
+
# =====================================================================
|
| 436 |
+
# analyze_sentiment_and_escalation()
|
| 437 |
+
# =====================================================================
|
| 438 |
def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
| 439 |
user_message = "" if user_message is None else str(user_message).strip()
|
| 440 |
+
|
| 441 |
system_prompt = """
|
| 442 |
+
You are a classifier for a food delivery chatbot.
|
| 443 |
+
|
| 444 |
+
Return ONLY JSON (no extra text):
|
| 445 |
+
{
|
| 446 |
+
"sentiment": "calm" | "neutral" | "frustrated" | "angry",
|
| 447 |
+
"escalate": true or false
|
| 448 |
+
}
|
| 449 |
+
|
| 450 |
+
Escalate=true when user is frustrated/angry, mentions repeated attempts, or demands urgent help.
|
| 451 |
+
If uncertain: {"sentiment":"neutral","escalate":false}
|
| 452 |
"""
|
| 453 |
+
|
| 454 |
response = client.chat.completions.create(
|
| 455 |
model="llama-3.3-70b-versatile",
|
| 456 |
temperature=0.0,
|
| 457 |
+
messages=[
|
| 458 |
+
{"role": "system", "content": system_prompt},
|
| 459 |
+
{"role": "user", "content": user_message},
|
| 460 |
+
],
|
| 461 |
)
|
| 462 |
+
|
| 463 |
raw = (response.choices[0].message.content or "").strip()
|
| 464 |
try:
|
| 465 |
info = json.loads(raw)
|
| 466 |
if not isinstance(info, dict):
|
| 467 |
+
raise ValueError("Not dict")
|
| 468 |
+
if "sentiment" not in info or "escalate" not in info:
|
| 469 |
+
raise ValueError("Missing keys")
|
| 470 |
+
info["sentiment"] = str(info["sentiment"]).strip().lower()
|
| 471 |
+
info["escalate"] = bool(info["escalate"])
|
| 472 |
+
if info["sentiment"] not in {"calm", "neutral", "frustrated", "angry"}:
|
| 473 |
+
raise ValueError("Bad label")
|
| 474 |
+
return info
|
| 475 |
except Exception:
|
| 476 |
return {"sentiment": "neutral", "escalate": False}
|
| 477 |
|
| 478 |
+
# =====================================================================
|
| 479 |
+
# create_ticket()
|
| 480 |
+
# =====================================================================
|
| 481 |
def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general", order_id: Optional[str] = None) -> str:
|
| 482 |
ticket_id = "TKT-" + uuid.uuid4().hex[:8].upper()
|
| 483 |
created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 484 |
+
|
| 485 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 486 |
cur = conn.cursor()
|
| 487 |
cur.execute("""
|
|
|
|
| 500 |
VALUES (?, ?, ?, ?, ?, 'Open', ?);
|
| 501 |
""", (ticket_id, ticket_type, sentiment, user_message, order_id, created_at))
|
| 502 |
conn.commit()
|
| 503 |
+
|
| 504 |
return ticket_id
|
| 505 |
|
| 506 |
+
# =====================================================================
|
| 507 |
+
# sql_result_to_response()
|
| 508 |
+
# =====================================================================
|
| 509 |
def sql_result_to_response(user_message: str, df: pd.DataFrame, max_rows_to_list: int = 5) -> str:
|
| 510 |
if isinstance(df, pd.DataFrame) and "message" in df.columns and len(df) == 1:
|
| 511 |
msg = str(df.iloc[0]["message"])
|
|
|
|
| 514 |
return msg
|
| 515 |
|
| 516 |
if df.empty:
|
| 517 |
+
return (
|
| 518 |
+
f"{WELCOME_LINE}\n"
|
| 519 |
+
"I couldnβt find a matching order for the details provided.\n"
|
| 520 |
+
f"{ASK_ORDER_ID_LINE}"
|
| 521 |
+
)
|
| 522 |
|
| 523 |
df_for_llm = df.head(max_rows_to_list).copy()
|
| 524 |
result_data = df_for_llm.to_dict(orient="records")
|
| 525 |
|
| 526 |
system_prompt = f"""
|
| 527 |
You are a polite and professional customer support chatbot for FoodHub.
|
| 528 |
+
|
| 529 |
+
Always start with EXACTLY:
|
| 530 |
+
"{WELCOME_LINE}"
|
| 531 |
+
|
| 532 |
+
VERY IMPORTANT POLICY:
|
| 533 |
+
- If ANY matching record exists in the data I give you β The order is CONFIRMED.
|
| 534 |
+
- NEVER tell the user to re-check their order ID if matching rows exist.
|
| 535 |
+
- NEVER say "order not found", "unable to locate order", or similar messages
|
| 536 |
+
when there is at least one matching record.
|
| 537 |
+
- Instead, confidently acknowledge the order and use available data.
|
| 538 |
+
|
| 539 |
+
CRITICAL RULES:
|
| 540 |
+
- NEVER invent or guess items, ETA, status, payment, or any order details.
|
| 541 |
+
- Use ONLY the data provided in "Matching order data".
|
| 542 |
+
- If information is missing, say it's unavailable in tracking.
|
| 543 |
+
|
| 544 |
+
DELIVERED TEMPLATE (use when order_status_std indicates delivered OR is_delivered == 1):
|
| 545 |
+
- Use this meaning and style:
|
| 546 |
+
"I've checked on your order, and I'm happy to inform you that it has been delivered β
. You should have received your order.
|
| 547 |
+
If you have any further concerns or issues, please let me know. If you need anything else, Iβm here to help! π"
|
| 548 |
+
|
| 549 |
+
If NOT delivered:
|
| 550 |
+
- Provide the latest known status.
|
| 551 |
+
- If delivery_time is missing but delivery_eta exists: share ETA.
|
| 552 |
+
- If both delivery_time and delivery_eta missing: apologize for limited tracking.
|
| 553 |
+
|
| 554 |
+
Keep it concise and helpful.
|
| 555 |
"""
|
| 556 |
+
|
| 557 |
+
content = f"User question: {user_message}\n\nMatching order data (up to {max_rows_to_list} rows):\n{result_data}"
|
| 558 |
+
|
| 559 |
resp = client.chat.completions.create(
|
| 560 |
model="llama-3.3-70b-versatile",
|
| 561 |
temperature=0.2,
|
| 562 |
+
messages=[
|
| 563 |
+
{"role": "system", "content": system_prompt},
|
| 564 |
+
{"role": "user", "content": content},
|
| 565 |
+
],
|
| 566 |
)
|
| 567 |
return (resp.choices[0].message.content or "").strip()
|
| 568 |
|
| 569 |
+
# =====================================================================
|
| 570 |
+
# answer_user_question()
|
| 571 |
+
# =====================================================================
|
| 572 |
def answer_user_question(user_message: str) -> str:
|
| 573 |
if needs_order_id(user_message) and not extract_order_id(user_message):
|
| 574 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 575 |
+
|
| 576 |
sql = llm_to_sql(user_message)
|
| 577 |
df = run_sql_query(sql)
|
| 578 |
+
return sql_result_to_response(user_message, df, max_rows_to_list=5)
|
| 579 |
|
| 580 |
+
# =====================================================================
|
| 581 |
+
# chatbot_response() β main orchestration
|
| 582 |
+
# =====================================================================
|
| 583 |
def chatbot_response(user_message: str) -> str:
|
| 584 |
text = (user_message or "").strip()
|
| 585 |
t = text.lower().strip()
|
|
|
|
| 592 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 593 |
|
| 594 |
if any(w in t for w in thanks_words):
|
| 595 |
+
return (
|
| 596 |
+
"Youβre most welcome! π\n"
|
| 597 |
+
"If you need help with an order, please share your Order ID (like `O12488`)."
|
| 598 |
+
)
|
| 599 |
|
| 600 |
if is_third_party_order_request(text):
|
| 601 |
+
return (
|
| 602 |
+
f"{WELCOME_LINE}\n"
|
| 603 |
+
"π I canβt help with someone elseβs order details for privacy reasons.\n\n"
|
| 604 |
+
"Please ask them to contact FoodHub Support directly, or have them share their **Order ID** themselves. π"
|
| 605 |
+
)
|
| 606 |
|
| 607 |
if "hack" in t or "hacker" in t or "all orders" in t or "dump" in t or "database" in t:
|
| 608 |
+
return (
|
| 609 |
+
f"{WELCOME_LINE}\n"
|
| 610 |
+
"π Sorry, I canβt assist with that.\n"
|
| 611 |
+
"For privacy and security reasons, I can only help with your own order using a valid Order ID. π"
|
| 612 |
+
)
|
| 613 |
|
| 614 |
if _is_escalation_intent(text):
|
| 615 |
senti = analyze_sentiment_and_escalation(text)
|
| 616 |
+
ticket_id = create_ticket(
|
| 617 |
+
user_message=text,
|
| 618 |
+
sentiment=senti.get("sentiment", "frustrated"),
|
| 619 |
+
ticket_type="escalation",
|
| 620 |
+
order_id=order_id
|
| 621 |
+
)
|
| 622 |
return (
|
| 623 |
f"{WELCOME_LINE}\n"
|
| 624 |
"π Iβm really sorry youβve had to follow up multiple times.\n\n"
|
|
|
|
| 627 |
f"{ASK_ORDER_ID_LINE if not order_id else 'If you need anything else, Iβm here to help! π'}"
|
| 628 |
)
|
| 629 |
|
| 630 |
+
# Payment / Refund intent (your desired response 2)
|
| 631 |
if _is_payment_refund_intent(text) and not order_id:
|
| 632 |
return (
|
| 633 |
"π Iβm sorry youβre facing an issue with the payment or refund. Iβll help you with this right away.\n\n"
|
|
|
|
| 636 |
"If the payment was received, the refund will be processed within 48 hours."
|
| 637 |
)
|
| 638 |
|
| 639 |
+
# Cancel intent (your desired wording)
|
| 640 |
if _is_cancel_intent(text) and not order_id:
|
| 641 |
return (
|
| 642 |
"π Iβm sorry to hear youβd like to cancel your order β I completely understand and Iβm here to help. "
|
|
|
|
| 652 |
# ================== SESSION STATE ==================
|
| 653 |
if "welcome_shown" not in st.session_state:
|
| 654 |
st.session_state["welcome_shown"] = False
|
| 655 |
+
|
| 656 |
if "messages" not in st.session_state:
|
| 657 |
+
st.session_state["messages"] = [] # [{"role":"user"/"assistant","content":"..."}]
|
| 658 |
+
|
| 659 |
if "pending_message" not in st.session_state:
|
| 660 |
st.session_state["pending_message"] = None
|
| 661 |
+
|
| 662 |
if "pending_quick_exact" not in st.session_state:
|
| 663 |
st.session_state["pending_quick_exact"] = None
|
| 664 |
|
|
|
|
| 666 |
st.markdown(
|
| 667 |
"""
|
| 668 |
<div class="foodhub-card">
|
| 669 |
+
<div style="display:flex; align-items:flex-start; gap:12px;">
|
| 670 |
<div style="font-size:28px; line-height:1;">π</div>
|
| 671 |
<div style="flex:1;">
|
| 672 |
<div class="foodhub-header-title">FoodHub AI Support</div>
|
|
|
|
| 689 |
st.caption("Because great food deserves great service π")
|
| 690 |
st.session_state["welcome_shown"] = True
|
| 691 |
|
| 692 |
+
# ================== QUICK BUTTONS (3 x 4 with FULL TEXT) ==================
|
| 693 |
st.markdown('<div class="quick-actions-title">Quick help</div>', unsafe_allow_html=True)
|
| 694 |
|
| 695 |
+
row1 = st.columns(4)
|
| 696 |
+
if row1[0].button("π Track order", use_container_width=True):
|
| 697 |
st.session_state["pending_message"] = "Where is my order?"
|
| 698 |
+
if row1[1].button("π³ Payment status", use_container_width=True):
|
| 699 |
st.session_state["pending_message"] = "I have an issue with my payment / refund."
|
| 700 |
+
if row1[2].button("β Cancel order", use_container_width=True):
|
| 701 |
st.session_state["pending_message"] = "I want to cancel my order."
|
| 702 |
+
if row1[3].button("π§Ύ Order details", use_container_width=True):
|
| 703 |
st.session_state["pending_message"] = "Show me the details for my order."
|
| 704 |
|
| 705 |
+
row2 = st.columns(4)
|
| 706 |
+
if row2[0].button("π¦ Order not arrived", use_container_width=True):
|
| 707 |
st.session_state["pending_quick_exact"] = "Order never arrived."
|
| 708 |
+
if row2[1].button("π§Ύ Missing items", use_container_width=True):
|
| 709 |
st.session_state["pending_quick_exact"] = "Wrong or missing items."
|
| 710 |
+
if row2[2].button("π₯‘ Damaged order", use_container_width=True):
|
| 711 |
st.session_state["pending_quick_exact"] = "Food damage or quality issue."
|
| 712 |
+
if row2[3].button("π‘οΈ Food safety", use_container_width=True):
|
| 713 |
st.session_state["pending_quick_exact"] = "Delivery or food safety issue."
|
| 714 |
|
| 715 |
+
row3 = st.columns(4)
|
| 716 |
+
if row3[0].button("π³ Duplicate payment", use_container_width=True):
|
| 717 |
st.session_state["pending_quick_exact"] = "My card was charged more than once."
|
| 718 |
+
if row3[1].button("π Refund status", use_container_width=True):
|
| 719 |
st.session_state["pending_quick_exact"] = "I want an update on my refund."
|
| 720 |
+
if row3[2].button("β Another order issue", use_container_width=True):
|
|
|
|
|
|
|
| 721 |
st.session_state["pending_quick_exact"] = "Another order issue."
|
| 722 |
+
if row3[3].button("π§βπ» Followed up many times", use_container_width=True):
|
| 723 |
+
st.session_state["pending_message"] = "I have followed multiple times about my order."
|
| 724 |
|
| 725 |
st.markdown("---")
|
| 726 |
|
| 727 |
+
# ================== CHAT HISTORY (scrollable container) ==================
|
| 728 |
+
def render_chat_history():
|
|
|
|
| 729 |
for m in st.session_state["messages"]:
|
| 730 |
if m["role"] == "user":
|
| 731 |
+
user_html = format_for_bubble(f"π¬ {m['content']}")
|
| 732 |
+
st.markdown('<div class="user-label">You</div>', unsafe_allow_html=True)
|
| 733 |
+
st.markdown(f"<div class='user-bubble'>{user_html}</div>", unsafe_allow_html=True)
|
|
|
|
| 734 |
else:
|
| 735 |
+
bot_html = format_for_bubble(f"π€ {m['content']}")
|
| 736 |
+
st.markdown('<div class="bot-label">FoodHub Assistant</div>', unsafe_allow_html=True)
|
| 737 |
+
st.markdown(f"<div class='bot-bubble'>{bot_html}</div>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 738 |
|
| 739 |
+
# OPEN container (unsafe)
|
| 740 |
+
st.markdown('<div class="chat-scroll">', unsafe_allow_html=True)
|
| 741 |
+
render_chat_history()
|
| 742 |
+
# CLOSE container (unsafe) β fixes </div> showing up
|
| 743 |
+
st.markdown("</div>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 744 |
|
| 745 |
# ================== SEND LOGIC ==================
|
| 746 |
def append_user_and_bot(user_text: str, bot_text: str):
|
|
|
|
| 756 |
reply = chatbot_response(user_text)
|
| 757 |
st.session_state["messages"].append({"role": "assistant", "content": reply})
|
| 758 |
|
| 759 |
+
# 1) Quick buttons with EXACT replies
|
| 760 |
if st.session_state.get("pending_quick_exact"):
|
| 761 |
q = st.session_state["pending_quick_exact"]
|
| 762 |
st.session_state["pending_quick_exact"] = None
|
|
|
|
| 764 |
append_user_and_bot(q, exact)
|
| 765 |
st.rerun()
|
| 766 |
|
| 767 |
+
# 2) Normal quick buttons
|
| 768 |
if st.session_state.get("pending_message"):
|
| 769 |
pm = st.session_state["pending_message"]
|
| 770 |
st.session_state["pending_message"] = None
|
| 771 |
send_message(pm)
|
| 772 |
st.rerun()
|
| 773 |
|
| 774 |
+
# ================== INPUT (FORM) ==================
|
|
|
|
|
|
|
|
|
|
| 775 |
with st.form("chat_form", clear_on_submit=True):
|
| 776 |
query = st.text_input(
|
| 777 |
"π¬ Type your question here:",
|