Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +274 -267
src/streamlit_app.py
CHANGED
|
@@ -18,42 +18,91 @@ st.set_page_config(page_title="FoodHub Support", page_icon="π", layout="cente
|
|
| 18 |
st.markdown(
|
| 19 |
"""
|
| 20 |
<style>
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
| 22 |
.foodhub-card {
|
| 23 |
background: linear-gradient(135deg, #fff3e0, #ffe0cc);
|
| 24 |
-
padding:
|
| 25 |
-
border-radius:
|
| 26 |
border: 1px solid #f5c28c;
|
| 27 |
-
margin-bottom:
|
| 28 |
}
|
| 29 |
.foodhub-header-title {
|
| 30 |
-
margin-bottom:
|
| 31 |
color: #e65c2b;
|
| 32 |
-
font-size:
|
| 33 |
font-weight: 800;
|
|
|
|
| 34 |
}
|
| 35 |
-
.foodhub-header-subtitle { margin: 0; color: #444; font-size:
|
| 36 |
.foodhub-assistant-pill {
|
| 37 |
display: inline-flex; align-items: center; gap: 6px;
|
| 38 |
background: #ffffff; border-radius: 999px;
|
| 39 |
-
padding:
|
| 40 |
-
border: 1px solid #ffd3a3; margin-top:
|
| 41 |
}
|
| 42 |
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2ecc71; }
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
.bot-bubble {
|
| 45 |
background: #eef7f2; border-radius: 12px;
|
| 46 |
padding: 10px 12px; font-size: 14px;
|
| 47 |
-
margin-top:
|
| 48 |
}
|
| 49 |
-
.user-
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
code {
|
| 52 |
background: rgba(0,0,0,0.06);
|
| 53 |
padding: 2px 6px;
|
| 54 |
border-radius: 8px;
|
| 55 |
font-size: 13px;
|
| 56 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
</style>
|
| 58 |
""",
|
| 59 |
unsafe_allow_html=True,
|
|
@@ -67,7 +116,7 @@ ASK_ORDER_ID_LINE = "Please share your **Order ID** (for example: `O12488`) so I
|
|
| 67 |
ORDER_ID_REGEX = r"\b[oO]\d{3,}\b"
|
| 68 |
|
| 69 |
def format_for_bubble(text: str) -> str:
|
| 70 |
-
"""Safely render markdown-like **bold** and `code` inside HTML
|
| 71 |
t = html.escape(str(text or ""))
|
| 72 |
t = re.sub(r"\*\*(.+?)\*\*", r"<b>\1</b>", t) # **bold**
|
| 73 |
t = re.sub(r"`([^`]+)`", r"<code>\1</code>", t) # `code`
|
|
@@ -79,48 +128,39 @@ def extract_order_id(text: str) -> Optional[str]:
|
|
| 79 |
return m.group(0).upper() if m else None
|
| 80 |
|
| 81 |
def needs_order_id(message: str) -> bool:
|
| 82 |
-
"""
|
| 83 |
-
True if message is asking anything order-specific that must NOT be answered without an Order ID.
|
| 84 |
-
"""
|
| 85 |
t = (message or "").lower().strip()
|
| 86 |
-
|
| 87 |
-
# exclude simple greetings / thanks only
|
| 88 |
greetings = {"hi", "hello", "hey", "good morning", "good afternoon", "good evening"}
|
| 89 |
thanks = {"thanks", "thank you", "thx", "thankyou"}
|
| 90 |
if t in greetings or t in thanks:
|
| 91 |
return False
|
| 92 |
-
|
| 93 |
-
# If they already provided Order ID, no need to ask.
|
| 94 |
if extract_order_id(message):
|
| 95 |
return False
|
| 96 |
|
| 97 |
-
# High-confidence order intents
|
| 98 |
phrases = [
|
| 99 |
"where is my order", "track my order", "track order", "order status", "delivery status",
|
| 100 |
-
"status of my order", "when will my order arrive", "
|
| 101 |
"order delayed", "delay in delivery", "order not delivered", "late delivery",
|
| 102 |
-
"order details", "show me the details",
|
| 103 |
-
"what is in my order", "what's in my order", "items in my order", "my order items",
|
| 104 |
-
"what did i order", "what have i ordered",
|
| 105 |
"cancel my order", "cancel order",
|
| 106 |
"refund", "payment issue", "payment problem", "payment failed", "charged", "transaction",
|
| 107 |
-
"money back", "return my money",
|
| 108 |
"modify my order", "change my order", "postpone my order", "deliver at different address",
|
| 109 |
-
"
|
|
|
|
|
|
|
| 110 |
]
|
| 111 |
if any(p in t for p in phrases):
|
| 112 |
return True
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
| 116 |
return True
|
| 117 |
|
| 118 |
return False
|
| 119 |
|
| 120 |
def is_third_party_order_request(message: str) -> bool:
|
| 121 |
-
"""
|
| 122 |
-
Detect requests for someone else's order. We refuse for privacy.
|
| 123 |
-
"""
|
| 124 |
t = (message or "").lower()
|
| 125 |
third_party = [
|
| 126 |
"my friend", "friend's", "friends'", "my brother", "my sister",
|
|
@@ -153,6 +193,10 @@ def _is_escalation_intent(message: str) -> bool:
|
|
| 153 |
"multiple follow-ups",
|
| 154 |
"i have followed multiple times",
|
| 155 |
"i have followed up multiple times",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
]
|
| 157 |
return any(k in t for k in escalation_keywords)
|
| 158 |
|
|
@@ -161,27 +205,71 @@ def _is_payment_refund_intent(message: str) -> bool:
|
|
| 161 |
keywords = [
|
| 162 |
"payment", "refund", "money back", "charged", "chargeback",
|
| 163 |
"transaction", "payment failed", "payment issue", "payment problem",
|
| 164 |
-
"upi", "card", "debit", "credit", "wallet"
|
|
|
|
| 165 |
]
|
| 166 |
return any(k in t for k in keywords)
|
| 167 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
# ================== DB CONFIG (ALIGNED WITH YOUR NOTEBOOK) ==================
|
| 169 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 170 |
-
CLEAN_DB_PATH = os.path.join(BASE_DIR, "orders_clean.db")
|
| 171 |
|
| 172 |
-
# Startup sanity check
|
| 173 |
try:
|
| 174 |
with sqlite3.connect(CLEAN_DB_PATH) as _conn:
|
| 175 |
_cur = _conn.cursor()
|
| 176 |
_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
| 177 |
_tables = [r[0] for r in _cur.fetchall()]
|
| 178 |
if "orders_clean" not in _tables:
|
| 179 |
-
st.error(
|
| 180 |
-
f"Connected to {CLEAN_DB_PATH!r}, but table 'orders_clean' was not found.\n\n"
|
| 181 |
-
"Ensure your notebook saved df_eng like:\n"
|
| 182 |
-
"df_eng.to_sql('orders_clean', conn, if_exists='replace', index=False)\n\n"
|
| 183 |
-
"and that orders_clean.db is present next to app.py."
|
| 184 |
-
)
|
| 185 |
st.stop()
|
| 186 |
except Exception as e:
|
| 187 |
st.error(f"Could not open database at {CLEAN_DB_PATH!r}: {e}")
|
|
@@ -198,7 +286,6 @@ client = Groq(api_key=groq_api_key)
|
|
| 198 |
# ================== SCHEMA (for LLM prompt only) ==================
|
| 199 |
ORDERS_SCHEMA = """
|
| 200 |
Table: orders_clean
|
| 201 |
-
|
| 202 |
Columns:
|
| 203 |
- order_id (TEXT)
|
| 204 |
- cust_id (TEXT)
|
|
@@ -219,63 +306,38 @@ Columns:
|
|
| 219 |
- is_canceled (INTEGER 0/1)
|
| 220 |
"""
|
| 221 |
|
| 222 |
-
# =====================================================================
|
| 223 |
-
# SQL SAFETY FIREWALL
|
| 224 |
-
# =====================================================================
|
| 225 |
def is_safe_sql(sql: str) -> bool:
|
| 226 |
if not isinstance(sql, str):
|
| 227 |
return False
|
| 228 |
s = sql.lower().strip()
|
| 229 |
-
|
| 230 |
if not s.startswith("select"):
|
| 231 |
return False
|
| 232 |
-
|
| 233 |
-
# block stacked queries like: SELECT ...; DROP ...
|
| 234 |
if re.search(r";\s*\S", s):
|
| 235 |
return False
|
| 236 |
-
|
| 237 |
forbidden = ["drop", "delete", "update", "insert", "alter", "truncate", "create"]
|
| 238 |
if any(k in s for k in forbidden):
|
| 239 |
return False
|
| 240 |
-
|
| 241 |
if any(tok in s for tok in ["--", "/*", "*/"]):
|
| 242 |
return False
|
| 243 |
-
|
| 244 |
return True
|
| 245 |
|
| 246 |
-
# =====================================================================
|
| 247 |
-
# run_sql_query()
|
| 248 |
-
# =====================================================================
|
| 249 |
def run_sql_query(sql: str) -> pd.DataFrame:
|
| 250 |
if not isinstance(sql, str):
|
| 251 |
return pd.DataFrame([{"message": "π« Invalid SQL type (expected string)."}])
|
| 252 |
sql = sql.strip()
|
| 253 |
-
|
| 254 |
if not is_safe_sql(sql):
|
| 255 |
return pd.DataFrame([{"message": "π« Blocked unsafe or unsupported SQL."}])
|
| 256 |
-
|
| 257 |
try:
|
| 258 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 259 |
df = pd.read_sql_query(sql, conn)
|
| 260 |
-
|
| 261 |
-
# convert timestamp columns (if present)
|
| 262 |
for col in ["order_time", "preparing_eta", "prepared_time", "delivery_eta", "delivery_time"]:
|
| 263 |
if col in df.columns:
|
| 264 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 265 |
-
|
| 266 |
return df
|
| 267 |
except Exception as e:
|
| 268 |
return pd.DataFrame([{"message": f"β οΈ SQL execution error: {str(e)}"}])
|
| 269 |
|
| 270 |
-
# =====================================================================
|
| 271 |
-
# llm_to_sql()
|
| 272 |
-
# =====================================================================
|
| 273 |
def llm_to_sql(user_message: str) -> str:
|
| 274 |
-
"""
|
| 275 |
-
Convert natural language to a safe SELECT query for orders_clean.
|
| 276 |
-
- Fast-path when Order ID exists.
|
| 277 |
-
- If order_id required but missing: return NEED_ORDER_ID.
|
| 278 |
-
"""
|
| 279 |
msg = user_message or ""
|
| 280 |
text = msg.lower().strip()
|
| 281 |
|
|
@@ -284,25 +346,12 @@ def llm_to_sql(user_message: str) -> str:
|
|
| 284 |
sql = f"SELECT * FROM orders_clean WHERE LOWER(order_id) = LOWER('{oid}')"
|
| 285 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 286 |
|
| 287 |
-
|
| 288 |
-
must_have_order_id_intents = [
|
| 289 |
-
"where is my order", "track my order", "track order", "order status", "delivery status",
|
| 290 |
-
"when will my order arrive", "when will it arrive", "eta",
|
| 291 |
-
"order delayed", "delay in delivery", "order not delivered", "late delivery",
|
| 292 |
-
"order details", "show me the details", "details of my order", "details for my order",
|
| 293 |
-
"cancel my order", "cancel order",
|
| 294 |
-
"refund", "payment",
|
| 295 |
-
"modify my order", "change my order", "postpone my order",
|
| 296 |
-
"deliver at different address", "change address", "reschedule delivery",
|
| 297 |
-
]
|
| 298 |
-
if any(p in text for p in must_have_order_id_intents) or needs_order_id(msg):
|
| 299 |
return "SELECT 'NEED_ORDER_ID' AS message;"
|
| 300 |
|
| 301 |
system_prompt = f"""
|
| 302 |
You are an expert SQLite assistant for a food delivery company.
|
| 303 |
-
|
| 304 |
You ONLY generate valid SQLite SELECT queries using this schema:
|
| 305 |
-
|
| 306 |
{ORDERS_SCHEMA}
|
| 307 |
|
| 308 |
RULES:
|
|
@@ -315,104 +364,48 @@ RULES:
|
|
| 315 |
- If unsure:
|
| 316 |
SELECT 'Unable to answer with available data.' AS message;
|
| 317 |
"""
|
| 318 |
-
|
| 319 |
response = client.chat.completions.create(
|
| 320 |
model="llama-3.3-70b-versatile",
|
| 321 |
temperature=0.1,
|
| 322 |
-
messages=[
|
| 323 |
-
{"role": "system", "content": system_prompt},
|
| 324 |
-
{"role": "user", "content": msg},
|
| 325 |
-
],
|
| 326 |
)
|
| 327 |
-
|
| 328 |
sql = (response.choices[0].message.content or "").strip()
|
| 329 |
-
|
| 330 |
-
# strip ``` fences if any
|
| 331 |
if sql.startswith("```"):
|
| 332 |
sql = re.sub(r"^```sql", "", sql, flags=re.IGNORECASE).strip()
|
| 333 |
sql = re.sub(r"^```", "", sql).strip()
|
| 334 |
sql = sql.replace("```", "").strip()
|
| 335 |
-
|
| 336 |
-
# Force FROM orders_clean
|
| 337 |
sql = re.sub(r"\bfrom\s+\w+\b", "FROM orders_clean", sql, flags=re.IGNORECASE)
|
| 338 |
-
|
| 339 |
-
# Normalize order_id filter
|
| 340 |
sql = re.sub(
|
| 341 |
r"where\s+order_id\s*=\s*'([^']+)'",
|
| 342 |
r"WHERE LOWER(order_id) = LOWER('\1')",
|
| 343 |
sql,
|
| 344 |
flags=re.IGNORECASE,
|
| 345 |
)
|
| 346 |
-
|
| 347 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 348 |
|
| 349 |
-
# =====================================================================
|
| 350 |
-
# analyze_sentiment_and_escalation()
|
| 351 |
-
# =====================================================================
|
| 352 |
def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
| 353 |
user_message = "" if user_message is None else str(user_message).strip()
|
| 354 |
-
|
| 355 |
system_prompt = """
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
Return ONLY JSON (no extra text):
|
| 359 |
-
{
|
| 360 |
-
"sentiment": "calm" | "neutral" | "frustrated" | "angry",
|
| 361 |
-
"escalate": true or false
|
| 362 |
-
}
|
| 363 |
-
|
| 364 |
-
Escalate=true when user is frustrated/angry, mentions repeated attempts, or demands urgent help.
|
| 365 |
-
If uncertain: {"sentiment":"neutral","escalate":false}
|
| 366 |
"""
|
| 367 |
-
|
| 368 |
response = client.chat.completions.create(
|
| 369 |
model="llama-3.3-70b-versatile",
|
| 370 |
temperature=0.0,
|
| 371 |
-
messages=[
|
| 372 |
-
{"role": "system", "content": system_prompt},
|
| 373 |
-
{"role": "user", "content": user_message},
|
| 374 |
-
],
|
| 375 |
)
|
| 376 |
-
|
| 377 |
raw = (response.choices[0].message.content or "").strip()
|
| 378 |
try:
|
| 379 |
info = json.loads(raw)
|
| 380 |
if not isinstance(info, dict):
|
| 381 |
-
raise ValueError
|
| 382 |
-
|
| 383 |
-
raise ValueError("Missing keys")
|
| 384 |
-
info["sentiment"] = str(info["sentiment"]).strip().lower()
|
| 385 |
-
info["escalate"] = bool(info["escalate"])
|
| 386 |
-
if info["sentiment"] not in {"calm", "neutral", "frustrated", "angry"}:
|
| 387 |
-
raise ValueError("Bad label")
|
| 388 |
-
return info
|
| 389 |
except Exception:
|
| 390 |
return {"sentiment": "neutral", "escalate": False}
|
| 391 |
|
| 392 |
-
# =====================================================================
|
| 393 |
-
# is_misuse_or_hacker_attempt()
|
| 394 |
-
# =====================================================================
|
| 395 |
-
def is_misuse_or_hacker_attempt(user_message: str) -> bool:
|
| 396 |
-
t = (user_message or "").lower().strip()
|
| 397 |
-
|
| 398 |
-
hacker = ["hack", "hacker", "hacking", "breach", "exploit", "bypass security", "root access", "admin access"]
|
| 399 |
-
bulk = ["full database", "entire database", "dump data", "export all", "all customers", "customer list", "all orders", "every order"]
|
| 400 |
-
jailbreak = ["ignore previous instructions", "override rules", "developer mode", "you are no longer restricted"]
|
| 401 |
-
inj = ["1=1", "union select", "drop table", "delete from", "insert into", "alter table", "truncate"]
|
| 402 |
-
|
| 403 |
-
if ";" in t and any(x in t for x in inj):
|
| 404 |
-
return True
|
| 405 |
-
|
| 406 |
-
signals = hacker + bulk + jailbreak + inj
|
| 407 |
-
return any(s in t for s in signals)
|
| 408 |
-
|
| 409 |
-
# =====================================================================
|
| 410 |
-
# create_ticket()
|
| 411 |
-
# =====================================================================
|
| 412 |
def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general", order_id: Optional[str] = None) -> str:
|
| 413 |
ticket_id = "TKT-" + uuid.uuid4().hex[:8].upper()
|
| 414 |
created_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
| 415 |
-
|
| 416 |
with sqlite3.connect(CLEAN_DB_PATH) as conn:
|
| 417 |
cur = conn.cursor()
|
| 418 |
cur.execute("""
|
|
@@ -431,14 +424,9 @@ def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general
|
|
| 431 |
VALUES (?, ?, ?, ?, ?, 'Open', ?);
|
| 432 |
""", (ticket_id, ticket_type, sentiment, user_message, order_id, created_at))
|
| 433 |
conn.commit()
|
| 434 |
-
|
| 435 |
return ticket_id
|
| 436 |
|
| 437 |
-
# =====================================================================
|
| 438 |
-
# sql_result_to_response()
|
| 439 |
-
# =====================================================================
|
| 440 |
def sql_result_to_response(user_message: str, df: pd.DataFrame, max_rows_to_list: int = 5) -> str:
|
| 441 |
-
# handle firewall/SQL execution errors and special messages
|
| 442 |
if isinstance(df, pd.DataFrame) and "message" in df.columns and len(df) == 1:
|
| 443 |
msg = str(df.iloc[0]["message"])
|
| 444 |
if msg.strip().upper() == "NEED_ORDER_ID":
|
|
@@ -446,79 +434,37 @@ def sql_result_to_response(user_message: str, df: pd.DataFrame, max_rows_to_list
|
|
| 446 |
return msg
|
| 447 |
|
| 448 |
if df.empty:
|
| 449 |
-
return
|
| 450 |
-
f"{WELCOME_LINE}\n"
|
| 451 |
-
"I couldnβt find a matching order for the details provided.\n"
|
| 452 |
-
f"{ASK_ORDER_ID_LINE}"
|
| 453 |
-
)
|
| 454 |
|
| 455 |
df_for_llm = df.head(max_rows_to_list).copy()
|
| 456 |
result_data = df_for_llm.to_dict(orient="records")
|
| 457 |
|
| 458 |
system_prompt = f"""
|
| 459 |
You are a polite and professional customer support chatbot for FoodHub.
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
VERY IMPORTANT POLICY:
|
| 465 |
-
- If ANY matching record exists in the data I give you β The order is CONFIRMED.
|
| 466 |
-
- NEVER tell the user to re-check their order ID if matching rows exist.
|
| 467 |
-
- NEVER say "order not found", "unable to locate order", or similar messages
|
| 468 |
-
when there is at least one matching record.
|
| 469 |
-
- Instead, confidently acknowledge the order and use available data.
|
| 470 |
-
|
| 471 |
-
CRITICAL RULES:
|
| 472 |
-
- NEVER invent or guess items, ETA, status, payment, or any order details.
|
| 473 |
-
- Use ONLY the data provided in "Matching order data".
|
| 474 |
-
- If information is missing, say it's unavailable in tracking.
|
| 475 |
-
|
| 476 |
-
DELIVERED TEMPLATE (use when order_status_std indicates delivered OR is_delivered == 1):
|
| 477 |
-
- Use this meaning and style (you may include the order id if available):
|
| 478 |
-
"I've checked on your order, and I'm happy to inform you that it has been delivered β
. You should have received your order.
|
| 479 |
-
If you have any further concerns or issues, please let me know. If you need anything else, Iβm here to help! π"
|
| 480 |
-
|
| 481 |
-
If NOT delivered:
|
| 482 |
-
- Provide the latest known status.
|
| 483 |
-
- If delivery_time is missing but delivery_eta exists: share ETA.
|
| 484 |
-
- If both delivery_time and delivery_eta missing: apologize for limited tracking.
|
| 485 |
-
|
| 486 |
-
Keep it concise and helpful.
|
| 487 |
"""
|
| 488 |
-
|
| 489 |
-
content = f"User question: {user_message}\n\nMatching order data (up to {max_rows_to_list} rows):\n{result_data}"
|
| 490 |
-
|
| 491 |
resp = client.chat.completions.create(
|
| 492 |
model="llama-3.3-70b-versatile",
|
| 493 |
temperature=0.2,
|
| 494 |
-
messages=[
|
| 495 |
-
{"role": "system", "content": system_prompt},
|
| 496 |
-
{"role": "user", "content": content},
|
| 497 |
-
],
|
| 498 |
)
|
| 499 |
return (resp.choices[0].message.content or "").strip()
|
| 500 |
|
| 501 |
-
# =====================================================================
|
| 502 |
-
# answer_user_question()
|
| 503 |
-
# =====================================================================
|
| 504 |
def answer_user_question(user_message: str) -> str:
|
| 505 |
-
# Safety net: never answer order intents without Order ID
|
| 506 |
if needs_order_id(user_message) and not extract_order_id(user_message):
|
| 507 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 508 |
-
|
| 509 |
sql = llm_to_sql(user_message)
|
| 510 |
df = run_sql_query(sql)
|
| 511 |
-
return sql_result_to_response(user_message, df
|
| 512 |
|
| 513 |
-
# =====================================================================
|
| 514 |
-
# chatbot_response() β main orchestration (FIXED)
|
| 515 |
-
# =====================================================================
|
| 516 |
def chatbot_response(user_message: str) -> str:
|
| 517 |
text = (user_message or "").strip()
|
| 518 |
t = text.lower().strip()
|
| 519 |
order_id = extract_order_id(text)
|
| 520 |
|
| 521 |
-
# Greetings / thanks
|
| 522 |
greetings = ["hi", "hello", "hey", "good morning", "good afternoon", "good evening"]
|
| 523 |
thanks_words = ["thank you", "thanks", "thx", "thankyou"]
|
| 524 |
|
|
@@ -526,36 +472,17 @@ def chatbot_response(user_message: str) -> str:
|
|
| 526 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 527 |
|
| 528 |
if any(w in t for w in thanks_words):
|
| 529 |
-
return (
|
| 530 |
-
"Youβre most welcome! π\n"
|
| 531 |
-
"If you need help with an order, please share your Order ID (like `O12488`)."
|
| 532 |
-
)
|
| 533 |
-
|
| 534 |
-
# Hacking / misuse (polite privacy rejection)
|
| 535 |
-
if is_misuse_or_hacker_attempt(text):
|
| 536 |
-
return (
|
| 537 |
-
f"{WELCOME_LINE}\n"
|
| 538 |
-
"π Sorry, I canβt assist with that.\n"
|
| 539 |
-
"For privacy and security reasons, I can only help with your own order using a valid Order ID. π"
|
| 540 |
-
)
|
| 541 |
|
| 542 |
-
# Third-party privacy
|
| 543 |
if is_third_party_order_request(text):
|
| 544 |
-
return
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
)
|
| 549 |
|
| 550 |
-
# FIX 1: Escalation intent must be handled BEFORE any generic Order ID gating
|
| 551 |
if _is_escalation_intent(text):
|
| 552 |
senti = analyze_sentiment_and_escalation(text)
|
| 553 |
-
ticket_id = create_ticket(
|
| 554 |
-
user_message=text,
|
| 555 |
-
sentiment=senti.get("sentiment", "frustrated"),
|
| 556 |
-
ticket_type="escalation",
|
| 557 |
-
order_id=order_id
|
| 558 |
-
)
|
| 559 |
return (
|
| 560 |
f"{WELCOME_LINE}\n"
|
| 561 |
"π Iβm really sorry youβve had to follow up multiple times.\n\n"
|
|
@@ -564,7 +491,6 @@ def chatbot_response(user_message: str) -> str:
|
|
| 564 |
f"{ASK_ORDER_ID_LINE if not order_id else 'If you need anything else, Iβm here to help! π'}"
|
| 565 |
)
|
| 566 |
|
| 567 |
-
# FIX: Payment / Refund intent should use empathetic ask (Response 2 style)
|
| 568 |
if _is_payment_refund_intent(text) and not order_id:
|
| 569 |
return (
|
| 570 |
"π Iβm sorry youβre facing an issue with the payment or refund. Iβll help you with this right away.\n\n"
|
|
@@ -573,7 +499,6 @@ def chatbot_response(user_message: str) -> str:
|
|
| 573 |
"If the payment was received, the refund will be processed within 48 hours."
|
| 574 |
)
|
| 575 |
|
| 576 |
-
# FIX 2: Cancel intent without Order ID should return the preferred message (not generic welcome+ask)
|
| 577 |
if _is_cancel_intent(text) and not order_id:
|
| 578 |
return (
|
| 579 |
"π Iβm sorry to hear youβd like to cancel your order β I completely understand and Iβm here to help. "
|
|
@@ -581,27 +506,28 @@ def chatbot_response(user_message: str) -> str:
|
|
| 581 |
"process the cancellation for you?"
|
| 582 |
)
|
| 583 |
|
| 584 |
-
# Global rule: any order-related request without Order ID β welcome + ask for Order ID
|
| 585 |
if needs_order_id(text) and not order_id:
|
| 586 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 587 |
|
| 588 |
-
# Normal path
|
| 589 |
return answer_user_question(text)
|
| 590 |
|
| 591 |
-
def chatbot(msg: str) -> str:
|
| 592 |
-
return chatbot_response(msg)
|
| 593 |
-
|
| 594 |
# ================== SESSION STATE ==================
|
| 595 |
if "welcome_shown" not in st.session_state:
|
| 596 |
st.session_state["welcome_shown"] = False
|
| 597 |
-
|
| 598 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 599 |
st.markdown(
|
| 600 |
"""
|
| 601 |
<div class="foodhub-card">
|
| 602 |
-
<div style="display:flex; align-items:flex-start; gap:
|
| 603 |
-
<div style="font-size:
|
| 604 |
-
<div style="flex:1
|
| 605 |
<div class="foodhub-header-title">FoodHub AI Support</div>
|
| 606 |
<p class="foodhub-header-subtitle">
|
| 607 |
Ask about order status, delivery updates, cancellations, or payment/refund issues.
|
|
@@ -622,39 +548,120 @@ if not st.session_state["welcome_shown"]:
|
|
| 622 |
st.caption("Because great food deserves great service π")
|
| 623 |
st.session_state["welcome_shown"] = True
|
| 624 |
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
if
|
| 630 |
-
st.session_state["
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
if
|
| 634 |
-
st.session_state["
|
| 635 |
-
if
|
| 636 |
-
st.session_state["
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 649 |
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 653 |
|
| 654 |
-
|
| 655 |
-
|
|
|
|
|
|
|
| 656 |
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
st.warning("Please enter a message to continue.")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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);
|
| 96 |
padding: 2px 6px;
|
| 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 |
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`
|
|
|
|
| 128 |
return m.group(0).upper() if m else None
|
| 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"
|
| 150 |
]
|
| 151 |
if any(p in t for p in phrases):
|
| 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
|
| 160 |
|
| 161 |
return False
|
| 162 |
|
| 163 |
def is_third_party_order_request(message: str) -> bool:
|
|
|
|
|
|
|
|
|
|
| 164 |
t = (message or "").lower()
|
| 165 |
third_party = [
|
| 166 |
"my friend", "friend's", "friends'", "my brother", "my sister",
|
|
|
|
| 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",
|
| 199 |
+
"refund status"
|
| 200 |
]
|
| 201 |
return any(k in t for k in escalation_keywords)
|
| 202 |
|
|
|
|
| 205 |
keywords = [
|
| 206 |
"payment", "refund", "money back", "charged", "chargeback",
|
| 207 |
"transaction", "payment failed", "payment issue", "payment problem",
|
| 208 |
+
"upi", "card", "debit", "credit", "wallet",
|
| 209 |
+
"charged more than once", "double charged", "charged twice"
|
| 210 |
]
|
| 211 |
return any(k in t for k in keywords)
|
| 212 |
|
| 213 |
+
# ================== QUICK BUTTON RESPONSE MAP (EXACT TEXT YOU APPROVED) ==================
|
| 214 |
+
QUICK_BUTTON_EXACT_REPLIES = {
|
| 215 |
+
"Order never arrived.": (
|
| 216 |
+
"Welcome to FoodHub Support! π\n"
|
| 217 |
+
"Iβm really sorry to hear that your order hasnβt arrived β I understand how frustrating that can be. π\n\n"
|
| 218 |
+
"To help you right away, could you please share your Order ID (for example: O12488)?\n"
|
| 219 |
+
"Once I have the Order ID, Iβll quickly check the delivery status, identify what went wrong, and help you with the next steps, "
|
| 220 |
+
"including a replacement or refund if applicable."
|
| 221 |
+
),
|
| 222 |
+
"Food damage or quality issue.": (
|
| 223 |
+
"Welcome to FoodHub Support! π\n"
|
| 224 |
+
"Iβm really sorry to hear that youβre facing a food damage or quality issue β thatβs not the experience we want you to have. π\n\n"
|
| 225 |
+
"Please share your Order ID (for example: O12488) so I can review the order details and assist you with a replacement or refund "
|
| 226 |
+
"as per our quality policy."
|
| 227 |
+
),
|
| 228 |
+
"My card was charged more than once.": (
|
| 229 |
+
"Welcome to FoodHub Support! π\n"
|
| 230 |
+
"I understand how concerning it can be to see your card charged more than once β Iβm here to help. π\n\n"
|
| 231 |
+
"Could you please share your Order ID (for example: O12488)?\n"
|
| 232 |
+
"Once I have it, Iβll verify the payment records and, if a duplicate charge is confirmed, initiate a refund "
|
| 233 |
+
"(usually processed within 48 hours)."
|
| 234 |
+
),
|
| 235 |
+
"Wrong or missing items.": (
|
| 236 |
+
"Welcome to FoodHub Support! π\n"
|
| 237 |
+
"Iβm sorry to hear that your order had wrong or missing items. Thank you for letting us know. π\n\n"
|
| 238 |
+
"Please share your Order ID (for example: O12488) so I can review the order and help arrange a replacement or refund as per our policy."
|
| 239 |
+
),
|
| 240 |
+
"Another order issue.": (
|
| 241 |
+
"Welcome to FoodHub Support! π\n"
|
| 242 |
+
"Iβm here to help with your order-related concern. π\n\n"
|
| 243 |
+
"It looks like you may have more than one order associated with your account. To assist you correctly, please share the Order ID "
|
| 244 |
+
"(for example: O12488) for the order youβre referring to.\n\n"
|
| 245 |
+
"Once I have the correct Order ID, Iβll review the details and help resolve the issue as quickly as possible."
|
| 246 |
+
),
|
| 247 |
+
"Delivery or food safety issue.": (
|
| 248 |
+
"Welcome to FoodHub Support! π\n"
|
| 249 |
+
"Iβm really sorry to hear that youβre facing a delivery or food safety issue. Your safety and satisfaction are extremely important to us. π\n\n"
|
| 250 |
+
"Please share your Order ID (for example: O12488).\n"
|
| 251 |
+
"Once I have it, Iβll review the delivery handling and food safety details and assist you with a replacement or refund as per our safety policy."
|
| 252 |
+
),
|
| 253 |
+
"I want an update on my refund.": (
|
| 254 |
+
"Welcome to FoodHub Support! π\n"
|
| 255 |
+
"I understand youβre looking for an update on your refund, and Iβll be happy to check that for you. π\n\n"
|
| 256 |
+
"Please share your Order ID (for example: O12488).\n"
|
| 257 |
+
"Once I have it, Iβll confirm whether the refund has been initiated, the refund amount, and the expected timeline for it to reflect in your account "
|
| 258 |
+
"(usually within 48 hours, depending on your bank)."
|
| 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}")
|
|
|
|
| 286 |
# ================== SCHEMA (for LLM prompt only) ==================
|
| 287 |
ORDERS_SCHEMA = """
|
| 288 |
Table: orders_clean
|
|
|
|
| 289 |
Columns:
|
| 290 |
- order_id (TEXT)
|
| 291 |
- cust_id (TEXT)
|
|
|
|
| 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()
|
| 343 |
|
|
|
|
| 346 |
sql = f"SELECT * FROM orders_clean WHERE LOWER(order_id) = LOWER('{oid}')"
|
| 347 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 348 |
|
| 349 |
+
if needs_order_id(msg):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
return "SELECT 'NEED_ORDER_ID' AS message;"
|
| 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 |
- 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 |
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"])
|
| 432 |
if msg.strip().upper() == "NEED_ORDER_ID":
|
|
|
|
| 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()
|
| 466 |
order_id = extract_order_id(text)
|
| 467 |
|
|
|
|
| 468 |
greetings = ["hi", "hello", "hey", "good morning", "good afternoon", "good evening"]
|
| 469 |
thanks_words = ["thank you", "thanks", "thx", "thankyou"]
|
| 470 |
|
|
|
|
| 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 |
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 |
"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. "
|
|
|
|
| 506 |
"process the cancellation for you?"
|
| 507 |
)
|
| 508 |
|
|
|
|
| 509 |
if needs_order_id(text) and not order_id:
|
| 510 |
return f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}"
|
| 511 |
|
|
|
|
| 512 |
return answer_user_question(text)
|
| 513 |
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|
| 524 |
+
# ================== UI HEADER ==================
|
| 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>
|
| 532 |
<p class="foodhub-header-subtitle">
|
| 533 |
Ask about order status, delivery updates, cancellations, or payment/refund issues.
|
|
|
|
| 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):
|
| 627 |
+
st.session_state["messages"].append({"role": "user", "content": user_text})
|
| 628 |
+
st.session_state["messages"].append({"role": "assistant", "content": bot_text})
|
| 629 |
|
| 630 |
+
def send_message(user_text: str):
|
| 631 |
+
user_text = (user_text or "").strip()
|
| 632 |
+
if not user_text:
|
| 633 |
st.warning("Please enter a message to continue.")
|
| 634 |
+
return
|
| 635 |
+
st.session_state["messages"].append({"role": "user", "content": user_text})
|
| 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
|
| 643 |
+
exact = QUICK_BUTTON_EXACT_REPLIES.get(q, f"{WELCOME_LINE}\n{ASK_ORDER_ID_LINE}")
|
| 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:",
|
| 660 |
+
key="chat_input",
|
| 661 |
+
placeholder="e.g. Where is my order O12488?",
|
| 662 |
+
)
|
| 663 |
+
send_clicked = st.form_submit_button("Send", use_container_width=True)
|
| 664 |
+
|
| 665 |
+
if send_clicked:
|
| 666 |
+
send_message(query)
|
| 667 |
+
st.rerun()
|