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(
event_count = 0
ai_text_response = ""
final_product_ids = []
final_question_recommend = []
all_accumulated_messages = []
seen_message_ids = set()
......@@ -284,6 +285,8 @@ async def chat_controller(
ai_text_response = ai_json.get("ai_response", ai_text_response)
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"]]
if not final_question_recommend and isinstance(ai_json.get("question_recommend"), list):
final_question_recommend = ai_json["question_recommend"]
except json.JSONDecodeError:
ai_match = re.search(r'"ai_response"\s*:\s*"((?:[^"\\]|\\.)*)"', raw_content)
if ai_match:
......@@ -315,6 +318,8 @@ async def chat_controller(
ai_text_response = ai_json.get("ai_response", ai_text_response)
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"]]
if not final_question_recommend and isinstance(ai_json.get("question_recommend"), list):
final_question_recommend = ai_json["question_recommend"]
except json.JSONDecodeError:
# 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)
......@@ -358,9 +363,59 @@ async def chat_controller(
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 = {
"ai_response": ai_text_response,
"product_ids": enriched_products,
"question_recommend": final_question_recommend,
}
if user_insight_dict is not None:
response_payload["user_insight"] = user_insight_dict
......
......@@ -61,13 +61,58 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) ->
# Normalize double braces → single (LLM sometimes outputs {{ }} per prompt instruction)
normalized = json_content.replace("{{", "{").replace("}}", "}")
# Regex match user_insight object
insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', normalized, re.DOTALL)
# --- Robust balanced-brace extraction ---
# 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:
insight_json_str = insight_match.group(1)
# Parse to validate
try:
insight_dict = json.loads(insight_json_str)
except json.JSONDecodeError as je:
logger.warning(f"⚠️ [Background] user_insight JSON parse error: {je} | raw: {insight_json_str[:100]}...")
return None
insight_str = json.dumps(insight_dict, ensure_ascii=False, indent=2)
# Save to Redis
......@@ -76,9 +121,6 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) ->
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}")
return None
except Exception as e:
logger.error(f"❌ [Background] Failed to extract user_insight for {identity_key}: {e}")
return None
......
......@@ -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:**
**TUYỆT ĐỐI KHÔNG BỊA MÃ SẢN PHẨM!**
......
## 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)
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
......@@ -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:
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".
---
......@@ -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:**
```
description: [Mô tả SP / HYDE text]
product_name: [Tên SP chuẩn]
description: [Mô tả ngắn gọn nhu cầu khách, dùng cho fallback search]
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]
age_by_product: [adult/kid/null]
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]
discount_min / discount_max: [% giảm giá nếu khách nhắc sale]
discovery_mode: [new / best_seller / null]
```
**description vs product_name:**
- Hỏi THẲNG tên SP → description = product_name: "quần jeans" → cả hai = "Quần jean"
- Nhóm SP liên quan → description ghi cả 2, product_name dùng "/": "Áo lót/Áo bra active"
- 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"
**⚡ product_name — QUY TẮC QUAN TRỌNG NHẤT:**
- PHẢI là tên trong **BẢNG PRODUCT_LINE** bên dưới (cột trái)
- Khách nói slang → LLM tự map: "sịp" → `"Quần lót"`, "quần bò" → `"Quần jean"`
- 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:**
- 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 @@
`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
{
......@@ -11,6 +11,7 @@
"TARGET": "Quan hệ + Giới tính + Adult/Kid + Style/Gu",
"GOAL": "Sản phẩm + Dịp sử dụng",
"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",
"LAST_ACTION": "Việc bot vừa làm ở turn này (factual)",
"SUMMARY_HISTORY": "Tóm tắt lịch sử chat"
......@@ -46,6 +47,26 @@
**[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)."`
**[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ó.
**[LAST_ACTION]** — Hành động bot VỪA LÀM ở turn này:
......@@ -65,20 +86,23 @@
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
3. **LAST_ACTION** ghi factual hành động turn hiện tại
4. **SUMMARY_HISTORY** thêm dòng mới mỗi turn — không viết lại toàn bộ
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. **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ợ
| Turn | User nói | 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." |
| 2 | "Đen, size M, 500k" | 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." |
| 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." |
| Turn | User nói | STAGE | Bot làm | LAST_ACTION |
|------|----------|-------|---------|-------------|
| 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" | `BROWSE` | Gọi tool, show 3 mẫu | "Show [SKU1, SKU2, SKU3], hỏi ưng mẫu nào." |
| 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 | "Ư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,7 +10,9 @@
{{
"ai_response": "Câu trả lời, mô tả bằng [SKU]",
"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": {{
"STAGE": "BROWSE | CONSIDER | DECIDE | UPSELL",
"USER": "...",
"TARGET": "...",
"GOAL": "...",
......@@ -29,7 +31,13 @@
- LUÔN kèm mã [SKU] để khách tra cứu
**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"
{{
"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": [],
"question_recommend": ["Xem sản phẩm mới", "Đồ đang sale hot", "Tư vấn phối đồ"],
"user_insight": {{
"STAGE": "BROWSE",
"USER": "Chưa rõ.",
"TARGET": "Chưa rõ.",
"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)
{{
"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"],
"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": {{
"STAGE": "CONSIDER",
"USER": "Nam, Adult, mua cho vợ.",
"TARGET": "Vợ (Nữ, Adult).",
"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
{{
"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"],
"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": {{
"STAGE": "UPSELL",
"USER": "Nam, Adult, có vợ.",
"TARGET": "Vợ (Nữ, Adult, size M, thích đen).",
"GOAL": "ĐÃ CHỐT [6VP24W010].",
......
......@@ -16,6 +16,15 @@ logger = logging.getLogger(__name__)
# API Canifa stock
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):
model_config = {"extra": "forbid"}
......@@ -48,8 +57,7 @@ async def fetch_stock_from_canifa(skus: list[str], timeout: float = 5.0) -> dict
url = f"{CANIFA_STOCK_API}?skus={sku_string}"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url)
resp = await _get_stock_client(timeout).get(url)
resp.raise_for_status()
data = resp.json()
......
......@@ -33,20 +33,22 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel):
model_config = {"extra": "ignore"} # Gemini may send extra fields
# ====== SEMANTIC SEARCH ======
# ====== SEARCH TEXT ======
description: str = Field(
description=(
"Mô tả sản phẩm cho semantic search. "
"FORMAT: 'product_name: [tên]. description_text: [mô tả]. "
"material_group: [chất liệu]. style: [phong cách]. fitting: [dáng]. "
"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. "
"Mô tả ngắn nhu cầu khách bằng ngôn ngữ tự nhiên. "
"Dùng cho fallback search khi các filter khác không đủ. "
"VD: 'Váy đi dự tiệc sang trọng', 'Áo đồng điệu cả nhà'. "
"⚠️ KHÔNG đưa master_color vào description — dùng field master_color riêng! "
)
)
product_name: str | None = Field(
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 ======
......@@ -125,7 +127,7 @@ class MultiSearchParams(BaseModel):
async def _execute_single_search(
db, item: SearchItem, query_vector: list[float] | None = None
db, item: SearchItem,
) -> tuple[list[dict], dict]:
"""
Thực thi một search query đơn lẻ (Async).
......@@ -146,7 +148,7 @@ async def _execute_single_search(
# Timer: build query (sử dụng vector đã có hoặc build mới)
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
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:
if not db:
return json.dumps({"status": "error", "message": "Database connection failed"})
# ══════ PARALLEL SQL: all queries run concurrently ══════
combined_results = []
all_filter_infos = []
tasks = []
for item in searches:
for i, item in enumerate(searches):
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)
if filter_info:
all_filter_infos.append(filter_info)
......
......@@ -41,7 +41,7 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Quần mặc nhà": ["quần mặc nhà"],
"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": ["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": ["quần leggings"],
"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():
for syn in synonyms:
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
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
......
import logging
import os
import time
# Note: tracing handled by parent data_retrieval_tool via context manager
from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__)
......@@ -84,42 +80,36 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]:
sql_params.extend([f"%{color_lower}%", f"%{color_lower}%"])
logger.info(f"🎨 [SQL FILTER] Color: {color_val}")
# Product name filter: resolve synonym → split words → skip generic → LIKE AND
# "áo cổ bẻ khaki" → resolve → "Áo Polo khaki" → keywords: ["polo", "khaki"]
# "áo thun disney" → resolve → "Áo phông disney" → keywords: ["phông", "disney"]
# Auto-derive generic prefix words from PRODUCT_LINE_MAP keys
# "Áo Sơ mi" → "áo", "Quần jean" → "quần", "Váy liền" → "váy", ...
from agent.tools.product_mapping import PRODUCT_LINE_MAP
GENERIC_WORDS = {key.split()[0].lower() for key in PRODUCT_LINE_MAP.keys()}
# Product name filter: resolve synonym → exact match or LIKE fallback
name_val = getattr(params, "product_name", None)
if name_val:
from agent.tools.product_mapping import resolve_product_name, get_related_lines
from agent.tools.product_mapping import resolve_product_name, PRODUCT_LINE_MAP
# Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"]
name_parts = [p.strip() for p in name_val.split("/") if p.strip()]
all_phrases = set()
like_parts = []
for part in name_parts:
resolved = resolve_product_name(part)
# Also expand related lines
for rname in get_related_lines(resolved):
words = rname.strip().split()
phrase = " ".join(w for w in words if w.lower() not in GENERIC_WORDS)
if phrase:
all_phrases.add(phrase.lower())
if all_phrases:
like_parts = []
for phrase in all_phrases:
# DB key → product_line_vn LIKE match (covers sub-categories)
# VD: 'Quần lót' → matches 'Quần lót', 'Quần lót đùi', 'Quần lót tam giác'
if resolved in PRODUCT_LINE_MAP:
like_parts.append("LOWER(product_line_vn) LIKE %s")
sql_params.append(f"%{resolved.lower()}%")
logger.info(f"🏷️ [SQL FILTER] Product line: '{part}' → LIKE '%{resolved.lower()}%'")
else:
# Fallback: LIKE on product_name
like_parts.append("LOWER(product_name) LIKE %s")
sql_params.append(f"%{phrase}%")
sql_params.append(f"%{resolved.lower()}%")
logger.info(f"🏷️ [SQL FILTER] Product name LIKE: '{part}' → '%{resolved.lower()}%'")
if like_parts:
clauses.append(f"({' OR '.join(like_parts)})")
logger.info(f"🏷️ [SQL FILTER] Product name: '{name_val}' → phrases: {all_phrases}")
return clauses
async def build_starrocks_query(params, query_vector: list[float] | None = None) -> tuple[str, list]:
async def build_starrocks_query(params) -> tuple[str, list]:
"""
Build SQL query với Parameterized Query để tránh SQL Injection.
Returns: (sql_string, params_list)
......@@ -143,7 +133,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
ROUND(CASE WHEN original_price > 0 THEN ((original_price - sale_price) / original_price * 100) ELSE 0 END, 0) as discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
......@@ -200,7 +190,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
ROUND(CASE WHEN original_price > 0 THEN ((original_price - sale_price) / original_price * 100) ELSE 0 END, 0) as discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
......@@ -217,86 +207,64 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# ============================================================
# CASE 3: SEMANTIC VECTOR SEARCH
# CASE 3: STRUCTURED SQL SEARCH (No Embedding)
# Dùng structured filters: product_name, gender, color, price...
# ============================================================
query_text = getattr(params, "description", None)
if query_text and query_vector is None:
query_vector = await create_embedding_async(query_text)
if not query_vector:
return "", []
# Vector params
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# Collect All Filters
sql_params: list = []
# 1. Price
price_clauses = _get_price_clauses(params, sql_params)
# 2. Metadata: Gender + Age + Color (HARD FILTER — all at SQL level)
# 1. Metadata filters (gender, age, color, product_name LIKE)
metadata_clauses = _get_metadata_clauses(params, sql_params)
all_clauses = price_clauses + metadata_clauses
# 2. Price filters
price_clauses = _get_price_clauses(params, sql_params)
# Discovery mode filters
discovery_mode = getattr(params, "discovery_mode", None)
if discovery_mode:
discovery_mode = discovery_mode.lower().strip()
if discovery_mode == "new":
all_clauses.append("is_new_product = 1")
logger.info("🆕 [SQL FILTER] Discovery: new products only")
elif discovery_mode == "best_seller":
all_clauses.append("quantity_sold > 0")
logger.info("🔥 [SQL FILTER] Discovery: best sellers")
all_clauses = metadata_clauses + price_clauses
# Get discount params
# 3. Discount filters
discount_min, discount_max = _get_discount_params(params)
post_filter_conditions = []
# Price + Gender + Age filters
if all_clauses:
post_filter_conditions.extend(all_clauses)
# Discount filters
if discount_min is not None or discount_max is not None:
post_filter_conditions.append("sale_price < original_price") # Ensure has discount
all_clauses.append("sale_price < original_price") # Ensure has discount
if discount_min is not None:
post_filter_conditions.append("discount_percent >= %s")
all_clauses.append(
"ROUND(CASE WHEN original_price > 0 THEN ((original_price - sale_price) / original_price * 100) ELSE 0 END, 0) >= %s"
)
sql_params.append(discount_min)
if discount_max is not None:
post_filter_conditions.append("discount_percent <= %s")
all_clauses.append(
"ROUND(CASE WHEN original_price > 0 THEN ((original_price - sale_price) / original_price * 100) ELSE 0 END, 0) <= %s"
)
sql_params.append(discount_max)
post_filter_where = ""
if post_filter_conditions:
post_filter_where = " WHERE " + " AND ".join(post_filter_conditions)
# 4. Fallback: nếu không có filter nào → dùng description LIKE
if not all_clauses:
desc_text = getattr(params, "description", None)
if desc_text:
# Tách keywords từ description để LIKE match
keywords = [w.strip() for w in desc_text.split() if len(w.strip()) > 1]
if keywords:
like_parts = []
for kw in keywords[:5]: # Max 5 keywords to avoid slow query
like_parts.append("(LOWER(product_name) LIKE %s OR LOWER(description_text) LIKE %s)")
sql_params.extend([f"%{kw.lower()}%", f"%{kw.lower()}%"])
all_clauses.append(f"({' AND '.join(like_parts)})")
logger.info("🔍 [FALLBACK LIKE] Using description keywords: %s", keywords[:5])
# Determine sort order: best_seller uses quantity_sold, otherwise similarity_score
if discovery_mode == "best_seller":
final_order = "ORDER BY max_sold DESC, max_score DESC"
extra_agg = ",\n MAX(quantity_sold) as max_sold"
else:
final_order = "ORDER BY max_score DESC"
extra_agg = ""
where_str = " AND ".join(all_clauses) if all_clauses else "1=1"
sql = f"""
WITH vector_matches AS (
SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
ROUND(CASE WHEN original_price > 0 THEN ((original_price - sale_price) / original_price * 100) ELSE 0 END, 0) as discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
......@@ -304,60 +272,12 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
description_text,
size_scale,
quantity_sold,
is_new_product,
approx_cosine_similarity(vector, {v_str}) as similarity_score
is_new_product
FROM shared_source.magento_product_dimension_with_text_embedding
ORDER BY similarity_score DESC
LIMIT 200
),
filtered_matches AS (
SELECT * FROM vector_matches
{post_filter_where}
ORDER BY similarity_score DESC
LIMIT 150
)
SELECT
internal_ref_code,
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
product_color_code,
MAX_BY(product_name, similarity_score) as product_name,
MAX_BY(master_color, similarity_score) as master_color,
MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail,
MAX_BY(product_web_url, similarity_score) as product_web_url,
MAX_BY(sale_price, similarity_score) as sale_price,
MAX_BY(original_price, similarity_score) as original_price,
MAX_BY(discount_amount, similarity_score) as discount_amount,
MAX_BY(discount_percent, similarity_score) as discount_percent,
MAX_BY(description_text, similarity_score) as description_text,
MAX_BY(gender_by_product, similarity_score) as gender_by_product,
MAX_BY(age_by_product, similarity_score) as age_by_product,
MAX_BY(product_line_vn, similarity_score) as product_line_vn,
MAX_BY(quantity_sold, similarity_score) as quantity_sold,
MAX_BY(size_scale, similarity_score) as size_scale,
MAX(similarity_score) as max_score{extra_agg}
FROM filtered_matches
GROUP BY product_color_code, internal_ref_code
{final_order}
LIMIT 80
WHERE {where_str}
ORDER BY quantity_sold DESC, magento_ref_code
LIMIT 20
"""
# try:
# query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt")
# # Build executable query by substituting %s with actual values
# executable_sql = sql
# for param in sql_params:
# if isinstance(param, str):
# # Escape single quotes and wrap in quotes
# escaped = param.replace("'", "''")
# executable_sql = executable_sql.replace("%s", f"'{escaped}'", 1)
# else:
# executable_sql = executable_sql.replace("%s", str(param), 1)
# with open(query_log_path, "w", encoding="utf-8") as f:
# f.write(f"-- [HYDE SEARCH] Full Executable Query\n-- Original Params: {sql_params}\n{executable_sql}")
# except Exception as e:
# logger.error(f"Error writing to query.txt: {e}")
logger.info("⚡ [STRUCTURED SQL] Direct filter search — no embedding needed")
return sql, sql_params
......@@ -62,6 +62,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"question_recommend": result.get("question_recommend", []),
"trace_id": result.get("trace_id", ""),
"limit_info": {
"limit": usage_info["limit"],
......@@ -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}")
# 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:
# DEV MODE: Return ai_response + products immediately
result = await chat_controller(
......@@ -112,6 +121,7 @@ async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_ta
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"question_recommend": result.get("question_recommend", []),
"trace_id": result.get("trace_id", ""),
"insight_status": "success" if result.get("user_insight") else "pending",
"user_insight": result.get("user_insight"),
......
......@@ -84,7 +84,7 @@ JWT_SECRET: str | None = os.getenv("JWT_SECRET")
JWT_ALGORITHM: str | None = os.getenv("JWT_ALGORITHM")
# ====================== 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")
# ====================== LANGFUSE CONFIGURATION (DEPRECATED) ======================
......@@ -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)
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: 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:
container_name: canifa-backend-dev-experimental
env_file: .env
ports:
- "5005:5000"
- "5005:5005"
volumes:
- .:/app
environment:
- PORT=5000
- PORT=5005
restart: unless-stopped
deploy:
resources:
......
......@@ -7,11 +7,11 @@ services:
container_name: canifa-backend-dev-experimental
env_file: .env
ports:
- "5005:5000"
- "5005:5005"
volumes:
- .:/app
environment:
- PORT=5000
- PORT=5005
restart: unless-stopped
deploy:
resources:
......
......@@ -26,7 +26,7 @@ from common.middleware import middleware_manager
from config import PORT
if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...")
print("[INFO] Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Configure Logging
......@@ -42,7 +42,7 @@ logger = logging.getLogger(__name__)
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
print(f" Static dir resolved: {STATIC_DIR}")
print(f"[OK] Static dir resolved: {STATIC_DIR}")
app = FastAPI(
......@@ -110,15 +110,15 @@ app.include_router(experiment_links_router) # Experiment links sidebar
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...")
print(">> Contract AI Service Starting...")
print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
print(f" REST API: http://localhost:{PORT}")
print(f" Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f" API Docs: http://localhost:{PORT}/docs")
print("=" * 60)
ENABLE_RELOAD = False
print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
print(f" Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"]
......
......@@ -877,6 +877,170 @@
.filtered-content .md-content .md-heading.h1 { font-size: 1.2em; }
.filtered-content .md-content .md-heading.h2 { font-size: 1.1em; }
.filtered-content .md-content .md-heading.h3 { font-size: 1.0em; }
/* Suggestion Chips — Zalando-style */
.suggestion-chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
justify-content: flex-start;
}
.suggestion-chips::before {
content: '✨ Suggestions';
display: block;
width: 100%;
font-size: 0.75em;
color: #888;
margin-bottom: 2px;
}
.suggestion-chip {
background: transparent;
border: 1px solid #555;
color: #e0e0e0;
padding: 6px 14px;
border-radius: 20px;
font-size: 0.85em;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.suggestion-chip:hover {
border-color: #0d9488;
color: #0d9488;
background: rgba(13, 148, 136, 0.1);
}
/* Per-product action buttons */
.product-actions {
display: flex;
gap: 6px;
margin-top: 8px;
}
.product-action-btn {
flex: 1;
background: transparent;
border: 1px solid #555;
color: #ccc;
padding: 5px 8px;
border-radius: 6px;
font-size: 0.75em;
cursor: pointer;
transition: all 0.2s;
text-align: center;
}
.product-action-btn:hover {
border-color: #0d9488;
color: #0d9488;
background: rgba(13, 148, 136, 0.08);
}
/* ── INSIGHT SIDEBAR ── */
.insight-sidebar {
width: 260px;
min-width: 260px;
background: #1a1a1e;
border-radius: 16px;
border: 1px solid #333;
margin-left: 16px;
display: flex;
flex-direction: column;
overflow-y: auto;
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
}
.insight-sidebar-header {
padding: 16px 16px 12px;
border-bottom: 1px solid #2a2a2e;
font-weight: 700;
font-size: 0.95em;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.stage-track {
padding: 16px 20px;
display: flex;
flex-direction: column;
align-items: center;
gap: 0;
}
.stage-step {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
}
.stage-dot {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
flex-shrink: 0;
transition: all 0.4s ease;
}
.stage-dot.future {
background: rgba(255,255,255,0.04);
border: 2px solid rgba(255,255,255,0.1);
}
.stage-dot.past {
background: rgba(0,210,106,0.2);
border: 2px solid rgba(0,210,106,0.4);
}
.stage-dot.active {
background: linear-gradient(135deg, #00d26a, #00b85c);
box-shadow: 0 0 14px rgba(0,210,106,0.6), 0 0 28px rgba(0,210,106,0.25);
animation: stagePulse 2s ease-in-out infinite;
}
.stage-label {
font-size: 0.82em;
font-weight: 500;
transition: all 0.3s;
}
.stage-label.future { color: rgba(255,255,255,0.3); }
.stage-label.past { color: rgba(0,210,106,0.6); }
.stage-label.active { color: #00d26a; font-weight: 700; }
.stage-connector {
width: 2px;
height: 20px;
margin-left: 16px;
transition: background 0.4s;
}
.stage-connector.filled { background: rgba(0,210,106,0.4); }
.stage-connector.empty { background: rgba(255,255,255,0.06); }
.insight-fields {
padding: 12px 16px;
border-top: 1px solid #2a2a2e;
flex: 1;
overflow-y: auto;
}
.insight-field {
margin-bottom: 10px;
}
.insight-field-key {
font-size: 0.7em;
text-transform: uppercase;
color: #666;
letter-spacing: 0.5px;
margin-bottom: 2px;
}
.insight-field-val {
font-size: 0.82em;
color: #ccc;
line-height: 1.5;
word-break: break-word;
}
.insight-empty {
padding: 30px 16px;
text-align: center;
color: #555;
font-size: 0.85em;
}
@keyframes stagePulse {
0%,100% { box-shadow: 0 0 14px rgba(0,210,106,0.6), 0 0 28px rgba(0,210,106,0.25); }
50% { box-shadow: 0 0 20px rgba(0,210,106,0.8), 0 0 40px rgba(0,210,106,0.35); }
}
</style>
</head>
......@@ -967,6 +1131,17 @@
</div>
</div>
<!-- Insight Sidebar -->
<div class="insight-sidebar" id="insightSidebar">
<div class="insight-sidebar-header">🧠 Shopping Journey</div>
<div class="stage-track" id="stageTrack">
<!-- Rendered by JS -->
</div>
<div class="insight-fields" id="insightFields">
<div class="insight-empty">Chưa có insight. Bắt đầu chat để xem hành trình mua sắm.</div>
</div>
</div>
<!-- Prompt Editor Panel -->
<div class="prompt-panel" id="promptPanel">
<div class="prompt-header">
......@@ -1858,6 +2033,32 @@
link.innerText = '🛍️ Xem chi tiết';
body.appendChild(link);
// Per-product action buttons
const actionsDiv = document.createElement('div');
actionsDiv.className = 'product-actions';
const adviseBtn = document.createElement('button');
adviseBtn.className = 'product-action-btn';
adviseBtn.innerText = '💬 Tư vấn';
adviseBtn.onclick = (e) => {
e.stopPropagation();
document.getElementById('userInput').value = `Tư vấn chi tiết [${product.sku}]`;
sendMessage();
};
actionsDiv.appendChild(adviseBtn);
const similarBtn = document.createElement('button');
similarBtn.className = 'product-action-btn';
similarBtn.innerText = '🔍 Tương tự';
similarBtn.onclick = (e) => {
e.stopPropagation();
document.getElementById('userInput').value = `Xem đồ tương tự [${product.sku}]`;
sendMessage();
};
actionsDiv.appendChild(similarBtn);
body.appendChild(actionsDiv);
card.appendChild(body);
productsContainer.appendChild(card);
});
......@@ -1971,6 +2172,25 @@
}
container1.appendChild(botMsgDiv1);
// ── Suggestion Chips (question_recommend) ──
if (data.question_recommend && data.question_recommend.length > 0) {
const chipsDiv = document.createElement('div');
chipsDiv.className = 'suggestion-chips';
data.question_recommend.forEach(q => {
const chip = document.createElement('button');
chip.className = 'suggestion-chip';
chip.innerText = q;
chip.onclick = () => {
document.getElementById('userInput').value = q;
chipsDiv.remove();
sendMessage();
};
chipsDiv.appendChild(chip);
});
container1.appendChild(chipsDiv);
}
messagesArea.appendChild(container1);
chatBox.scrollTop = chatBox.scrollHeight;
......@@ -1978,6 +2198,60 @@
// MESSAGE 2: User Insight (Polling - real backend delay)
// ============================================
const renderUserInsightMessage = (insightObj) => {
// ── UPDATE SIDEBAR (persistent) ──
const stages = ['BROWSE', 'CONSIDER', 'DECIDE', 'UPSELL'];
const stageIcons = { BROWSE: '🔍', CONSIDER: '🤔', DECIDE: '✅', UPSELL: '🛒' };
const stageLabels = { BROWSE: 'Tìm kiếm', CONSIDER: 'Cân nhắc', DECIDE: 'Chốt đơn', UPSELL: 'Mua thêm' };
const currentStage = (insightObj.STAGE || '').toUpperCase().trim();
const currentIdx = stages.indexOf(currentStage);
// Render stage track (vertical)
const trackEl = document.getElementById('stageTrack');
if (trackEl) {
trackEl.innerHTML = '';
stages.forEach((stage, i) => {
const isActive = i === currentIdx;
const isPast = i < currentIdx;
const cls = isActive ? 'active' : isPast ? 'past' : 'future';
const step = document.createElement('div');
step.className = 'stage-step';
const dot = document.createElement('div');
dot.className = 'stage-dot ' + cls;
dot.textContent = stageIcons[stage];
const label = document.createElement('div');
label.className = 'stage-label ' + cls;
label.innerHTML = `<strong>${stage}</strong><br><span style="font-size:0.85em;opacity:0.7">${stageLabels[stage]}</span>`;
step.appendChild(dot);
step.appendChild(label);
trackEl.appendChild(step);
// Connector (except last)
if (i < stages.length - 1) {
const conn = document.createElement('div');
conn.className = 'stage-connector ' + (i < currentIdx ? 'filled' : 'empty');
trackEl.appendChild(conn);
}
});
}
// Render insight fields
const fieldsEl = document.getElementById('insightFields');
if (fieldsEl) {
fieldsEl.innerHTML = '';
Object.entries(insightObj).forEach(([key, value]) => {
if (key === 'STAGE') return;
const field = document.createElement('div');
field.className = 'insight-field';
field.innerHTML = `<div class="insight-field-key">${key}</div><div class="insight-field-val">${value}</div>`;
fieldsEl.appendChild(field);
});
}
// ── ALSO render lightweight insight in chat ──
const container2 = document.createElement('div');
container2.className = 'message-container bot';
......@@ -1988,22 +2262,9 @@
const botMsgDiv2 = document.createElement('div');
botMsgDiv2.className = 'message bot';
botMsgDiv2.style.cssText = 'font-size:0.85em;opacity:0.7;padding:8px 12px;';
botMsgDiv2.innerHTML = `<span style="color:#00d26a">🧠 Stage: <strong>${currentStage || 'N/A'}</strong></span> · <span style="color:#aaa">Insight updated → xem sidebar</span>`;
const insightDiv = document.createElement('div');
insightDiv.className = 'user-insight';
insightDiv.innerHTML = '<strong>🧠 User Insight:</strong><br/>';
Object.entries(insightObj).forEach(([key, value]) => {
const line = document.createElement('div');
line.style.fontSize = '0.9em';
line.style.marginTop = '2px';
line.innerHTML = `<strong>${key}:</strong> ${value}`;
insightDiv.appendChild(line);
});
botMsgDiv2.appendChild(insightDiv);
// Real timing for insight render
const insightTime = ((Date.now() - startTime) / 1000).toFixed(2);
const insightTimeDiv = document.createElement('div');
insightTimeDiv.className = 'response-time';
......
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