Commit 091f7146 authored by Vũ Hoàng Anh's avatar Vũ Hoàng Anh

Fix product extraction for grouped variants and enrich variant media fields

parent 8d0785b1
...@@ -17,7 +17,7 @@ from langfuse import Langfuse, get_client as get_langfuse ...@@ -17,7 +17,7 @@ from langfuse import Langfuse, get_client as get_langfuse
from common.cache import redis_cache from common.cache import redis_cache
from common.conversation_manager import get_conversation_manager from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler from common.langfuse_client import async_flush_langfuse, get_callback_handler
from config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON from config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON
from .controller_helpers import ( from .controller_helpers import (
...@@ -256,6 +256,11 @@ async def chat_controller( ...@@ -256,6 +256,11 @@ async def chat_controller(
observation_ctx.__exit__(None, None, None) observation_ctx.__exit__(None, None, None)
except Exception: except Exception:
pass pass
# Flush Langfuse to ensure traces are sent immediately
try:
await async_flush_langfuse()
except Exception:
pass
try: try:
# Ensure stream completes for tool messages # Ensure stream completes for tool messages
......
...@@ -5,21 +5,18 @@ Các hàm tiện ích cho chat controller. ...@@ -5,21 +5,18 @@ Các hàm tiện ích cho chat controller.
import json import json
import logging import logging
import re
import uuid import uuid
from decimal import Decimal from decimal import Decimal
from langchain_core.messages import HumanMessage, ToolMessage from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from common.conversation_manager import ConversationManager from common.conversation_manager import ConversationManager
from common.langfuse_client import get_callback_handler from common.langfuse_client import get_callback_handler
from common.starrocks_connection import get_db_connection
from .models import AgentState from .models import AgentState
import re
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -86,7 +83,7 @@ def _neutralize_generic_print(name: str) -> str: ...@@ -86,7 +83,7 @@ def _neutralize_generic_print(name: str) -> str:
for p in _GENERIC_PRINT_PATTERNS: for p in _GENERIC_PRINT_PATTERNS:
if clean_lower.endswith(p): if clean_lower.endswith(p):
# Replace the generic phrase with neutral wording # Replace the generic phrase with neutral wording
clean = clean[:len(clean) - len(p)].strip().rstrip(",. ") clean = clean[: len(clean) - len(p)].strip().rstrip(",. ")
clean = f"{clean} có họa tiết trang trí" clean = f"{clean} có họa tiết trang trí"
break break
return clean return clean
...@@ -145,7 +142,7 @@ def format_product_results(products: list[dict]) -> list[dict]: ...@@ -145,7 +142,7 @@ def format_product_results(products: list[dict]) -> list[dict]:
color_code_only = parts[1] if len(parts) > 1 else "" color_code_only = parts[1] if len(parts) > 1 else ""
# Use magento_ref as base SKU for grouping # Use magento_ref as base SKU for grouping
base_sku = magento_ref if magento_ref else product_color_code base_sku = magento_ref or product_color_code
if not base_sku: if not base_sku:
continue continue
...@@ -194,7 +191,6 @@ def format_product_results(products: list[dict]) -> list[dict]: ...@@ -194,7 +191,6 @@ def format_product_results(products: list[dict]) -> list[dict]:
return formatted return formatted
def decimal_default(obj): def decimal_default(obj):
""" """
JSON serializer for objects not serializable by default json code. JSON serializer for objects not serializable by default json code.
...@@ -251,12 +247,12 @@ def extract_product_ids(messages: list) -> list[dict]: ...@@ -251,12 +247,12 @@ def extract_product_ids(messages: list) -> list[dict]:
if "variants" in product and product.get("variants"): if "variants" in product and product.get("variants"):
# Grouped product - expand EACH variant into separate product # Grouped product - expand EACH variant into separate product
product_name = product.get("name", "") product_name = product.get("name", "")
base_sku = product.get("product_id") # ✅ Giữ base SKU giống nhau base_sku = product.get("sku") or product.get("product_id")
for variant in product["variants"]: for variant in product["variants"]:
variant_sku = variant.get("sku") variant_sku = variant.get("sku") or variant.get("sku_color")
# ✅ Use base_sku instead of variant_sku for consistency # ✅ Use base_sku instead of variant_sku for consistency
display_sku = base_sku if base_sku else variant_sku display_sku = base_sku or variant_sku
# Create unique key for dedup using variant SKU # Create unique key for dedup using variant SKU
dedup_key = variant_sku or display_sku dedup_key = variant_sku or display_sku
...@@ -268,6 +264,7 @@ def extract_product_ids(messages: list) -> list[dict]: ...@@ -268,6 +264,7 @@ def extract_product_ids(messages: list) -> list[dict]:
product_obj = { product_obj = {
**variant, # Copy all variant fields (color, price, discount, stock, url, thumbnail, etc.) **variant, # Copy all variant fields (color, price, discount, stock, url, thumbnail, etc.)
"sku": display_sku, # Override with base SKU "sku": display_sku, # Override with base SKU
"sku_color": variant.get("sku_color") or variant_sku or "",
"name": product_name, # Override with product name "name": product_name, # Override with product name
} }
products.append(product_obj) products.append(product_obj)
...@@ -408,7 +405,7 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu ...@@ -408,7 +405,7 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
if mentioned_skus_in_text: if mentioned_skus_in_text:
explicit_set = set(str(s) for s in explicit_skus) explicit_set = set(str(s) for s in explicit_skus)
target_skus = explicit_set.intersection(mentioned_skus_in_text) target_skus = explicit_set.intersection(mentioned_skus_in_text)
if not target_skus: if not target_skus:
target_skus = mentioned_skus_in_text target_skus = mentioned_skus_in_text
else: else:
target_skus = set(str(s) for s in explicit_skus) target_skus = set(str(s) for s in explicit_skus)
...@@ -424,7 +421,6 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu ...@@ -424,7 +421,6 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
found_products = [] found_products = []
for sku in target_skus: for sku in target_skus:
if sku in product_lookup: if sku in product_lookup:
found_products.append(product_lookup[sku]) found_products.append(product_lookup[sku])
......
...@@ -63,28 +63,65 @@ Mình tìm mẫu form rộng cho bạn nhé! 😊" ...@@ -63,28 +63,65 @@ Mình tìm mẫu form rộng cho bạn nhé! 😊"
Khi khách nói dịp/hoàn cảnh → bot **gợi ý kiểu áo phù hợp** và gọi tool ngay: Khi khách nói dịp/hoàn cảnh → bot **gợi ý kiểu áo phù hợp** và gọi tool ngay:
| Dịp / Hoàn cảnh | Gợi ý áo | Gợi ý quần | | Dịp / Hoàn cảnh | Gợi ý áo | Gợi ý quần | Mức formal |
|------------------|-----------|-------------| |------------------|-----------|-------------|------------|
| Đi biển / du lịch biển | Áo phông ngắn tay, áo tank top, áo khoác chống nắng | Quần soóc, quần đùi | | Gặp khách hàng / đối tác | Sơ mi dài tay, áo polo CÓ CỔ | Quần tây, quần khaki | 🔴 Cao |
| Đi chơi / dạo phố | Áo phông, áo polo ngắn tay | Quần khaki, quần jean | | Phỏng vấn xin việc | Sơ mi dài tay (trắng/xanh nhạt) | Quần tây | 🔴 Cao |
| Đi làm / công sở | Áo sơ mi dài tay, áo kiểu | Quần tây, chân váy | | Đám cưới / đi tiệc | Sơ mi dài tay, váy liền thanh lịch | Quần tây | 🔴 Cao |
| Đi tiệc / đám cưới | Áo sơ mi dài tay, váy liền | Quần tây | | Ra mắt bố mẹ người yêu | Sơ mi hoặc polo CÓ CỔ, áo kiểu lịch sự | Quần tây, quần khaki | 🔴 Cao |
| Ở nhà / thư giãn | Đồ mặc nhà, áo phông | Quần nỉ, quần ngắn | | Ăn tối nhà hàng / date | Sơ mi, polo CÓ CỔ | Quần tây, chinos, khaki | 🟡 Trung bình |
| Mùa đông / trời lạnh | Áo len, áo nỉ, áo khoác | Quần nỉ, quần dài | | Đi làm / công sở | Sơ mi dài tay, áo kiểu, polo | Quần tây, chân váy | 🟡 Trung bình |
| Gym / thể thao | Áo thể thao (active), áo ba lỗ | Quần thể thao | | Đi chơi / dạo phố | Áo phông, áo polo ngắn tay | Quần khaki, quần jean | 🟢 Thấp |
| Chống nắng | Áo chống nắng, áo khoác mỏng | — | | Đi biển / du lịch biển | Áo phông ngắn tay, áo tank top, áo chống nắng | Quần soóc, quần đùi | 🟢 Thấp |
| Ở nhà / thư giãn | Đồ mặc nhà, áo phông | Quần nỉ, quần ngắn | 🟢 Thấp |
| Mùa đông / trời lạnh | Áo len, áo nỉ, áo khoác | Quần nỉ, quần dài | — |
| Gym / thể thao | Áo thể thao (active), áo ba lỗ | Quần thể thao | — |
| Chống nắng | Áo chống nắng, áo khoác mỏng | — | — |
⚠️ **QUY TẮC FORMAL — BOT PHẢI TỰ SUY LUẬN:**
1. **DỊP 🔴 CAO (gặp khách hàng, phỏng vấn, đám cưới, ra mắt bố mẹ):**
- BẮT BUỘC recommend áo CÓ CỔ (sơ mi, polo) → KHÔNG gợi ý áo phông/hoodie
- BẮT BUỘC quần tây/khaki/chinos → CẤM quần gió, quần nỉ, quần jogger
- Bot PHẢI giải thích: "Dịp này cần lịch sự, mình recommend sơ mi có cổ để tạo ấn tượng tốt"
2. **DỊP 🟡 TRUNG BÌNH (đi làm, date, ăn tối):**
- Ưu tiên áo có cổ nhưng có thể áo phông gọn gàng nếu khách thích casual
- Quần khaki/chinos OK, CẤM quần gió/nỉ
3. **DỊP 🟢 THẤP (đi chơi, ở nhà, biển):**
- Thoải mái, tất cả kiểu áo/quần đều OK
4. **BOT TỰ SUY LUẬN — nếu khách KHÔNG nói dịp cụ thể nhưng có dấu hiệu:**
- "Gặp sếp" / "đi họp" / "meeting" → 🔴 Formal → sơ mi + quần tây
- "Date" / "hẹn hò" / "đi với crush" → 🟡 Smart casual → polo/sơ mi + khaki
- "Đi chơi với bạn" / "cuối tuần" → 🟢 Casual → áo phông OK
- "Ra mắt gia đình" / "gặp phụ huynh" → 🔴 Formal → sơ mi CÓ CỔ
``` ```
❌ SAI (hỏi thừa): ❌ SAI (recommend quần gió cho dịp formal):
User: "Tìm đồ đi biển" User: "Đi gặp khách hàng mặc gì?"
Bot: "Bạn muốn tìm áo hay quần trước ạ?" Bot: "Áo khoác + quần gió active cho thoải mái nhé!"
→ CẤM! Đi biển = gọi tool ngay: Áo phông + Quần soóc → CẤM! Gặp khách hàng = formal = sơ mi + quần tây!
✅ ĐÚNG (gọi tool ngay 2 queries): ❌ SAI (recommend áo phông cho ra mắt bố mẹ):
User: "Tìm đồ đi biển" User: "Ra mắt bố mẹ bồ nên mặc gì?"
Bot: → query 1: Áo phông / áo thun thoáng mát ngắn tay Bot: "Áo thun + quần jean thoải mái nha bạn!"
→ query 2: Quần soóc / quần đùi → CẤM! Ra mắt = formal = áo có cổ!
→ Show kết quả + "Outfit đi biển thoải mái cho bạn nè!"
✅ ĐÚNG (suy luận đúng mức formal):
User: "Đi gặp khách hàng mặc gì?"
Bot: "Gặp khách hàng thì mình vote sơ mi dài tay + quần tây nhé! 💼
Sơ mi có cổ tạo ấn tượng chuyên nghiệp, phối quần tây
form slim cho gọn gàng, lịch sự mà không quá cứng.
Mình tìm mẫu chuẩn cho bạn nha!"
✅ ĐÚNG (suy luận date = smart casual):
User: "Đi date mặc gì cho đẹp?"
Bot: "Date thì mình suggest polo + khaki nhé! 😎
Polo có cổ nhìn lịch sự mà không quá formal, phối khaki
tạo cảm giác sạch sẽ, trẻ trung. Combo này lên hình
cuốn lắm! Mình tìm mẫu cho bạn nha!"
``` ```
**📏 ĐỘ DÀI RESPONSE — SALES THỊ, KHÔNG PHẢI MÁY TÌM KIẾM:** **📏 ĐỘ DÀI RESPONSE — SALES THỊ, KHÔNG PHẢI MÁY TÌM KIẾM:**
...@@ -276,15 +313,67 @@ Mẫu này chất organic cotton nên bền và giữ form tốt hơn nhiều. ...@@ -276,15 +313,67 @@ Mẫu này chất organic cotton nên bền và giữ form tốt hơn nhiều.
Nhưng để mình tìm mẫu tương tự tầm 200-300k cho bạn nhé — Nhưng để mình tìm mẫu tương tự tầm 200-300k cho bạn nhé —
sale đợt này có nhiều mẫu xịn giá mềm lắm! 🔥" sale đợt này có nhiều mẫu xịn giá mềm lắm! 🔥"
📌 5. STYLIST MODE — tư vấn phối đồ: 📌 5. STYLIST MODE — tư vấn phối đồ (PHẢI GIẢI THÍCH LÝ DO):
User: "Mặc đi đám cưới bạn nên chọn gì?" User: "Mặc đi đám cưới bạn nên chọn gì?"
Bot: "Đám cưới thì phải lên đồ xịn rồi! 🎉 Bot: "Đám cưới thì phải lên đồ xịn rồi! 🎉
Bạn cho mình biết đám cưới ở nhà hàng hay ngoài trời, Bạn cho mình biết đám cưới ở nhà hàng hay ngoài trời,
thích lịch sự hay trẻ trung — mình chọn outfit chuẩn cho bạn liền!" thích lịch sự hay trẻ trung — mình chọn outfit chuẩn cho bạn liền!"
User: "Nhà hàng, muốn lịch sự" User: "Nhà hàng, muốn lịch sự"
Bot: "Sơ mi slim fit + quần tây là combo bất bại cho nhà hàng! 💎 Bot: "Sơ mi slim fit + quần tây là combo bất bại cho nhà hàng! 💎
Mình chọn được mấy mẫu đẹp lắm, bạn xem bên dưới nhé. Mình chọn sơ mi slim fit vì dáng gọn gàng tôn dáng,
Phối với quần đen/navy là chuẩn sang rồi — thêm giỏ hàng luôn đi bạn! 😎" phù hợp không gian lịch sự. Phối quần tây đen/navy
tạo tổng thể sang trọng mà không quá formal.
Bạn xem bên dưới, thêm giỏ hàng luôn nhé! 😎"
📌 6. STYLIST MODE — phối đồ theo thời tiết:
User: "Trời se lạnh buổi sáng, đi cafe mặc gì?"
Bot: "Trời đầu xuân kiểu này mình gợi ý combo áo thun + cardigan nhé! ☕
Áo thun bên trong thoáng mát cho buổi trưa, cardigan khoác ngoài
giữ ấm buổi sáng — khi trời nóng lên cởi ra gọn gàng.
Phối quần jeans là đủ trendy cho cafe rồi! Bạn xem nha 😊"
```
---
**👔 CHẾ ĐỘ STYLIST — QUY TẮC PHỐI ĐỒ (BẮT BUỘC):**
Khi khách hỏi phối đồ / tư vấn outfit, **PHẢI làm 5 điều sau**:
1. **CHỌN 1-2 MẪU CỤ THỂ để recommend** — KHÔNG liệt kê "chọn khaki/quần âu/quần nỉ" rồi bảo khách tự chọn
2. **NÓI TÊN SẢN PHẨM CỤ THỂ** từ kết quả tool — "Mẫu quần khaki slimfit kia phối chuẩn luôn!" (KHÔNG nói chung chung "ưu tiên khaki")
3. **Giải thích TẠI SAO mẫu đó phù hợp** — form, chất liệu, màu sắc phối ra sao
4. **Liên hệ hoàn cảnh CỤ THỂ của khách** — đi chơi với ai, dịp gì, thời tiết
5. **ĐƯA Ý KIẾN MẠNH** — "Mình vote mẫu này!", "Combo này là best pick!" — KHÔNG nói "tùy bạn"
⚠️ QUAN TRỌNG: Stylist PHẢI có QUAN ĐIỂM — chọn hộ khách, không đẩy hết cho khách tự chọn!
```
❌ SAI (generic, không cụ thể sản phẩm):
"Áo khoác da: chọn mẫu màu tối để nhìn nam tính.
Quần nên ưu tiên khaki/quần âu form gọn.
Sịp đùi thì lấy loại thoải mái."
→ Lời khuyên ai cũng nói được, không gắn vào SP cụ thể nào!
❌ SAI (liệt kê hướng rồi bảo khách chọn):
"Mình recommend 2 hướng:
1) Lịch sự: quần khaki hoặc quần âu
2) Đi chơi: quần nỉ hoặc quần gió
Bạn chọn hướng nào?"
→ Stylist phải CHỌN HỘ, không đẩy hết cho khách!
✅ ĐÚNG (chọn hộ + giải thích cụ thể):
"Đi chơi với crush thì mình vote combo này cho bạn nè! 😎
Quần khaki slimfit phối với áo khoác da là combo cực chuẩn —
form gọn tôn dáng, nhìn sạch sẽ mà vẫn nam tính.
Màu tối của áo da + khaki sáng tạo contrast đẹp, lên hình cuốn lắm!
Sịp đùi cotton mặc bên trong thoải mái cho cả buổi đi chơi.
Bạn xem mấy mẫu bên dưới, mình đã chọn sẵn combo rồi nhé! 🔥"
✅ ĐÚNG (đi cafe thời tiết se lạnh):
"Trời đầu xuân kiểu này mình suggest combo áo thun + cardigan nhé! ☕
Cardigan len mỏng giữ ấm buổi sáng, trưa nắng cởi ra tiện.
Phối quần jeans là đủ trendy cho cafe rồi!
Mẫu cardigan len mỏng bên dưới đang sale, phối áo thun trắng là chuẩn! 😊"
``` ```
### 💰 QUY TẮC HIỂN THỊ GIÁ (BẮT BUỘC): ### 💰 QUY TẮC HIỂN THỊ GIÁ (BẮT BUỘC):
......
...@@ -52,56 +52,38 @@ DỊP = đã ĐỦ THÔNG TIN để search. Bot tự suy luận outfit phù hợ ...@@ -52,56 +52,38 @@ DỊP = đã ĐỦ THÔNG TIN để search. Bot tự suy luận outfit phù hợ
- Bất kỳ câu nào nhắc đến **hạng thẻ** (Green, Silver, Gold, Diamond, VIP) + "ưu đãi/quyền lợi/chiết khấu" → `canifa_knowledge_search` - Bất kỳ câu nào nhắc đến **hạng thẻ** (Green, Silver, Gold, Diamond, VIP) + "ưu đãi/quyền lợi/chiết khấu" → `canifa_knowledge_search`
- **QUY TẮC:** Có tên HẠNG THẺ → KHTT → `canifa_knowledge_search`. Không có hạng thẻ + hỏi KM chung → `canifa_get_promotions`. - **QUY TẮC:** Có tên HẠNG THẺ → KHTT → `canifa_knowledge_search`. Không có hạng thẻ + hỏi KM chung → `canifa_get_promotions`.
**⚠️ QUY TẮC TRẢ LỜI ƯU ĐÃI — BẮT BUỘC CHI TIẾT:** ⚠️ QUY TẮC TRẢ LỜI ƯU ĐÃI — DƯỚI 100 TỪ, KHÔNG MARKDOWN:
Khi tool trả về danh sách khuyến mãi, **PHẢI trình bày ĐẦY ĐỦ và CHI TIẾT**:
1. **Liệt kê TẤT CẢ** chương trình đang có — KHÔNG ĐƯỢC lược bỏ
2. **Mỗi chương trình phải có:**
- Tên chương trình (in đậm)
- Mô tả chi tiết: nội dung ưu đãi cụ thể (giảm bao nhiêu, áp dụng sản phẩm nào...)
- Thời gian áp dụng (từ ngày → đến ngày)
3. **KHÔNG trả lời qua loa** kiểu "Đang có vài chương trình khuyến mãi"
4. **KHÔNG gom chung** nhiều CTKM thành 1 câu — phải tách riêng từng cái
5. **Nếu data tool chỉ có tên + địa điểm áp dụng mà KHÔNG ghi cụ thể nội dung ưu đãi:**
→ Trình bày tên + thời gian + nội dung có sẵn
→ Rồi nói: "Để xem chi tiết ưu đãi cụ thể, bạn liên hệ hotline 1800 6061 hoặc vào canifa.com nhé!"
→ KHÔNG ĐƯỢC tự bịa % giảm giá hay điều kiện
**VÍ DỤ:** Khi tool trả về danh sách khuyến mãi:
```
❌ SAI (qua loa):
"Dạ hiện tại CANIFA đang có một số chương trình khuyến mãi.
Bạn ghé cửa hàng hoặc website để xem chi tiết nhé!"
❌ SAI (thiếu chi tiết):
"Đang có giảm 20% áo phông và mua 2 giảm thêm 10% ạ."
✅ ĐÚNG (khi data đầy đủ):
"Dạ hiện tại CANIFA đang có 2 chương trình bạn ơi!
**Giảm 20% Áo Phông Xuân Hè** 1. Tổng response DƯỚI 100 TỪ — CẤM viết dài
Giảm 20% toàn bộ áo phông nam nữ BST Xuân Hè 2026 2. GOM NHÓM các CTKM tương tự thành 1 dòng (VD: "Mua 2 giảm 20%, mua 3 giảm 30% đồ lót/tất")
Từ 01/03 đến 15/03/2026 3. Mỗi CTKM chỉ ghi TÊN + ưu đãi chính — BỎ thời gian, điều kiện, lưu ý
4. KHÔNG dùng dấu ** — viết text thuần
5. KHÔNG liệt kê từng cái chi tiết — chỉ highlight điểm nổi bật
6. Cuối cùng hỏi 1 câu ngắn để tư vấn tiếp
**Combo Mua 2 Giảm 10%** VÍ DỤ THỰC TẾ:
Mua 2 sản phẩm bất kỳ, giảm thêm 10% trên tổng đơn
Từ 01/03 đến 31/03/2026
Bạn đang quan tâm chương trình nào? Mình tư vấn sản phẩm phù hợp nha!" ```
❌ SAI NGHIÊM TRỌNG (liệt kê 13 CTKM, dùng **markdown**, quá dài):
✅ ĐÚNG (khi data thiếu nội dung cụ thể): "Dạ hôm nay CANIFA đang có khá nhiều ưu đãi nè bạn:
"Dạ hiện tại CANIFA đang có 2 chương trình khuyến mãi: 1) **Giảm 50k cho đơn từ 999k**
- Kênh: Online (Web/App)
**tet_tat** — áp dụng tại toàn bộ cửa hàng và Web/App CANIFA - Nội dung: Giảm 50.000đ cho hóa đơn thanh toán từ 999.000đ...
Từ 17/02 đến 31/03/2026 2) **Giảm 80k cho đơn Online đầu tiên từ 399k**
- Kênh: Online (Web/App)
**tet_khan** — áp dụng tương tự ..."
Từ 17/02 đến 31/03/2026 → CẤM! Quá dài, liệt kê chi tiết từng cái, dùng ** markdown!
Để xem chi tiết nội dung ưu đãi cụ thể (giảm bao nhiêu, áp dụng sản phẩm nào), ✅ ĐÚNG (gom nhóm, dưới 100 từ, không dùng **):
bạn liên hệ hotline 1800 6061 hoặc vào canifa.com nhé!" "Dạ CANIFA đang có một số ưu đãi nổi bật (áp dụng online Web/App và có cả chương trình ở cửa hàng nữa) như sau:
- Giảm 50k cho đơn từ 999k (Online Web/App)
- Giảm 80k cho đơn Online đầu tiên từ 399k (Online Web/App)
- Sale Corner: giảm tới 50%+++ (Online + Cửa hàng)
- Mua 2 giảm 20%, mua 3 giảm 30% cho Underwear/tất/home textile (Online + Cửa hàng)
- Happy Weekend: giá trải nghiệm dòng hàng thiết yếu từ 129k-199k (Online + Cửa hàng)
Bạn muốn mình lọc ưu đãi phù hợp mặt hàng bạn định mua không (áo/quần/váy, đồ mặc nhà, underwear/tất...)? 😊"
``` ```
--- ---
...@@ -355,11 +337,11 @@ description = "Váy liền thân/ Chân váy" # ← SAI khi hỏi "váy ...@@ -355,11 +337,11 @@ description = "Váy liền thân/ Chân váy" # ← SAI khi hỏi "váy
``` ```
**⚡ CROSS-SELL — Gợi ý mua thêm KHÉO LÉO, gắn trực tiếp với CTKM:** **⚡ CROSS-SELL — Gợi ý mua thêm KHÉO LÉO, gắn trực tiếp với CTKM:**
Sau khi giới thiệu SP + mention CTKM → **gợi ý mua thêm SP khác VÀ nói rõ lợi ích từ CTKM:** Sau khi giới thiệu SP + mention CTKM → gợi ý mua thêm SP khác VÀ nói rõ lợi ích từ CTKM:
- "Bạn mua thêm mấy cái quần lót/áo lót nữa là được **mua 2 giảm 20%, mua 3 giảm 30%** luôn đấy!" - "Bạn mua thêm mấy cái quần lót/áo lót nữa là được mua 2 giảm 20%, mua 3 giảm 30% luôn đấy!"
- "Áo này 199k, bạn thêm 1 quần nữa là đủ **399k được giảm 80k** cho đơn đầu tiên rồi!" - "Áo này 199k, bạn thêm 1 quần nữa là đủ 399k được giảm 80k cho đơn đầu tiên rồi!"
- "Nhân đợt sale bạn gom thêm mấy cái tất/khăn vào, mua chung **tiết kiệm hơn nhiều**!" - "Nhân đợt sale bạn gom thêm mấy cái tất/khăn vào, mua chung tiết kiệm hơn nhiều!"
**KEY:** Phải nói RÕ mua thêm gì + được hưởng CTKM nào. KHÔNG gợi ý chung chung! KEY: Phải nói RÕ mua thêm gì + được hưởng CTKM nào. KHÔNG gợi ý chung chung. KHÔNG dùng dấu ** trong response.
--- ---
......
"""
Push ALL prompts (modules + tools) to PRODUCTION Langfuse.
Usage: py push_prod.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
sys.stdout.reconfigure(encoding="utf-8")
# ---- PRODUCTION Langfuse credentials ----
os.environ["LANGFUSE_SECRET_KEY"] = "sk-lf-bd0c7202-a7f4-4399-a36e-65ebe2e35104"
os.environ["LANGFUSE_PUBLIC_KEY"] = "pk-lf-2bb249b1-77e3-4309-8954-312b8fb2fff9"
os.environ["LANGFUSE_BASE_URL"] = "http://172.16.2.207:3009"
from langfuse import Langfuse
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
PROMPT_DIR = os.path.dirname(BASE_DIR) # prompt_module/ (parent of push_production.py/)
TOOL_DIR = os.path.join(os.path.dirname(PROMPT_DIR), "tool_prompts")
# ---- Prompt modules ----
PROMPT_MODULES = [
("02_rules.txt", "canifa-02-rules", ["canifa", "system-core"]),
("03_context.txt", "canifa-03-context", ["canifa", "system-core"]),
("04a_sales_core.txt", "canifa-04a-sales-core", ["canifa", "system-sales"]),
("04b_sales_thaomai.txt", "canifa-04b-sales-thaomai", ["canifa", "system-sales"]),
("04c_sales_upsell.txt", "canifa-04c-sales-upsell", ["canifa", "system-sales"]),
("04d_sales_urgency.txt", "canifa-04d-sales-urgency", ["canifa", "system-sales"]),
("05_tool_routing.txt", "canifa-05-tool-routing", ["canifa", "system-core"]),
("05b_tool_results.txt", "canifa-05b-tool-results", ["canifa", "system-core"]),
("05c_comparison.txt", "canifa-05c-comparison", ["canifa", "system-core"]),
("06_user_insight.txt", "canifa-06-user-insight", ["canifa", "system-core"]),
("07_output_format.txt", "canifa-07-output-format", ["canifa", "system-core"]),
]
CORE_FILE = "01_core.txt"
CORE_PROMPT_NAME = "canifa-stylist-system-prompt"
SEASON_PROMPT_NAME = "canifa-08-season"
DEFAULT_SEASON_CONTENT = """## HƯỚNG DẪN TƯ VẤN THEO MÙA / EVENT
**Thời điểm hiện tại:** Tháng 3/2026 — Mùa Xuân, chuyển giao Đông → Hè
**Ưu tiên sản phẩm mùa này:**
- Áo khoác nhẹ, cardigan (trời se lạnh buổi sáng/tối)
- Áo phông, áo thun (ban ngày ấm)
- Sơ mi dài tay (đi làm)
- Quần jeans, quần kaki (đa năng)
**Khi khách hỏi chung chung ("có gì hot?", "gợi ý đi"):**
→ Ưu tiên giới thiệu sản phẩm phù hợp thời tiết hiện tại
→ Nhắc sale/khuyến mãi nếu có
**Event đang diễn ra:**
- (Marketing cập nhật event tại đây)
"""
# ---- Tool prompts ----
TOOL_PROMPTS = [
("data_retrieval_tool.txt", "canifa-tool-data-retrieval", ["canifa", "tool-prompt"]),
("brand_knowledge_tool.txt", "canifa-tool-brand-knowledge", ["canifa", "tool-prompt"]),
("check_is_stock.txt", "canifa-tool-check-is-stock", ["canifa", "tool-prompt"]),
("store_search_tool.txt", "canifa-tool-store-search", ["canifa", "tool-prompt"]),
("promotion_canifa_tool.txt", "canifa-tool-promotion", ["canifa", "tool-prompt"]),
]
def read_file(directory: str, filename: str) -> str:
with open(os.path.join(directory, filename), "r", encoding="utf-8") as f:
return f.read()
def main():
print("🚀 PUSH ALL TO PRODUCTION LANGFUSE")
print(f" URL: {os.environ['LANGFUSE_BASE_URL']}")
print()
lf = Langfuse()
# ── Step 1: Prompt modules ──
print("=" * 60)
print("STEP 1: Prompt Modules")
print("=" * 60)
for filename, name, tags in PROMPT_MODULES:
content = read_file(PROMPT_DIR, filename)
lf.create_prompt(name=name, prompt=content, labels=["production"], tags=tags, type="text")
print(f" ✅ {filename:30s} → {name} ({len(content):,} chars)")
# Season
lf.create_prompt(name=SEASON_PROMPT_NAME, prompt=DEFAULT_SEASON_CONTENT,
labels=["production"], tags=["canifa", "system-addon"], type="text")
print(f" ✅ {'(season)':30s} → {SEASON_PROMPT_NAME}")
# Core (with composable references)
core_content = read_file(PROMPT_DIR, CORE_FILE)
refs = "\n".join(f"@@@langfusePrompt:name={n}|label=production@@@" for _, n, _ in PROMPT_MODULES)
refs += f"\n@@@langfusePrompt:name={SEASON_PROMPT_NAME}|label=production@@@"
composed = core_content + "\n" + refs + "\n"
lf.create_prompt(name=CORE_PROMPT_NAME, prompt=composed,
labels=["production"], tags=["canifa", "system-prompt"], type="text")
print(f" ✅ {'01_core.txt (composed)':30s} → {CORE_PROMPT_NAME}")
# ── Step 2: Tool prompts ──
print("\n" + "=" * 60)
print("STEP 2: Tool Prompts")
print("=" * 60)
for filename, name, tags in TOOL_PROMPTS:
filepath = os.path.join(TOOL_DIR, filename)
if not os.path.exists(filepath):
print(f" ⚠️ SKIP {filename} — not found")
continue
content = read_file(TOOL_DIR, filename)
lf.create_prompt(name=name, prompt=content, labels=["production"], tags=tags, type="text")
print(f" ✅ {filename:35s} → {name} ({len(content):,} chars)")
# ── Step 3: Verify ──
print("\n" + "=" * 60)
print("STEP 3: Verification")
print("=" * 60)
prompt = lf.get_prompt(CORE_PROMPT_NAME, label="production", cache_ttl_seconds=0)
print(f" Assembled prompt: {len(prompt.prompt):,} chars")
checks = [
("01 Core", "C-stylist"),
("02 Rules", "QUY TẮC TRUNG THỰC"),
("03 Context", "CONTEXT AWARENESS"),
("04a Sales", "PHONG CÁCH TƯ VẤN"),
("05 Tool Route", "KHI NÀO GỌI TOOL"),
("05b Results", "XỬ LÝ KẾT QUẢ TOOL"),
("05c Compare", "SO SÁNH"),
("06 Insight", "USER INSIGHT 2.0"),
("07 Output", "FORMAT ĐẦU RA"),
("08 Season", "HƯỚNG DẪN TƯ VẤN THEO MÙA"),
]
all_ok = True
for label, keyword in checks:
found = keyword in prompt.prompt
print(f" {'✅' if found else '❌'} {label}: '{keyword}'")
if not found:
all_ok = False
for filename, name, _ in TOOL_PROMPTS:
if not os.path.exists(os.path.join(TOOL_DIR, filename)):
continue
try:
p = lf.get_prompt(name, label="production", cache_ttl_seconds=0)
print(f" ✅ {name} ({len(p.prompt):,} chars)")
except Exception as e:
print(f" ❌ {name} — {e}")
all_ok = False
lf.flush()
print(f"\n{'🎉 ALL DONE!' if all_ok else '⚠️ Done with warnings'}")
if __name__ == "__main__":
main()
...@@ -131,12 +131,14 @@ User: "đi concert / lễ hội" ...@@ -131,12 +131,14 @@ User: "đi concert / lễ hội"
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
🔍 `description` — SEMANTIC SEARCH (format DB columns): 🔍 `description` — SEMANTIC SEARCH (format DB columns):
product_name: [tên SP]. description_text: [mô tả chi tiết SP]. product_name: [tên SP BỎ MÀU SẮC!]. description_text: [mô tả chi tiết SP].
material_group: [chất liệu]. season: [mùa]. style: [phong cách]. material_group: [chất liệu]. season: [mùa]. style: [phong cách].
fitting: [dáng]. form_neckline: [cổ]. form_sleeve: [tay]. product_line_vn: [dòng SP]. fitting: [dáng]. form_neckline: [cổ]. form_sleeve: [tay]. product_line_vn: [dòng SP].
⚠️ description_text BẮT BUỘC LUÔN CÓ — mô tả ngắn gọn sản phẩm, dùng cho semantic search! ⚠️ description_text BẮT BUỘC LUÔN CÓ — mô tả ngắn gọn sản phẩm, dùng cho semantic search!
⚠️ KHÔNG đưa gender_by_product, age_by_product, master_color vào description — đó là SQL FILTER! ⛔ TUYỆT ĐỐI CẤM nhét tên màu (trắng, đen, xanh lá...), giới tính (nam, nữ), độ tuổi vào trong `description` hay `description_text`!
❌ SAI: description_text: "Áo sơ mi nữ màu xanh lá lịch sự" (Có Màu & Giới Tính → Gây nhiễu Vector Search → Kết quả = 0!)
✅ ĐÚNG: description_text: "Áo sơ mi lịch sự thanh lịch phối đi làm đi chơi" (Cực sạch sẽ, nhường Màu và Giới Tính cho SQL Filter xử lý!)
🔒 SQL FILTER (tách riêng, KHÔNG đưa vào description): 🔒 SQL FILTER (tách riêng, KHÔNG đưa vào description):
- product_name: Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể. - product_name: Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể.
...@@ -186,12 +188,19 @@ VÍ DỤ discovery_mode: ...@@ -186,12 +188,19 @@ VÍ DỤ discovery_mode:
- VD: "👕 [8TP25A005]: Áo polo nam - 229k (giảm từ 399k) | Đã bán 4.483 sp 🔥" - VD: "👕 [8TP25A005]: Áo polo nam - 229k (giảm từ 399k) | Đã bán 4.483 sp 🔥"
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
🚨🚨🚨 QUY TẮC product_name — CỰC KỲ QUAN TRỌNG 🚨🚨🚨 🚨🚨🚨 QUY TẮC product_name — CỰC KỲ QUAN TRỌNG (CẤM NHÉT MÀU SẮC) 🚨🚨🚨
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
product_name = CHÍNH XÁC CÂU USER NÓI, KHÔNG ĐƯỢC TỰ ĐỔI. product_name = CÂU USER NÓI, NHƯNG BẮT BUỘC PHẢI LOẠI BỎ TỪ CHỈ MÀU SẮC!
User nói "áo" → product_name: Áo (KHÔNG tự thêm "phông", "polo", "sơ mi"!) ⚠️ NẾU KHÁCH CÓ NHẮC TỚI MÀU SẮC, BẮT BUỘC TÁCH MÀU RA KHỎI TÊN SẢN PHẨM:
- User nói "áo sơ mi xanh lá" → product_name: "Áo sơ mi" | master_color: "xanh lá"
- User nói "quần jean đen" → product_name: "Quần jean" | master_color: "đen"
- User nói "áo phông trắng" → product_name: "Áo phông" | master_color: "trắng"
⛔ TUYỆT ĐỐI KHÔNG ĐỂ MÀU SẮC LỌT VÀO `product_name` (Ví dụ: product_name="Áo sơ mi xanh lá" sẽ gây lỗi database database).
QUY TẮC TỰ ĐỔI TÊN: KHÔNG ĐƯỢC TỰ THÊM TỪ!
User nói "áo" → product_name: Áo (KHÔNG tự thêm "phông", "polo"!)
User nói "quần" → product_name: Quần (KHÔNG tự thêm "jean", "khaki"!) User nói "quần" → product_name: Quần (KHÔNG tự thêm "jean", "khaki"!)
User nói "áo ngọ nguậy" → product_name: Áo ngọ nguậy User nói "áo ngọ nguậy" → product_name: Áo ngọ nguậy
User nói "áo cá sấu" → product_name: Áo cá sấu User nói "áo cá sấu" → product_name: Áo cá sấu
...@@ -256,7 +265,7 @@ PHỤ KIỆN: Khăn, Mũ, Túi xách, Tất, Khẩu trang ...@@ -256,7 +265,7 @@ PHỤ KIỆN: Khăn, Mũ, Túi xách, Tất, Khẩu trang
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
product_name: Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể. product_name: Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể.
master_color — Gửi CHÍNH XÁC màu khách nói (VD: 'trắng', 'đen'), tool tự match DB. ĐÂY LÀ SQL FILTER, KHÔNG đưa vào description! master_color — TÁCH LẤY CHÍNH XÁC TỪ CHỈ MÀU SẮC khách nói (VD: Khách nói "áo màu xanh nước biển" -> BẮT BUỘC chỉ trả về "xanh nước biển". Tuyệt đối KHÔNG TRẢ VỀ chứa chữ "màu" hay các từ thừa). ĐÂY LÀ SQL FILTER, KHÔNG đưa vào description!
style — Basic, Dynamic, Feminine, Utility, Smart Casual, Trend, Athleisure, Essential style — Basic, Dynamic, Feminine, Utility, Smart Casual, Trend, Athleisure, Essential
fitting — Regular, Slimfit, Relax, Oversize, Skinny, Slim, Boxy, Baby tee fitting — Regular, Slimfit, Relax, Oversize, Skinny, Slim, Boxy, Baby tee
form_sleeve — Full length Sleeve, Short Sleeve, Sleeveless form_sleeve — Full length Sleeve, Short Sleeve, Sleeveless
...@@ -266,7 +275,7 @@ season — Fall Winter, Spring Summer, Year ...@@ -266,7 +275,7 @@ season — Fall Winter, Spring Summer, Year
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
⛔⛔⛔ TỐI HẬU THƯ — gender_by_product / age_by_product ⛔⛔⛔ ⛔⛔⛔ TỐI HẬU THƯ — gender_by_product / age_by_product ⛔⛔⛔
═══════════════════════════════════════════════════════════════ ═════════════════════════════════════════════════════════════
CẤM TUYỆT ĐỐI tự suy diễn gender/age khi user CHƯA NÓI RÕ! CẤM TUYỆT ĐỐI tự suy diễn gender/age khi user CHƯA NÓI RÕ!
......
...@@ -30,3 +30,9 @@ Sử dụng tool này khi khách hàng hỏi về: ...@@ -30,3 +30,9 @@ Sử dụng tool này khi khách hàng hỏi về:
Tham số: Tham số:
- location (bắt buộc): Tên quận/huyện/tỉnh/thành phố/địa chỉ. Bỏ prefix "quận", "huyện", "tỉnh", "tp" khi truyền vào. - location (bắt buộc): Tên quận/huyện/tỉnh/thành phố/địa chỉ. Bỏ prefix "quận", "huyện", "tỉnh", "tp" khi truyền vào.
⚠️ QUAN TRỌNG — CHỈ TRUYỀN 1 ĐỊA ĐIỂM CỤ THỂ NHẤT:
- Nếu người dùng nói "Phúc Yên, Vĩnh Phúc" → location="Phúc Yên" (lấy phần cụ thể nhất)
- Nếu người dùng nói "Hoàng Mai, Hà Nội" → location="Hoàng Mai" (quận cụ thể hơn TP)
- KHÔNG truyền "Hoàng Mai, Hà Nội" — chỉ truyền "Hoàng Mai"
- Quy tắc: Quận/Huyện/Thị xã > Tỉnh/TP. Luôn lấy phần NHỎ NHẤT.
...@@ -12,23 +12,20 @@ import asyncio ...@@ -12,23 +12,20 @@ import asyncio
import json import json
import logging import logging
import time import time
import unicodedata
from langfuse import get_client as get_langfuse
from langchain_core.tools import tool from langchain_core.tools import tool
from langfuse import get_client as get_langfuse
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from agent.helper import format_product_results from agent.helper import format_product_results
from agent.prompt_utils import read_tool_prompt
from agent.tools.product_search_helpers import build_starrocks_query from agent.tools.product_search_helpers import build_starrocks_query
from common.starrocks_connection import get_db_connection from common.starrocks_connection import get_db_connection
# Setup Logger # Setup Logger
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel): class SearchItem(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE model_config = {"extra": "forbid"} # STRICT MODE
...@@ -99,6 +96,105 @@ class MultiSearchParams(BaseModel): ...@@ -99,6 +96,105 @@ class MultiSearchParams(BaseModel):
searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm") searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm")
def _normalize_text(value: str | None) -> str:
"""Normalize free text for robust case/accent-insensitive matching."""
if not value:
return ""
normalized = unicodedata.normalize("NFD", str(value))
no_diacritics = "".join(ch for ch in normalized if unicodedata.category(ch) != "Mn")
return " ".join(no_diacritics.lower().strip().split())
def _attach_variant_skus(formatted_products: list[dict], raw_products: list[dict]) -> None:
"""Attach full variant SKUs to grouped products for downstream stock lookup."""
by_base_sku: dict[str, list[dict]] = {}
for row in raw_products:
base_sku = str(row.get("magento_ref_code") or row.get("internal_ref_code") or "").strip()
variant_sku = str(row.get("product_color_code") or "").strip()
if not base_sku or not variant_sku:
continue
by_base_sku.setdefault(base_sku, []).append(
{
"sku_color": variant_sku,
"color": row.get("master_color") or "",
"url": row.get("product_web_url") or "",
"thumbnail_image_url": row.get("product_image_url_thumbnail") or "",
"thumbnail": row.get("product_image_url_thumbnail") or "",
"price": int(row.get("original_price") or 0),
"sale_price": int(row.get("sale_price") or row.get("original_price") or 0),
}
)
for product in formatted_products:
base_sku = str(product.get("sku") or "").strip()
if not base_sku:
continue
variants = by_base_sku.get(base_sku, [])
if not variants:
continue
deduped: list[dict] = []
seen_variant_skus: set[str] = set()
for variant in variants:
sku_color = str(variant.get("sku_color") or "").strip()
if not sku_color or sku_color in seen_variant_skus:
continue
seen_variant_skus.add(sku_color)
deduped.append(variant)
if deduped:
product["variants"] = deduped
def _resolve_stock_skus(searches: list[SearchItem], products: list[dict]) -> list[str]:
"""Resolve sku_color list from base SKU + optional color, ready for stock tool."""
resolved: list[str] = []
seen: set[str] = set()
for item in searches:
base_code = str(item.magento_ref_code or "").strip().upper()
if not base_code:
continue
target_color = _normalize_text(item.master_color)
candidates = [p for p in products if str(p.get("sku") or "").strip().upper() == base_code]
for product in candidates:
variants = product.get("variants") or []
# If user specified color, prefer matching sku_color by that color first.
if target_color:
for variant in variants:
sku_color = str(variant.get("sku_color") or "").strip()
variant_color = _normalize_text(variant.get("color"))
if not sku_color:
continue
if (target_color in variant_color or variant_color in target_color) and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
# Fallback: still return at least one variant SKU for the base code.
if not target_color or all(
s not in seen for s in [v.get("sku_color") for v in variants if v.get("sku_color")]
):
for variant in variants:
sku_color = str(variant.get("sku_color") or "").strip()
if sku_color and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
# Final fallback when no variants attached.
sku_color = str(product.get("sku_color") or "").strip()
if sku_color and sku_color not in seen:
seen.add(sku_color)
resolved.append(sku_color)
return resolved
async def _execute_single_search( async def _execute_single_search(
db, item: SearchItem, query_vector: list[float] | None = None db, item: SearchItem, query_vector: list[float] | None = None
) -> tuple[list[dict], dict]: ) -> tuple[list[dict], dict]:
...@@ -187,14 +283,18 @@ async def _execute_single_search( ...@@ -187,14 +283,18 @@ async def _execute_single_search(
"price": float(p["sale_price"]) if p.get("sale_price") else None, "price": float(p["sale_price"]) if p.get("sale_price") else None,
} }
for p in products[:3] for p in products[:3]
] if products else [], ]
if products
else [],
}, },
metadata={"sql_length": len(sql)}, metadata={"sql_length": len(sql)},
) )
except Exception as trace_err: except Exception as trace_err:
logger.warning("⚠️ Langfuse db-query-search span failed: %s", trace_err, exc_info=True) logger.warning("⚠️ Langfuse db-query-search span failed: %s", trace_err, exc_info=True)
return format_product_results(products), {"fallback_used": False} formatted_products = format_product_results(products)
_attach_variant_skus(formatted_products, products)
return formatted_products, {"fallback_used": False}
except Exception as e: except Exception as e:
logger.exception("Single search error for item %r: %s", item, e) logger.exception("Single search error for item %r: %s", item, e)
return [], {"fallback_used": False, "error": str(e)} return [], {"fallback_used": False, "error": str(e)}
...@@ -212,15 +312,15 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -212,15 +312,15 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
# Log search params cho debugging # Log search params cho debugging
for i, s in enumerate(searches): for i, s in enumerate(searches):
logger.info( logger.info(
"🔧 Search[%d]: desc=%r, name=%r, gender=%s, age=%s, color=%s, " "🔧 Search[%d]: desc=%r, name=%r, gender=%s, age=%s, color=%s, price=%s-%s, code=%s, mode=%s",
"price=%s-%s, code=%s, mode=%s",
i, i,
(s.description[:80] + "...") if s.description and len(s.description) > 80 else s.description, (s.description[:80] + "...") if s.description and len(s.description) > 80 else s.description,
s.product_name, s.product_name,
s.gender_by_product, s.gender_by_product,
s.age_by_product, s.age_by_product,
s.master_color, s.master_color,
s.price_min, s.price_max, s.price_min,
s.price_max,
s.magento_ref_code, s.magento_ref_code,
s.discovery_mode, s.discovery_mode,
) )
...@@ -233,9 +333,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -233,9 +333,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
combined_results = [] combined_results = []
all_filter_infos = [] all_filter_infos = []
tasks = [] tasks = [_execute_single_search(db, item) for item in searches]
for item in searches:
tasks.append(_execute_single_search(db, item))
results_list = await asyncio.gather(*tasks) results_list = await asyncio.gather(*tasks)
...@@ -262,6 +360,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -262,6 +360,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"status": "success", "status": "success",
"search_input": search_inputs, "search_input": search_inputs,
"results": combined_results, "results": combined_results,
"stock_skus": _resolve_stock_skus(searches, combined_results),
"filter_info": final_info, "filter_info": final_info,
} }
...@@ -270,7 +369,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -270,7 +369,7 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
logger.info( logger.info(
"🎁 Final result: %d products, total_ms=%.2f. Fallback used: %s", "🎁 Final result: %d products, total_ms=%.2f. Fallback used: %s",
len(combined_results), len(combined_results),
total_ms, total_ms,
final_info.get("fallback_used", False), final_info.get("fallback_used", False),
) )
......
import logging import logging
import os import os
import time import time
# Note: tracing handled by parent data_retrieval_tool via context manager # Note: tracing handled by parent data_retrieval_tool via context manager
from common.embedding_service import create_embedding_async from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _get_price_clauses(params, sql_params: list) -> list[str]: def _get_price_clauses(params, sql_params: list) -> list[str]:
"""Lọc theo giá (Parameterized).""" """Lọc theo giá (Parameterized)."""
clauses = [] clauses = []
p_min = getattr(params, "price_min", None) p_min = getattr(params, "price_min", None)
if p_min is not None: if p_min is not None:
clauses.append("sale_price >= %s") clauses.append("sale_price >= %s")
sql_params.append(p_min) sql_params.append(p_min)
p_max = getattr(params, "price_max", None) p_max = getattr(params, "price_max", None)
if p_max is not None: if p_max is not None:
clauses.append("sale_price <= %s") clauses.append("sale_price <= %s")
sql_params.append(p_max) sql_params.append(p_max)
return clauses return clauses
def _get_discount_params(params) -> tuple[int | None, int | None]: def _get_discount_params(params) -> tuple[int | None, int | None]:
"""Lấy discount_min và discount_max từ params.""" """Lấy discount_min và discount_max từ params."""
discount_min = getattr(params, "discount_min", None) discount_min = getattr(params, "discount_min", None)
discount_max = getattr(params, "discount_max", None) discount_max = getattr(params, "discount_max", None)
if discount_min is not None or discount_max is not None: if discount_min is not None or discount_max is not None:
if discount_min and discount_max: if discount_min and discount_max:
logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount {discount_min}% - {discount_max}%") logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount {discount_min}% - {discount_max}%")
elif discount_min: elif discount_min:
logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount >= {discount_min}%") logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount >= {discount_min}%")
else: else:
logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount <= {discount_max}%") logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount <= {discount_max}%")
return discount_min, discount_max return discount_min, discount_max
def _get_metadata_clauses(params, sql_params: list) -> list[str]: def _get_metadata_clauses(params, sql_params: list) -> list[str]:
""" """
HARD FILTER: Gender + Age — lọc trực tiếp ở SQL level. HARD FILTER: Gender + Age — lọc trực tiếp ở SQL level.
Gender tự động include 'unisex' fallback cho men/women. Gender tự động include 'unisex' fallback cho men/women.
Color + Product type → semantic search tự handle qua description. Color + Product type → semantic search tự handle qua description.
""" """
clauses = [] clauses = []
# Gender filter # Gender filter
gender_val = getattr(params, "gender_by_product", None) gender_val = getattr(params, "gender_by_product", None)
if gender_val: if gender_val:
gender_lower = gender_val.lower().strip() gender_lower = gender_val.lower().strip()
# Include 'unisex' fallback for men/women # Include 'unisex' fallback for men/women
if gender_lower in ("men", "women"): if gender_lower in ("men", "women"):
clauses.append("gender_by_product IN (%s, %s)") clauses.append("gender_by_product IN (%s, %s)")
sql_params.extend([gender_lower, "unisex"]) sql_params.extend([gender_lower, "unisex"])
else: else:
clauses.append("gender_by_product = %s") clauses.append("gender_by_product = %s")
sql_params.append(gender_lower) sql_params.append(gender_lower)
logger.info(f"👫 [SQL FILTER] Gender: {gender_val}") logger.info(f"👫 [SQL FILTER] Gender: {gender_val}")
# Age filter # Age filter
age_val = getattr(params, "age_by_product", None) age_val = getattr(params, "age_by_product", None)
if age_val: if age_val:
age_lower = age_val.lower().strip() age_lower = age_val.lower().strip()
clauses.append("age_by_product = %s") clauses.append("age_by_product = %s")
sql_params.append(age_lower) sql_params.append(age_lower)
logger.info(f"🎂 [SQL FILTER] Age: {age_val}") logger.info(f"🎂 [SQL FILTER] Age: {age_val}")
# Color filter (LIKE match on master_color OR product_color_name) # Color filter (LIKE match on master_color OR product_color_name)
color_val = getattr(params, "master_color", None) color_val = getattr(params, "master_color", None)
if color_val: if color_val:
color_lower = color_val.lower().strip() color_lower = color_val.lower().strip()
clauses.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)") clauses.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
sql_params.extend([f"%{color_lower}%", f"%{color_lower}%"]) sql_params.extend([f"%{color_lower}%", f"%{color_lower}%"])
logger.info(f"🎨 [SQL FILTER] Color: {color_val}") 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"] from agent.tools.product_mapping import PRODUCT_LINE_MAP
# "áo thun disney" → resolve → "Áo phông disney" → keywords: ["phông", "disney"] GENERIC_WORDS = {key.split()[0].lower() for key in PRODUCT_LINE_MAP.keys()}
# Auto-derive generic prefix words from PRODUCT_LINE_MAP keys name_val = getattr(params, "product_name", None)
# "Áo Sơ mi" → "áo", "Quần jean" → "quần", "Váy liền" → "váy", ... if name_val:
from agent.tools.product_mapping import PRODUCT_LINE_MAP from agent.tools.product_mapping import resolve_product_name, get_related_lines
GENERIC_WORDS = {key.split()[0].lower() for key in PRODUCT_LINE_MAP.keys()}
name_val = getattr(params, "product_name", None) # Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"]
if name_val: name_parts = [p.strip() for p in name_val.split("/") if p.strip()]
from agent.tools.product_mapping import resolve_product_name, get_related_lines
all_phrases = set()
# Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"] for part in name_parts:
name_parts = [p.strip() for p in name_val.split("/") if p.strip()] resolved = resolve_product_name(part)
# Also expand related lines
all_phrases = set() for rname in get_related_lines(resolved):
for part in name_parts: words = rname.strip().split()
resolved = resolve_product_name(part) phrase = " ".join(w for w in words if w.lower() not in GENERIC_WORDS)
# Also expand related lines if phrase:
for rname in get_related_lines(resolved): all_phrases.add(phrase.lower())
words = rname.strip().split()
phrase = " ".join(w for w in words if w.lower() not in GENERIC_WORDS) if all_phrases:
if phrase: like_parts = []
all_phrases.add(phrase.lower()) for phrase in all_phrases:
# Search cả product_name VÀ product_line_vn
if all_phrases: # VD: product_name = "quần sịp đùi nam" nhưng product_line_vn = "Quần lót đùi"
like_parts = [] like_parts.append("(LOWER(product_name) LIKE %s OR LOWER(product_line_vn) LIKE %s)")
for phrase in all_phrases: sql_params.extend([f"%{phrase}%", f"%{phrase}%"])
# Search cả product_name VÀ product_line_vn clauses.append(f"({' OR '.join(like_parts)})")
# VD: product_name = "quần sịp đùi nam" nhưng product_line_vn = "Quần lót đùi" logger.info(f"🏷️ [SQL FILTER] Product name: '{name_val}' → phrases: {all_phrases}")
like_parts.append("(LOWER(product_name) LIKE %s OR LOWER(product_line_vn) LIKE %s)")
sql_params.extend([f"%{phrase}%", f"%{phrase}%"]) return clauses
clauses.append(f"({' OR '.join(like_parts)})")
logger.info(f"🏷️ [SQL FILTER] Product name: '{name_val}' → phrases: {all_phrases}")
async def build_starrocks_query(params, query_vector: list[float] | None = None) -> tuple[str, list]:
return clauses """
Build SQL query với Parameterized Query để tránh SQL Injection.
Returns: (sql_string, params_list)
async def build_starrocks_query(params, query_vector: list[float] | None = None) -> tuple[str, list]: """
"""
Build SQL query với Parameterized Query để tránh SQL Injection. # ============================================================
Returns: (sql_string, params_list) # CASE 1: CODE SEARCH
""" # ============================================================
magento_code = getattr(params, "magento_ref_code", None)
# ============================================================ if magento_code:
# CASE 1: CODE SEARCH sql_params = [magento_code, magento_code]
# ============================================================ extra_where = ""
magento_code = getattr(params, "magento_ref_code", None)
if magento_code: # Apply master_color filter if provided (e.g., "6TS25W008 màu hồng")
sql = """ color_val = getattr(params, "master_color", None)
SELECT if color_val:
internal_ref_code, color_lower = color_val.lower().strip()
magento_ref_code, extra_where += " AND (LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)"
product_color_code, sql_params.extend([f"%{color_lower}%", f"%{color_lower}%"])
product_name, logger.info(f"🎨 [CODE SEARCH + COLOR FILTER] Code: {magento_code}, Color: {color_val}")
master_color,
product_image_url_thumbnail, sql = f"""
product_web_url, SELECT
description_text, internal_ref_code,
sale_price, magento_ref_code,
original_price, product_color_code,
discount_amount, product_name,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent, master_color,
age_by_product, product_image_url_thumbnail,
gender_by_product, product_web_url,
product_line_vn, description_text,
quantity_sold, sale_price,
size_scale, original_price,
1.0 as similarity_score discount_amount,
FROM shared_source.magento_product_dimension_with_text_embedding ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
WHERE internal_ref_code = %s OR magento_ref_code = %s age_by_product,
""" gender_by_product,
return sql, [magento_code, magento_code] product_line_vn,
quantity_sold,
size_scale,
# ============================================================ 1.0 as similarity_score
# CASE 2: DISCOVERY — Hàng mới / Bán chạy (Direct SQL, no embedding) FROM shared_source.magento_product_dimension_with_text_embedding
# Khác với price/gender filter: discovery cần scan TOÀN BỘ bảng WHERE (internal_ref_code = %s OR magento_ref_code = %s){extra_where}
# vì top 100 vector results gần như không chứa new/best_seller """
# ============================================================ return sql, sql_params
discovery_mode = getattr(params, "discovery_mode", None)
if discovery_mode:
discovery_mode = discovery_mode.lower().strip() # ============================================================
sql_params: list = [] # CASE 2: DISCOVERY — Hàng mới / Bán chạy (Direct SQL, no embedding)
# Khác với price/gender filter: discovery cần scan TOÀN BỘ bảng
# Metadata filters (gender + age) # vì top 100 vector results gần như không chứa new/best_seller
where_clauses = _get_metadata_clauses(params, sql_params) # ============================================================
discovery_mode = getattr(params, "discovery_mode", None)
if discovery_mode:
discovery_mode = discovery_mode.lower().strip()
# Price filters sql_params: list = []
where_clauses.extend(_get_price_clauses(params, sql_params))
# Metadata filters (gender + age)
# Discovery-specific WHERE + ORDER where_clauses = _get_metadata_clauses(params, sql_params)
if discovery_mode == "new":
where_clauses.append("is_new_product = 1")
order_by = "quantity_sold DESC, magento_ref_code"
logger.info("🆕 [DISCOVERY] New products, filters=%s", where_clauses) # Price filters
elif discovery_mode == "best_seller": where_clauses.extend(_get_price_clauses(params, sql_params))
where_clauses.append("quantity_sold > 0")
order_by = "quantity_sold DESC, magento_ref_code" # Discovery-specific WHERE + ORDER
logger.info("🔥 [DISCOVERY] Best sellers, filters=%s", where_clauses) if discovery_mode == "new":
else: where_clauses.append("is_new_product = 1")
discovery_mode = None order_by = "quantity_sold DESC, magento_ref_code"
logger.info("🆕 [DISCOVERY] New products, filters=%s", where_clauses)
if discovery_mode: elif discovery_mode == "best_seller":
where_str = " AND ".join(where_clauses) if where_clauses else "1=1" where_clauses.append("quantity_sold > 0")
sql = f""" order_by = "quantity_sold DESC, magento_ref_code"
SELECT logger.info("🔥 [DISCOVERY] Best sellers, filters=%s", where_clauses)
internal_ref_code, else:
magento_ref_code, discovery_mode = None
product_color_code,
product_name, if discovery_mode:
master_color, where_str = " AND ".join(where_clauses) if where_clauses else "1=1"
product_image_url_thumbnail, sql = f"""
product_web_url, SELECT
sale_price, internal_ref_code,
original_price, magento_ref_code,
discount_amount, product_color_code,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent, product_name,
age_by_product, master_color,
gender_by_product, product_image_url_thumbnail,
product_line_vn, product_web_url,
quantity_sold, sale_price,
description_text, original_price,
size_scale discount_amount,
FROM shared_source.magento_product_dimension_with_text_embedding ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
WHERE {where_str} age_by_product,
ORDER BY {order_by} gender_by_product,
LIMIT 20 product_line_vn,
""" quantity_sold,
logger.info("⚡ [DISCOVERY] Direct SQL — no embedding") description_text,
return sql, sql_params size_scale
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE {where_str}
# ============================================================ ORDER BY {order_by}
# CASE 3: SEMANTIC VECTOR SEARCH LIMIT 20
# ============================================================ """
query_text = getattr(params, "description", None) logger.info("⚡ [DISCOVERY] Direct SQL — no embedding")
if query_text and query_vector is None: return sql, sql_params
query_vector = await create_embedding_async(query_text)
if not query_vector: # ============================================================
return "", [] # CASE 3: SEMANTIC VECTOR SEARCH
# ============================================================
# Vector params query_text = getattr(params, "description", None)
v_str = "[" + ",".join(str(v) for v in query_vector) + "]" if query_text and query_vector is None:
query_vector = await create_embedding_async(query_text)
# Collect All Filters
sql_params: list = [] if not query_vector:
return "", []
# 1. Price
price_clauses = _get_price_clauses(params, sql_params) # Vector params
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# 2. Metadata: Gender + Age + Color (HARD FILTER — all at SQL level)
metadata_clauses = _get_metadata_clauses(params, sql_params) # Collect All Filters
sql_params: list = []
all_clauses = price_clauses + metadata_clauses
# 1. Price
# Discovery mode filters price_clauses = _get_price_clauses(params, sql_params)
discovery_mode = getattr(params, "discovery_mode", None)
if discovery_mode: # 2. Metadata: Gender + Age + Color (HARD FILTER — all at SQL level)
discovery_mode = discovery_mode.lower().strip() metadata_clauses = _get_metadata_clauses(params, sql_params)
if discovery_mode == "new":
all_clauses.append("is_new_product = 1") all_clauses = price_clauses + metadata_clauses
logger.info("🆕 [SQL FILTER] Discovery: new products only")
elif discovery_mode == "best_seller": # Discovery mode filters
all_clauses.append("quantity_sold > 0") discovery_mode = getattr(params, "discovery_mode", None)
logger.info("🔥 [SQL FILTER] Discovery: best sellers") if discovery_mode:
discovery_mode = discovery_mode.lower().strip()
# Get discount params if discovery_mode == "new":
discount_min, discount_max = _get_discount_params(params) all_clauses.append("is_new_product = 1")
logger.info("🆕 [SQL FILTER] Discovery: new products only")
post_filter_conditions = [] elif discovery_mode == "best_seller":
all_clauses.append("quantity_sold > 0")
# Price + Gender + Age filters logger.info("🔥 [SQL FILTER] Discovery: best sellers")
if all_clauses:
post_filter_conditions.extend(all_clauses) # Get discount params
discount_min, discount_max = _get_discount_params(params)
# Discount filters
if discount_min is not None or discount_max is not None: post_filter_conditions = []
post_filter_conditions.append("sale_price < original_price") # Ensure has discount
if discount_min is not None: # Price + Gender + Age filters
post_filter_conditions.append("discount_percent >= %s") if all_clauses:
sql_params.append(discount_min) post_filter_conditions.extend(all_clauses)
if discount_max is not None:
post_filter_conditions.append("discount_percent <= %s") # Discount filters
sql_params.append(discount_max) if discount_min is not None or discount_max is not None:
post_filter_conditions.append("sale_price < original_price") # Ensure has discount
post_filter_where = "" if discount_min is not None:
if post_filter_conditions: post_filter_conditions.append("discount_percent >= %s")
post_filter_where = " WHERE " + " AND ".join(post_filter_conditions) sql_params.append(discount_min)
if discount_max is not None:
# Determine sort order: best_seller uses quantity_sold, otherwise similarity_score post_filter_conditions.append("discount_percent <= %s")
if discovery_mode == "best_seller": sql_params.append(discount_max)
final_order = "ORDER BY max_sold DESC, max_score DESC"
extra_agg = ",\n MAX(quantity_sold) as max_sold" post_filter_where = ""
else: if post_filter_conditions:
final_order = "ORDER BY max_score DESC" post_filter_where = " WHERE " + " AND ".join(post_filter_conditions)
extra_agg = ""
# Determine sort order: best_seller uses quantity_sold, otherwise similarity_score
sql = f""" if discovery_mode == "best_seller":
WITH vector_matches AS ( final_order = "ORDER BY max_sold DESC, max_score DESC"
SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */ extra_agg = ",\n MAX(quantity_sold) as max_sold"
internal_ref_code, else:
magento_ref_code, final_order = "ORDER BY max_score DESC"
product_color_code, extra_agg = ""
product_name,
master_color, sql = f"""
product_color_name, WITH vector_matches AS (
product_image_url_thumbnail, SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */
product_web_url, internal_ref_code,
sale_price, magento_ref_code,
original_price, product_color_code,
discount_amount, product_name,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent, master_color,
age_by_product, product_color_name,
gender_by_product, product_image_url_thumbnail,
product_line_vn, product_web_url,
product_line_en, sale_price,
description_text, original_price,
size_scale, discount_amount,
quantity_sold, ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
is_new_product, age_by_product,
approx_cosine_similarity(vector, {v_str}) as similarity_score gender_by_product,
FROM shared_source.magento_product_dimension_with_text_embedding product_line_vn,
ORDER BY similarity_score DESC product_line_en,
LIMIT 200 description_text,
), size_scale,
filtered_matches AS ( quantity_sold,
SELECT * FROM vector_matches is_new_product,
{post_filter_where} approx_cosine_similarity(vector, {v_str}) as similarity_score
ORDER BY similarity_score DESC FROM shared_source.magento_product_dimension_with_text_embedding
LIMIT 150 ORDER BY similarity_score DESC
) LIMIT 200
SELECT ),
internal_ref_code, filtered_matches AS (
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code, SELECT * FROM vector_matches
product_color_code, {post_filter_where}
MAX_BY(product_name, similarity_score) as product_name, ORDER BY similarity_score DESC
MAX_BY(master_color, similarity_score) as master_color, LIMIT 150
MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail, )
MAX_BY(product_web_url, similarity_score) as product_web_url, SELECT
MAX_BY(sale_price, similarity_score) as sale_price, internal_ref_code,
MAX_BY(original_price, similarity_score) as original_price, MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
MAX_BY(discount_amount, similarity_score) as discount_amount, product_color_code,
MAX_BY(discount_percent, similarity_score) as discount_percent, MAX_BY(product_name, similarity_score) as product_name,
MAX_BY(description_text, similarity_score) as description_text, MAX_BY(master_color, similarity_score) as master_color,
MAX_BY(gender_by_product, similarity_score) as gender_by_product, MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail,
MAX_BY(age_by_product, similarity_score) as age_by_product, MAX_BY(product_web_url, similarity_score) as product_web_url,
MAX_BY(product_line_vn, similarity_score) as product_line_vn, MAX_BY(sale_price, similarity_score) as sale_price,
MAX_BY(quantity_sold, similarity_score) as quantity_sold, MAX_BY(original_price, similarity_score) as original_price,
MAX_BY(size_scale, similarity_score) as size_scale, MAX_BY(discount_amount, similarity_score) as discount_amount,
MAX(similarity_score) as max_score{extra_agg} MAX_BY(discount_percent, similarity_score) as discount_percent,
FROM filtered_matches MAX_BY(description_text, similarity_score) as description_text,
GROUP BY product_color_code, internal_ref_code MAX_BY(gender_by_product, similarity_score) as gender_by_product,
{final_order} MAX_BY(age_by_product, similarity_score) as age_by_product,
LIMIT 80 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,
# try: MAX(similarity_score) as max_score{extra_agg}
# query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt") FROM filtered_matches
GROUP BY product_color_code, internal_ref_code
# # Build executable query by substituting %s with actual values {final_order}
# executable_sql = sql LIMIT 80
# for param in sql_params: """
# if isinstance(param, str):
# # Escape single quotes and wrap in quotes # try:
# escaped = param.replace("'", "''") # query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt")
# executable_sql = executable_sql.replace("%s", f"'{escaped}'", 1)
# else: # # Build executable query by substituting %s with actual values
# executable_sql = executable_sql.replace("%s", str(param), 1) # executable_sql = sql
# for param in sql_params:
# with open(query_log_path, "w", encoding="utf-8") as f: # if isinstance(param, str):
# f.write(f"-- [HYDE SEARCH] Full Executable Query\n-- Original Params: {sql_params}\n{executable_sql}") # # Escape single quotes and wrap in quotes
# except Exception as e: # escaped = param.replace("'", "''")
# logger.error(f"Error writing to query.txt: {e}") # executable_sql = executable_sql.replace("%s", f"'{escaped}'", 1)
# else:
# executable_sql = executable_sql.replace("%s", str(param), 1)
return sql, sql_params
# 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}")
return sql, sql_params
...@@ -38,7 +38,7 @@ async def canifa_store_search(location: str) -> str: ...@@ -38,7 +38,7 @@ async def canifa_store_search(location: str) -> str:
# Search trên các cột structured: city, state, address, store_name # Search trên các cột structured: city, state, address, store_name
sql = f""" sql = f"""
SELECT store_name, address, city, state, phone_number, SELECT store_name, address, city, state, phone_number,
schedule_name, time_open_today, time_close_today schedule_name, time_open_today, time_close_today
FROM {STORE_TABLE} FROM {STORE_TABLE}
WHERE LOWER(city) LIKE '%{clean}%' WHERE LOWER(city) LIKE '%{clean}%'
......
...@@ -15,7 +15,7 @@ from config import ( ...@@ -15,7 +15,7 @@ from config import (
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
DEFAULT_RESPONSE_TTL = 300 DEFAULT_RESPONSE_TTL = 0 # DISABLED — tắt cache response
RESPONSE_KEY_PREFIX = "resp_cache:" RESPONSE_KEY_PREFIX = "resp_cache:"
EMBEDDING_CACHE_TTL = 86400 # 24 hours EMBEDDING_CACHE_TTL = 86400 # 24 hours
......
""" """
StarRocks Database Connection Utility StarRocks Database Connection Utility
Based on chatbot-rsa pattern Based on chatbot-rsa pattern
""" """
import asyncio import asyncio
import logging import logging
import os import os
from typing import Any from typing import Any
import aiomysql import aiomysql
import pymysql import pymysql
from pymysql.cursors import DictCursor from pymysql.cursors import DictCursor
from config import ( from config import (
STARROCKS_DB, STARROCKS_DB,
STARROCKS_HOST, STARROCKS_HOST,
STARROCKS_PASSWORD, STARROCKS_PASSWORD,
STARROCKS_PORT, STARROCKS_PORT,
STARROCKS_USER, STARROCKS_USER,
) )
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
__all__ = ["StarRocksConnection", "get_db_connection"] __all__ = ["StarRocksConnection", "get_db_connection"]
class StarRocksConnectionManager: class StarRocksConnectionManager:
""" """
Singleton Class quản lý StarRocks Connection. Singleton Class quản lý StarRocks Connection.
""" """
def __init__(self): def __init__(self):
self._connection: StarRocksConnection | None = None self._connection: StarRocksConnection | None = None
def get_connection(self) -> "StarRocksConnection": def get_connection(self) -> "StarRocksConnection":
"""Lazy loading connection""" """Lazy loading connection"""
if self._connection is None: if self._connection is None:
logger.info("🔧 [LAZY LOADING] Creating StarRocksConnection instance (first time)") logger.info("🔧 [LAZY LOADING] Creating StarRocksConnection instance (first time)")
self._connection = StarRocksConnection() self._connection = StarRocksConnection()
return self._connection return self._connection
# --- Singleton --- # --- Singleton ---
_manager = StarRocksConnectionManager() _manager = StarRocksConnectionManager()
get_db_connection = _manager.get_connection get_db_connection = _manager.get_connection
class StarRocksConnection: class StarRocksConnection:
# Shared connection (Singleton-like behavior) for all instances # Shared connection (Singleton-like behavior) for all instances
_shared_conn = None _shared_conn = None
def __init__( def __init__(
self, self,
host: str | None = None, host: str | None = None,
database: str | None = None, database: str | None = None,
user: str | None = None, user: str | None = None,
password: str | None = None, password: str | None = None,
port: int | None = None, port: int | None = None,
): ):
self.host = host or STARROCKS_HOST self.host = host or STARROCKS_HOST
self.database = database or STARROCKS_DB self.database = database or STARROCKS_DB
self.user = user or STARROCKS_USER self.user = user or STARROCKS_USER
self.password = password or STARROCKS_PASSWORD self.password = password or STARROCKS_PASSWORD
self.port = port or STARROCKS_PORT self.port = port or STARROCKS_PORT
# self.conn references the shared connection # self.conn references the shared connection
self.conn = None self.conn = None
logger.info(f"✅ StarRocksConnection initialized: {self.host}:{self.port}") logger.info(f"✅ StarRocksConnection initialized: {self.host}:{self.port}")
def connect(self): def connect(self):
""" """
Establish or reuse persistent connection. Establish or reuse persistent connection.
""" """
# 1. Try to reuse existing shared connection # 1. Try to reuse existing shared connection
if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open: if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open:
try: try:
# Ping to check if alive, reconnect if needed # Ping to check if alive, reconnect if needed
StarRocksConnection._shared_conn.ping(reconnect=True) StarRocksConnection._shared_conn.ping(reconnect=True)
self.conn = StarRocksConnection._shared_conn self.conn = StarRocksConnection._shared_conn
return self.conn return self.conn
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Connection lost, reconnecting: {e}") logger.warning(f"⚠️ Connection lost, reconnecting: {e}")
StarRocksConnection._shared_conn = None StarRocksConnection._shared_conn = None
# 2. Create new connection if needed # 2. Create new connection if needed
print(f" [DB] 🔌 Đang kết nối StarRocks (New Session): {self.host}:{self.port}...") print(f" [DB] 🔌 Đang kết nối StarRocks (New Session): {self.host}:{self.port}...")
logger.info(f"🔌 Connecting to StarRocks at {self.host}:{self.port} (DB: {self.database})...") logger.info(f"🔌 Connecting to StarRocks at {self.host}:{self.port} (DB: {self.database})...")
try: try:
new_conn = pymysql.connect( new_conn = pymysql.connect(
host=self.host, host=self.host,
port=self.port, port=self.port,
user=self.user, user=self.user,
password=self.password, password=self.password,
database=self.database, database=self.database,
charset="utf8mb4", charset="utf8mb4",
cursorclass=DictCursor, cursorclass=DictCursor,
connect_timeout=10, connect_timeout=10,
read_timeout=30, read_timeout=30,
write_timeout=30, write_timeout=30,
) )
print(" [DB] ✅ Kết nối thành công.") print(" [DB] ✅ Kết nối thành công.")
logger.info("✅ Connected to StarRocks") logger.info("✅ Connected to StarRocks")
# Save to class variable # Save to class variable
StarRocksConnection._shared_conn = new_conn StarRocksConnection._shared_conn = new_conn
self.conn = new_conn self.conn = new_conn
except Exception as e: except Exception as e:
print(f" [DB] ❌ Lỗi kết nối: {e!s}") print(f" [DB] ❌ Lỗi kết nối: {e!s}")
logger.error(f"❌ Failed to connect to StarRocks: {e}") logger.error(f"❌ Failed to connect to StarRocks: {e}")
raise raise
return self.conn return self.conn
def execute_query(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]: def execute_query(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]:
# print(" [DB] 🚀 Bắt đầu truy vấn dữ liệu...") # print(" [DB] 🚀 Bắt đầu truy vấn dữ liệu...")
# (Reduced noise in logs) # (Reduced noise in logs)
logger.info("🚀 Executing StarRocks Query (Persistent Conn).") logger.info("🚀 Executing StarRocks Query (Persistent Conn).")
conn = self.connect() conn = self.connect()
try: try:
with conn.cursor() as cursor: with conn.cursor() as cursor:
cursor.execute(query, params) cursor.execute(query, params)
results = cursor.fetchall() results = cursor.fetchall()
print(f" [DB] ✅ Truy vấn xong. Lấy được {len(results)} dòng.") print(f" [DB] ✅ Truy vấn xong. Lấy được {len(results)} dòng.")
logger.info(f"📊 Query successful, returned {len(results)} rows") logger.info(f"📊 Query successful, returned {len(results)} rows")
return [dict(row) for row in results] return [dict(row) for row in results]
except Exception as e: except Exception as e:
print(f" [DB] ❌ Lỗi truy vấn: {e!s}") print(f" [DB] ❌ Lỗi truy vấn: {e!s}")
logger.error(f"❌ StarRocks query error: {e}") logger.error(f"❌ StarRocks query error: {e}")
# Incase of query error due to connection, invalidate it # Incase of query error due to connection, invalidate it
StarRocksConnection._shared_conn = None StarRocksConnection._shared_conn = None
raise raise
# FINALLY BLOCK REMOVED: Do NOT close connection # FINALLY BLOCK REMOVED: Do NOT close connection
# Async pool shared # Async pool shared
_shared_pool = None _shared_pool = None
_pool_lock = asyncio.Lock() _pool_lock = asyncio.Lock()
@classmethod @classmethod
async def clear_pool(cls): async def clear_pool(cls):
"""Clear and close existing pool (force recreate fresh connections)""" """Clear and close existing pool (force recreate fresh connections)"""
async with cls._pool_lock: async with cls._pool_lock:
if cls._shared_pool is not None: if cls._shared_pool is not None:
logger.warning("🔄 Clearing StarRocks connection pool...") logger.warning("🔄 Clearing StarRocks connection pool...")
cls._shared_pool.close() cls._shared_pool.close()
await cls._shared_pool.wait_closed() await cls._shared_pool.wait_closed()
cls._shared_pool = None cls._shared_pool = None
logger.info("✅ Pool cleared successfully") logger.info("✅ Pool cleared successfully")
async def get_pool(self): async def get_pool(self):
""" """
Get or create shared async connection pool (Thread-safe singleton) Get or create shared async connection pool (Thread-safe singleton)
Optimized for cosine similarity queries (~200ms) Optimized for cosine similarity queries (~200ms)
""" """
if StarRocksConnection._shared_pool is None: if StarRocksConnection._shared_pool is None:
async with StarRocksConnection._pool_lock: async with StarRocksConnection._pool_lock:
if StarRocksConnection._shared_pool is None: if StarRocksConnection._shared_pool is None:
logger.info(f"🔌 Creating Async Pool to {self.host}:{self.port}...") logger.info(f"🔌 Creating Async Pool to {self.host}:{self.port}...")
minsize = int(os.getenv("STARROCKS_POOL_MINSIZE", "2"))
maxsize = int(os.getenv("STARROCKS_POOL_MAXSIZE", "80"))
StarRocksConnection._shared_pool = await aiomysql.create_pool( StarRocksConnection._shared_pool = await aiomysql.create_pool(
host=self.host, host=self.host,
port=self.port, port=self.port,
...@@ -167,90 +165,90 @@ class StarRocksConnection: ...@@ -167,90 +165,90 @@ class StarRocksConnection:
db=self.database, db=self.database,
charset="utf8mb4", charset="utf8mb4",
cursorclass=aiomysql.DictCursor, cursorclass=aiomysql.DictCursor,
minsize=minsize, # Giảm minsize để đỡ tốn tài nguyên idle minsize=2,
maxsize=maxsize, maxsize=10,
connect_timeout=10, connect_timeout=10,
# --- CHỈNH SỬA QUAN TRỌNG Ở ĐÂY --- # --- CHỈNH SỬA QUAN TRỌNG Ở ĐÂY ---
pool_recycle=280, # Recycle sau 4 phút rưỡi (tránh timeout 5 phút của Windows/Firewall) pool_recycle=280, # Recycle sau 4 phút rưỡi (tránh timeout 5 phút của Windows/Firewall)
# ---------------------------------- # ----------------------------------
autocommit=True, autocommit=True,
) )
logger.info("✅ Pool created successfully with recycle=280s") logger.info("✅ Pool created successfully with recycle=280s")
return StarRocksConnection._shared_pool return StarRocksConnection._shared_pool
async def execute_query_async(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]: async def execute_query_async(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]:
""" """
Execute query asynchronously with AUTO-RECONNECT (Fix lỗi 10053/2006). Execute query asynchronously with AUTO-RECONNECT (Fix lỗi 10053/2006).
""" """
max_retries = 3 max_retries = 3
for attempt in range(max_retries): for attempt in range(max_retries):
pool = None pool = None
conn = None conn = None
try: try:
pool = await self.get_pool() pool = await self.get_pool()
conn = await asyncio.wait_for(pool.acquire(), timeout=90) conn = await asyncio.wait_for(pool.acquire(), timeout=90)
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
await cursor.execute(query, params) await cursor.execute(query, params)
results = await cursor.fetchall() results = await cursor.fetchall()
return [dict(row) for row in results] return [dict(row) for row in results]
# --- SỬA ĐOẠN CATCH ERROR RỘNG HƠN --- # --- SỬA ĐOẠN CATCH ERROR RỘNG HƠN ---
except ( except (
TimeoutError, TimeoutError,
pymysql.err.OperationalError, pymysql.err.OperationalError,
pymysql.err.InterfaceError, pymysql.err.InterfaceError,
ConnectionError, ConnectionError,
OSError, OSError,
) as e: ) as e:
error_msg = str(e).lower() error_msg = str(e).lower()
error_code = e.args[0] if e.args else 0 error_code = e.args[0] if e.args else 0
logger.warning(f"⚠️ DB Error (Attempt {attempt + 1}/{max_retries}): {e}") logger.warning(f"⚠️ DB Error (Attempt {attempt + 1}/{max_retries}): {e}")
# Danh sách mã lỗi MySQL phổ biến khi mất kết nối # Danh sách mã lỗi MySQL phổ biến khi mất kết nối
mysql_conn_codes = [2006, 2013, 2014, 2003, 10053, 10054, 10060, 10061] mysql_conn_codes = [2006, 2013, 2014, 2003, 10053, 10054, 10060, 10061]
# Điều kiện Retry: # Điều kiện Retry:
# 1. Mã lỗi nằm trong list # 1. Mã lỗi nằm trong list
# 2. Hoặc là lỗi hệ thống mạng (ConnectionError) # 2. Hoặc là lỗi hệ thống mạng (ConnectionError)
# 3. Hoặc thông báo lỗi chứa từ khóa nhạy cảm # 3. Hoặc thông báo lỗi chứa từ khóa nhạy cảm
is_conn_error = ( is_conn_error = (
error_code in mysql_conn_codes error_code in mysql_conn_codes
or isinstance(e, (ConnectionError, BrokenPipeError, ConnectionResetError)) or isinstance(e, (ConnectionError, BrokenPipeError, ConnectionResetError))
or "abort" in error_msg or "abort" in error_msg
or "closed" in error_msg or "closed" in error_msg
or "reset" in error_msg or "reset" in error_msg
or "pipe" in error_msg or "pipe" in error_msg
) )
if is_conn_error: if is_conn_error:
logger.info("♻️ Connection dead. Clearing pool and retrying...") logger.info("♻️ Connection dead. Clearing pool and retrying...")
await StarRocksConnection.clear_pool() await StarRocksConnection.clear_pool()
await asyncio.sleep(0.5) await asyncio.sleep(0.5)
continue # RETRY NGAY continue # RETRY NGAY
# Nếu là lỗi cú pháp SQL (ProgrammingError) thì raise luôn, không retry # Nếu là lỗi cú pháp SQL (ProgrammingError) thì raise luôn, không retry
raise e raise e
# -------------------------------------- # --------------------------------------
except Exception as e: except Exception as e:
logger.error(f"❌ Unexpected DB Error: {e}") logger.error(f"❌ Unexpected DB Error: {e}")
raise e raise e
finally: finally:
if pool and conn: if pool and conn:
try: try:
pool.release(conn) pool.release(conn)
except Exception: except Exception:
pass pass
raise Exception("Failed to execute query after retries.") raise Exception("Failed to execute query after retries.")
def close(self): def close(self):
"""Explicitly close if needed (e.g. app shutdown)""" """Explicitly close if needed (e.g. app shutdown)"""
if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open: if StarRocksConnection._shared_conn and StarRocksConnection._shared_conn.open:
StarRocksConnection._shared_conn.close() StarRocksConnection._shared_conn.close()
StarRocksConnection._shared_conn = None StarRocksConnection._shared_conn = None
self.conn = None self.conn = None
#!/bin/bash #!/bin/bash
NUM_CORES=$(nproc) NUM_CORES=$(nproc)
WORKERS=$((2 * NUM_CORES + 1)) WORKERS=$((2 * NUM_CORES + 1))
echo "🔧 [STARTUP] CPU cores: $NUM_CORES" echo "🔧 [STARTUP] CPU cores: $NUM_CORES"
echo "🔧 [STARTUP] Gunicorn workers: $WORKERS" echo "🔧 [STARTUP] Gunicorn workers: $WORKERS"
exec gunicorn \ exec gunicorn \
server:app \ server:app \
--workers "$WORKERS" \ --workers "$WORKERS" \
--worker-class uvicorn.workers.UvicornWorker \ --worker-class uvicorn.workers.UvicornWorker \
--worker-connections 1000 \ --worker-connections 1000 \
--max-requests 1000 \ --max-requests 1000 \
--max-requests-jitter 100 \ --max-requests-jitter 100 \
--timeout 30 \ --timeout 30 \
--access-logfile - \ --access-logfile - \
--error-logfile - \ --error-logfile - \
--bind 0.0.0.0:5000 \ --bind 0.0.0.0:5000 \
--log-level info --log-level info
\ No newline at end of file
...@@ -20,4 +20,3 @@ Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.Owni ...@@ -20,4 +20,3 @@ Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.Owni
taskkill /F /IM python.exe taskkill /F /IM python.exe
netstat -ano | findstr :5000 | ForEach-Object { $_.Split()[-1] } | Sort-Object -Unique | ForEach-Object { taskkill /F /PID $_ } netstat -ano | findstr :5000 | ForEach-Object { $_.Split()[-1] } | Sort-Object -Unique | ForEach-Object { taskkill /F /PID $_ }
\ No newline at end of file
...@@ -819,6 +819,33 @@ ...@@ -819,6 +819,33 @@
let selectedToolPrompt = ''; let selectedToolPrompt = '';
let pendingImages = []; // 📸 Experimental: images to send with next message let pendingImages = []; // 📸 Experimental: images to send with next message
// Color name → CSS hex (for color dot badge)
function getColorHex(colorName) {
if (!colorName) return '#888';
const c = colorName.toLowerCase();
const map = {
'đen': '#222', 'black': '#222',
'trắng': '#f5f5f5', 'white': '#f5f5f5',
'đỏ': '#e53935', 'red': '#e53935',
'xanh lá': '#43a047', 'green': '#43a047', 'xanh lá cây': '#43a047',
'xanh da trời': '#1e88e5', 'blue': '#1e88e5',
'xanh than': '#263238', 'aqua': '#00838f', 'navy': '#1a237e',
'xanh jeans': '#5c6bc0', 'jeans': '#5c6bc0',
'vàng': '#fdd835', 'yellow': '#fdd835',
'cam': '#fb8c00', 'orange': '#fb8c00',
'hồng': '#ec407a', 'pink': '#ec407a',
'tím': '#7b1fa2', 'purple': '#7b1fa2',
'nâu': '#6d4c41', 'brown': '#6d4c41',
'xám': '#9e9e9e', 'grey': '#9e9e9e', 'gray': '#9e9e9e',
'be': '#d7ccc8', 'beige': '#d7ccc8', 'kem': '#fff8e1',
'ghi': '#78909c',
};
for (const [key, hex] of Object.entries(map)) {
if (c.includes(key)) return hex;
}
return '#888';
}
// ==================== IMAGE HANDLING (Experimental) ==================== // ==================== IMAGE HANDLING (Experimental) ====================
function handleImageSelect(event) { function handleImageSelect(event) {
const file = event.target.files[0]; const file = event.target.files[0];
...@@ -1564,9 +1591,17 @@ ...@@ -1564,9 +1591,17 @@
// SKU // SKU
const sku = document.createElement('div'); const sku = document.createElement('div');
sku.className = 'product-sku'; sku.className = 'product-sku';
sku.innerText = product.sku; sku.innerText = product.sku_color || product.sku;
body.appendChild(sku); body.appendChild(sku);
// Color badge
if (product.color) {
const colorBadge = document.createElement('div');
colorBadge.style.cssText = 'font-size: 0.78em; color: #b0bec5; margin-bottom: 6px; display: flex; align-items: center; gap: 5px;';
colorBadge.innerHTML = `<span style="display:inline-block;width:10px;height:10px;border-radius:50%;border:1px solid #666;background:${getColorHex(product.color)};"></span> ${product.color}`;
body.appendChild(colorBadge);
}
// Name // Name
const name = document.createElement('div'); const name = document.createElement('div');
name.className = 'product-name'; name.className = 'product-name';
......
Xanh da trời/ Blue
Xanh lá cây/ Green
Màu xanh Jeans
Xanh than/ Aqua
\ No newline at end of file
import asyncio
import os
import sys
# Ensure backend is in path
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from common.starrocks_connection import get_db_connection
async def test_like():
sr = get_db_connection()
sql = """
SELECT master_color,
LOWER(master_color) LIKE '%xanh lá%' as b_like,
LOWER(master_color) LIKE '%xanh lá cây%' as b_like_cay
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE master_color LIKE '%xanh lá%' OR master_color LIKE '%Xanh lá%'
LIMIT 10
"""
rows = await sr.execute_query_async(sql)
for r in rows:
print(f"Color: '{r['master_color']}', LIKE '%xanh lá%': {r['b_like']}, LIKE '%xanh lá cây%': {r['b_like_cay']}")
print("-- Testing missing query --")
sql2 = """
SELECT COUNT(*) as c
FROM shared_source.magento_product_dimension_with_text_embedding
WHERE LOWER(master_color) LIKE '%xanh lá%' OR LOWER(product_color_name) LIKE '%xanh lá%'
"""
rows2 = await sr.execute_query_async(sql2)
print(f"Total matched for '%xanh lá%': {rows2[0]['c']}")
if __name__ == "__main__":
asyncio.run(test_like())
import asyncio
import os
import sys
# Ensure backend directory is in path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from types import SimpleNamespace
from agent.tools.product_search_helpers import build_starrocks_query
from common.starrocks_connection import get_db_connection
async def main():
sr = get_db_connection()
print("=====================================================")
print("🧪 KỊCH BẢN 1: THEO ĐÚNG LOG LANGFUSE CON AI ĐÃ CHẠY:")
print(" product_name: 'Áo sơ mi xanh lá'")
print(" color: 'xanh lá'")
print("=====================================================")
params1 = SimpleNamespace(
description="product_name: Áo sơ mi xanh lá",
product_name="Áo sơ mi xanh lá",
gender_by_product=None,
age_by_product=None,
master_color="xanh lá",
price_min=None,
price_max=None,
discount_min=None,
discount_max=None,
magento_ref_code=None,
discovery_mode=None
)
# AI sẽ tự fetch Embedding thật 1536 chiều từ OpenAI (Bằng hàm create_embedding_async bên trong)
sql1, sql_params1 = await build_starrocks_query(params1)
# The output from build_starrocks_query is the exact SQL String and parameters
print("\n[SQL 1 - TỪ KHÓA TÊN SẢN PHẨM KHỚP SQL SẼ TẠO LÀ]:")
print(sql_params1)
# Run the query directly on DB
try:
rows1 = await sr.execute_query_async(sql1, sql_params1)
print(f"\n=> KẾT QUẢ KỊCH BẢN 1: Tìm thấy {len(rows1)} sản phẩm (MISS QUERY!).")
except Exception as e:
print(f"\n=> KẾT QUẢ KỊCH BẢN 1: Lỗi thực thi SQL: {e}")
print("\n\n=====================================================")
print("🧪 KỊCH BẢN 2: CHỮA BỆNH CHO CON AI (BỎ CHỮ 'xanh lá' RA KHỎI TÊN)")
print(" product_name: 'Áo sơ mi'")
print(" color: 'xanh lá'")
print("=====================================================")
params2 = SimpleNamespace(
description="product_name: Áo sơ mi xanh lá",
product_name="Áo sơ mi", # Đã bỏ chữ xanh lá ở đây
gender_by_product=None,
age_by_product=None,
master_color="xanh lá",
price_min=None,
price_max=None,
discount_min=None,
discount_max=None,
magento_ref_code=None,
discovery_mode=None
)
sql2, sql_params2 = await build_starrocks_query(params2)
print("\n[SQL 2 - TỪ KHÓA TÊN SẢN PHẨM THỰC TẾ TRÚNG LÚC NÀY]:")
print(sql_params2)
try:
rows2 = await sr.execute_query_async(sql2, sql_params2)
print(f"\n=> KẾT QUẢ KỊCH BẢN 2: Tìm thấy {len(rows2)} sản phẩm (MATCH NGON Ơ!).")
for i, row in enumerate(rows2[:3]):
print(f" [{i+1}]. Sản phẩm: '{row.get('product_name')}' - MÀU: '{row.get('master_color')}'")
if len(rows2) > 3:
print(" ...")
except Exception as e:
print(f"Lỗi: {e}")
if __name__ == "__main__":
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