SuhasGholkar commited on
Commit
26a0344
·
verified ·
1 Parent(s): b8e63d0

Update src/chat.py

Browse files
Files changed (1) hide show
  1. src/chat.py +666 -29
src/chat.py CHANGED
@@ -1,10 +1,510 @@
1
  # src/chat.py
 
 
2
  import time
 
3
  from datetime import datetime
 
4
  import pandas as pd
5
  import streamlit as st
 
 
 
 
 
6
  from src.utils import get_connection
7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  def ensure_chat_schema():
9
  with get_connection() as conn:
10
  conn.execute("""
@@ -34,39 +534,176 @@ def save_chat_to_db(conn, user_id, user_message, bot_reply,
34
  """, (
35
  user_id, datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
36
  user_message, bot_reply, intent, sentiment, resolution, ftr_ms,
37
- sql_query, sql_params
38
  ))
39
  conn.commit()
40
 
 
 
 
41
  def render_chat_tab(conn):
42
- """Simple demo chat to prove wiring; replace with your LLM/intents later."""
43
- st.title("💬 Chat")
44
- if "chat_session" not in st.session_state:
45
- st.session_state.chat_session = []
46
-
47
- text = st.text_input("You:")
48
- if st.button("Send"):
49
- if text.strip():
50
- t0 = time.time()
51
- # TODO: replace with your real chatbot logic
52
- reply = f"Echo: {text}"
53
- ftr_ms = round((time.time() - t0) * 1000, 2)
54
-
55
- # append session and persist with user_id
56
- st.session_state.chat_session.append({
57
- "timestamp": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
58
- "user": text, "bot": reply
59
- })
60
- user_id = st.session_state["user"]["id"]
61
- save_chat_to_db(conn, user_id, text, reply, intent=None, sentiment=None,
62
- resolution="Responded", ftr_ms=ftr_ms, sql_query=None, sql_params=None)
63
- st.experimental_rerun()
64
-
65
- # Show recent (session)
66
- for turn in st.session_state.chat_session[-50:]:
67
- st.markdown(f"**You:** {turn['user']}")
68
- st.markdown(f"**Bot:** {turn['bot']}")
69
- st.markdown("---")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
 
71
  def render_user_export_tab(conn, user_id: int):
72
  st.subheader("Export My Chats")
 
1
  # src/chat.py
2
+ import json
3
+ import re
4
  import time
5
+ import logging
6
  from datetime import datetime
7
+
8
  import pandas as pd
9
  import streamlit as st
10
+ import dateparser
11
+ import sqlite3
12
+
13
+ from openai import OpenAI
14
+
15
  from src.utils import get_connection
16
 
17
+ logger = logging.getLogger(__name__)
18
+ if not logger.handlers:
19
+ logging.basicConfig(level=logging.INFO)
20
+
21
+ # OpenAI client (expects OPENAI_API_KEY in env)
22
+ _client = OpenAI()
23
+
24
+ # =========================
25
+ # INTENT TEMPLATES (SQL)
26
+ # =========================
27
+ INTENTS = {
28
+ # --- Orders ---
29
+ "order_status": {
30
+ "required": ["order_id"],
31
+ "optional": [],
32
+ "sql": """
33
+ SELECT o.order_id, o.order_status,
34
+ o.order_purchase_timestamp,
35
+ o.order_delivered_customer_date,
36
+ o.order_estimated_delivery_date
37
+ FROM olist_orders o
38
+ WHERE o.order_id = :order_id
39
+ LIMIT 1;
40
+ """
41
+ },
42
+ "orders_by_city": {
43
+ "required": ["city"],
44
+ "optional": ["start_date","end_date"],
45
+ "sql": """
46
+ SELECT c.customer_city AS city, COUNT(*) AS orders_count
47
+ FROM olist_orders o
48
+ JOIN olist_customers c USING(customer_id)
49
+ WHERE LOWER(c.customer_city) = LOWER(:city)
50
+ AND (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
51
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
52
+ GROUP BY c.customer_city;
53
+ """
54
+ },
55
+ "delivery_delay_metrics": {
56
+ "required": [],
57
+ "optional": ["start_date","end_date"],
58
+ "sql": """
59
+ SELECT ROUND(AVG(JULIANDAY(o.order_delivered_customer_date) -
60
+ JULIANDAY(o.order_estimated_delivery_date)), 2) AS avg_delay_days,
61
+ ROUND(SUM(CASE WHEN o.order_delivered_customer_date IS NOT NULL
62
+ AND o.order_estimated_delivery_date IS NOT NULL
63
+ AND JULIANDAY(o.order_delivered_customer_date) >
64
+ JULIANDAY(o.order_estimated_delivery_date)
65
+ THEN 1 ELSE 0 END)*1.0 /
66
+ SUM(CASE WHEN o.order_delivered_customer_date IS NOT NULL
67
+ AND o.order_estimated_delivery_date IS NOT NULL
68
+ THEN 1 ELSE 0 END), 3) AS late_ratio
69
+ FROM olist_orders o
70
+ WHERE o.order_delivered_customer_date IS NOT NULL
71
+ AND o.order_estimated_delivery_date IS NOT NULL
72
+ AND (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
73
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date);
74
+ """
75
+ },
76
+ "late_orders_list": {
77
+ "required": [],
78
+ "optional": ["start_date","end_date","limit"],
79
+ "sql": """
80
+ SELECT o.order_id, o.order_purchase_timestamp,
81
+ o.order_delivered_customer_date, o.order_estimated_delivery_date,
82
+ ROUND(JULIANDAY(o.order_delivered_customer_date) -
83
+ JULIANDAY(o.order_estimated_delivery_date), 2) AS delay_days
84
+ FROM olist_orders o
85
+ WHERE o.order_delivered_customer_date IS NOT NULL
86
+ AND o.order_estimated_delivery_date IS NOT NULL
87
+ AND JULIANDAY(o.order_delivered_customer_date) >
88
+ JULIANDAY(o.order_estimated_delivery_date)
89
+ AND (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
90
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
91
+ ORDER BY delay_days DESC
92
+ LIMIT :limit;
93
+ """
94
+ },
95
+
96
+ # --- Products ---
97
+ "top_products_revenue": {
98
+ "required": [],
99
+ "optional": ["start_date","end_date","k"],
100
+ "sql": """
101
+ SELECT i.product_id,
102
+ ROUND(SUM(i.price), 2) AS revenue,
103
+ COUNT(*) AS quantity_sold
104
+ FROM olist_order_items i
105
+ JOIN olist_orders o USING(order_id)
106
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
107
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
108
+ GROUP BY i.product_id
109
+ ORDER BY revenue DESC
110
+ LIMIT :k;
111
+ """
112
+ },
113
+ "top_products_quantity": {
114
+ "required": [],
115
+ "optional": ["start_date","end_date","k"],
116
+ "sql": """
117
+ SELECT i.product_id,
118
+ COUNT(*) AS quantity_sold,
119
+ ROUND(SUM(i.price), 2) AS revenue
120
+ FROM olist_order_items i
121
+ JOIN olist_orders o USING(order_id)
122
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
123
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
124
+ GROUP BY i.product_id
125
+ ORDER BY quantity_sold DESC
126
+ LIMIT :k;
127
+ """
128
+ },
129
+ "category_sales": {
130
+ "required": [],
131
+ "optional": ["category","start_date","end_date"],
132
+ "sql": """
133
+ SELECT p.product_category_name, p.product_category_name_english,
134
+ ROUND(SUM(i.price), 2) AS revenue,
135
+ COUNT(*) AS lines
136
+ FROM olist_order_items i
137
+ JOIN olist_orders o USING(order_id)
138
+ JOIN olist_products p USING(product_id)
139
+ WHERE (:category IS NULL
140
+ OR LOWER(p.product_category_name) = LOWER(:category)
141
+ OR LOWER(p.product_category_name_english) = LOWER(:category))
142
+ AND (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
143
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
144
+ GROUP BY p.product_category_name, p.product_category_name_english
145
+ ORDER BY revenue DESC;
146
+ """
147
+ },
148
+ "seller_performance": {
149
+ "required": ["seller_id"],
150
+ "optional": ["start_date","end_date"],
151
+ "sql": """
152
+ SELECT i.seller_id,
153
+ ROUND(SUM(i.price),2) AS revenue,
154
+ COUNT(*) AS items_sold,
155
+ ROUND(AVG(i.freight_value),2) AS avg_freight
156
+ FROM olist_order_items i
157
+ JOIN olist_orders o USING(order_id)
158
+ WHERE i.seller_id = :seller_id
159
+ AND (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
160
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
161
+ GROUP BY i.seller_id;
162
+ """
163
+ },
164
+
165
+ # --- Customers & Geo ---
166
+ "orders_by_state": {
167
+ "required": [],
168
+ "optional": ["start_date","end_date","k"],
169
+ "sql": """
170
+ SELECT c.customer_state, COUNT(*) AS orders_count
171
+ FROM olist_orders o
172
+ JOIN olist_customers c USING(customer_id)
173
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
174
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
175
+ GROUP BY c.customer_state
176
+ ORDER BY orders_count DESC
177
+ LIMIT :k;
178
+ """
179
+ },
180
+ "repeat_rate": {
181
+ "required": [],
182
+ "optional": ["start_date","end_date"],
183
+ "sql": """
184
+ WITH cust_orders AS (
185
+ SELECT c.customer_unique_id AS cuid, COUNT(*) AS n
186
+ FROM olist_orders o
187
+ JOIN olist_customers c USING(customer_id)
188
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
189
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
190
+ GROUP BY c.customer_unique_id
191
+ )
192
+ SELECT ROUND(SUM(CASE WHEN n>=2 THEN 1 ELSE 0 END)*1.0 / COUNT(*), 3) AS repeat_customer_ratio
193
+ FROM cust_orders;
194
+ """
195
+ },
196
+
197
+ # --- Payments ---
198
+ "payments_breakdown": {
199
+ "required": [],
200
+ "optional": ["start_date","end_date"],
201
+ "sql": """
202
+ SELECT p.payment_type,
203
+ COUNT(*) AS payments_count,
204
+ ROUND(SUM(p.payment_value),2) AS total_value,
205
+ ROUND(AVG(p.payment_installments),2) AS avg_installments
206
+ FROM olist_order_payments p
207
+ JOIN olist_orders o USING(order_id)
208
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
209
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
210
+ GROUP BY p.payment_type
211
+ ORDER BY total_value DESC;
212
+ """
213
+ },
214
+
215
+ # --- AOV Trend ---
216
+ "aov_trend_monthly": {
217
+ "required": [],
218
+ "optional": ["start_date","end_date"],
219
+ "sql": """
220
+ WITH order_value AS (
221
+ SELECT o.order_id,
222
+ DATE(o.order_purchase_timestamp, 'start of month') AS month,
223
+ SUM(i.price) AS order_value
224
+ FROM olist_orders o
225
+ JOIN olist_order_items i USING(order_id)
226
+ WHERE (:start_date IS NULL OR o.order_purchase_timestamp >= :start_date)
227
+ AND (:end_date IS NULL OR o.order_purchase_timestamp < :end_date)
228
+ GROUP BY o.order_id, month
229
+ )
230
+ SELECT month, ROUND(AVG(order_value), 2) AS avg_order_value
231
+ FROM order_value
232
+ GROUP BY month
233
+ ORDER BY month;
234
+ """
235
+ },
236
+
237
+ # --- Reviews ---
238
+ "latest_reviews": {
239
+ "required": [],
240
+ "optional": ["product_id","category","limit"],
241
+ "sql": """
242
+ SELECT r.order_id, r.review_score, r.review_creation_date, r.review_comment_message
243
+ FROM olist_order_reviews r
244
+ WHERE (:product_id IS NULL OR EXISTS (
245
+ SELECT 1 FROM olist_order_items i
246
+ WHERE i.order_id = r.order_id AND i.product_id = :product_id))
247
+ AND (:category IS NULL OR EXISTS (
248
+ SELECT 1 FROM olist_order_items i
249
+ JOIN olist_products p USING(product_id)
250
+ WHERE i.order_id = r.order_id
251
+ AND (LOWER(p.product_category_name) = LOWER(:category)
252
+ OR LOWER(p.product_category_name_english) = LOWER(:category))
253
+ ))
254
+ ORDER BY r.review_creation_date DESC
255
+ LIMIT :limit;
256
+ """
257
+ },
258
+ "rating_summary": {
259
+ "required": [],
260
+ "optional": ["product_id","category","start_date","end_date"],
261
+ "sql": """
262
+ SELECT ROUND(AVG(r.review_score),2) AS avg_rating,
263
+ COUNT(*) AS n_reviews
264
+ FROM olist_order_reviews r
265
+ JOIN olist_orders o ON o.order_id = r.order_id
266
+ WHERE (:start_date IS NULL OR r.review_creation_date >= :start_date)
267
+ AND (:end_date IS NULL OR r.review_creation_date < :end_date)
268
+ AND (:product_id IS NULL OR EXISTS (
269
+ SELECT 1 FROM olist_order_items i
270
+ WHERE i.order_id = r.order_id AND i.product_id = :product_id))
271
+ AND (:category IS NULL OR EXISTS (
272
+ SELECT 1 FROM olist_order_items i
273
+ JOIN olist_products p USING(product_id)
274
+ WHERE i.order_id = r.order_id
275
+ AND (LOWER(p.product_category_name) = LOWER(:category)
276
+ OR LOWER(p.product_category_name_english) = LOWER(:category))
277
+ ));
278
+ """
279
+ },
280
+ }
281
+
282
+ INTENT_KEYWORDS = {
283
+ "order_status": ["order", "status", "track", "tracking", "delivered", "delivery", "estimate", "order id"],
284
+ "orders_by_city": ["city", "town", "municipality"],
285
+ "delivery_delay_metrics": ["delay", "late", "delivery time", "estimated", "sla"],
286
+ "late_orders_list": ["late", "delayed", "overdue"],
287
+ "top_products_revenue": ["top", "revenue", "sales", "gross", "gmv"],
288
+ "top_products_quantity": ["top", "quantity", "units", "sold", "bestseller"],
289
+ "category_sales": ["category", "department", "sales by category"],
290
+ "seller_performance": ["seller", "merchant", "vendor", "performance"],
291
+ "orders_by_state": ["state", "region", "province"],
292
+ "repeat_rate": ["repeat", "retention", "returning"],
293
+ "payments_breakdown": ["payment", "installments", "card", "boleto", "pix", "method"],
294
+ "aov_trend_monthly": ["aov", "average order value", "trend", "monthly"],
295
+ "latest_reviews": ["review", "comment", "feedback", "opinion", "rating"],
296
+ "rating_summary": ["rating", "score", "stars", "nps", "csat"],
297
+ }
298
+
299
+ def _has_overlap(intent: str, text: str) -> bool:
300
+ kws = INTENT_KEYWORDS.get(intent, [])
301
+ if not kws:
302
+ return True
303
+ t = text.lower()
304
+ return any(re.search(r"\b" + re.escape(kw.lower()) + r"\b", t) for kw in kws)
305
+
306
+ # =========================
307
+ # GPT HELPERS
308
+ # =========================
309
+ def gpt_classify_intent(user_text: str) -> dict:
310
+ options = list(INTENTS.keys())
311
+ system = (
312
+ "You are an intent classifier for an e-commerce analytics chatbot. "
313
+ "You MUST return strict JSON. If none of the intents clearly applies, return UNMAPPED."
314
+ )
315
+ user = (
316
+ "Available intents (keys): " + ", ".join(options) + "\n"
317
+ "Task: Choose the single BEST matching intent key OR 'UNMAPPED' if none clearly fits.\n"
318
+ "Output STRICT JSON with fields: intent (string), confidence (0.0-1.0), reason (short string).\n"
319
+ "Do NOT guess. If the message is unrelated, return UNMAPPED.\n\n"
320
+ f"Message: {user_text}"
321
+ )
322
+ resp = _client.chat.completions.create(
323
+ model="gpt-3.5-turbo",
324
+ messages=[{"role":"system","content": system},
325
+ {"role":"user","content": user}],
326
+ temperature=0
327
+ )
328
+ txt = resp.choices[0].message.content.strip()
329
+ try:
330
+ data = json.loads(txt)
331
+ raw_intent = str(data.get("intent","")).strip().lower()
332
+ if raw_intent == "" or raw_intent in ("none","n/a","unknown"):
333
+ data["intent"] = "UNMAPPED"
334
+ else:
335
+ if raw_intent not in [k.lower() for k in INTENTS.keys()] and raw_intent != "unmapped":
336
+ data["intent"] = "UNMAPPED"
337
+ else:
338
+ for k in INTENTS.keys():
339
+ if k.lower() == raw_intent:
340
+ data["intent"] = k
341
+ break
342
+ try:
343
+ c = float(data.get("confidence", 0))
344
+ data["confidence"] = max(0.0, min(1.0, c))
345
+ except Exception:
346
+ data["confidence"] = 0.0
347
+ data["reason"] = str(data.get("reason","")).strip()
348
+ return data
349
+ except Exception:
350
+ return {"intent":"UNMAPPED","confidence":0.0,"reason":"parse_error"}
351
+
352
+ def gpt_classify_sentiment(user_text: str) -> str:
353
+ prompt = "Classify sentiment as Positive, Negative, or Neutral.\n\nMessage: " + user_text
354
+ resp = _client.chat.completions.create(
355
+ model="gpt-3.5-turbo",
356
+ messages=[
357
+ {"role":"system","content":"You are a sentiment classifier."},
358
+ {"role":"user","content": prompt}
359
+ ],
360
+ temperature=0
361
+ )
362
+ return resp.choices[0].message.content.strip()
363
+
364
+ def gpt_extract_params(intent_key: str, user_text: str) -> dict:
365
+ if intent_key not in INTENTS:
366
+ return {}
367
+ cfg = INTENTS[intent_key]
368
+ param_list = cfg["required"] + cfg["optional"]
369
+ prompt = (
370
+ "Extract the following parameters from the user's query and return STRICT JSON only.\n"
371
+ f"Keys: {param_list}. If a parameter is missing set it to null. "
372
+ "Dates may be natural language; return them as-is.\n\n"
373
+ f"User query: {user_text}"
374
+ )
375
+ resp = _client.chat.completions.create(
376
+ model="gpt-3.5-turbo",
377
+ messages=[
378
+ {"role":"system","content":"You extract parameters and ONLY output valid JSON."},
379
+ {"role":"user","content": prompt}
380
+ ],
381
+ temperature=0
382
+ )
383
+ txt = resp.choices[0].message.content.strip()
384
+ try:
385
+ params = json.loads(txt)
386
+ except Exception:
387
+ params = {p: None for p in param_list}
388
+ return params
389
+
390
+ def gpt_fallback_response(user_text: str) -> str:
391
+ options = list(INTENTS.keys())
392
+ system = (
393
+ "You are a helpful support agent for an e-commerce analytics chatbot. "
394
+ "The app could not map the user's request to any known intent/SQL template. "
395
+ "Be helpful: either answer conversationally, gather missing details with specific "
396
+ "questions, or suggest the closest capability."
397
+ )
398
+ user = (
399
+ "No matching intent was found for the following message.\n"
400
+ f"Known intents are: {options}\n\n"
401
+ f"User message: {user_text}\n\n"
402
+ "Provide a concise, helpful response. If appropriate, suggest the nearest "
403
+ "matching capability from the list or ask one or two targeted follow-up questions."
404
+ )
405
+ resp = _client.chat.completions.create(
406
+ model="gpt-3.5-turbo",
407
+ messages=[{"role":"system","content": system},
408
+ {"role":"user","content": user}],
409
+ temperature=0.3
410
+ )
411
+ return resp.choices[0].message.content.strip()
412
+
413
+ def gpt_label_unmapped(user_text: str) -> str:
414
+ system = (
415
+ "You label short messages into one category: "
416
+ "greeting | clarification | thanks | complaint | small_talk | farewell | other. "
417
+ "Return ONLY the label, nothing else."
418
+ )
419
+ user = f"Message: {user_text}"
420
+ resp = _client.chat.completions.create(
421
+ model="gpt-3.5-turbo",
422
+ messages=[{"role":"system","content": system},
423
+ {"role":"user","content": user}],
424
+ temperature=0
425
+ )
426
+ label = resp.choices[0].message.content.strip().lower()
427
+ allowed = {"greeting","clarification","thanks","complaint","small_talk","farewell","other"}
428
+ return label if label in allowed else "other"
429
+
430
+ # =========================
431
+ # PARAM PROCESSING
432
+ # =========================
433
+ def process_params(intent_key: str, params: dict) -> dict:
434
+ if params is None:
435
+ params = {}
436
+ cfg = INTENTS[intent_key]
437
+ required = cfg.get("required", [])
438
+ optional = cfg.get("optional", [])
439
+ allowed_keys = set(required + optional)
440
+
441
+ clean = {k: params.get(k) for k in allowed_keys}
442
+
443
+ # integer defaults
444
+ if "k" in allowed_keys:
445
+ v = clean.get("k")
446
+ try:
447
+ clean["k"] = int(str(v).strip()) if v not in (None, "", "null", "None") else 10
448
+ except Exception:
449
+ clean["k"] = 10
450
+ if "limit" in allowed_keys:
451
+ v = clean.get("limit")
452
+ try:
453
+ clean["limit"] = int(str(v).strip()) if v not in (None, "", "null", "None") else 50
454
+ except Exception:
455
+ clean["limit"] = 50
456
+
457
+ # defaults for common keys
458
+ for k in ("start_date", "end_date", "category", "product_id", "city", "seller_id", "order_id"):
459
+ if k in allowed_keys and k not in clean:
460
+ clean[k] = None
461
+
462
+ # parse natural language dates to YYYY-MM-DD
463
+ for k in ("start_date", "end_date"):
464
+ if k in clean:
465
+ v = clean.get(k)
466
+ if v and str(v).strip().lower() not in ("none", "null", ""):
467
+ parsed = dateparser.parse(str(v))
468
+ clean[k] = parsed.strftime("%Y-%m-%d") if parsed else None
469
+ else:
470
+ clean[k] = None
471
+ return clean
472
+
473
+ # =========================
474
+ # EXECUTE INTENT (SQLite)
475
+ # =========================
476
+ def run_intent(conn: sqlite3.Connection, intent_key: str, params: dict):
477
+ cfg = INTENTS[intent_key]
478
+ sql = cfg["sql"]
479
+ required = cfg.get("required", [])
480
+ optional = cfg.get("optional", [])
481
+ allowed_keys = set(required + optional)
482
+
483
+ bound = {k: params.get(k) for k in allowed_keys}
484
+ if "k" in allowed_keys:
485
+ try: bound["k"] = int(bound.get("k", 10))
486
+ except Exception: bound["k"] = 10
487
+ if "limit" in allowed_keys:
488
+ try: bound["limit"] = int(bound.get("limit", 50))
489
+ except Exception: bound["limit"] = 50
490
+ for k in ("start_date","end_date"):
491
+ if k in bound:
492
+ v = bound[k]
493
+ if v is not None and not isinstance(v, str):
494
+ try: bound[k] = v.strftime("%Y-%m-%d")
495
+ except Exception: bound[k] = None
496
+
497
+ logger.info(f"SQL intent={intent_key} | params={bound}")
498
+
499
+ cur = conn.cursor()
500
+ cur.execute(sql, bound)
501
+ rows = cur.fetchall()
502
+ col_names = [d[0] for d in cur.description]
503
+ return rows, col_names, sql, bound
504
+
505
+ # =========================
506
+ # DB SAVE
507
+ # =========================
508
  def ensure_chat_schema():
509
  with get_connection() as conn:
510
  conn.execute("""
 
534
  """, (
535
  user_id, datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
536
  user_message, bot_reply, intent, sentiment, resolution, ftr_ms,
537
+ sql_query, json.dumps(sql_params or {})
538
  ))
539
  conn.commit()
540
 
541
+ # =========================
542
+ # CHAT UI
543
+ # =========================
544
  def render_chat_tab(conn):
545
+ """Full Olist chatbot with intents + SQL, scoped to current user."""
546
+ st.title("💬 Olist E-commerce Chatbot")
547
+
548
+ # session state for on-screen history (UI only)
549
+ if "history" not in st.session_state:
550
+ st.session_state.history = []
551
+ if "clear_input" not in st.session_state:
552
+ st.session_state.clear_input = False
553
+ if "user_input" not in st.session_state:
554
+ st.session_state.user_input = ""
555
+
556
+ # clear input after submit
557
+ if st.session_state.clear_input:
558
+ st.session_state.user_input = ""
559
+ st.session_state.clear_input = False
560
+
561
+ user_input = st.text_input("You:", key="user_input")
562
+ show_hist = st.checkbox("Show conversation history (this session)")
563
+
564
+ if st.button("Submit"):
565
+ if st.session_state.user_input.strip():
566
+ user_text = st.session_state.user_input
567
+ start_time = time.time()
568
+ current_time = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
569
+
570
+ # 1) intent + sentiment
571
+ cls = gpt_classify_intent(user_text)
572
+ intent = cls.get("intent", "UNMAPPED")
573
+ confidence = cls.get("confidence", 0.0)
574
+ reason = cls.get("reason", "")
575
+ logger.info(f"intent={intent} conf={confidence} reason={reason}")
576
+
577
+ sentiment = gpt_classify_sentiment(user_text)
578
+
579
+ # keyword guardrail
580
+ if intent != "UNMAPPED" and not _has_overlap(intent, user_text):
581
+ intent = "UNMAPPED"
582
+
583
+ sql_query, sql_params = "", {}
584
+ table_cols, table_rows = None, None
585
+
586
+ if intent == "UNMAPPED":
587
+ meta = gpt_label_unmapped(user_text)
588
+ intent = meta # store meta label
589
+ bot_reply = gpt_fallback_response(user_text)
590
+ resolution = "Responded"
591
+ else:
592
+ # 2) extract + process params
593
+ params = gpt_extract_params(intent, user_text)
594
+ params = process_params(intent, params)
595
+
596
+ # 3) required params check
597
+ required = INTENTS[intent]["required"]
598
+ missing = [p for p in required if not params.get(p)]
599
+
600
+ if missing:
601
+ bot_reply = f"Could you please provide the following information: {', '.join(missing)}"
602
+ resolution = "Unresolved"
603
+ else:
604
+ rows, col_names, sql_query, sql_params = run_intent(conn, intent, params)
605
+ if rows:
606
+ table_cols, table_rows = col_names, rows
607
+ bot_reply = "Please see the table above for results."
608
+ resolution = "Resolved"
609
+ else:
610
+ bot_reply = "No data found."
611
+ resolution = "Unresolved"
612
+
613
+ ftr_ms = round((time.time() - start_time) * 1000, 2)
614
+
615
+ # 4) show + save
616
+ entry = {
617
+ "timestamp": current_time,
618
+ "user": user_text,
619
+ "intent": intent,
620
+ "sentiment": sentiment,
621
+ "resolution": resolution,
622
+ "reply": bot_reply,
623
+ "ftr_ms": ftr_ms,
624
+ "table_cols": table_cols,
625
+ "table_rows": table_rows,
626
+ }
627
+ st.session_state.history.append(entry)
628
+ if len(st.session_state.history) > 50:
629
+ st.session_state.history.pop(0)
630
+
631
+ # persist with user_id
632
+ uid = st.session_state["user"]["id"]
633
+ try:
634
+ save_chat_to_db(
635
+ conn,
636
+ user_id=uid,
637
+ user_message=user_text,
638
+ bot_reply=bot_reply,
639
+ intent=intent,
640
+ sentiment=sentiment,
641
+ resolution=resolution,
642
+ ftr_ms=ftr_ms,
643
+ sql_query=sql_query,
644
+ sql_params=sql_params if intent in INTENTS else {}
645
+ )
646
+ except Exception as e:
647
+ logger.exception("Failed to save chat: %s", e)
648
+
649
+ # clear input next render
650
+ st.session_state.clear_input = True
651
+ try:
652
+ st.rerun()
653
+ except Exception:
654
+ st.experimental_rerun()
655
+
656
+ # renderer
657
+ def _render_turn(turn):
658
+ ts = turn["timestamp"]
659
+ user_text = turn["user"]
660
+ intent_display = turn["intent"] if turn["intent"] else "UNMAPPED"
661
+ sentiment = turn["sentiment"]
662
+ resolution = turn["resolution"]
663
+ bot_reply = turn["reply"]
664
+ table_cols = turn.get("table_cols")
665
+ table_rows = turn.get("table_rows")
666
+
667
+ st.markdown(
668
+ f"""
669
+ <div style='background-color:#e6f2ff;padding:10px;border-radius:10px;max-width:80%;margin-bottom:5px;'>
670
+ <div style='font-size:0.8em;color:gray;'>{ts}</div>
671
+ <b>Customer:</b> {user_text}
672
+ </div>
673
+ """,
674
+ unsafe_allow_html=True
675
+ )
676
+ st.markdown(
677
+ f"""
678
+ <div style='background-color:#ffffff;padding:10px;border-radius:10px;max-width:80%;margin-bottom:5px;'>
679
+ <b>Platform Analysis:</b><br>
680
+ <b>Intent:</b> {intent_display}<br>
681
+ <b>Sentiment:</b> {sentiment}<br>
682
+ <b>Resolution:</b> {resolution}
683
+ </div>
684
+ """,
685
+ unsafe_allow_html=True
686
+ )
687
+ if table_cols and table_rows:
688
+ df_result = pd.DataFrame(table_rows, columns=table_cols)
689
+ st.table(df_result)
690
+ st.markdown(
691
+ f"""
692
+ <div style='background-color:#e6ffe6;padding:10px;border-radius:10px;max-width:80%;margin-left:auto;'>
693
+ <div style='font-size:0.8em;color:gray;'>{ts}</div>
694
+ <b>Support Agent:</b> {bot_reply}
695
+ </div>
696
+ """,
697
+ unsafe_allow_html=True
698
+ )
699
+
700
+ if st.session_state.history:
701
+ st.subheader("Conversation")
702
+ if show_hist:
703
+ for turn in st.session_state.history[-50:]:
704
+ _render_turn(turn)
705
+ else:
706
+ _render_turn(st.session_state.history[-1])
707
 
708
  def render_user_export_tab(conn, user_id: int):
709
  st.subheader("Export My Chats")