Suunil-Dabral commited on
Commit
b853b20
Β·
verified Β·
1 Parent(s): 2038939

Update src/streamlit_app.py

Browse files
Files changed (1) hide show
  1. 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 14px;
28
  border-radius: 16px;
29
  border: 1px solid #f5c28c;
30
  margin-bottom: 10px;
31
  }
32
  .foodhub-header-title {
33
- margin-bottom: 2px;
34
  color: #e65c2b;
35
- font-size: 20px;
36
  font-weight: 800;
37
  line-height: 1.1;
38
  }
39
- .foodhub-header-subtitle { margin: 0; color: #444; font-size: 12px; line-height: 1.25; }
 
 
 
 
 
40
  .foodhub-assistant-pill {
41
  display: inline-flex; align-items: center; gap: 6px;
42
  background: #ffffff; border-radius: 999px;
43
- padding: 4px 8px; font-size: 10.5px; color: #555;
 
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: 12px;
50
  font-weight: 700;
51
  color: #555;
52
  margin-top: 6px;
53
  margin-bottom: 6px;
54
  }
55
 
56
- /* Make Streamlit buttons compact so 4 columns fit nicely */
57
  div.stButton > button {
58
- padding: 0.25rem 0.35rem !important;
59
- font-size: 0.80rem !important;
60
- height: 34px !important;
61
  border-radius: 12px !important;
 
62
  white-space: nowrap !important;
63
  }
64
 
65
- /* Chat scroll container */
66
- .chat-shell {
67
- border: 1px solid rgba(0,0,0,0.10);
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
- padding: 2px 6px;
 
 
 
 
 
78
  }
79
 
80
- /* Bubbles */
81
  .bot-bubble {
82
- background: #eef7f2; border-radius: 12px;
83
- padding: 10px 12px; font-size: 14px;
84
- margin-top: 6px; line-height: 1.35;
 
 
 
85
  }
86
  .user-bubble {
87
- background: #ffffff; border-radius: 12px;
88
- padding: 10px 12px; font-size: 14px;
89
- margin-top: 6px; line-height: 1.35;
90
- border: 1px solid rgba(0,0,0,0.06);
 
 
 
 
 
 
 
 
 
 
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 (ALIGNED WITH YOUR NOTEBOOK) ==================
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("Table 'orders_clean' not found in orders_clean.db (must be next to app.py).")
 
 
 
 
 
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=[{"role": "system", "content": system_prompt}, {"role": "user", "content": msg}],
 
 
 
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
- Return ONLY JSON:
390
- {"sentiment":"calm"|"neutral"|"frustrated"|"angry","escalate":true|false}
 
 
 
 
 
 
 
 
391
  """
 
392
  response = client.chat.completions.create(
393
  model="llama-3.3-70b-versatile",
394
  temperature=0.0,
395
- messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": user_message}],
 
 
 
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
- return {"sentiment": str(info.get("sentiment", "neutral")).strip().lower(), "escalate": bool(info.get("escalate", False))}
 
 
 
 
 
 
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 f"{WELCOME_LINE}\nI couldn’t find a matching order.\n{ASK_ORDER_ID_LINE}"
 
 
 
 
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
- Always start with EXACTLY: "{WELCOME_LINE}"
445
- Use ONLY the data given.
446
- End warmly.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
447
  """
448
- content = f"User question: {user_message}\n\nMatching order data:\n{result_data}"
 
 
449
  resp = client.chat.completions.create(
450
  model="llama-3.3-70b-versatile",
451
  temperature=0.2,
452
- messages=[{"role": "system", "content": system_prompt}, {"role": "user", "content": content}],
 
 
 
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 "You’re most welcome! πŸ’›\nIf you need help with an order, please share your Order ID (like `O12488`)."
 
 
 
476
 
477
  if is_third_party_order_request(text):
478
- return f"{WELCOME_LINE}\nπŸ˜” I can’t help with someone else’s order details for privacy reasons.\n\nPlease ask them to contact FoodHub Support directly. πŸ’›"
 
 
 
 
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 f"{WELCOME_LINE}\nπŸ˜” Sorry, I can’t assist with that.\nFor privacy and security reasons, I can only help with your own order using a valid Order ID. πŸ’›"
 
 
 
 
482
 
483
  if _is_escalation_intent(text):
484
  senti = analyze_sentiment_and_escalation(text)
485
- ticket_id = create_ticket(text, senti.get("sentiment", "frustrated"), "escalation", order_id)
 
 
 
 
 
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:10px;">
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 GRID) ==================
552
  st.markdown('<div class="quick-actions-title">Quick help</div>', unsafe_allow_html=True)
553
 
554
- r1 = st.columns(4)
555
- if r1[0].button("πŸ“ Track", use_container_width=True):
556
  st.session_state["pending_message"] = "Where is my order?"
557
- if r1[1].button("πŸ’³ Payment", use_container_width=True):
558
  st.session_state["pending_message"] = "I have an issue with my payment / refund."
559
- if r1[2].button("❌ Cancel", use_container_width=True):
560
  st.session_state["pending_message"] = "I want to cancel my order."
561
- if r1[3].button("🧾 Details", use_container_width=True):
562
  st.session_state["pending_message"] = "Show me the details for my order."
563
 
564
- r2 = st.columns(4)
565
- if r2[0].button("πŸ“¦ Not arrived", use_container_width=True):
566
  st.session_state["pending_quick_exact"] = "Order never arrived."
567
- if r2[1].button("🧾 Missing", use_container_width=True):
568
  st.session_state["pending_quick_exact"] = "Wrong or missing items."
569
- if r2[2].button("πŸ₯‘ Damage", use_container_width=True):
570
  st.session_state["pending_quick_exact"] = "Food damage or quality issue."
571
- if r2[3].button("πŸ›‘οΈ Safety", use_container_width=True):
572
  st.session_state["pending_quick_exact"] = "Delivery or food safety issue."
573
 
574
- r3 = st.columns(4)
575
- if r3[0].button("πŸ’³ Charged 2x", use_container_width=True):
576
  st.session_state["pending_quick_exact"] = "My card was charged more than once."
577
- if r3[1].button("πŸ” Refund", use_container_width=True):
578
  st.session_state["pending_quick_exact"] = "I want an update on my refund."
579
- if r3[2].button("πŸ§‘β€πŸ’» Follow-ups", use_container_width=True):
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 (SCROLLABLE CONTAINER ABOVE INPUT) ==================
587
- def render_chat_history_inside_scroll():
588
- parts = []
589
  for m in st.session_state["messages"]:
590
  if m["role"] == "user":
591
- parts.append(
592
- f"<div class='user-label'>You</div>"
593
- f"<div class='user-bubble'>{format_for_bubble('πŸ’¬ ' + m['content'])}</div>"
594
- )
595
  else:
596
- parts.append(
597
- f"<div class='bot-label'>FoodHub Assistant</div>"
598
- f"<div class='bot-bubble'>{format_for_bubble('πŸ€– ' + m['content'])}</div>"
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
- st.markdown(
615
- f"""
616
- <div class="chat-shell">
617
- <div class="chat-window">
618
- {''.join(parts) if parts else "<div style='color:#777; font-size:12px;'>Start by typing a message or using the quick buttons above.</div>"}
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
- # Process quick clicks BEFORE rendering the chat (so user sees result instantly)
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
- # Render scrollable chat history
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:",