Commit 10557fc9 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

feat: STAGE-aware upsell + fix insight sidebar + robust extraction

parent e5f37a41
...@@ -149,6 +149,7 @@ async def chat_controller( ...@@ -149,6 +149,7 @@ async def chat_controller(
event_count = 0 event_count = 0
ai_text_response = "" ai_text_response = ""
final_product_ids = [] final_product_ids = []
final_question_recommend = []
all_accumulated_messages = [] all_accumulated_messages = []
seen_message_ids = set() seen_message_ids = set()
...@@ -284,6 +285,8 @@ async def chat_controller( ...@@ -284,6 +285,8 @@ async def chat_controller(
ai_text_response = ai_json.get("ai_response", ai_text_response) ai_text_response = ai_json.get("ai_response", ai_text_response)
if not final_product_ids and isinstance(ai_json.get("product_ids"), list): if not final_product_ids and isinstance(ai_json.get("product_ids"), list):
final_product_ids = [str(s) for s in ai_json["product_ids"]] final_product_ids = [str(s) for s in ai_json["product_ids"]]
if not final_question_recommend and isinstance(ai_json.get("question_recommend"), list):
final_question_recommend = ai_json["question_recommend"]
except json.JSONDecodeError: except json.JSONDecodeError:
ai_match = re.search(r'"ai_response"\s*:\s*"((?:[^"\\]|\\.)*)"', raw_content) ai_match = re.search(r'"ai_response"\s*:\s*"((?:[^"\\]|\\.)*)"', raw_content)
if ai_match: if ai_match:
...@@ -315,6 +318,8 @@ async def chat_controller( ...@@ -315,6 +318,8 @@ async def chat_controller(
ai_text_response = ai_json.get("ai_response", ai_text_response) ai_text_response = ai_json.get("ai_response", ai_text_response)
if not final_product_ids and isinstance(ai_json.get("product_ids"), list): if not final_product_ids and isinstance(ai_json.get("product_ids"), list):
final_product_ids = [str(s) for s in ai_json["product_ids"]] final_product_ids = [str(s) for s in ai_json["product_ids"]]
if not final_question_recommend and isinstance(ai_json.get("question_recommend"), list):
final_question_recommend = ai_json["question_recommend"]
except json.JSONDecodeError: except json.JSONDecodeError:
# Regex fallback for Codex {{/}} braces that break JSON parse # Regex fallback for Codex {{/}} braces that break JSON parse
ai_match = re.search(r'"ai_response"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"product_ids"', ai_text_response, re.DOTALL) ai_match = re.search(r'"ai_response"\s*:\s*"((?:[^"\\]|\\.)*)"\s*,\s*"product_ids"', ai_text_response, re.DOTALL)
...@@ -358,9 +363,59 @@ async def chat_controller( ...@@ -358,9 +363,59 @@ async def chat_controller(
trace_id=trace_id, trace_id=trace_id,
) )
# ═══ FALLBACK: Journey-aware question_recommend ═══
if not final_question_recommend:
suggestions = []
# Read user_insight for journey detection
insight_text = ""
if user_insight_dict and isinstance(user_insight_dict, str):
insight_text = user_insight_dict.lower()
elif user_insight_dict and isinstance(user_insight_dict, dict):
insight_text = " ".join(str(v) for v in user_insight_dict.values()).lower()
ai_lower = (ai_text_response or "").lower()
# Detect journey stage
is_checkout = any(k in insight_text for k in ["đã chốt", "confirm", "chốt đơn", "đặt hàng"])
is_comparing = any(k in ai_lower for k in ["so sánh", "mình thấy mẫu", "recommend", "hợp nhất"])
is_size_asked = any(k in ai_lower for k in ["chiều cao", "cân nặng", "size", "số đo"])
has_products = bool(enriched_products)
if is_checkout:
# STAGE: CHECKOUT — push mua + upsell
suggestions = ["Phụ kiện kèm theo", "Chính sách vận chuyển", "Mã giảm giá"]
elif is_size_asked:
# STAGE: SIZE CONSULTATION — dẫn tới chốt
suggestions = ["Đặt hàng ngay", "Xem thêm màu khác", "Chính sách đổi size"]
elif is_comparing and has_products:
# STAGE: COMPARING — giúp quyết định
suggestions = ["Tư vấn size cho tôi", "Đặt hàng ngay", "Xem thêm mẫu khác"]
elif has_products:
# STAGE: PRODUCT SEARCH — outfit coordination
product_names = " ".join(p.get("name", "") for p in enriched_products).lower()
if any(k in product_names for k in ["váy", "chân váy", "đầm"]):
suggestions.append("Áo phối cùng váy này")
elif any(k in product_names for k in ["áo", "phông", "polo", "sơ mi", "khoác"]):
suggestions.append("Quần phối cùng áo này")
elif any(k in product_names for k in ["quần"]):
suggestions.append("Áo phối cùng quần này")
suggestions.append("Xem thêm màu khác")
if any(p.get("sale_price") and p.get("price") and p["sale_price"] < p["price"] for p in enriched_products):
suggestions.append("Đồ sale thêm")
suggestions.append("Tư vấn size")
else:
# STAGE: DISCOVERY — no products yet
suggestions = ["Xem sản phẩm mới", "Đồ đang sale hot", "Tư vấn phối đồ"]
final_question_recommend = suggestions[:4]
response_payload = { response_payload = {
"ai_response": ai_text_response, "ai_response": ai_text_response,
"product_ids": enriched_products, "product_ids": enriched_products,
"question_recommend": final_question_recommend,
} }
if user_insight_dict is not None: if user_insight_dict is not None:
response_payload["user_insight"] = user_insight_dict response_payload["user_insight"] = user_insight_dict
......
...@@ -61,23 +61,65 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) -> ...@@ -61,23 +61,65 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) ->
# Normalize double braces → single (LLM sometimes outputs {{ }} per prompt instruction) # Normalize double braces → single (LLM sometimes outputs {{ }} per prompt instruction)
normalized = json_content.replace("{{", "{").replace("}}", "}") normalized = json_content.replace("{{", "{").replace("}}", "}")
# Regex match user_insight object # --- Robust balanced-brace extraction ---
insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', normalized, re.DOTALL) # Find "user_insight" key position
key_index = normalized.find('"user_insight"')
if key_index == -1:
logger.warning(f"⚠️ [Background] No user_insight key found for {identity_key}")
return None
# Find opening { after the key
brace_start = normalized.find('{', key_index + len('"user_insight"'))
if brace_start == -1:
logger.warning(f"⚠️ [Background] No opening brace for user_insight for {identity_key}")
return None
# Count balanced braces to find matching }
depth = 0
in_string = False
escape_next = False
brace_end = -1
for i in range(brace_start, len(normalized)):
ch = normalized[i]
if escape_next:
escape_next = False
continue
if ch == '\\':
escape_next = True
continue
if ch == '"' and not escape_next:
in_string = not in_string
continue
if in_string:
continue
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
brace_end = i
break
if brace_end == -1:
logger.warning(f"⚠️ [Background] Unbalanced braces in user_insight for {identity_key}")
return None
insight_json_str = normalized[brace_start:brace_end + 1]
if insight_match: try:
insight_json_str = insight_match.group(1)
# Parse to validate
insight_dict = json.loads(insight_json_str) insight_dict = json.loads(insight_json_str)
insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2) except json.JSONDecodeError as je:
logger.warning(f"⚠️ [Background] user_insight JSON parse error: {je} | raw: {insight_json_str[:100]}...")
return None
# Save to Redis insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2)
await save_user_insight_to_redis(identity_key, insight_str)
elapsed = time.time() - start_time
logger.warning(f"✅ [user_insight] Extracted + saved in {elapsed:.2f}s | Key: {identity_key}")
return insight_dict
logger.warning(f"⚠️ [Background] No user_insight found in JSON for {identity_key}") # Save to Redis
return None await save_user_insight_to_redis(identity_key, insight_str)
elapsed = time.time() - start_time
logger.warning(f"✅ [user_insight] Extracted + saved in {elapsed:.2f}s | Key: {identity_key}")
return insight_dict
except Exception as e: except Exception as e:
logger.error(f"❌ [Background] Failed to extract user_insight for {identity_key}: {e}") logger.error(f"❌ [Background] Failed to extract user_insight for {identity_key}: {e}")
......
...@@ -48,6 +48,63 @@ Mua combo tiết kiệm hơn mua lẻ đó bạn! 😘" ...@@ -48,6 +48,63 @@ Mua combo tiết kiệm hơn mua lẻ đó bạn! 😘"
--- ---
#### 🎁 4.6.1. TỰ ĐỘNG KÉO CTKM KHI KHÁCH CHỐT ĐƠN (STAGE-AWARE)
**QUY TẮC BẮT BUỘC:** Khi user_insight [STAGE] = **DECIDE** hoặc **UPSELL**:
1. **TỰ ĐỘNG GỌI** `canifa_get_promotions()` (song song với các tool khác nếu có)
2. **LỒNG GHÉP CTKM** vào lời tư vấn mua thêm — KHÔNG chỉ liệt kê khô khan
3. **TẠO URGENCY** từ CTKM thật — KHÔNG BỊA khuyến mãi!
**FLOW CHUẨN:**
```
Khách: "Oke em yêu chốt đơn cái váy này"
→ Bot nhận STAGE = DECIDE
→ GỌI SONG SONG: check_is_stock(SKU) + canifa_get_promotions()
→ Trả lời KẾT HỢP:
```
**VÍ DỤ CHUẨN:**
```
✅ ĐÚNG (Lồng ghép CTKM vào upsell):
"Dạ, anh chốt mẫu Váy midi [8DS25W003] size M quá chuẩn! 🎯
Vợ anh mặc mẫu này chắc xinh như Suzy luôn!
Mà anh ơi, ĐÚNG LÚC đang có ưu đãi HOT lắm nè! 🔥
• 🎉 **Mua 2 giảm 10%** — anh mua thêm 1 áo phối cho vợ
là tiết kiệm ngay hơn 100k!
• 🚚 **Freeship đơn từ 499k** — anh mua thêm 1 món nữa
là đủ freeship luôn!
Váy này phối thêm áo phông trắng hoặc áo kiểu nhẹ nhàng
là outfit HOÀN HẢO đó anh! Mình tìm mấy mẫu áo cho vợ anh nhé? 😊"
❌ SAI (Chỉ chốt đơn, bỏ qua CTKM):
"Dạ, anh đặt hàng nhé!" ← THIẾU UPSELL + CTKM!
❌ SAI (BỊA khuyến mãi):
"Đang giảm 50% nè anh!" ← KHÔNG CÓ TRONG DATA = BỊA!
```
**CÁC PATTERN ĐỂ LỒNG CTKM VÀO UPSELL:**
| CTKM thật | Cách lồng vào upsell |
|-----------|---------------------|
| Freeship từ 499k | "Mua thêm 1 món nữa là đủ freeship luôn anh ơi!" |
| Mua 2 giảm 10% | "Thêm 1 áo nữa là được giảm 10% cả 2 món luôn!" |
| Giảm giá sản phẩm cụ thể | "Mà đúng lúc mẫu áo này đang sale chỉ còn Xk thôi!" |
| Tặng voucher | "Mua hôm nay còn được voucher Xk cho lần sau nữa anh!" |
| Không có CTKM nào | Vẫn upsell bình thường, KHÔNG BỊA CTKM |
**⚠️ LƯU Ý QUAN TRỌNG:**
- CTKM phải từ **data tool trả về** — TUYỆT ĐỐI KHÔNG BỊA
- Nếu `canifa_get_promotions()` trả về rỗng/không có CTKM → vẫn upsell nhưng KHÔNG nhắc ưu đãi
- **Tone giọng:** Như đang share deal hot cho bạn thân, KHÔNG giống quảng cáo
---
**⚠️ QUY TẮC VÀNG KHI UPSELL:** **⚠️ QUY TẮC VÀNG KHI UPSELL:**
**TUYỆT ĐỐI KHÔNG BỊA MÃ SẢN PHẨM!** **TUYỆT ĐỐI KHÔNG BỊA MÃ SẢN PHẨM!**
......
## 5. KHI NÀO GỌI TOOL ## 5. KHI NÀO GỌI TOOL
### ⚡ PARALLEL TOOL CALLING — GỌI SONG SONG ĐỂ TỐI ĐA TỐC ĐỘ
**QUY TẮC VÀNG:** Khi có thể, GỌI NHIỀU TOOL CÙNG LÚC thay vì tuần tự. Khách hàng KHÔNG muốn đợi.
| Tình huống | Gọi SONG SONG |
|-----------|--------------|
| Khách gửi SKU + hỏi "còn không?" | `data_retrieval_tool(SKU)` + `check_is_stock(SKU)` |
| Khách chốt SP + hỏi khuyến mãi | `check_is_stock(SKU)` + `canifa_get_promotions()` |
| Tư vấn phối đồ (áo + quần/váy) | `data_retrieval_tool(áo)` + `data_retrieval_tool(quần/váy)` (2 searches trong 1 call) |
| Khách hỏi SP + chính sách đổi trả | `data_retrieval_tool(SP)` + `canifa_knowledge_search("đổi trả")` |
| So sánh 2 mẫu | `data_retrieval_tool(searches=[mẫu_A, mẫu_B])` |
**TUYỆT ĐỐI KHÔNG** gọi tuần tự khi có thể song song. Mỗi lần đợi = khách mất kiên nhẫn.
---
### ⛔ QUY TẮC VÀNG — GENDER/AGE (ÁP DỤNG MỌI NƠI) ### ⛔ QUY TẮC VÀNG — GENDER/AGE (ÁP DỤNG MỌI NƠI)
Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, `age_by_product = null`. Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, `age_by_product = null`.
- "quần váy" → gender: null | "áo lót" → gender: null | "áo phông" → gender: null - "quần váy" → gender: null | "áo lót" → gender: null | "áo phông" → gender: null
...@@ -34,6 +50,9 @@ Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, ` ...@@ -34,6 +50,9 @@ Nếu user KHÔNG NÓI RÕ giới tính/tuổi → `gender_by_product = null`, `
### 5.1.3. GỌI `canifa_get_promotions` KHI: ### 5.1.3. GỌI `canifa_get_promotions` KHI:
Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể → truyền `check_date`. Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể → truyền `check_date`.
**🔥 AUTO-CALL KHI STAGE = DECIDE/UPSELL:**
Khi user_insight [STAGE] = DECIDE hoặc UPSELL → **TỰ ĐỘNG GỌI** `canifa_get_promotions()` (KHÔNG cần khách hỏi). Lồng CTKM vào lời upsell (xem mục 4.6.1).
**Quy tắc trả lời:** Liệt kê TẤT CẢ CTKM, mỗi cái phải có: tên (in đậm) + mô tả chi tiết + thời gian. KHÔNG gom chung, KHÔNG qua loa. Nếu data thiếu nội dung cụ thể → trình bày có gì + redirect "1800 6061 hoặc canifa.com". **Quy tắc trả lời:** Liệt kê TẤT CẢ CTKM, mỗi cái phải có: tên (in đậm) + mô tả chi tiết + thời gian. KHÔNG gom chung, KHÔNG qua loa. Nếu data thiếu nội dung cụ thể → trình bày có gì + redirect "1800 6061 hoặc canifa.com".
--- ---
...@@ -49,19 +68,91 @@ Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể ...@@ -49,19 +68,91 @@ Khách hỏi ưu đãi/khuyến mãi/sale/voucher/CTKM. Hỏi ngày cụ thể
**Cấu trúc query:** **Cấu trúc query:**
``` ```
description: [Mô tả SP / HYDE text] description: [Mô tả ngắn gọn nhu cầu khách, dùng cho fallback search]
product_name: [Tên SP chuẩn] product_name: [⚡ BẮT BUỘC lấy từ BẢNG PRODUCT_LINE bên dưới]
gender_by_product: [women/men/girl/boy/unisex/null] gender_by_product: [women/men/girl/boy/unisex/null]
age_by_product: [adult/kid/null] age_by_product: [adult/kid/null]
master_color: [Màu nếu có] master_color: [Màu nếu có]
style / season / material_group / fitting / form_neckline / form_sleeve: [nếu có]
price_min / price_max: [nếu có — KHÔNG đưa giá vào description] price_min / price_max: [nếu có — KHÔNG đưa giá vào description]
discount_min / discount_max: [% giảm giá nếu khách nhắc sale]
discovery_mode: [new / best_seller / null]
``` ```
**description vs product_name:** **⚡ product_name — QUY TẮC QUAN TRỌNG NHẤT:**
- Hỏi THẲNG tên SP → description = product_name: "quần jeans" → cả hai = "Quần jean" - PHẢI là tên trong **BẢNG PRODUCT_LINE** bên dưới (cột trái)
- Nhóm SP liên quan → description ghi cả 2, product_name dùng "/": "Áo lót/Áo bra active" - Khách nói slang → LLM tự map: "sịp" → `"Quần lót"`, "quần bò" → `"Quần jean"`
- Hỏi MÔ TẢ/NHU CẦU (HYDE) → description ≠ product_name: "đi dự tiệc" → description = "Váy dự tiệc sang trọng", product_name = "Váy liền" - Nhiều SP → dùng "/": `"Áo lót/Áo bra active"`
- KHÔNG BAO GIỜ bịa tên SP không có trong bảng
**description — Vai trò mới (fallback):**
- Mô tả NGẮN nhu cầu khách bằng ngôn ngữ tự nhiên
- Chỉ dùng khi các filter khác không đủ → search LIKE trên description_text
- VD: "Váy đi dự tiệc sang trọng", "Áo đồng điệu cả nhà"
**⚡ BẢNG PRODUCT_LINE — BẮT BUỘC dùng tên DB (cột trái):**
| product_line_vn (DÙNG CÁI NÀY) | Khách hay gọi là |
|----|----|
| Áo phông | áo thun, áo cổ v, áo cổ tym |
| Áo Polo | áo cổ bẻ |
| Áo Sơ mi | áo sơ mi |
| Áo nỉ | áo nỉ |
| Áo nỉ có mũ | áo hoodie nỉ |
| Áo len | áo len |
| Áo len gilet | áo gile len |
| Áo Body | croptop, baby tee, áo lửng |
| Áo ba lỗ | áo sát nách, tanktop, áo 2 dây, áo hai dây |
| Áo kiểu | áo kiểu |
| Áo mặc nhà | áo ở nhà |
| Áo khoác | áo khoác (chung) |
| Áo khoác gió | áo gió, áo khoác mỏng |
| Áo khoác chần bông | áo trần bông |
| Áo khoác gilet chần bông | áo gilet trần bông |
| Áo khoác gilet | áo gile khoác |
| Áo khoác chống nắng | áo chống nắng |
| Áo khoác lông vũ | áo lông vũ |
| Áo khoác dáng ngắn | áo khoác ngắn |
| Áo khoác dạ | áo dạ |
| Áo khoác sợi | áo khoác sợi |
| Áo khoác nỉ không mũ | áo khoác nỉ |
| Áo khoác nỉ có mũ | áo hoodie khoác |
| Áo giữ nhiệt | áo giữ nhiệt, áo heattech |
| Áo lót | áo ngực, áo quây |
| Áo bra active | áo bra, bra |
| Quần jean | quần bò, jeans, denim |
| Quần Khaki | quần âu, quần vải, quần tây |
| Quần soóc | quần đùi, quần short, quần ngố |
| Quần nỉ | quần jogger, quần ống bo chun |
| Quần dài | quần suông, quần ống rộng |
| Quần lót | sịp, quần chip, quần sịp, quần xì |
| Quần lót đùi | quần boxer |
| Quần lót tam giác | quần sịp tam giác |
| Quần giả váy | quần váy, skort |
| Quần leggings | legging |
| Quần leggings mặc nhà | legging mặc nhà |
| Quần mặc nhà | quần ở nhà |
| Quần giữ nhiệt | quần heattech |
| Quần culottes | quần ống rộng culottes |
| Quần Body | quần body |
| Váy liền | đầm, váy dự tiệc |
| Chân váy | váy ngắn, váy maxi, váy midi |
| Bộ mặc nhà | đồ ngủ, đồ mặc nhà |
| Bộ quần áo | đồ bộ |
| Bộ thể thao | đồ thể thao, đồ tập |
| Pyjama | pyjama, đồ ngủ pyjama |
| Cardigan | áo cardigan |
| Blazer | áo blazer |
| Mũ | nón |
| Tất | vớ, bao chân |
| Túi xách | túi |
| Khăn | khăn (chung) |
| Khăn tắm | khăn tắm |
| Khăn mặt | khăn mặt |
| Khăn lau đầu | khăn lau đầu |
| Găng tay chống nắng | găng tay |
| Chăn cá nhân | chăn |
⚠️ Khách nói "sịp" → product_name = "Quần lót". Khách nói "quần bò" → product_name = "Quần jean". LUÔN dùng tên cột trái!
**Quy tắc đặc biệt VÁY:** **Quy tắc đặc biệt VÁY:**
- CẤM dùng `product_name: "Váy"` (1 từ) → phải cụ thể: "Váy liền thân", "Chân váy", "Váy maxi" - CẤM dùng `product_name: "Váy"` (1 từ) → phải cụ thể: "Váy liền thân", "Chân váy", "Váy maxi"
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
`user_insight` là **BỘ NÃO GHI NHỚ** có cấu trúc chặt chẽ, KHÔNG phải note dữ liệu tĩnh. `user_insight` là **BỘ NÃO GHI NHỚ** có cấu trúc chặt chẽ, KHÔNG phải note dữ liệu tĩnh.
### 8.1. CẤU TRÚC BẮT BUỘC (6 TẦNG) ### 8.1. CẤU TRÚC BẮT BUỘC (7 TẦNG)
```json ```json
{ {
...@@ -11,6 +11,7 @@ ...@@ -11,6 +11,7 @@
"TARGET": "Quan hệ + Giới tính + Adult/Kid + Style/Gu", "TARGET": "Quan hệ + Giới tính + Adult/Kid + Style/Gu",
"GOAL": "Sản phẩm + Dịp sử dụng", "GOAL": "Sản phẩm + Dịp sử dụng",
"CONSTRAINS": "Budget, Size, Màu, Chất liệu, DISLIKES...", "CONSTRAINS": "Budget, Size, Màu, Chất liệu, DISLIKES...",
"STAGE": "BROWSE | CONSIDER | DECIDE | UPSELL",
"LATEST_PRODUCT_INTEREST": "SP vừa hỏi/xem gần nhất", "LATEST_PRODUCT_INTEREST": "SP vừa hỏi/xem gần nhất",
"LAST_ACTION": "Việc bot vừa làm ở turn này (factual)", "LAST_ACTION": "Việc bot vừa làm ở turn này (factual)",
"SUMMARY_HISTORY": "Tóm tắt lịch sử chat" "SUMMARY_HISTORY": "Tóm tắt lịch sử chat"
...@@ -46,6 +47,26 @@ ...@@ -46,6 +47,26 @@
**[CONSTRAINS]** — Ràng buộc cứng (CỘNG DỒN, không xóa trừ khi khách đổi ý): **[CONSTRAINS]** — Ràng buộc cứng (CỘNG DỒN, không xóa trừ khi khách đổi ý):
- VD: `"Budget: 400-600k (HARD). Màu: Đen (HARD). DISLIKES: Cổ đức, nilon (HARD)."` - VD: `"Budget: 400-600k (HARD). Màu: Đen (HARD). DISLIKES: Cổ đức, nilon (HARD)."`
**[STAGE]** — Giai đoạn mua sắm hiện tại (FACTUAL, chỉ ghi trạng thái):
| Stage | Nghĩa | Tín hiệu chuyển stage |
|-------|--------|----------------------|
| `BROWSE` | Khách mới bắt đầu, đang tìm hiểu | Câu hỏi đầu tiên, hỏi chung chung |
| `CONSIDER` | Đã xem SP, đang cân nhắc/so sánh | Hỏi chi tiết SP, so sánh, hỏi size/màu |
| `DECIDE` | Khách muốn chốt SP cụ thể | "lấy cái này", "ưng rồi", "mua", "chốt" |
| `UPSELL` | Đã chốt ≥1 SP, có thể mua thêm | Đã xong 1 SP, hỏi thêm hoặc bot gợi ý |
**Quy tắc STAGE:**
- Bắt đầu = `BROWSE`, chuyển tiến khi có tín hiệu rõ ràng
- CÓ THỂ lùi stage: khách chốt xong hỏi SP mới → quay `BROWSE`
- Ghi FACTUAL: `"CONSIDER"` — KHÔNG ghi gợi ý hành động
**Cách dùng STAGE để tư vấn:**
- `BROWSE` → Ưu tiên show nhiều SP phù hợp, hỏi thêm nhu cầu
- `CONSIDER` → Tư vấn chi tiết, so sánh, gợi ý check size/stock
- `DECIDE` → Check tồn kho + nói CTKM + hướng dẫn mua
- `UPSELL` → Gợi ý phối đồ thêm, mention freeship/CTKM combo
**[LATEST_PRODUCT_INTEREST]** — SP quan tâm gần nhất + SKU nếu có. **[LATEST_PRODUCT_INTEREST]** — SP quan tâm gần nhất + SKU nếu có.
**[LAST_ACTION]** — Hành động bot VỪA LÀM ở turn này: **[LAST_ACTION]** — Hành động bot VỪA LÀM ở turn này:
...@@ -65,20 +86,23 @@ ...@@ -65,20 +86,23 @@
1. **Merge thông tin mới** vào insight cũ — KHÔNG xóa trừ khi khách đổi ý 1. **Merge thông tin mới** vào insight cũ — KHÔNG xóa trừ khi khách đổi ý
2. **CONSTRAINS** mang tính cộng dồn — luôn thêm, hiếm khi xóa 2. **CONSTRAINS** mang tính cộng dồn — luôn thêm, hiếm khi xóa
3. **LAST_ACTION** ghi factual hành động turn hiện tại 3. **STAGE** cập nhật dựa trên tín hiệu khách → chuyển stage khi có bằng chứng rõ ràng
4. **SUMMARY_HISTORY** thêm dòng mới mỗi turn — không viết lại toàn bộ 4. **LAST_ACTION** ghi factual hành động turn hiện tại
5. **SUMMARY_HISTORY** thêm dòng mới mỗi turn — không viết lại toàn bộ
--- ---
### 8.4. FLOW TÓM TẮT (4 TURN) ### 8.4. FLOW TÓM TẮT (5 TURN — ĐẦY ĐỦ SHOPPING JOURNEY)
> Scenario: Khách tìm váy cho vợ > Scenario: Khách tìm váy cho vợ
| Turn | User nói | Bot làm | LAST_ACTION | | Turn | User nói | STAGE | Bot làm | LAST_ACTION |
|------|----------|---------|-------------| |------|----------|-------|---------|-------------|
| 1 | "Tìm váy cho vợ" | Hỏi màu/size/giá | "Hỏi khách màu, size, ngân sách." | | 1 | "Tìm váy cho vợ" | `BROWSE` | Hỏi màu/size/giá | "Hỏi khách màu, size, ngân sách." |
| 2 | "Đen, size M, 500k" | Gọi tool, show 3 mẫu | "Show [SKU1, SKU2, SKU3], hỏi ưng mẫu nào." | | 2 | "Đen, size M, 500k" | `BROWSE` | Gọi tool, show 3 mẫu | "Show [SKU1, SKU2, SKU3], hỏi ưng mẫu nào." |
| 3 | "SKU2 già quá" | Ghi DISLIKE, tìm lại | "Khách chê SKU2 già → Show [SKU5, SKU7] trẻ trung hơn." | | 3 | "SKU2 hơi già, SKU1 thì sao?" | `CONSIDER` | Tư vấn chi tiết SKU1, so sánh | "Tư vấn chi tiết SKU1, so sánh với SKU3 trẻ trung hơn." |
| 4 | "Lấy SKU5, vợ 1m60/52kg" | Tư vấn size, hướng dẫn mua | "Tư vấn size L, confirm chốt [SKU5], hướng dẫn mua." | | 4 | "Ưng SKU1, vợ 1m60/52kg" | `DECIDE` | Check stock + tư vấn size + CTKM | "Check stock SKU1: còn M. Tư vấn size M. Mention CTKM giảm 20%." |
| 5 | "Ok lấy luôn" | `UPSELL` | Hướng dẫn mua + gợi ý thêm | "Gửi link mua SKU1. Gợi ý phối thêm giày/túi, đang freeship đơn 500k+." |
**Key insight:** STAGE chuyển tự nhiên: BROWSE → CONSIDER → DECIDE → UPSELL. Bot dùng STAGE để biết nên push bán hay nên tư vấn thêm.
**Key insight:** CONSTRAINS cộng dồn qua mỗi turn: Budget → + Màu → + Size → + DISLIKE cổ điển → + Size L.
...@@ -10,14 +10,16 @@ ...@@ -10,14 +10,16 @@
{{ {{
"ai_response": "Câu trả lời, mô tả bằng [SKU]", "ai_response": "Câu trả lời, mô tả bằng [SKU]",
"product_ids": ["8TS24W001", "8TS24W002"], "product_ids": ["8TS24W001", "8TS24W002"],
"question_recommend": ["Quần phối cùng áo này", "Xem thêm màu khác", "Check tồn kho size L"],
"user_insight": {{ "user_insight": {{
"STAGE": "BROWSE | CONSIDER | DECIDE | UPSELL",
"USER": "...", "USER": "...",
"TARGET": "...", "TARGET": "...",
"GOAL": "...", "GOAL": "...",
"CONSTRAINS": "...", "CONSTRAINS": "...",
"LATEST_PRODUCT_INTEREST": "...", "LATEST_PRODUCT_INTEREST": "...",
"LAST_ACTION": "...", "LAST_ACTION": "...",
"SUMMARY_HISTORY": "..." "SUMMARY_HISTORY": "..."
}} }}
}} }}
...@@ -29,7 +31,13 @@ ...@@ -29,7 +31,13 @@
- LUÔN kèm mã [SKU] để khách tra cứu - LUÔN kèm mã [SKU] để khách tra cứu
**product_ids:** Array of string (mã SKU), KHÔNG phải object. **product_ids:** Array of string (mã SKU), KHÔNG phải object.
**user_insight:** Theo đúng format 6 tầng (xem mục 8). **question_recommend:** Array 2-4 cụm gợi ý ngắn (< 30 ký tự mỗi cụm). Thay đổi theo GIAI ĐOẠN mua sắm:
- **Khám phá** (chưa hỏi SP): "Xem sản phẩm mới", "Đồ đang sale", "Tư vấn phối đồ"
- **Tìm kiếm** (vừa show SP): Cross-sell phối đồ ("Quần phối cùng áo này"), variation ("Xem thêm màu"), size check
- **So sánh** (đã xem 2+ SP): "So sánh 2 mẫu", "Tư vấn size", "Đặt hàng ngay"
- **Chốt đơn** (khách confirm): "Phụ kiện kèm theo", "Chính sách vận chuyển", "Mã giảm giá"
- Viết ngắn gọn kiểu keyword, KHÔNG viết câu hỏi dài.
**user_insight:** Theo đúng format 7 tầng (xem mục 8). **PHẢI CÓ STAGE** ở dòng đầu tiên.
--- ---
...@@ -42,7 +50,9 @@ Input: "Chào shop" ...@@ -42,7 +50,9 @@ Input: "Chào shop"
{{ {{
"ai_response": "Chào bạn! 👋 Mình là Canifa-AI Stylist, rất vui được hỗ trợ bạn hôm nay. Bạn đang muốn tìm sản phẩm gì ạ? 😊", "ai_response": "Chào bạn! 👋 Mình là Canifa-AI Stylist, rất vui được hỗ trợ bạn hôm nay. Bạn đang muốn tìm sản phẩm gì ạ? 😊",
"product_ids": [], "product_ids": [],
"question_recommend": ["Xem sản phẩm mới", "Đồ đang sale hot", "Tư vấn phối đồ"],
"user_insight": {{ "user_insight": {{
"STAGE": "BROWSE",
"USER": "Chưa rõ.", "USER": "Chưa rõ.",
"TARGET": "Chưa rõ.", "TARGET": "Chưa rõ.",
"GOAL": "Chưa rõ, đang khám phá.", "GOAL": "Chưa rõ, đang khám phá.",
...@@ -63,7 +73,9 @@ Tool trả về: 3 sản phẩm (data có tên, giá, description_text) ...@@ -63,7 +73,9 @@ Tool trả về: 3 sản phẩm (data có tên, giá, description_text)
{{ {{
"ai_response": "Anh mua cho vợ chu đáo quá! Mình tìm được mấy mẫu váy đang sale sâu nè:\n\n**[6DS25S017]: Váy dáng A dài phối chỉ** - ~~799k~~ còn 299k!\nDáng A dài qua gối, mặc đi làm hay đi cafe đều thanh lịch. Form này tôn dáng mà không kén người mặc.\n\n**[6DS25S010]: Váy cổ tròn dáng A xếp ly** - ~~699k~~ còn 299k!\nXếp ly ở chân váy tạo độ xòe nhẹ rất thướt tha. Cổ tròn đơn giản dễ phối, hợp đi dạo phố hoặc hẹn hò cuối tuần.\n\n**[6DS25S007]: Váy cổ sơ mi dáng xòe A** - ~~799k~~ còn 399k!\nCổ sơ mi tạo cảm giác chỉn chu, đi đám cưới hay đi event đều sang. Mẫu này có chi tiết thêu tinh tế là điểm nhấn nổi bật.\n\nMình thấy mẫu [6DS25S017] dáng A dài rất dễ mặc và hợp nhiều dịp nhất. Anh cho mình biết chiều cao cân nặng của chị nhà để tư vấn size chuẩn nhé!", "ai_response": "Anh mua cho vợ chu đáo quá! Mình tìm được mấy mẫu váy đang sale sâu nè:\n\n**[6DS25S017]: Váy dáng A dài phối chỉ** - ~~799k~~ còn 299k!\nDáng A dài qua gối, mặc đi làm hay đi cafe đều thanh lịch. Form này tôn dáng mà không kén người mặc.\n\n**[6DS25S010]: Váy cổ tròn dáng A xếp ly** - ~~699k~~ còn 299k!\nXếp ly ở chân váy tạo độ xòe nhẹ rất thướt tha. Cổ tròn đơn giản dễ phối, hợp đi dạo phố hoặc hẹn hò cuối tuần.\n\n**[6DS25S007]: Váy cổ sơ mi dáng xòe A** - ~~799k~~ còn 399k!\nCổ sơ mi tạo cảm giác chỉn chu, đi đám cưới hay đi event đều sang. Mẫu này có chi tiết thêu tinh tế là điểm nhấn nổi bật.\n\nMình thấy mẫu [6DS25S017] dáng A dài rất dễ mặc và hợp nhiều dịp nhất. Anh cho mình biết chiều cao cân nặng của chị nhà để tư vấn size chuẩn nhé!",
"product_ids": ["6DS25S017", "6DS25S010", "6DS25S007"], "product_ids": ["6DS25S017", "6DS25S010", "6DS25S007"],
"question_recommend": ["Áo phối cùng váy này", "Xem thêm váy dáng khác", "Check size còn hàng"],
"user_insight": {{ "user_insight": {{
"STAGE": "CONSIDER",
"USER": "Nam, Adult, mua cho vợ.", "USER": "Nam, Adult, mua cho vợ.",
"TARGET": "Vợ (Nữ, Adult).", "TARGET": "Vợ (Nữ, Adult).",
"GOAL": "Tìm váy tặng vợ.", "GOAL": "Tìm váy tặng vợ.",
...@@ -87,7 +99,9 @@ Bot ĐỌC LAST_ACTION → Khách confirm → Hướng dẫn chốt đơn ...@@ -87,7 +99,9 @@ Bot ĐỌC LAST_ACTION → Khách confirm → Hướng dẫn chốt đơn
{{ {{
"ai_response": "Tuyệt vời! 🎉 Mẫu [6VP24W010] đang sale 350k!\n\n**Bước tiếp theo:**\n1. Vào canifa.com tìm mã [6VP24W010]\n2. Chọn size, màu\n3. Checkout\n\nHoặc gọi hotline **1800 6061** để order nhanh! 😊\n\nBạn có muốn tìm thêm item phối không?", "ai_response": "Tuyệt vời! 🎉 Mẫu [6VP24W010] đang sale 350k!\n\n**Bước tiếp theo:**\n1. Vào canifa.com tìm mã [6VP24W010]\n2. Chọn size, màu\n3. Checkout\n\nHoặc gọi hotline **1800 6061** để order nhanh! 😊\n\nBạn có muốn tìm thêm item phối không?",
"product_ids": ["6VP24W010"], "product_ids": ["6VP24W010"],
"question_recommend": ["Phụ kiện phối cùng váy", "Xem thêm váy đen khác", "Chính sách đổi trả"],
"user_insight": {{ "user_insight": {{
"STAGE": "UPSELL",
"USER": "Nam, Adult, có vợ.", "USER": "Nam, Adult, có vợ.",
"TARGET": "Vợ (Nữ, Adult, size M, thích đen).", "TARGET": "Vợ (Nữ, Adult, size M, thích đen).",
"GOAL": "ĐÃ CHỐT [6VP24W010].", "GOAL": "ĐÃ CHỐT [6VP24W010].",
......
...@@ -16,10 +16,19 @@ logger = logging.getLogger(__name__) ...@@ -16,10 +16,19 @@ logger = logging.getLogger(__name__)
# API Canifa stock # API Canifa stock
CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent" CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent"
# Singleton httpx client — tái sử dụng TCP connection pool
_stock_client: httpx.AsyncClient | None = None
def _get_stock_client(timeout: float = 5.0) -> httpx.AsyncClient:
global _stock_client
if _stock_client is None:
_stock_client = httpx.AsyncClient(timeout=timeout)
return _stock_client
class StockCheckInput(BaseModel): class StockCheckInput(BaseModel):
model_config = {"extra": "forbid"} model_config = {"extra": "forbid"}
skus: str = Field( skus: str = Field(
description=( description=(
"Mã sản phẩm cần kiểm tra tồn kho. " "Mã sản phẩm cần kiểm tra tồn kho. "
...@@ -32,30 +41,29 @@ class StockCheckInput(BaseModel): ...@@ -32,30 +41,29 @@ class StockCheckInput(BaseModel):
async def fetch_stock_from_canifa(skus: list[str], timeout: float = 5.0) -> dict: async def fetch_stock_from_canifa(skus: list[str], timeout: float = 5.0) -> dict:
""" """
Gọi API Canifa để lấy thông tin tồn kho. Gọi API Canifa để lấy thông tin tồn kho.
Args: Args:
skus: List of product_color_code (e.g., ['5TS25S023-SY322']) skus: List of product_color_code (e.g., ['5TS25S023-SY322'])
timeout: Request timeout in seconds timeout: Request timeout in seconds
Returns: Returns:
Raw API response as dict Raw API response as dict
""" """
if not skus: if not skus:
return {} return {}
# API chấp nhận nhiều SKU cách nhau bằng dấu phẩy # API chấp nhận nhiều SKU cách nhau bằng dấu phẩy
sku_string = ",".join(skus) sku_string = ",".join(skus)
url = f"{CANIFA_STOCK_API}?skus={sku_string}" url = f"{CANIFA_STOCK_API}?skus={sku_string}"
try: try:
async with httpx.AsyncClient(timeout=timeout) as client: resp = await _get_stock_client(timeout).get(url)
resp = await client.get(url) resp.raise_for_status()
resp.raise_for_status() data = resp.json()
data = resp.json()
logger.info(f"📦 Stock API response: {len(data.get('result', []))} variants")
logger.info(f"📦 Stock API response: {len(data.get('result', []))} variants") return data
return data
except httpx.TimeoutException: except httpx.TimeoutException:
logger.warning(f"⏰ Stock API timeout ({timeout}s)") logger.warning(f"⏰ Stock API timeout ({timeout}s)")
return {"code": 500, "error": "timeout"} return {"code": 500, "error": "timeout"}
...@@ -72,16 +80,16 @@ async def check_is_stock(skus: str) -> str: ...@@ -72,16 +80,16 @@ async def check_is_stock(skus: str) -> str:
""" """
if not skus: if not skus:
return json.dumps({"error": "Thiếu mã sản phẩm để kiểm tra tồn kho"}) return json.dumps({"error": "Thiếu mã sản phẩm để kiểm tra tồn kho"})
# Parse SKUs # Parse SKUs
sku_list = [s.strip() for s in skus.split(",") if s.strip()] sku_list = [s.strip() for s in skus.split(",") if s.strip()]
if not sku_list: if not sku_list:
return json.dumps({"error": "Không có mã sản phẩm hợp lệ"}) return json.dumps({"error": "Không có mã sản phẩm hợp lệ"})
# Fetch stock from Canifa API - return raw response # Fetch stock from Canifa API - return raw response
stock_data = await fetch_stock_from_canifa(sku_list) stock_data = await fetch_stock_from_canifa(sku_list)
return json.dumps(stock_data, ensure_ascii=False) return json.dumps(stock_data, ensure_ascii=False)
......
...@@ -33,20 +33,22 @@ from agent.prompt_utils import read_tool_prompt ...@@ -33,20 +33,22 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel): class SearchItem(BaseModel):
model_config = {"extra": "ignore"} # Gemini may send extra fields model_config = {"extra": "ignore"} # Gemini may send extra fields
# ====== SEMANTIC SEARCH ====== # ====== SEARCH TEXT ======
description: str = Field( description: str = Field(
description=( description=(
"Mô tả sản phẩm cho semantic search. " "Mô tả ngắn nhu cầu khách bằng ngôn ngữ tự nhiên. "
"FORMAT: 'product_name: [tên]. description_text: [mô tả]. " "Dùng cho fallback search khi các filter khác không đủ. "
"material_group: [chất liệu]. style: [phong cách]. fitting: [dáng]. " "VD: 'Váy đi dự tiệc sang trọng', 'Áo đồng điệu cả nhà'. "
"form_sleeve: [tay]. form_neckline: [cổ]. product_line_vn: [dòng SP]'. "
"🚨 product_name = CHÍNH XÁC lời user nói, TUYỆT ĐỐI KHÔNG tự đổi sang tên khác. "
"⚠️ KHÔNG đưa master_color vào description — dùng field master_color riêng! " "⚠️ KHÔNG đưa master_color vào description — dùng field master_color riêng! "
) )
) )
product_name: str | None = Field( product_name: str | None = Field(
default=None, default=None,
description="Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể.", description=(
"⚡ BẮT BUỘC lấy từ BẢNG PRODUCT_LINE trong prompt (cột trái). "
"Khách nói 'sịp' → 'Quần lót'. Khách nói 'quần bò' → 'Quần jean'. "
"Nhiều SP dùng '/': 'Áo lót/Áo bra active'. KHÔNG bịa tên ngoài bảng."
),
) )
# ====== SKU LOOKUP ====== # ====== SKU LOOKUP ======
...@@ -125,7 +127,7 @@ class MultiSearchParams(BaseModel): ...@@ -125,7 +127,7 @@ class MultiSearchParams(BaseModel):
async def _execute_single_search( async def _execute_single_search(
db, item: SearchItem, query_vector: list[float] | None = None db, item: SearchItem,
) -> tuple[list[dict], dict]: ) -> tuple[list[dict], dict]:
""" """
Thực thi một search query đơn lẻ (Async). Thực thi một search query đơn lẻ (Async).
...@@ -146,7 +148,7 @@ async def _execute_single_search( ...@@ -146,7 +148,7 @@ async def _execute_single_search(
# Timer: build query (sử dụng vector đã có hoặc build mới) # Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time() query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector) sql, params = await build_starrocks_query(item)
query_build_time = (time.time() - query_build_start) * 1000 # Convert to ms query_build_time = (time.time() - query_build_start) * 1000 # Convert to ms
logger.debug("SQL built, length=%s, build_time_ms=%.2f", len(sql), query_build_time) logger.debug("SQL built, length=%s, build_time_ms=%.2f", len(sql), query_build_time)
...@@ -255,16 +257,21 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -255,16 +257,21 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
if not db: if not db:
return json.dumps({"status": "error", "message": "Database connection failed"}) return json.dumps({"status": "error", "message": "Database connection failed"})
# ══════ PARALLEL SQL: all queries run concurrently ══════
combined_results = [] combined_results = []
all_filter_infos = [] all_filter_infos = []
tasks = [] tasks = []
for item in searches: for i, item in enumerate(searches):
tasks.append(_execute_single_search(db, item)) tasks.append(_execute_single_search(db, item))
results_list = await asyncio.gather(*tasks) results_list = await asyncio.gather(*tasks, return_exceptions=True)
for products, filter_info in results_list: for result in results_list:
if isinstance(result, Exception):
logger.error("❌ [PARALLEL SQL] One search failed: %s", result)
continue
products, filter_info = result
combined_results.extend(products) combined_results.extend(products)
if filter_info: if filter_info:
all_filter_infos.append(filter_info) all_filter_infos.append(filter_info)
......
...@@ -41,7 +41,7 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = { ...@@ -41,7 +41,7 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Quần mặc nhà": ["quần mặc nhà"], "Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi"], "Quần lót đùi": ["quần lót đùi"],
"Quần lót tam giác": ["quần lót tam giác"], "Quần lót tam giác": ["quần lót tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít"], "Quần lót": ["quần lót", "quần chip", "quần sịp", "sịp", "chip", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"], "Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings"], "Quần leggings": ["quần leggings"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"], "Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"],
...@@ -76,18 +76,6 @@ for db_value, synonyms in PRODUCT_LINE_MAP.items(): ...@@ -76,18 +76,6 @@ for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms: for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first # Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True) _SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
...@@ -127,4 +115,4 @@ def resolve_product_line(raw_value: str) -> list[str]: ...@@ -127,4 +115,4 @@ def resolve_product_line(raw_value: str) -> list[str]:
resolved.append(mapped) resolved.append(mapped)
else: else:
resolved.append(part) resolved.append(part)
return resolved return resolved
\ No newline at end of file
...@@ -62,6 +62,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: ...@@ -62,6 +62,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
"status": "success", "status": "success",
"ai_response": result["ai_response"], "ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []), "product_ids": result.get("product_ids", []),
"question_recommend": result.get("question_recommend", []),
"trace_id": result.get("trace_id", ""), "trace_id": result.get("trace_id", ""),
"limit_info": { "limit_info": {
"limit": usage_info["limit"], "limit": usage_info["limit"],
...@@ -94,6 +95,14 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta ...@@ -94,6 +95,14 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
logger.info(f"📥 [Incoming Query - Dev] User: {identity_id} | Query: {req.user_query}") logger.info(f"📥 [Incoming Query - Dev] User: {identity_id} | Query: {req.user_query}")
# Clear stale user_insight from Redis so polling only finds NEW data
try:
client = redis_cache.get_client()
if client:
await client.delete(f"identity_key_insight:{identity_id}")
except Exception:
pass # Non-critical, continue with chat
try: try:
# DEV MODE: Return ai_response + products immediately # DEV MODE: Return ai_response + products immediately
result = await chat_controller( result = await chat_controller(
...@@ -112,6 +121,7 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta ...@@ -112,6 +121,7 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
"status": "success", "status": "success",
"ai_response": result["ai_response"], "ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []), "product_ids": result.get("product_ids", []),
"question_recommend": result.get("question_recommend", []),
"trace_id": result.get("trace_id", ""), "trace_id": result.get("trace_id", ""),
"insight_status": "success" if result.get("user_insight") else "pending", "insight_status": "success" if result.get("user_insight") else "pending",
"user_insight": result.get("user_insight"), "user_insight": result.get("user_insight"),
......
...@@ -84,7 +84,7 @@ JWT_SECRET: str | None = os.getenv("JWT_SECRET") ...@@ -84,7 +84,7 @@ JWT_SECRET: str | None = os.getenv("JWT_SECRET")
JWT_ALGORITHM: str | None = os.getenv("JWT_ALGORITHM") JWT_ALGORITHM: str | None = os.getenv("JWT_ALGORITHM")
# ====================== SERVER CONFIG ====================== # ====================== SERVER CONFIG ======================
PORT: int = int(os.getenv("PORT", "5000")) PORT: int = int(os.getenv("PORT", "5005"))
FIRECRAWL_API_KEY: str | None = os.getenv("FIRECRAWL_API_KEY") FIRECRAWL_API_KEY: str | None = os.getenv("FIRECRAWL_API_KEY")
# ====================== LANGFUSE CONFIGURATION (DEPRECATED) ====================== # ====================== LANGFUSE CONFIGURATION (DEPRECATED) ======================
...@@ -146,4 +146,4 @@ RATE_LIMIT_USER: int = int(os.getenv("RATE_LIMIT_USER", "100")) ...@@ -146,4 +146,4 @@ RATE_LIMIT_USER: int = int(os.getenv("RATE_LIMIT_USER", "100"))
# External Canifa Stock API (dùng trực tiếp nếu cần) # External Canifa Stock API (dùng trực tiếp nếu cần)
STOCK_API_URL: str = os.getenv("STOCK_API_URL", "https://canifa.com/v1/middleware/stock_get_stock_list") STOCK_API_URL: str = os.getenv("STOCK_API_URL", "https://canifa.com/v1/middleware/stock_get_stock_list")
# Internal Stock API (có logic expand SKU từ base code) # Internal Stock API (có logic expand SKU từ base code)
INTERNAL_STOCK_API: str = os.getenv("INTERNAL_STOCK_API", "http://localhost:5000/api/stock/check") INTERNAL_STOCK_API: str = os.getenv("INTERNAL_STOCK_API", "http://localhost:5005/api/stock/check")
...@@ -5,11 +5,11 @@ services: ...@@ -5,11 +5,11 @@ services:
container_name: canifa-backend-dev-experimental container_name: canifa-backend-dev-experimental
env_file: .env env_file: .env
ports: ports:
- "5005:5000" - "5005:5005"
volumes: volumes:
- .:/app - .:/app
environment: environment:
- PORT=5000 - PORT=5005
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
......
...@@ -7,11 +7,11 @@ services: ...@@ -7,11 +7,11 @@ services:
container_name: canifa-backend-dev-experimental container_name: canifa-backend-dev-experimental
env_file: .env env_file: .env
ports: ports:
- "5005:5000" - "5005:5005"
volumes: volumes:
- .:/app - .:/app
environment: environment:
- PORT=5000 - PORT=5005
restart: unless-stopped restart: unless-stopped
deploy: deploy:
resources: resources:
......
...@@ -26,7 +26,7 @@ from common.middleware import middleware_manager ...@@ -26,7 +26,7 @@ from common.middleware import middleware_manager
from config import PORT from config import PORT
if platform.system() == "Windows": if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...") print("[INFO] Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Configure Logging # Configure Logging
...@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__) ...@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static") STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
if not os.path.exists(STATIC_DIR): if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR) os.makedirs(STATIC_DIR)
print(f" Static dir resolved: {STATIC_DIR}") print(f"[OK] Static dir resolved: {STATIC_DIR}")
app = FastAPI( app = FastAPI(
...@@ -110,15 +110,15 @@ app.include_router(experiment_links_router) # Experiment links sidebar ...@@ -110,15 +110,15 @@ app.include_router(experiment_links_router) # Experiment links sidebar
if __name__ == "__main__": if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print("🚀 Contract AI Service Starting...") print(">> Contract AI Service Starting...")
print("=" * 60) print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}") print(f" REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html") print(f" Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs") print(f" API Docs: http://localhost:{PORT}/docs")
print("=" * 60) print("=" * 60)
ENABLE_RELOAD = False ENABLE_RELOAD = False
print(f"⚠️ Hot reload: {ENABLE_RELOAD}") print(f" Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"] reload_dirs = ["common", "api", "agent"]
......
This diff is collapsed.
import asyncio, json, sys
sys.stdout.reconfigure(encoding='utf-8')
sys.path.insert(0, r'd:\cnf\chatbot-canifa-dev-experimental\backend')
from common.starrocks_connection import get_db_connection
async def main():
db = get_db_connection()
rows = await db.execute_query_async(
"SELECT product_name, LEFT(description_text, 400) as desc_text, "
"product_line_vn, master_color, style, fitting "
"FROM shared_source.magento_product_dimension_with_text_embedding "
"WHERE description_text IS NOT NULL AND description_text != '' "
"LIMIT 5"
)
for r in rows:
print(json.dumps(r, ensure_ascii=False, default=str))
print("---")
asyncio.run(main())
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment