Spaces:
Sleeping
Sleeping
Update src/streamlit_app.py
Browse files- src/streamlit_app.py +113 -442
src/streamlit_app.py
CHANGED
|
@@ -19,92 +19,55 @@ st.set_page_config(
|
|
| 19 |
st.markdown(
|
| 20 |
"""
|
| 21 |
<style>
|
| 22 |
-
.main {
|
| 23 |
-
max-width: 480px;
|
| 24 |
-
margin: 0 auto;
|
| 25 |
-
}
|
| 26 |
.foodhub-card {
|
| 27 |
background: linear-gradient(135deg, #fff3e0, #ffe0cc);
|
| 28 |
-
padding: 16px 20px;
|
| 29 |
-
border-
|
| 30 |
-
border: 1px solid #f5c28c;
|
| 31 |
-
margin-bottom: 16px;
|
| 32 |
}
|
| 33 |
.foodhub-header-title {
|
| 34 |
-
margin-bottom: 4px;
|
| 35 |
-
|
| 36 |
-
font-size: 26px;
|
| 37 |
-
font-weight: 800;
|
| 38 |
-
}
|
| 39 |
-
.foodhub-header-subtitle {
|
| 40 |
-
margin: 0;
|
| 41 |
-
color: #444;
|
| 42 |
-
font-size: 14px;
|
| 43 |
}
|
|
|
|
| 44 |
.foodhub-assistant-pill {
|
| 45 |
-
display: inline-flex;
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
border-radius: 999px;
|
| 50 |
-
padding: 6px 10px;
|
| 51 |
-
font-size: 11px;
|
| 52 |
-
color: #555;
|
| 53 |
-
border: 1px solid #ffd3a3;
|
| 54 |
-
margin-top: 8px;
|
| 55 |
-
}
|
| 56 |
-
.status-dot {
|
| 57 |
-
width: 8px;
|
| 58 |
-
height: 8px;
|
| 59 |
-
border-radius: 50%;
|
| 60 |
-
background: #2ecc71;
|
| 61 |
}
|
|
|
|
| 62 |
.quick-actions-title {
|
| 63 |
-
font-size: 13px;
|
| 64 |
-
|
| 65 |
-
color: #555;
|
| 66 |
-
margin-top: 4px;
|
| 67 |
-
margin-bottom: 4px;
|
| 68 |
}
|
| 69 |
.bot-bubble {
|
| 70 |
-
background: #eef7f2;
|
| 71 |
-
|
| 72 |
-
padding: 10px 12px;
|
| 73 |
-
font-size: 14px;
|
| 74 |
-
margin-top: 12px;
|
| 75 |
-
}
|
| 76 |
-
.user-label {
|
| 77 |
-
font-size: 12px;
|
| 78 |
-
color: #777;
|
| 79 |
-
margin-top: 8px;
|
| 80 |
-
margin-bottom: 2px;
|
| 81 |
-
}
|
| 82 |
-
.bot-label {
|
| 83 |
-
font-size: 12px;
|
| 84 |
-
color: #777;
|
| 85 |
-
margin-top: 12px;
|
| 86 |
-
margin-bottom: 2px;
|
| 87 |
}
|
|
|
|
|
|
|
| 88 |
</style>
|
| 89 |
""",
|
| 90 |
unsafe_allow_html=True,
|
| 91 |
)
|
| 92 |
|
| 93 |
-
# ================== DB CONFIG ==================
|
| 94 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 95 |
-
CLEAN_DB_PATH = os.path.join(BASE_DIR, "orders_clean.db") #
|
| 96 |
|
| 97 |
-
#
|
| 98 |
try:
|
| 99 |
with sqlite3.connect(CLEAN_DB_PATH) as _conn:
|
| 100 |
_cur = _conn.cursor()
|
| 101 |
_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
| 102 |
_tables = [r[0] for r in _cur.fetchall()]
|
|
|
|
| 103 |
if "orders_clean" not in _tables:
|
| 104 |
st.error(
|
| 105 |
f"Connected to {CLEAN_DB_PATH!r}, but table 'orders_clean' was not found.\n\n"
|
| 106 |
-
"Please ensure
|
| 107 |
-
"and that
|
| 108 |
)
|
| 109 |
st.stop()
|
| 110 |
except Exception as e:
|
|
@@ -120,15 +83,6 @@ if not groq_api_key:
|
|
| 120 |
client = Groq(api_key=groq_api_key)
|
| 121 |
|
| 122 |
# ================== SCHEMA (for LLM prompt only) ==================
|
| 123 |
-
# ==============================================================================
|
| 124 |
-
# ORDERS_SCHEMA — FINAL, CLEANED & FEATURE-ENGINEERED
|
| 125 |
-
# ------------------------------------------------------------------------------
|
| 126 |
-
# Reference schema for the fully cleaned + feature-engineered orders table
|
| 127 |
-
# stored inside orders_clean.db
|
| 128 |
-
# ------------------------------------------------------------------------------
|
| 129 |
-
# Table Name: orders_clean
|
| 130 |
-
# ==============================================================================
|
| 131 |
-
|
| 132 |
ORDERS_SCHEMA = """
|
| 133 |
Table: orders_clean
|
| 134 |
|
|
@@ -143,60 +97,39 @@ Columns:
|
|
| 143 |
- delivery_eta (DATETIME/TEXT) : Estimated delivery timestamp
|
| 144 |
- delivery_time (DATETIME/TEXT) : Actual delivery timestamp
|
| 145 |
|
| 146 |
-
- order_status_std (TEXT) : Normalized order status
|
| 147 |
-
|
| 148 |
-
- payment_status_std (TEXT) : Normalized payment status
|
| 149 |
-
(e.g., 'cod', 'completed', 'canceled')
|
| 150 |
|
| 151 |
- item_in_order (TEXT) : Comma-separated list of items
|
| 152 |
- item_count (INTEGER) : Derived integer count of ordered items
|
| 153 |
|
| 154 |
--- Feature Engineered Columns ---
|
| 155 |
-
- prep_duration_min (REAL)
|
| 156 |
-
- delivery_duration_min (REAL)
|
| 157 |
-
- total_duration_min (REAL)
|
| 158 |
-
|
| 159 |
-
- on_time_delivery (INTEGER: 0/1)
|
| 160 |
-
- is_delivered (INTEGER: 0/1)
|
| 161 |
-
- is_canceled (INTEGER: 0/1)
|
| 162 |
-
|
| 163 |
-
Usage Notes:
|
| 164 |
-
------------
|
| 165 |
-
✓ Convert datetime-like TEXT columns to actual Python datetime objects before computing durations
|
| 166 |
-
✓ Always use LOWER(order_id) for safe matching
|
| 167 |
-
✓ Prefer explicit column selection — avoid SELECT * in production agents
|
| 168 |
-
✓ All engineered duration fields depend on successful datetime conversion
|
| 169 |
"""
|
| 170 |
|
| 171 |
# =====================================================================
|
| 172 |
# SQL SAFETY FIREWALL
|
| 173 |
# =====================================================================
|
| 174 |
def is_safe_sql(sql: str) -> bool:
|
| 175 |
-
"""
|
| 176 |
-
Simple SQL firewall:
|
| 177 |
-
- only SELECT allowed
|
| 178 |
-
- no stacked queries
|
| 179 |
-
- block destructive / DDL keywords
|
| 180 |
-
- block SQL comment injections
|
| 181 |
-
"""
|
| 182 |
if not isinstance(sql, str):
|
| 183 |
return False
|
| 184 |
|
| 185 |
sql_lower = sql.lower().strip()
|
| 186 |
|
| 187 |
-
# Must start with SELECT (read-only)
|
| 188 |
if not sql_lower.startswith("select"):
|
| 189 |
return False
|
| 190 |
|
| 191 |
-
#
|
| 192 |
if re.search(r";\s*\S", sql_lower):
|
| 193 |
return False
|
| 194 |
|
| 195 |
-
forbidden_keywords = [
|
| 196 |
-
"drop", "delete", "update", "insert",
|
| 197 |
-
"alter", "truncate", "create",
|
| 198 |
-
" hack ",
|
| 199 |
-
]
|
| 200 |
if any(kw in sql_lower for kw in forbidden_keywords):
|
| 201 |
return False
|
| 202 |
|
|
@@ -208,67 +141,44 @@ def is_safe_sql(sql: str) -> bool:
|
|
| 208 |
|
| 209 |
# =====================================================================
|
| 210 |
# run_sql_query()
|
| 211 |
-
# (aligned with your final run_sql_query from Code 1/3)
|
| 212 |
# =====================================================================
|
| 213 |
def run_sql_query(sql: str) -> pd.DataFrame:
|
| 214 |
-
"""
|
| 215 |
-
Safely execute SQL queries on the cleaned database
|
| 216 |
-
and automatically convert timestamp fields into proper datetime types
|
| 217 |
-
for accurate chatbot calculations (ETA, SLA, delays, durations, etc.).
|
| 218 |
-
"""
|
| 219 |
-
# Step 1: Basic normalization
|
| 220 |
if not isinstance(sql, str):
|
| 221 |
return pd.DataFrame([{"message": "🚫 Invalid SQL type (expected string)."}])
|
| 222 |
sql = sql.strip()
|
| 223 |
|
| 224 |
-
# Step 2: Validate SQL using custom firewall logic
|
| 225 |
if not is_safe_sql(sql):
|
| 226 |
return pd.DataFrame([{"message": "🚫 Blocked unsafe or unsupported SQL."}])
|
| 227 |
|
| 228 |
try:
|
| 229 |
-
# Step 3: Execute SQL using a fresh DB connection
|
| 230 |
with sqlite3.connect(CLEAN_DB_PATH) as connection:
|
| 231 |
df = pd.read_sql_query(sql, connection)
|
| 232 |
|
| 233 |
-
# Step 4: Convert all string timestamp columns back to datetime
|
| 234 |
time_cols = ["order_time", "preparing_eta", "prepared_time", "delivery_eta", "delivery_time"]
|
| 235 |
for col in time_cols:
|
| 236 |
if col in df.columns:
|
| 237 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 238 |
|
| 239 |
return df
|
| 240 |
-
|
| 241 |
except Exception as e:
|
| 242 |
-
# Step 5: Fail gracefully with diagnostic message
|
| 243 |
return pd.DataFrame([{"message": f"⚠️ SQL execution error: {str(e)}"}])
|
| 244 |
|
| 245 |
# =====================================================================
|
| 246 |
-
# llm_to_sql()
|
| 247 |
# =====================================================================
|
| 248 |
def llm_to_sql(user_message: str) -> str:
|
| 249 |
"""
|
| 250 |
-
Convert
|
| 251 |
targeting the orders_clean table inside orders_clean.db.
|
| 252 |
"""
|
| 253 |
-
# Normalize user text for pattern matching
|
| 254 |
text = (user_message or "").lower().strip()
|
| 255 |
|
| 256 |
-
# -------------------------------------------------------
|
| 257 |
-
# FAST-PATH: If an order_id like O12488 exists → skip LLM
|
| 258 |
-
# -------------------------------------------------------
|
| 259 |
match = re.search(r"\b(o\d+)\b", text)
|
| 260 |
if match:
|
| 261 |
-
order_id = match.group(1)
|
| 262 |
-
sql = (
|
| 263 |
-
"SELECT * FROM orders_clean "
|
| 264 |
-
f"WHERE LOWER(order_id) = LOWER('{order_id}')"
|
| 265 |
-
)
|
| 266 |
-
# Final safety gate
|
| 267 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 268 |
|
| 269 |
-
# -------------------------------------------------------
|
| 270 |
-
# LLM PATH — General business questions (no order_id found)
|
| 271 |
-
# -------------------------------------------------------
|
| 272 |
system_prompt = f"""
|
| 273 |
You are an expert SQLite assistant for a food delivery company.
|
| 274 |
|
|
@@ -282,7 +192,7 @@ RULES:
|
|
| 282 |
- Allowed operation: SELECT only
|
| 283 |
- If filtering by order_id, ALWAYS use:
|
| 284 |
LOWER(order_id) = LOWER('<value>')
|
| 285 |
-
- If unsure
|
| 286 |
SELECT 'Unable to answer with available data.' AS message;
|
| 287 |
"""
|
| 288 |
|
|
@@ -291,27 +201,26 @@ RULES:
|
|
| 291 |
temperature=0.1,
|
| 292 |
messages=[
|
| 293 |
{"role": "system", "content": system_prompt},
|
| 294 |
-
{"role": "user",
|
| 295 |
],
|
| 296 |
)
|
| 297 |
|
| 298 |
sql = (response.choices[0].message.content or "").strip()
|
| 299 |
|
| 300 |
-
# Clean fences if LLM returned ```sql ...```
|
| 301 |
if sql.startswith("```"):
|
| 302 |
sql = re.sub(r"^```sql", "", sql, flags=re.IGNORECASE).strip()
|
| 303 |
sql = re.sub(r"^```", "", sql).strip()
|
| 304 |
sql = sql.replace("```", "").strip()
|
| 305 |
|
| 306 |
-
#
|
| 307 |
sql = re.sub(
|
| 308 |
-
r"\bfrom\s
|
| 309 |
"FROM orders_clean",
|
| 310 |
sql,
|
|
|
|
| 311 |
flags=re.IGNORECASE,
|
| 312 |
)
|
| 313 |
|
| 314 |
-
# Normalize order_id equality to LOWER(...) form
|
| 315 |
sql = re.sub(
|
| 316 |
r"where\s+order_id\s*=\s*'([^']+)'",
|
| 317 |
r"WHERE LOWER(order_id) = LOWER('\1')",
|
|
@@ -319,17 +228,15 @@ RULES:
|
|
| 319 |
flags=re.IGNORECASE,
|
| 320 |
)
|
| 321 |
|
| 322 |
-
# Final safety gate
|
| 323 |
if not is_safe_sql(sql):
|
| 324 |
return "SELECT 'Unable to answer safely.' AS message;"
|
| 325 |
|
| 326 |
return sql
|
| 327 |
|
| 328 |
# =====================================================================
|
| 329 |
-
# analyze_sentiment_and_escalation()
|
| 330 |
# =====================================================================
|
| 331 |
def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
| 332 |
-
# Basic normalization
|
| 333 |
if user_message is None:
|
| 334 |
user_message = ""
|
| 335 |
user_message = str(user_message).strip()
|
|
@@ -337,36 +244,21 @@ def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
|
| 337 |
system_prompt = """
|
| 338 |
You are a classifier for a food delivery chatbot.
|
| 339 |
|
| 340 |
-
|
| 341 |
-
|
| 342 |
{
|
| 343 |
"sentiment": "calm" | "neutral" | "frustrated" | "angry",
|
| 344 |
"escalate": true or false
|
| 345 |
}
|
| 346 |
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
- Use "neutral" when they are just asking for information.
|
| 350 |
-
- Use "frustrated" when they express dissatisfaction, confusion, or mild complaints.
|
| 351 |
-
- Use "angry" when they use strong negative language, threats, or clear anger.
|
| 352 |
-
|
| 353 |
-
Escalate = true when:
|
| 354 |
-
- the user sounds upset, frustrated, or angry,
|
| 355 |
-
- they mention repeated attempts with no resolution,
|
| 356 |
-
- they demand urgent or immediate help.
|
| 357 |
-
|
| 358 |
-
If you are uncertain, default to:
|
| 359 |
-
{
|
| 360 |
-
"sentiment": "neutral",
|
| 361 |
-
"escalate": false
|
| 362 |
-
}
|
| 363 |
"""
|
| 364 |
|
| 365 |
response = client.chat.completions.create(
|
| 366 |
model="llama-3.3-70b-versatile",
|
| 367 |
messages=[
|
| 368 |
{"role": "system", "content": system_prompt},
|
| 369 |
-
{"role": "user",
|
| 370 |
],
|
| 371 |
temperature=0.0,
|
| 372 |
)
|
|
@@ -378,105 +270,47 @@ If you are uncertain, default to:
|
|
| 378 |
if not isinstance(info, dict):
|
| 379 |
raise ValueError("Not a JSON object")
|
| 380 |
if "sentiment" not in info or "escalate" not in info:
|
| 381 |
-
raise ValueError("Missing
|
|
|
|
|
|
|
|
|
|
|
|
|
| 382 |
except (json.JSONDecodeError, ValueError):
|
| 383 |
info = {"sentiment": "neutral", "escalate": False}
|
| 384 |
|
| 385 |
return info
|
| 386 |
|
| 387 |
# =====================================================================
|
| 388 |
-
# is_misuse_or_hacker_attempt()
|
| 389 |
# =====================================================================
|
| 390 |
def is_misuse_or_hacker_attempt(user_message: str) -> bool:
|
| 391 |
-
"""
|
| 392 |
-
Detect attempts to:
|
| 393 |
-
- Hack or bypass security
|
| 394 |
-
- Retrieve unauthorized bulk data (ALL orders / ALL customers)
|
| 395 |
-
- Perform SQL injection or prompt injection
|
| 396 |
-
"""
|
| 397 |
text = (user_message or "").lower().strip()
|
| 398 |
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
"hack",
|
| 402 |
-
"
|
| 403 |
-
"hacking",
|
| 404 |
-
"hacked",
|
| 405 |
-
"i am the hacker",
|
| 406 |
-
"i am hacker",
|
| 407 |
-
"breach",
|
| 408 |
-
"exploit",
|
| 409 |
-
"bypass security",
|
| 410 |
-
"disable firewall",
|
| 411 |
-
"root access",
|
| 412 |
-
"admin access",
|
| 413 |
"reverse engineer",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 414 |
]
|
| 415 |
|
| 416 |
-
|
| 417 |
-
bulk_keywords = [
|
| 418 |
-
"all orders",
|
| 419 |
-
"every order",
|
| 420 |
-
"all my orders",
|
| 421 |
-
"all previous orders",
|
| 422 |
-
"all past orders",
|
| 423 |
-
"order history",
|
| 424 |
-
"full database",
|
| 425 |
-
"entire database",
|
| 426 |
-
"all customers",
|
| 427 |
-
"customer list",
|
| 428 |
-
"download database",
|
| 429 |
-
"dump data",
|
| 430 |
-
"export all",
|
| 431 |
-
"export everything",
|
| 432 |
-
"show everything",
|
| 433 |
-
]
|
| 434 |
-
|
| 435 |
-
# Jailbreak / prompt injection indicators
|
| 436 |
-
jailbreak_keywords = [
|
| 437 |
-
"ignore previous instructions",
|
| 438 |
-
"override rules",
|
| 439 |
-
"forget rules",
|
| 440 |
-
"developer mode",
|
| 441 |
-
"you are no longer restricted",
|
| 442 |
-
"pretend you are",
|
| 443 |
-
"act like",
|
| 444 |
-
]
|
| 445 |
-
|
| 446 |
-
# SQL injection indicators
|
| 447 |
-
sql_injection_keywords = [
|
| 448 |
-
"1=1",
|
| 449 |
-
"union select",
|
| 450 |
-
"drop table",
|
| 451 |
-
"delete from",
|
| 452 |
-
"insert into",
|
| 453 |
-
"alter table",
|
| 454 |
-
"truncate",
|
| 455 |
-
]
|
| 456 |
-
|
| 457 |
-
# Only flag semicolons if other SQL keywords appear (stacked SQL)
|
| 458 |
-
if ";" in text and any(sig in text for sig in sql_injection_keywords):
|
| 459 |
return True
|
| 460 |
|
| 461 |
-
all_signals = (
|
| 462 |
-
hacker_keywords
|
| 463 |
-
+ bulk_keywords
|
| 464 |
-
+ jailbreak_keywords
|
| 465 |
-
+ sql_injection_keywords
|
| 466 |
-
)
|
| 467 |
-
|
| 468 |
return any(sig in text for sig in all_signals)
|
| 469 |
|
| 470 |
# =====================================================================
|
| 471 |
-
# create_ticket() (
|
| 472 |
# =====================================================================
|
| 473 |
-
def create_ticket(
|
| 474 |
-
user_message: str,
|
| 475 |
-
sentiment: str,
|
| 476 |
-
ticket_type: str = "general",
|
| 477 |
-
order_id: str = None
|
| 478 |
-
) -> str:
|
| 479 |
-
# Extract order_id if not given (pattern: O12345)
|
| 480 |
if order_id is None:
|
| 481 |
match = re.search(r"\b(o\d+)\b", (user_message or "").lower())
|
| 482 |
order_id = match.group(1).upper() if match else None
|
|
@@ -498,10 +332,7 @@ def create_ticket(
|
|
| 498 |
);
|
| 499 |
""")
|
| 500 |
cursor.execute("""
|
| 501 |
-
INSERT INTO tickets (
|
| 502 |
-
ticket_id, ticket_type, sentiment, message,
|
| 503 |
-
order_id, status, created_at
|
| 504 |
-
)
|
| 505 |
VALUES (?, ?, ?, ?, ?, 'Open', ?);
|
| 506 |
""", (ticket_id, ticket_type, sentiment, user_message, order_id, created_at))
|
| 507 |
conn.commit()
|
|
@@ -509,7 +340,7 @@ def create_ticket(
|
|
| 509 |
return ticket_id
|
| 510 |
|
| 511 |
# =====================================================================
|
| 512 |
-
# sql_result_to_response()
|
| 513 |
# =====================================================================
|
| 514 |
def sql_result_to_response(
|
| 515 |
user_message: str,
|
|
@@ -519,175 +350,94 @@ def sql_result_to_response(
|
|
| 519 |
language: str = "auto",
|
| 520 |
max_rows_to_list: int = 5
|
| 521 |
) -> str:
|
| 522 |
-
|
| 523 |
-
if (
|
| 524 |
-
isinstance(df, pd.DataFrame)
|
| 525 |
-
and "message" in df.columns
|
| 526 |
-
and len(df) == 1
|
| 527 |
-
):
|
| 528 |
return str(df.iloc[0]["message"])
|
| 529 |
|
| 530 |
-
# 1. Handle NO MATCHING DATA explicitly
|
| 531 |
if df.empty:
|
| 532 |
return (
|
| 533 |
"I couldn’t find any orders matching the details you provided. "
|
| 534 |
-
"Please double-check your order ID or the information you entered, "
|
| 535 |
-
"and try again."
|
| 536 |
)
|
| 537 |
|
| 538 |
-
# 2. Prepare data (truncate)
|
| 539 |
df_for_llm = df.head(max_rows_to_list).copy()
|
| 540 |
result_data = df_for_llm.to_dict(orient="records")
|
| 541 |
|
| 542 |
-
# Emojis
|
| 543 |
if include_emojis:
|
| 544 |
-
intro_line =
|
| 545 |
-
|
| 546 |
-
)
|
| 547 |
-
emoji_instruction = (
|
| 548 |
-
"Include a few relevant, tasteful emojis (like ✅, 🚚, ⏱️, 🍕, ℹ️, ⚠️, 💛) "
|
| 549 |
-
"to make it friendly, but do not overuse them.\n"
|
| 550 |
-
)
|
| 551 |
else:
|
| 552 |
-
intro_line =
|
| 553 |
-
|
| 554 |
-
)
|
| 555 |
-
emoji_instruction = "Do NOT use any emojis in your response.\n"
|
| 556 |
|
| 557 |
-
|
| 558 |
-
if
|
| 559 |
-
tone_instruction = "Use a friendly, conversational tone while staying respectful.\n"
|
| 560 |
-
elif tone == "formal":
|
| 561 |
-
tone_instruction = "Use a formal, professional tone.\n"
|
| 562 |
-
else:
|
| 563 |
-
tone_instruction = "Use a polite, professional tone, warm but not overly casual.\n"
|
| 564 |
-
|
| 565 |
-
# Language
|
| 566 |
-
if language == "auto":
|
| 567 |
-
language_instruction = (
|
| 568 |
-
"Detect the user's language and respond in the SAME language. "
|
| 569 |
-
"If unsure, default to English.\n"
|
| 570 |
-
)
|
| 571 |
-
else:
|
| 572 |
-
language_instruction = f"Respond in this language: {language}.\n"
|
| 573 |
|
| 574 |
system_prompt = f"""
|
| 575 |
You are a polite and professional customer support chatbot for FoodHub.
|
| 576 |
|
| 577 |
-
Always start with
|
| 578 |
{intro_line}
|
| 579 |
|
| 580 |
-
|
| 581 |
-
- If
|
| 582 |
-
-
|
| 583 |
-
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
When answering:
|
| 588 |
-
- Explain the latest known order status clearly.
|
| 589 |
-
- If 'delivery_time' is missing but 'delivery_eta' exists:
|
| 590 |
-
- Say the order is still in progress, and share ETA.
|
| 591 |
-
- If both ETA and delivery_time are missing:
|
| 592 |
-
- Apologize for limited tracking information.
|
| 593 |
-
- If delivered and 'delivery_time' exists:
|
| 594 |
-
- Confirm delivery and mention the exact time (in friendly wording).
|
| 595 |
-
- If SLA columns exist:
|
| 596 |
-
- If 'on_time_delivery' == 1 → reassure user their order was/has been on time.
|
| 597 |
-
- If delayed → apologize sincerely and reassure support.
|
| 598 |
-
- For multiple rows → summarize count + list top {max_rows_to_list} orders.
|
| 599 |
-
|
| 600 |
-
Tone & style rules:
|
| 601 |
-
{tone_instruction}
|
| 602 |
-
{emoji_instruction}
|
| 603 |
-
{language_instruction}
|
| 604 |
-
- Keep responses concise and human-friendly.
|
| 605 |
-
- Use natural phrasing like "around 1:00 PM" instead of "13:00:00".
|
| 606 |
-
- End warmly, e.g.: "If you need anything else, I’m here to help! 💛"
|
| 607 |
"""
|
| 608 |
|
| 609 |
-
content = (
|
| 610 |
-
f"User question: {user_message}\n\n"
|
| 611 |
-
f"Matching order data (up to {max_rows_to_list} rows):\n{result_data}"
|
| 612 |
-
)
|
| 613 |
|
| 614 |
response = client.chat.completions.create(
|
| 615 |
model="llama-3.3-70b-versatile",
|
| 616 |
temperature=0.4,
|
| 617 |
messages=[
|
| 618 |
{"role": "system", "content": system_prompt},
|
| 619 |
-
{"role": "user",
|
| 620 |
],
|
| 621 |
)
|
| 622 |
-
|
| 623 |
return response.choices[0].message.content.strip()
|
| 624 |
|
| 625 |
# =====================================================================
|
| 626 |
-
# answer_user_question()
|
| 627 |
# =====================================================================
|
| 628 |
def answer_user_question(user_message: str) -> str:
|
| 629 |
-
"""
|
| 630 |
-
Secure natural-language answering pipeline:
|
| 631 |
-
- Convert natural language �� SQL
|
| 632 |
-
- Run safe SQL
|
| 633 |
-
- Summarize result into a polite, empathetic customer-service style reply
|
| 634 |
-
(Hacker/misuse is handled at chatbot_response level via is_misuse_or_hacker_attempt)
|
| 635 |
-
"""
|
| 636 |
-
# Step 1 — Natural language → SQL
|
| 637 |
sql = llm_to_sql(user_message)
|
| 638 |
print("Generated SQL:", sql)
|
| 639 |
-
|
| 640 |
-
# Step 2 — Run SQL safely
|
| 641 |
df = run_sql_query(sql)
|
| 642 |
-
|
| 643 |
-
# Step 3 — Convert SQL results to a friendly, human-like response
|
| 644 |
-
reply = sql_result_to_response(
|
| 645 |
-
user_message=user_message,
|
| 646 |
-
df=df,
|
| 647 |
-
tone="polite-professional",
|
| 648 |
-
include_emojis=True,
|
| 649 |
-
language="auto",
|
| 650 |
-
max_rows_to_list=5,
|
| 651 |
-
)
|
| 652 |
-
|
| 653 |
-
return reply
|
| 654 |
|
| 655 |
# =====================================================================
|
| 656 |
-
# chatbot_response()
|
| 657 |
# =====================================================================
|
| 658 |
def chatbot_response(user_message: str) -> str:
|
| 659 |
-
# Safely normalize user input
|
| 660 |
text = (user_message or "").lower().strip()
|
| 661 |
|
| 662 |
-
# Pre-compute order ID presence once
|
| 663 |
order_id_match = re.search(r"\b[oO]\d{3,}\b", text)
|
| 664 |
has_order_id = order_id_match is not None
|
| 665 |
|
| 666 |
-
# 0) Small-talk / greetings handling
|
| 667 |
greetings = ["hi", "hello", "hey", "good morning", "good evening", "good afternoon"]
|
| 668 |
thanks_words = ["thank you", "thanks", "thx", "thankyou"]
|
| 669 |
|
| 670 |
if any(text == g or text.startswith(g + " ") for g in greetings):
|
| 671 |
return (
|
| 672 |
"👋 Hi there! Welcome to FoodHub Support.\n"
|
| 673 |
-
"You can ask
|
| 674 |
)
|
| 675 |
|
| 676 |
if any(w in text for w in thanks_words):
|
| 677 |
return (
|
| 678 |
"You’re most welcome! 💛\n"
|
| 679 |
-
"If you need
|
| 680 |
)
|
| 681 |
|
| 682 |
-
# 1) Block hacking / misuse attempts
|
| 683 |
if is_misuse_or_hacker_attempt(user_message):
|
| 684 |
return (
|
| 685 |
"😔 Sorry, I can’t assist with that.\n"
|
| 686 |
"For everyone’s safety, I can only help with your own order using a valid order ID.\n"
|
| 687 |
-
"Try
|
| 688 |
)
|
| 689 |
|
| 690 |
-
# 2) Escalation detection for repeated unresolved concerns
|
| 691 |
escalation_keywords = [
|
| 692 |
"raised the query multiple times",
|
| 693 |
"asked multiple times",
|
|
@@ -699,106 +449,38 @@ def chatbot_response(user_message: str) -> str:
|
|
| 699 |
senti_info = analyze_sentiment_and_escalation(user_message)
|
| 700 |
sentiment = senti_info.get("sentiment", "angry")
|
| 701 |
|
| 702 |
-
ticket_id = create_ticket(
|
| 703 |
-
user_message,
|
| 704 |
-
sentiment,
|
| 705 |
-
ticket_type="escalation",
|
| 706 |
-
)
|
| 707 |
-
|
| 708 |
-
# Empathetic escalation response
|
| 709 |
-
return (
|
| 710 |
-
"💛 I’m really sorry that you’ve had to follow up multiple times and still don’t have a clear update — "
|
| 711 |
-
"I completely understand how frustrating that feels.\n\n"
|
| 712 |
-
"I’ve now **escalated your case to a senior support agent** so it gets immediate attention.\n\n"
|
| 713 |
-
f"📌 Your escalation ticket ID is **{ticket_id}**.\n\n"
|
| 714 |
-
"They’ll review your case and the order history on priority and get back to you as soon as possible.\n"
|
| 715 |
-
"If you haven’t already, please share your **Order ID** (for example: `O12488`) and any key details so we can resolve this even faster."
|
| 716 |
-
)
|
| 717 |
|
| 718 |
-
# 3) Order cancellation intent detection
|
| 719 |
-
if (
|
| 720 |
-
"cancel my order" in text
|
| 721 |
-
or "cancel order" in text
|
| 722 |
-
or "i want to cancel my order" in text
|
| 723 |
-
or ("cancel" in text and "order" in text)
|
| 724 |
-
):
|
| 725 |
-
match = order_id_match
|
| 726 |
-
if match:
|
| 727 |
-
order_id = match.group(0).upper()
|
| 728 |
-
|
| 729 |
-
senti_info = analyze_sentiment_and_escalation(user_message)
|
| 730 |
-
sentiment = senti_info.get("sentiment", "neutral")
|
| 731 |
-
|
| 732 |
-
ticket_id = create_ticket(
|
| 733 |
-
f"Cancellation request for {order_id}. Original message: {user_message}",
|
| 734 |
-
sentiment,
|
| 735 |
-
ticket_type="cancellation",
|
| 736 |
-
order_id=order_id,
|
| 737 |
-
)
|
| 738 |
-
|
| 739 |
-
return (
|
| 740 |
-
f"🙏 I understand you would like to cancel **Order {order_id}**, and I'm really sorry "
|
| 741 |
-
f"if there was any inconvenience that led to this decision.\n\n"
|
| 742 |
-
f"📝 Your cancellation request has been successfully recorded.\n"
|
| 743 |
-
f"🔎 A customer support specialist will now verify and process it as soon as possible.\n\n"
|
| 744 |
-
f"📌 **Ticket Reference ID:** `{ticket_id}`\n\n"
|
| 745 |
-
f"Please let me know if you'd like to modify the order instead — I'm here to help 😊"
|
| 746 |
-
)
|
| 747 |
-
|
| 748 |
-
# No order ID found in a cancel intent → empathetic ask
|
| 749 |
return (
|
| 750 |
-
"💛 I’m sorry
|
| 751 |
-
"
|
| 752 |
-
"
|
|
|
|
| 753 |
)
|
| 754 |
|
| 755 |
-
# 4) Payment / refund intent detection (needs order ID)
|
| 756 |
if ("payment" in text or "refund" in text) and not has_order_id:
|
| 757 |
return (
|
| 758 |
-
"💛 I’m sorry you’re facing
|
| 759 |
-
"I
|
| 760 |
-
"Could you please share your **Order ID** (for example: `O12488`) "
|
| 761 |
-
"so I can check the payment status and update you?"
|
| 762 |
)
|
| 763 |
|
| 764 |
-
# 5) Status / details intents that require Order ID
|
| 765 |
order_status_intents = [
|
| 766 |
-
"where is my order",
|
| 767 |
-
"
|
| 768 |
-
"
|
| 769 |
-
"order status",
|
| 770 |
-
"status of my order",
|
| 771 |
-
"delivery status",
|
| 772 |
-
"when will my order arrive",
|
| 773 |
-
"when will it arrive",
|
| 774 |
-
"order not delivered",
|
| 775 |
-
"order is late",
|
| 776 |
-
"order delayed",
|
| 777 |
-
"delay in delivery",
|
| 778 |
-
"order details",
|
| 779 |
-
"details for my order",
|
| 780 |
-
"details of my order",
|
| 781 |
-
"show me the details for my order",
|
| 782 |
]
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
if has_status_intent and not has_order_id:
|
| 786 |
-
return (
|
| 787 |
-
"I’d be happy to check that for you 💛\n"
|
| 788 |
-
"Please share your **Order ID** (for example: `O12488`) so I can look up the exact status."
|
| 789 |
-
)
|
| 790 |
|
| 791 |
-
# 6) Normal path → delegate to SQL-based order assistant
|
| 792 |
try:
|
| 793 |
return answer_user_question(user_message)
|
| 794 |
except Exception as e:
|
| 795 |
-
print("[chatbot_response] Error
|
| 796 |
return (
|
| 797 |
"Sorry, I ran into a small issue while processing this request. 🙏\n"
|
| 798 |
-
"Please make sure you
|
| 799 |
)
|
| 800 |
|
| 801 |
-
# Simple wrapper so the UI still calls `chatbot(...)`
|
| 802 |
def chatbot(msg: str) -> str:
|
| 803 |
return chatbot_response(msg)
|
| 804 |
|
|
@@ -806,20 +488,16 @@ def chatbot(msg: str) -> str:
|
|
| 806 |
if "welcome_shown" not in st.session_state:
|
| 807 |
st.session_state["welcome_shown"] = False
|
| 808 |
|
| 809 |
-
# ================== UI
|
| 810 |
-
|
| 811 |
-
# Header card (replaces logo / banner)
|
| 812 |
st.markdown(
|
| 813 |
"""
|
| 814 |
<div class="foodhub-card">
|
| 815 |
<div style="display:flex; align-items:flex-start; gap:12px;">
|
| 816 |
<div style="font-size:32px;">🍕</div>
|
| 817 |
<div style="flex:1%;">
|
| 818 |
-
<div class="foodhub-header-title">
|
| 819 |
-
FoodHub AI Support
|
| 820 |
-
</div>
|
| 821 |
<p class="foodhub-header-subtitle">
|
| 822 |
-
Ask about
|
| 823 |
</p>
|
| 824 |
<div class="foodhub-assistant-pill">
|
| 825 |
<span class="status-dot"></span>
|
|
@@ -832,13 +510,11 @@ st.markdown(
|
|
| 832 |
unsafe_allow_html=True,
|
| 833 |
)
|
| 834 |
|
| 835 |
-
# One-time welcome (not repeated per message)
|
| 836 |
if not st.session_state["welcome_shown"]:
|
| 837 |
st.success("Welcome to FoodHub Support! I’m your virtual assistant for order, delivery, and payment queries.")
|
| 838 |
st.caption("Because great food deserves great service 💛")
|
| 839 |
st.session_state["welcome_shown"] = True
|
| 840 |
|
| 841 |
-
# Quick action suggestions (mobile friendly)
|
| 842 |
st.markdown('<div class="quick-actions-title">Quick help options</div>', unsafe_allow_html=True)
|
| 843 |
col1, col2 = st.columns(2)
|
| 844 |
|
|
@@ -853,7 +529,6 @@ if col3.button("❌ Cancel order", use_container_width=True):
|
|
| 853 |
if col4.button("🧾 Order details", use_container_width=True):
|
| 854 |
st.session_state["chat_input"] = "Show me the details for my order."
|
| 855 |
|
| 856 |
-
# Main chat input
|
| 857 |
query = st.text_input(
|
| 858 |
"💬 Type your question here:",
|
| 859 |
key="chat_input",
|
|
@@ -866,14 +541,10 @@ if send_clicked:
|
|
| 866 |
if query.strip():
|
| 867 |
reply = chatbot(query.strip())
|
| 868 |
|
| 869 |
-
# Simple conversational layout (last turn)
|
| 870 |
st.markdown('<div class="user-label">You</div>', unsafe_allow_html=True)
|
| 871 |
st.markdown(f"<div class='bot-bubble' style='background:#fff;'>💬 {query}</div>", unsafe_allow_html=True)
|
| 872 |
|
| 873 |
st.markdown('<div class="bot-label">FoodHub Assistant</div>', unsafe_allow_html=True)
|
| 874 |
-
st.markdown(
|
| 875 |
-
f"<div class='bot-bubble'>🤖 {reply}</div>",
|
| 876 |
-
unsafe_allow_html=True,
|
| 877 |
-
)
|
| 878 |
else:
|
| 879 |
st.warning("Please enter a message to continue.")
|
|
|
|
| 19 |
st.markdown(
|
| 20 |
"""
|
| 21 |
<style>
|
| 22 |
+
.main { max-width: 480px; margin: 0 auto; }
|
|
|
|
|
|
|
|
|
|
| 23 |
.foodhub-card {
|
| 24 |
background: linear-gradient(135deg, #fff3e0, #ffe0cc);
|
| 25 |
+
padding: 16px 20px; border-radius: 18px;
|
| 26 |
+
border: 1px solid #f5c28c; margin-bottom: 16px;
|
|
|
|
|
|
|
| 27 |
}
|
| 28 |
.foodhub-header-title {
|
| 29 |
+
margin-bottom: 4px; color: #e65c2b;
|
| 30 |
+
font-size: 26px; font-weight: 800;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
}
|
| 32 |
+
.foodhub-header-subtitle { margin: 0; color: #444; font-size: 14px; }
|
| 33 |
.foodhub-assistant-pill {
|
| 34 |
+
display: inline-flex; align-items: center; gap: 6px;
|
| 35 |
+
background: #ffffff; border-radius: 999px;
|
| 36 |
+
padding: 6px 10px; font-size: 11px; color: #555;
|
| 37 |
+
border: 1px solid #ffd3a3; margin-top: 8px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
+
.status-dot { width: 8px; height: 8px; border-radius: 50%; background: #2ecc71; }
|
| 40 |
.quick-actions-title {
|
| 41 |
+
font-size: 13px; font-weight: 600; color: #555;
|
| 42 |
+
margin-top: 4px; margin-bottom: 4px;
|
|
|
|
|
|
|
|
|
|
| 43 |
}
|
| 44 |
.bot-bubble {
|
| 45 |
+
background: #eef7f2; border-radius: 12px;
|
| 46 |
+
padding: 10px 12px; font-size: 14px; margin-top: 12px;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
}
|
| 48 |
+
.user-label { font-size: 12px; color: #777; margin-top: 8px; margin-bottom: 2px; }
|
| 49 |
+
.bot-label { font-size: 12px; color: #777; margin-top: 12px; margin-bottom: 2px; }
|
| 50 |
</style>
|
| 51 |
""",
|
| 52 |
unsafe_allow_html=True,
|
| 53 |
)
|
| 54 |
|
| 55 |
+
# ================== DB CONFIG (ALIGNED WITH NOTEBOOK) ==================
|
| 56 |
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 57 |
+
CLEAN_DB_PATH = os.path.join(BASE_DIR, "orders_clean.db") # <-- aligned with notebook
|
| 58 |
|
| 59 |
+
# Startup sanity check – catches wrong DB uploads early
|
| 60 |
try:
|
| 61 |
with sqlite3.connect(CLEAN_DB_PATH) as _conn:
|
| 62 |
_cur = _conn.cursor()
|
| 63 |
_cur.execute("SELECT name FROM sqlite_master WHERE type='table';")
|
| 64 |
_tables = [r[0] for r in _cur.fetchall()]
|
| 65 |
+
|
| 66 |
if "orders_clean" not in _tables:
|
| 67 |
st.error(
|
| 68 |
f"Connected to {CLEAN_DB_PATH!r}, but table 'orders_clean' was not found.\n\n"
|
| 69 |
+
"Please ensure df_eng was saved to this DB with table name 'orders_clean' "
|
| 70 |
+
"(df_eng.to_sql('orders_clean', ...)) and that orders_clean.db is present next to app.py."
|
| 71 |
)
|
| 72 |
st.stop()
|
| 73 |
except Exception as e:
|
|
|
|
| 83 |
client = Groq(api_key=groq_api_key)
|
| 84 |
|
| 85 |
# ================== SCHEMA (for LLM prompt only) ==================
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
ORDERS_SCHEMA = """
|
| 87 |
Table: orders_clean
|
| 88 |
|
|
|
|
| 97 |
- delivery_eta (DATETIME/TEXT) : Estimated delivery timestamp
|
| 98 |
- delivery_time (DATETIME/TEXT) : Actual delivery timestamp
|
| 99 |
|
| 100 |
+
- order_status_std (TEXT) : Normalized order status
|
| 101 |
+
- payment_status_std (TEXT) : Normalized payment status
|
|
|
|
|
|
|
| 102 |
|
| 103 |
- item_in_order (TEXT) : Comma-separated list of items
|
| 104 |
- item_count (INTEGER) : Derived integer count of ordered items
|
| 105 |
|
| 106 |
--- Feature Engineered Columns ---
|
| 107 |
+
- prep_duration_min (REAL)
|
| 108 |
+
- delivery_duration_min (REAL)
|
| 109 |
+
- total_duration_min (REAL)
|
| 110 |
+
|
| 111 |
+
- on_time_delivery (INTEGER: 0/1)
|
| 112 |
+
- is_delivered (INTEGER: 0/1)
|
| 113 |
+
- is_canceled (INTEGER: 0/1)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
"""
|
| 115 |
|
| 116 |
# =====================================================================
|
| 117 |
# SQL SAFETY FIREWALL
|
| 118 |
# =====================================================================
|
| 119 |
def is_safe_sql(sql: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
if not isinstance(sql, str):
|
| 121 |
return False
|
| 122 |
|
| 123 |
sql_lower = sql.lower().strip()
|
| 124 |
|
|
|
|
| 125 |
if not sql_lower.startswith("select"):
|
| 126 |
return False
|
| 127 |
|
| 128 |
+
# block stacked queries
|
| 129 |
if re.search(r";\s*\S", sql_lower):
|
| 130 |
return False
|
| 131 |
|
| 132 |
+
forbidden_keywords = ["drop", "delete", "update", "insert", "alter", "truncate", "create"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
if any(kw in sql_lower for kw in forbidden_keywords):
|
| 134 |
return False
|
| 135 |
|
|
|
|
| 141 |
|
| 142 |
# =====================================================================
|
| 143 |
# run_sql_query()
|
|
|
|
| 144 |
# =====================================================================
|
| 145 |
def run_sql_query(sql: str) -> pd.DataFrame:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
if not isinstance(sql, str):
|
| 147 |
return pd.DataFrame([{"message": "🚫 Invalid SQL type (expected string)."}])
|
| 148 |
sql = sql.strip()
|
| 149 |
|
|
|
|
| 150 |
if not is_safe_sql(sql):
|
| 151 |
return pd.DataFrame([{"message": "🚫 Blocked unsafe or unsupported SQL."}])
|
| 152 |
|
| 153 |
try:
|
|
|
|
| 154 |
with sqlite3.connect(CLEAN_DB_PATH) as connection:
|
| 155 |
df = pd.read_sql_query(sql, connection)
|
| 156 |
|
|
|
|
| 157 |
time_cols = ["order_time", "preparing_eta", "prepared_time", "delivery_eta", "delivery_time"]
|
| 158 |
for col in time_cols:
|
| 159 |
if col in df.columns:
|
| 160 |
df[col] = pd.to_datetime(df[col], errors="coerce")
|
| 161 |
|
| 162 |
return df
|
|
|
|
| 163 |
except Exception as e:
|
|
|
|
| 164 |
return pd.DataFrame([{"message": f"⚠️ SQL execution error: {str(e)}"}])
|
| 165 |
|
| 166 |
# =====================================================================
|
| 167 |
+
# llm_to_sql()
|
| 168 |
# =====================================================================
|
| 169 |
def llm_to_sql(user_message: str) -> str:
|
| 170 |
"""
|
| 171 |
+
Convert natural language to a safe SQLite SELECT query
|
| 172 |
targeting the orders_clean table inside orders_clean.db.
|
| 173 |
"""
|
|
|
|
| 174 |
text = (user_message or "").lower().strip()
|
| 175 |
|
|
|
|
|
|
|
|
|
|
| 176 |
match = re.search(r"\b(o\d+)\b", text)
|
| 177 |
if match:
|
| 178 |
+
order_id = match.group(1)
|
| 179 |
+
sql = "SELECT * FROM orders_clean " f"WHERE LOWER(order_id) = LOWER('{order_id}')"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
return sql if is_safe_sql(sql) else "SELECT 'Unable to answer safely.' AS message;"
|
| 181 |
|
|
|
|
|
|
|
|
|
|
| 182 |
system_prompt = f"""
|
| 183 |
You are an expert SQLite assistant for a food delivery company.
|
| 184 |
|
|
|
|
| 192 |
- Allowed operation: SELECT only
|
| 193 |
- If filtering by order_id, ALWAYS use:
|
| 194 |
LOWER(order_id) = LOWER('<value>')
|
| 195 |
+
- If unsure:
|
| 196 |
SELECT 'Unable to answer with available data.' AS message;
|
| 197 |
"""
|
| 198 |
|
|
|
|
| 201 |
temperature=0.1,
|
| 202 |
messages=[
|
| 203 |
{"role": "system", "content": system_prompt},
|
| 204 |
+
{"role": "user", "content": user_message},
|
| 205 |
],
|
| 206 |
)
|
| 207 |
|
| 208 |
sql = (response.choices[0].message.content or "").strip()
|
| 209 |
|
|
|
|
| 210 |
if sql.startswith("```"):
|
| 211 |
sql = re.sub(r"^```sql", "", sql, flags=re.IGNORECASE).strip()
|
| 212 |
sql = re.sub(r"^```", "", sql).strip()
|
| 213 |
sql = sql.replace("```", "").strip()
|
| 214 |
|
| 215 |
+
# safer: only replace first FROM target if not already orders_clean
|
| 216 |
sql = re.sub(
|
| 217 |
+
r"\bfrom\s+(?!orders_clean\b)([a-zA-Z_]\w*)\b",
|
| 218 |
"FROM orders_clean",
|
| 219 |
sql,
|
| 220 |
+
count=1,
|
| 221 |
flags=re.IGNORECASE,
|
| 222 |
)
|
| 223 |
|
|
|
|
| 224 |
sql = re.sub(
|
| 225 |
r"where\s+order_id\s*=\s*'([^']+)'",
|
| 226 |
r"WHERE LOWER(order_id) = LOWER('\1')",
|
|
|
|
| 228 |
flags=re.IGNORECASE,
|
| 229 |
)
|
| 230 |
|
|
|
|
| 231 |
if not is_safe_sql(sql):
|
| 232 |
return "SELECT 'Unable to answer safely.' AS message;"
|
| 233 |
|
| 234 |
return sql
|
| 235 |
|
| 236 |
# =====================================================================
|
| 237 |
+
# analyze_sentiment_and_escalation()
|
| 238 |
# =====================================================================
|
| 239 |
def analyze_sentiment_and_escalation(user_message: str) -> dict:
|
|
|
|
| 240 |
if user_message is None:
|
| 241 |
user_message = ""
|
| 242 |
user_message = str(user_message).strip()
|
|
|
|
| 244 |
system_prompt = """
|
| 245 |
You are a classifier for a food delivery chatbot.
|
| 246 |
|
| 247 |
+
Return ONLY JSON:
|
|
|
|
| 248 |
{
|
| 249 |
"sentiment": "calm" | "neutral" | "frustrated" | "angry",
|
| 250 |
"escalate": true or false
|
| 251 |
}
|
| 252 |
|
| 253 |
+
Escalate = true when user is frustrated/angry, repeats no resolution, or demands urgent help.
|
| 254 |
+
If uncertain, return: {"sentiment":"neutral","escalate":false}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 255 |
"""
|
| 256 |
|
| 257 |
response = client.chat.completions.create(
|
| 258 |
model="llama-3.3-70b-versatile",
|
| 259 |
messages=[
|
| 260 |
{"role": "system", "content": system_prompt},
|
| 261 |
+
{"role": "user", "content": user_message},
|
| 262 |
],
|
| 263 |
temperature=0.0,
|
| 264 |
)
|
|
|
|
| 270 |
if not isinstance(info, dict):
|
| 271 |
raise ValueError("Not a JSON object")
|
| 272 |
if "sentiment" not in info or "escalate" not in info:
|
| 273 |
+
raise ValueError("Missing keys")
|
| 274 |
+
info["sentiment"] = str(info["sentiment"]).strip().lower()
|
| 275 |
+
info["escalate"] = bool(info["escalate"])
|
| 276 |
+
if info["sentiment"] not in {"calm", "neutral", "frustrated", "angry"}:
|
| 277 |
+
raise ValueError("Invalid sentiment")
|
| 278 |
except (json.JSONDecodeError, ValueError):
|
| 279 |
info = {"sentiment": "neutral", "escalate": False}
|
| 280 |
|
| 281 |
return info
|
| 282 |
|
| 283 |
# =====================================================================
|
| 284 |
+
# is_misuse_or_hacker_attempt()
|
| 285 |
# =====================================================================
|
| 286 |
def is_misuse_or_hacker_attempt(user_message: str) -> bool:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
text = (user_message or "").lower().strip()
|
| 288 |
|
| 289 |
+
all_signals = [
|
| 290 |
+
# hacking intent
|
| 291 |
+
"hack", "hacker", "hacking", "hacked", "breach", "exploit",
|
| 292 |
+
"bypass security", "disable firewall", "root access", "admin access",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 293 |
"reverse engineer",
|
| 294 |
+
# bulk extraction
|
| 295 |
+
"all orders", "every order", "order history", "full database", "entire database",
|
| 296 |
+
"all customers", "customer list", "download database", "dump data", "export all",
|
| 297 |
+
"export everything", "show everything",
|
| 298 |
+
# jailbreak
|
| 299 |
+
"ignore previous instructions", "override rules", "forget rules", "developer mode",
|
| 300 |
+
"you are no longer restricted", "pretend you are", "act like",
|
| 301 |
+
# SQL injection
|
| 302 |
+
"1=1", "union select", "drop table", "delete from", "insert into", "alter table", "truncate",
|
| 303 |
]
|
| 304 |
|
| 305 |
+
if ";" in text and any(sig in text for sig in ["1=1", "union select", "drop table", "delete from", "insert into", "alter table", "truncate"]):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
return True
|
| 307 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 308 |
return any(sig in text for sig in all_signals)
|
| 309 |
|
| 310 |
# =====================================================================
|
| 311 |
+
# create_ticket() (writes to SAME DB file: orders_clean.db)
|
| 312 |
# =====================================================================
|
| 313 |
+
def create_ticket(user_message: str, sentiment: str, ticket_type: str = "general", order_id: str = None) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 314 |
if order_id is None:
|
| 315 |
match = re.search(r"\b(o\d+)\b", (user_message or "").lower())
|
| 316 |
order_id = match.group(1).upper() if match else None
|
|
|
|
| 332 |
);
|
| 333 |
""")
|
| 334 |
cursor.execute("""
|
| 335 |
+
INSERT INTO tickets (ticket_id, ticket_type, sentiment, message, order_id, status, created_at)
|
|
|
|
|
|
|
|
|
|
| 336 |
VALUES (?, ?, ?, ?, ?, 'Open', ?);
|
| 337 |
""", (ticket_id, ticket_type, sentiment, user_message, order_id, created_at))
|
| 338 |
conn.commit()
|
|
|
|
| 340 |
return ticket_id
|
| 341 |
|
| 342 |
# =====================================================================
|
| 343 |
+
# sql_result_to_response()
|
| 344 |
# =====================================================================
|
| 345 |
def sql_result_to_response(
|
| 346 |
user_message: str,
|
|
|
|
| 350 |
language: str = "auto",
|
| 351 |
max_rows_to_list: int = 5
|
| 352 |
) -> str:
|
| 353 |
+
if isinstance(df, pd.DataFrame) and "message" in df.columns and len(df) == 1:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
return str(df.iloc[0]["message"])
|
| 355 |
|
|
|
|
| 356 |
if df.empty:
|
| 357 |
return (
|
| 358 |
"I couldn’t find any orders matching the details you provided. "
|
| 359 |
+
"Please double-check your order ID or the information you entered, and try again."
|
|
|
|
| 360 |
)
|
| 361 |
|
|
|
|
| 362 |
df_for_llm = df.head(max_rows_to_list).copy()
|
| 363 |
result_data = df_for_llm.to_dict(orient="records")
|
| 364 |
|
|
|
|
| 365 |
if include_emojis:
|
| 366 |
+
intro_line = "Welcome to FoodHub Support! 👋 I'm your virtual assistant — here to help you.\n"
|
| 367 |
+
emoji_instruction = "Include a few relevant, tasteful emojis (✅ 🚚 ⏱️ 🍕 ℹ️ ⚠️ 💛), but do not overuse.\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 368 |
else:
|
| 369 |
+
intro_line = "Welcome to FoodHub Support! I’m your virtual assistant — here to help you.\n"
|
| 370 |
+
emoji_instruction = "Do NOT use emojis.\n"
|
|
|
|
|
|
|
| 371 |
|
| 372 |
+
tone_instruction = "Use a polite, professional tone, warm but not overly casual.\n"
|
| 373 |
+
language_instruction = "Detect the user's language and respond in the SAME language. If unsure, use English.\n" if language == "auto" else f"Respond in: {language}.\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
system_prompt = f"""
|
| 376 |
You are a polite and professional customer support chatbot for FoodHub.
|
| 377 |
|
| 378 |
+
Always start with:
|
| 379 |
{intro_line}
|
| 380 |
|
| 381 |
+
Policy:
|
| 382 |
+
- If any matching record exists → order is confirmed. Do NOT say "order not found".
|
| 383 |
+
- Use delivery_eta if delivery_time missing.
|
| 384 |
+
- Keep concise and human-friendly.
|
| 385 |
+
|
| 386 |
+
{tone_instruction}{emoji_instruction}{language_instruction}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
"""
|
| 388 |
|
| 389 |
+
content = f"User question: {user_message}\n\nMatching order data (up to {max_rows_to_list} rows):\n{result_data}"
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
response = client.chat.completions.create(
|
| 392 |
model="llama-3.3-70b-versatile",
|
| 393 |
temperature=0.4,
|
| 394 |
messages=[
|
| 395 |
{"role": "system", "content": system_prompt},
|
| 396 |
+
{"role": "user", "content": content},
|
| 397 |
],
|
| 398 |
)
|
|
|
|
| 399 |
return response.choices[0].message.content.strip()
|
| 400 |
|
| 401 |
# =====================================================================
|
| 402 |
+
# answer_user_question()
|
| 403 |
# =====================================================================
|
| 404 |
def answer_user_question(user_message: str) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 405 |
sql = llm_to_sql(user_message)
|
| 406 |
print("Generated SQL:", sql)
|
|
|
|
|
|
|
| 407 |
df = run_sql_query(sql)
|
| 408 |
+
return sql_result_to_response(user_message=user_message, df=df)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 409 |
|
| 410 |
# =====================================================================
|
| 411 |
+
# chatbot_response()
|
| 412 |
# =====================================================================
|
| 413 |
def chatbot_response(user_message: str) -> str:
|
|
|
|
| 414 |
text = (user_message or "").lower().strip()
|
| 415 |
|
|
|
|
| 416 |
order_id_match = re.search(r"\b[oO]\d{3,}\b", text)
|
| 417 |
has_order_id = order_id_match is not None
|
| 418 |
|
|
|
|
| 419 |
greetings = ["hi", "hello", "hey", "good morning", "good evening", "good afternoon"]
|
| 420 |
thanks_words = ["thank you", "thanks", "thx", "thankyou"]
|
| 421 |
|
| 422 |
if any(text == g or text.startswith(g + " ") for g in greetings):
|
| 423 |
return (
|
| 424 |
"👋 Hi there! Welcome to FoodHub Support.\n"
|
| 425 |
+
"You can ask about order status, delivery, cancellation, or payment/refund issues. 💛"
|
| 426 |
)
|
| 427 |
|
| 428 |
if any(w in text for w in thanks_words):
|
| 429 |
return (
|
| 430 |
"You’re most welcome! 💛\n"
|
| 431 |
+
"If you need help, share your question or Order ID (like `O12488`)."
|
| 432 |
)
|
| 433 |
|
|
|
|
| 434 |
if is_misuse_or_hacker_attempt(user_message):
|
| 435 |
return (
|
| 436 |
"😔 Sorry, I can’t assist with that.\n"
|
| 437 |
"For everyone’s safety, I can only help with your own order using a valid order ID.\n"
|
| 438 |
+
"Try: *Where is my order O12488?* 😊"
|
| 439 |
)
|
| 440 |
|
|
|
|
| 441 |
escalation_keywords = [
|
| 442 |
"raised the query multiple times",
|
| 443 |
"asked multiple times",
|
|
|
|
| 449 |
senti_info = analyze_sentiment_and_escalation(user_message)
|
| 450 |
sentiment = senti_info.get("sentiment", "angry")
|
| 451 |
|
| 452 |
+
ticket_id = create_ticket(user_message, sentiment, ticket_type="escalation")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
return (
|
| 455 |
+
"💛 I’m sorry you’ve had to follow up multiple times.\n\n"
|
| 456 |
+
"I’ve **escalated your case to a senior support agent**.\n\n"
|
| 457 |
+
f"📌 Ticket ID: **{ticket_id}**\n\n"
|
| 458 |
+
"Please share your **Order ID** (e.g., `O12488`) so we can resolve faster."
|
| 459 |
)
|
| 460 |
|
|
|
|
| 461 |
if ("payment" in text or "refund" in text) and not has_order_id:
|
| 462 |
return (
|
| 463 |
+
"💛 I’m sorry you’re facing a payment/refund issue.\n"
|
| 464 |
+
"Please share your **Order ID** (e.g., `O12488`) so I can check and update you."
|
|
|
|
|
|
|
| 465 |
)
|
| 466 |
|
|
|
|
| 467 |
order_status_intents = [
|
| 468 |
+
"where is my order", "track my order", "order status", "delivery status",
|
| 469 |
+
"when will my order arrive", "order delayed", "delay in delivery",
|
| 470 |
+
"order details", "details of my order",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 471 |
]
|
| 472 |
+
if any(p in text for p in order_status_intents) and not has_order_id:
|
| 473 |
+
return "Please share your **Order ID** (e.g., `O12488`) so I can check the exact status. 💛"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
|
|
|
|
| 475 |
try:
|
| 476 |
return answer_user_question(user_message)
|
| 477 |
except Exception as e:
|
| 478 |
+
print("[chatbot_response] Error:", e)
|
| 479 |
return (
|
| 480 |
"Sorry, I ran into a small issue while processing this request. 🙏\n"
|
| 481 |
+
"Please make sure you include a valid Order ID like `O12488`."
|
| 482 |
)
|
| 483 |
|
|
|
|
| 484 |
def chatbot(msg: str) -> str:
|
| 485 |
return chatbot_response(msg)
|
| 486 |
|
|
|
|
| 488 |
if "welcome_shown" not in st.session_state:
|
| 489 |
st.session_state["welcome_shown"] = False
|
| 490 |
|
| 491 |
+
# ================== UI ==================
|
|
|
|
|
|
|
| 492 |
st.markdown(
|
| 493 |
"""
|
| 494 |
<div class="foodhub-card">
|
| 495 |
<div style="display:flex; align-items:flex-start; gap:12px;">
|
| 496 |
<div style="font-size:32px;">🍕</div>
|
| 497 |
<div style="flex:1%;">
|
| 498 |
+
<div class="foodhub-header-title">FoodHub AI Support</div>
|
|
|
|
|
|
|
| 499 |
<p class="foodhub-header-subtitle">
|
| 500 |
+
Ask about order status, delivery updates, cancellations, or payment issues.
|
| 501 |
</p>
|
| 502 |
<div class="foodhub-assistant-pill">
|
| 503 |
<span class="status-dot"></span>
|
|
|
|
| 510 |
unsafe_allow_html=True,
|
| 511 |
)
|
| 512 |
|
|
|
|
| 513 |
if not st.session_state["welcome_shown"]:
|
| 514 |
st.success("Welcome to FoodHub Support! I’m your virtual assistant for order, delivery, and payment queries.")
|
| 515 |
st.caption("Because great food deserves great service 💛")
|
| 516 |
st.session_state["welcome_shown"] = True
|
| 517 |
|
|
|
|
| 518 |
st.markdown('<div class="quick-actions-title">Quick help options</div>', unsafe_allow_html=True)
|
| 519 |
col1, col2 = st.columns(2)
|
| 520 |
|
|
|
|
| 529 |
if col4.button("🧾 Order details", use_container_width=True):
|
| 530 |
st.session_state["chat_input"] = "Show me the details for my order."
|
| 531 |
|
|
|
|
| 532 |
query = st.text_input(
|
| 533 |
"💬 Type your question here:",
|
| 534 |
key="chat_input",
|
|
|
|
| 541 |
if query.strip():
|
| 542 |
reply = chatbot(query.strip())
|
| 543 |
|
|
|
|
| 544 |
st.markdown('<div class="user-label">You</div>', unsafe_allow_html=True)
|
| 545 |
st.markdown(f"<div class='bot-bubble' style='background:#fff;'>💬 {query}</div>", unsafe_allow_html=True)
|
| 546 |
|
| 547 |
st.markdown('<div class="bot-label">FoodHub Assistant</div>', unsafe_allow_html=True)
|
| 548 |
+
st.markdown(f"<div class='bot-bubble'>🤖 {reply}</div>", unsafe_allow_html=True)
|
|
|
|
|
|
|
|
|
|
| 549 |
else:
|
| 550 |
st.warning("Please enter a message to continue.")
|