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

feat: Codex/Responses API compatibility, cache TTL 24h, prompt optimization,...

feat: Codex/Responses API compatibility, cache TTL 24h, prompt optimization, n8n test tools, gitignore cleanup
parent 90ea36b3
...@@ -2,6 +2,19 @@ ...@@ -2,6 +2,19 @@
"mcpServers": { "mcpServers": {
"canifa-api": { "canifa-api": {
"url": "http://localhost:5000/mcp" "url": "http://localhost:5000/mcp"
},
"n8n-mcp": {
"command": "npx",
"args": [
"n8n-mcp"
],
"env": {
"MCP_MODE": "stdio",
"LOG_LEVEL": "error",
"DISABLE_CONSOLE_OUTPUT": "true",
"N8N_API_URL": "http://localhost:5678",
"N8N_API_KEY": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIwOTdkMTNhOS01NzQ0LTQyY2UtYTM5Yi00YjMwZTk4NDU4OWMiLCJpc3MiOiJuOG4iLCJhdWQiOiJwdWJsaWMtYXBpIiwianRpIjoiMTVmZmNlZjUtNzkzOC00MWU4LTg5NzktY2NhMWI0YzUzY2RmIiwiaWF0IjoxNzcyNjc1OTM3fQ.K58ZsX8BgdukDdON15sMCQ0eynTeYSEbi7nF6xIPY9I"
}
} }
} }
} }
\ No newline at end of file
...@@ -55,3 +55,25 @@ Thumbs.db ...@@ -55,3 +55,25 @@ Thumbs.db
run.txt run.txt
backend/agent/tools/query.txt backend/agent/tools/query.txt
backend/schema_dump.json backend/schema_dump.json
# Document folder
document/
# n8n workflow exports & temp files
canifa_workflow_export.json
prod_workflow.json
prod_workflow_fixed.json
fix_n8n_connections.py
*.png
!backend/static/**/*.png
# Playwright MCP
.playwright-mcp/
# Test credentials (sensitive)
backend/tests/google_credentials.json
backend/tests/google_sheets_credentials.json
backend/tests/sheet_info.json
backend/tests/test_n8n_api_output.txt
backend/n8n_result.json
diff_*.txt
# 🔬 VERIFICATION: LangGraph Streaming Behavior
## 🎯 MỤC ĐÍCH
Kiểm tra xem LangGraph `astream()` có stream **incremental** (từng phần) hay chỉ emit event **sau khi node hoàn thành**.
---
## 📊 KẾT QUẢ EXPECTED
### **Scenario 1: Incremental Streaming (Lý tưởng)** ✅
Nếu LangGraph stream incremental, backend logs sẽ hiển thị:
```
🌊 Starting LLM streaming...
📦 Event #1 at t=2.50s | Keys: ['messages']
📦 Event #2 at t=3.20s | Keys: ['ai_response']
📡 Event #2 (t=3.20s): ai_response with 150 chars
Preview: {"ai_response": "Anh chọn áo thun th...
📦 Event #3 at t=4.10s | Keys: ['ai_response']
📡 Event #3 (t=4.10s): ai_response with 380 chars
Preview: {"ai_response": "Anh chọn áo thun thể thao nam chuẩn luôn! Em tìm...
📦 Event #4 at t=5.50s | Keys: ['ai_response']
📡 Event #4 (t=5.50s): ai_response with 620 chars
Preview: {"ai_response": "...", "product_ids": ["SKU1", "SKU2"]...
🎯 Event #4 (t=5.50s): Regex matched product_ids!
✅ Extracted 3 SKUs: ['SKU1', 'SKU2', 'SKU3']
🚨 BREAKING at Event #4 (t=5.50s) - user_insight KHÔNG ĐỢI!
```
**→ Content tăng dần (150 → 380 → 620 chars)**
**→ Break sớm khi có product_ids (t=5.5s thay vì t=12s)**
---
### **Scenario 2: Event-based (Sau khi xong)** ❌
Nếu LangGraph chỉ emit sau khi node xong, logs sẽ là:
```
🌊 Starting LLM streaming...
📦 Event #1 at t=2.30s | Keys: ['messages'] ← Tool execution
📦 Event #2 at t=11.80s | Keys: ['ai_response'] ← LLM node hoàn thành
📡 Event #2 (t=11.80s): ai_response with 1250 chars ← TOÀN BỘ RESPONSE
Preview: {"ai_response": "Anh chọn áo thun thể thao nam chuẩn luôn!...", "product_ids": ["SKU1", "SKU2", "SKU3"], "user_insight": {...}}
🎯 Event #2 (t=11.80s): Regex matched product_ids!
✅ Extracted 3 SKUs: ['SKU1', 'SKU2', 'SKU3']
🚨 BREAKING at Event #2 (t=11.80s) - user_insight KHÔNG ĐỢI!
```
**→ CHỈ 1 EVENT duy nhất với full content**
**→ Emit sau khi LLM xong hết (t=11.8s)**
**→ KHÔNG THỂ break sớm hơn!**
---
## 🔍 PHÂN TÍCH
### **Nếu Scenario 2 (Event-based):**
**Giải thích:**
- LLM **đang stream tokens internal** từ t=2s → t=12s
- LangGraph **chờ node xong** mới emit event
- Event chứa **full response** luôn
- Regex match ngay lập tức vì đã có đầy đủ
**Kết luận:**
- ✅ Code đã đúng, streaming đã bật
- ❌ Nhưng không thể break sớm hơn vì event chưa có
- ⏱️ Latency không giảm được (~12s)
---
## 💡 GIẢI PHÁP
Nếu kết quả là Scenario 2, muốn stream thực sự cần:
### **Option A: Custom Streaming Callback**
```python
from langchain.callbacks.base import AsyncCallbackHandler
class StreamingCallback(AsyncCallbackHandler):
async def on_llm_new_token(self, token: str, **kwargs):
# Accumulate và check regex
self.accumulated += token
if '"product_ids"' in self.accumulated:
# Trigger break somehow
pass
```
### **Option B: SSE Endpoint**
Stream events trực tiếp cho client, client tự parse
### **Option C: Giữ nguyên**
Code đã tối ưu trong giới hạn, accept latency
---
## 📝 NOTES
- **Streaming=True** trong LLM → LangChain stream tokens internal
- **graph.astream()** → Stream events, không phải tokens
- **Break early** chỉ có ý nghĩa nếu events emit incremental
**Hãy check logs backend để xác định scenario nào!**
...@@ -268,6 +268,8 @@ async def chat_controller( ...@@ -268,6 +268,8 @@ async def chat_controller(
# Extract ai_response from streaming content (fallbacks) # Extract ai_response from streaming content (fallbacks)
if early_response and not ai_text_response: if early_response and not ai_text_response:
raw_content = streaming_callback.accumulated_content raw_content = streaming_callback.accumulated_content
# Strip Codex reasoning objects before parsing
raw_content = ProductIDStreamingCallback.strip_reasoning(raw_content)
if raw_content: if raw_content:
try: try:
raw_normalized = raw_content.replace("{{", "{").replace("}}", "}") raw_normalized = raw_content.replace("{{", "{").replace("}}", "}")
...@@ -284,10 +286,21 @@ async def chat_controller( ...@@ -284,10 +286,21 @@ async def chat_controller(
if not ai_text_response and all_accumulated_messages: if not ai_text_response and all_accumulated_messages:
for msg in reversed(all_accumulated_messages): for msg in reversed(all_accumulated_messages):
if isinstance(msg, AIMessage) and msg.content: if isinstance(msg, AIMessage) and msg.content:
ai_text_response = msg.content # Responses API may return content as list
content = msg.content
if isinstance(content, list):
content = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in content)
# Strip Codex reasoning objects
content = ProductIDStreamingCallback.strip_reasoning(content)
ai_text_response = content
break break
# Parse JSON-wrapped ai_response # Parse JSON-wrapped ai_response
# Ensure ai_text_response is str (Responses API may return list)
if isinstance(ai_text_response, list):
ai_text_response = "".join(str(c.get("text", c) if isinstance(c, dict) else c) for c in ai_text_response)
# Strip Codex reasoning objects before JSON parse
ai_text_response = ProductIDStreamingCallback.strip_reasoning(ai_text_response)
if ai_text_response and ai_text_response.lstrip().startswith("{"): if ai_text_response and ai_text_response.lstrip().startswith("{"):
try: try:
ai_normalized = ai_text_response.replace("{{", "{").replace("}}", "}") ai_normalized = ai_text_response.replace("{{", "{").replace("}}", "}")
...@@ -297,7 +310,13 @@ async def chat_controller( ...@@ -297,7 +310,13 @@ async def chat_controller(
if not final_product_ids and isinstance(ai_json.get("product_ids"), list): if not final_product_ids and isinstance(ai_json.get("product_ids"), list):
final_product_ids = [str(s) for s in ai_json["product_ids"]] final_product_ids = [str(s) for s in ai_json["product_ids"]]
except json.JSONDecodeError: except json.JSONDecodeError:
pass # 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)
if ai_match:
ai_text_response = ai_match.group(1).replace('\\"', '"').replace("\\n", "\n")
pid_match = re.search(r'"product_ids"\s*:\s*\[(.*?)\]', ai_text_response if not ai_match else ai_normalized, re.DOTALL)
if pid_match and not final_product_ids:
final_product_ids = re.findall(r'"([^"]+)"', pid_match.group(1))
# Extract & filter products # Extract & filter products
enriched_products = [] enriched_products = []
......
...@@ -360,17 +360,14 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu ...@@ -360,17 +360,14 @@ async def parse_ai_response_async(ai_raw_content: str, all_products: list) -> tu
mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9]+)\]", ai_text_response)) mentioned_skus_in_text = set(re.findall(r"\[([A-Z0-9]+)\]", ai_text_response))
logger.info(f"📝 SKUs mentioned in ai_response: {mentioned_skus_in_text}") logger.info(f"📝 SKUs mentioned in ai_response: {mentioned_skus_in_text}")
# Determine target SKUs
target_skus = set() target_skus = set()
# 1. Use explicit SKUs if available and confirmed by text, OR just explicit # 1. Use explicit SKUs if available and confirmed by text, OR just explicit
if explicit_skus and isinstance(explicit_skus, list): if explicit_skus and isinstance(explicit_skus, list):
# Optional: Filter explicit SKUs to only those actually in text to reduce hallucination
# But if explicit list is provided, we generally trust it unless we want strict text-match
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 intersection empty, fallback to text mentions 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)
......
...@@ -26,14 +26,17 @@ ...@@ -26,14 +26,17 @@
**🛒 HƯỚNG DẪN ĐẶT HÀNG (BẮT BUỘC KHI KHÁCH HỎI CÁCH MUA):** **🛒 HƯỚNG DẪN ĐẶT HÀNG (BẮT BUỘC KHI KHÁCH HỎI CÁCH MUA):**
**Khi đã show sản phẩm ra (có product card):** **Khi ĐÃ show sản phẩm (có product card trong conversation):**
→ "Bạn bấm vào icon 🛒 **Giỏ hàng** ở góc dưới bên phải sản phẩm, chọn size, chọn màu rồi thêm vào giỏ hàng là đặt hàng được luôn nhé!" → Nói khách bấm icon 🛒 ở góc dưới bên phải hình sản phẩm, chọn size + màu rồi thêm vào giỏ hàng.
→ Hỏi khách cần xem thêm SP khác không.
**Khi chưa show sản phẩm (hỏi chung "mua sao?"):** **Khi CHƯA show sản phẩm (conversation mới, chưa tìm SP):**
→ "Bạn ghé **canifa.com** để xem sản phẩm nhé! Hoặc nói mình biết bạn đang tìm gì, mình tìm giúp luôn! 😊" → Hướng dẫn 5 bước: vào canifa.com/App → tìm SP → chọn size + màu → thêm giỏ hàng → thanh toán.
→ Hỏi khách cần mình tìm SP gì không.
⚠️ **QUAN TRỌNG:** ⚠️ **QUAN TRỌNG:**
- Khi khách hỏi "mua sao?", "đặt hàng sao?", "làm sao để mua?", "mua ở đâu?" → Trả lời ĐÚNG theo 2 case trên - Phải TỰ VIẾT câu trả lời tự nhiên theo ngữ cảnh, KHÔNG copy nguyên mẫu!
- **CHECK context** trước: đã show SP hay chưa → chọn case A hoặc B
- **KHÔNG** hướng dẫn vào website tìm mã SP khi đã có product card → chỉ cần bấm icon 🛒 - **KHÔNG** hướng dẫn vào website tìm mã SP khi đã có product card → chỉ cần bấm icon 🛒
- Sau khi giới thiệu SP ưng ý → nhắc khách bấm 🛒 để đặt hàng - Sau khi giới thiệu SP ưng ý → nhắc khách bấm 🛒 để đặt hàng
......
...@@ -473,6 +473,17 @@ Trước khi trả lời, bạn phải đối chiếu kết quả từ tool vớ ...@@ -473,6 +473,17 @@ Trước khi trả lời, bạn phải đối chiếu kết quả từ tool vớ
- **LUÔN DÙNG NGOẶC KÉP `{{` và `}}` CHO TẤT CẢ JSON OUTPUT** - **LUÔN DÙNG NGOẶC KÉP `{{` và `}}` CHO TẤT CẢ JSON OUTPUT**
- **⛔ CẤM TỰ SUY DIỄN gender/age** khi user không nói rõ. "quần váy" → gender: null. "áo lót" → gender: null. CHỈ điền khi user NÓI RÕ! - **⛔ CẤM TỰ SUY DIỄN gender/age** khi user không nói rõ. "quần váy" → gender: null. "áo lót" → gender: null. CHỈ điền khi user NÓI RÕ!
**⛔⛔⛔ TỐI HẬU THƯ — HƯỚNG DẪN ĐẶT HÀNG ⛔⛔⛔**
- Khi khách hỏi "hướng dẫn đặt hàng" mà CHƯA show sản phẩm nào → Hướng dẫn vào canifa.com/App, tìm SP, chọn size + màu, thêm giỏ hàng, thanh toán
- Khi khách hỏi "hướng dẫn đặt hàng" mà ĐÃ show sản phẩm → Nói bấm icon 🛒 ở góc dưới bên phải hình SP
- ⛔ **CẤM** nhét câu "Nếu mình đã tìm được SP cho bạn rồi..." vào khi CHƯA tìm SP nào!
- ⛔ **CẤM copy nguyên mẫu** template! TỰ VIẾT tự nhiên theo context!
**⛔⛔⛔ TỐI HẬU THƯ — CẤM BỊA MÃ SKU ⛔⛔⛔**
- Chỉ dùng mã SKU ĐÚNG NGUYÊN từ data_retrieval_tool hoặc khách đưa
- ❌ CẤM tự thêm suffix: "6TE25S001" → KHÔNG ĐƯỢC bịa thành "6TE25S001-SZ001"
- Tool tự expand biến thể, bot KHÔNG cần tự ghép color code!
**⚡ QUY TẮC [LAST_ACTION] - QUAN TRỌNG:** **⚡ QUY TẮC [LAST_ACTION] - QUAN TRỌNG:**
- **TRƯỚC KHI TRẢ LỜI** → Đọc `[LAST_ACTION]` từ insight turn trước để hiểu context - **TRƯỚC KHI TRẢ LỜI** → Đọc `[LAST_ACTION]` từ insight turn trước để hiểu context
- **TỰ SUY RA** bước tiếp theo dựa trên LAST_ACTION + tin nhắn mới của khách - **TỰ SUY RA** bước tiếp theo dựa trên LAST_ACTION + tin nhắn mới của khách
...@@ -510,6 +521,11 @@ Mình check ngay cho bạn! ⚡" ...@@ -510,6 +521,11 @@ Mình check ngay cho bạn! ⚡"
--- ---
### "Hướng dẫn đặt hàng online" ### "Hướng dẫn đặt hàng online"
"Bạn đang muốn đặt sản phẩm gì ạ? 🛒
Bạn cho mình biết để mình tư vấn và hỗ trợ ⚠️ PHÂN BIỆT 2 CASE — check context trước khi trả lời:
đặt hàng luôn cho tiện nha! 😄"
**CASE A: ĐÃ show SP trước đó** → Nói khách bấm icon 🛒 ở góc dưới bên phải hình SP, chọn size + màu, thêm giỏ hàng. Hỏi cần xem SP khác không.
**CASE B: CHƯA show SP** → Hướng dẫn các bước: vào canifa.com/App → tìm SP → chọn size + màu → thêm giỏ hàng → thanh toán. Hỏi cần tìm SP gì không.
⛔ **TỰ VIẾT** câu trả lời tự nhiên, **KHÔNG copy nguyên** mẫu! Mỗi lần trả lời phải khác nhau, tự nhiên như đang nói chuyện.
\ No newline at end of file
...@@ -18,9 +18,9 @@ logger = logging.getLogger(__name__) ...@@ -18,9 +18,9 @@ logger = logging.getLogger(__name__)
LANGFUSE_SYSTEM_PROMPT_NAME = "canifa-stylist-system-prompt" LANGFUSE_SYSTEM_PROMPT_NAME = "canifa-stylist-system-prompt"
# Cache 5 phút — balance giữa update nhanh vs performance # Cache vĩnh viễn (24h) — chỉ refresh khi gọi force_refresh_prompts()
# Gọi force_refresh_prompts() nếu cần update ngay lập tức # Trước đó là 300s (5 phút), giờ giữ prompt trong RAM luôn
CACHE_TTL = 300 CACHE_TTL = 86400 # 24 hours — practically permanent
LANGFUSE_TOOL_PROMPT_MAP = { LANGFUSE_TOOL_PROMPT_MAP = {
"brand_knowledge_tool": "canifa-tool-brand-knowledge", "brand_knowledge_tool": "canifa-tool-brand-knowledge",
......
...@@ -19,6 +19,11 @@ class ProductIDStreamingCallback(AsyncCallbackHandler): ...@@ -19,6 +19,11 @@ class ProductIDStreamingCallback(AsyncCallbackHandler):
Khi có product_ids → trigger break ngay, không đợi user_insight! Khi có product_ids → trigger break ngay, không đợi user_insight!
""" """
# Regex to match Codex reasoning objects like {'id': 'rs_...', 'type': 'reasoning', ...}
_REASONING_RE = re.compile(
r"\{['\"]id['\"]\s*:\s*['\"]rs_[^}]*['\"]type['\"]\s*:\s*['\"]reasoning['\"][^}]*\}",
)
def __init__(self): def __init__(self):
self.accumulated_content = "" self.accumulated_content = ""
self.product_ids_found = False self.product_ids_found = False
...@@ -26,16 +31,31 @@ class ProductIDStreamingCallback(AsyncCallbackHandler): ...@@ -26,16 +31,31 @@ class ProductIDStreamingCallback(AsyncCallbackHandler):
self.product_skus = [] self.product_skus = []
self.product_found_event = asyncio.Event() # ✅ Event thay vì polling! self.product_found_event = asyncio.Event() # ✅ Event thay vì polling!
@staticmethod
def strip_reasoning(text: str) -> str:
"""Remove Codex reasoning objects from text."""
if not text or "reasoning" not in text:
return text
return ProductIDStreamingCallback._REASONING_RE.sub("", text).strip()
async def on_llm_new_token(self, token: str, **kwargs: Any) -> None: async def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
""" """
Callback khi LLM sinh token mới. Callback khi LLM sinh token mới.
Accumulate và check regex ngay! Accumulate và check regex ngay!
""" """
# Responses API may send token as list instead of str
if isinstance(token, list):
token = "".join(str(t) for t in token)
elif not isinstance(token, str):
token = str(token)
self.accumulated_content += token self.accumulated_content += token
# Check xem đã có product_ids chưa # Check xem đã có product_ids chưa
if not self.product_ids_found: if not self.product_ids_found:
product_match = re.search(r'"product_ids"\s*:\s*\[(.*?)\]', self.accumulated_content, re.DOTALL) # Strip reasoning objects (Codex) + normalize {{/}} before regex matching
clean_content = self.strip_reasoning(self.accumulated_content)
clean_content = clean_content.replace("{{", "{").replace("}}", "}")
product_match = re.search(r'"product_ids"\s*:\s*\[(.*?)\]', clean_content, re.DOTALL)
if product_match: if product_match:
logger.warning(f"🎯 FOUND product_ids at {len(self.accumulated_content)} chars!") logger.warning(f"🎯 FOUND product_ids at {len(self.accumulated_content)} chars!")
...@@ -44,7 +64,7 @@ class ProductIDStreamingCallback(AsyncCallbackHandler): ...@@ -44,7 +64,7 @@ class ProductIDStreamingCallback(AsyncCallbackHandler):
# Extract ai_response với regex robust hơn (handle escaped quotes) # Extract ai_response với regex robust hơn (handle escaped quotes)
ai_text_match = re.search( ai_text_match = re.search(
r'"ai_response"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"\s*,\s*"product_ids"', r'"ai_response"\s*:\s*"((?:[^"\\\\]|\\\\.)*)"\s*,\s*"product_ids"',
self.accumulated_content, clean_content,
re.DOTALL, re.DOTALL,
) )
......
...@@ -20,11 +20,15 @@ QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL: ...@@ -20,11 +20,15 @@ QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL:
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó. - Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response. - Sau khi tool trả kết quả mới được sinh ai_response.
⛔ CẤM TUYỆT ĐỐI TỰ BỊA MÃ SKU: ⛔⛔⛔ TỐI HẬU THƯ — CẤM TUYỆT ĐỐI TỰ BỊA MÃ SKU ⛔⛔⛔
- Truyền ĐÚNG NGUYÊN MÃ khách đưa, KHÔNG tự ghép/sáng tạo suffix. - Truyền ĐÚNG NGUYÊN MÃ từ data_retrieval_tool trả về hoặc khách đưa.
- KHÔNG ĐƯỢC tự ghép thêm suffix -SZ001, -SK010, -SW001 hay BẤT KỲ ký tự nào!
- Tool trả về sku="6TE25S001" → skus: "6TE25S001" (ĐÚNG)
❌ skus: "6TE25S001-SZ001" (SAI — BỊA MÃ!)
❌ skus: "6TE25S001-SK010" (SAI — BỊA MÃ!)
- Khách nói "6TS25S018 còn size S không?" → skus: "6TS25S018" (ĐÚNG) - Khách nói "6TS25S018 còn size S không?" → skus: "6TS25S018" (ĐÚNG)
- KHÔNG ĐƯỢC bịa thành "6TS25S018-SZ001" hay bất kỳ mã nào khách KHÔNG đưa. ❌ skus: "6TS25S018-SZ001" (SAI — BỊA!)
- Nếu khách chỉ cho base code (VD: 6TS25S018) → truyền base code đó, tool sẽ tự expand. - Tool sẽ TỰ EXPAND ra tất cả biến thể từ DB, KHÔNG cần bot tự thêm color code!
----- VÍ DỤ CHI TIẾT ----- ----- VÍ DỤ CHI TIẾT -----
......
...@@ -74,7 +74,8 @@ Chỉ CHUẨN HÓA khi user dùng từ đồng nghĩa RÕ RÀNG (bảng mapping ...@@ -74,7 +74,8 @@ Chỉ CHUẨN HÓA khi user dùng từ đồng nghĩa RÕ RÀNG (bảng mapping
📋 BẢNG MAPPING SYNONYM → TÊN DB (tool tự xử lý, LLM giữ nguyên từ user): 📋 BẢNG MAPPING SYNONYM → TÊN DB (tool tự xử lý, LLM giữ nguyên từ user):
áo thun, áo thun ngắn tay, áo cổ v, áo cổ tym → Áo phông áo thun, áo thun ngắn tay, áo cổ v, áo cổ tym → Áo phông
áo cổ bẻ → Áo Polo áo cổ bẻ → Áo Polo
áo bra, áo ngực, áo quây → Áo lót áo bra, áo bra active, bra → Áo bra active (liên quan: Áo lót)
áo ngực, áo quây → Áo lót (liên quan: Áo bra active)
áo gió, áo khoác mỏng → Áo khoác gió áo gió, áo khoác mỏng → Áo khoác gió
áo croptop, croptop, baby tee, áo lửng, áo dáng ngắn → Áo Body áo croptop, croptop, baby tee, áo lửng, áo dáng ngắn → Áo Body
áo sát nách, tanktop, tank top, áo dây, áo 2 dây, áo hai dây → Áo ba lỗ áo sát nách, tanktop, tank top, áo dây, áo 2 dây, áo hai dây → Áo ba lỗ
...@@ -205,11 +206,18 @@ CASE 10: "Áo khaki" ...@@ -205,11 +206,18 @@ CASE 10: "Áo khaki"
→ description: "product_name: Áo khaki. description_text: Áo chất liệu khaki form đẹp" → description: "product_name: Áo khaki. description_text: Áo chất liệu khaki form đẹp"
→ product_line_vn: "Áo" → product_line_vn: "Áo"
CASE 11: "Áo lót" hoặc "Áo bra" (NHÓM SP LIÊN QUAN) CASE 11: "Áo bra" → product_name PHẢI là "Áo bra" (tool tự resolve → Áo bra active + Áo lót)
→ description: "product_name: Áo lót/Áo bra active. description_text: Áo lót. Áo bra active thoáng mát co giãn tốt" → description: "product_name: Áo bra. description_text: Áo bra active thể thao thoáng mát co giãn tốt hỗ trợ tập luyện"
→ product_name: "Áo lót/Áo bra active" → product_name: "Áo bra"
⚠️ KHÔNG tự suy gender/age! User nói "áo lót" chung → để null. Chỉ điền khi user NÓI RÕ (VD: "áo lót nữ" → women, "áo lót trẻ em" → kid) → product_line_vn: "Áo"
⚠️ description_text PHẢI ghi CẢ 2 tên (Áo lót + Áo bra active) để semantic search tìm được cả 2 loại! ⚠️ KHÔNG tự suy gender/age! User nói "áo bra" chung → để null.
CASE 12: "Áo lót" → product_name PHẢI là "Áo lót" (tool tự resolve → Áo lót + Áo bra active)
→ description: "product_name: Áo lót. description_text: Áo lót thoáng mát mềm mại thoải mái"
→ product_name: "Áo lót"
→ product_line_vn: "Áo"
⚠️ KHÔNG tự suy gender/age! Chỉ điền khi user NÓI RÕ (VD: "áo lót nữ" → women, "áo lót trẻ em" → kid)
⚠️ Tool tự tìm CẢ 2 loại (Áo lót + Áo bra active) nhờ RELATED_LINES — LLM chỉ cần giữ đúng tên user nói!
═══════════════════════════════════════════════════════════════ ═══════════════════════════════════════════════════════════════
🎉 DỊP LỄ / SỰ KIỆN — description_text ghi lý do + gợi ý phong cách 🎉 DỊP LỄ / SỰ KIỆN — description_text ghi lý do + gợi ý phong cách
......
...@@ -111,24 +111,19 @@ async def mock_db_search(req: MockDBRequest): ...@@ -111,24 +111,19 @@ async def mock_db_search(req: MockDBRequest):
logger.info("📍 Data Retrieval Tool called") logger.info("📍 Data Retrieval Tool called")
start_time = time.time() start_time = time.time()
# Xây dựng SearchItem từ request - include all required fields # Xây dựng SearchItem từ request - pass all required fields
search_item = SearchItem( search_item = SearchItem(
query=req.query or "sản phẩm", description=f"product_name: {req.query or 'sản phẩm'}. product_line_vn: {req.query or 'sản phẩm'}",
product_name=None,
magento_ref_code=req.magento_ref_code, magento_ref_code=req.magento_ref_code,
price_min=req.price_min,
price_max=req.price_max,
action="search",
# Metadata fields - all required with None default
gender_by_product=None, gender_by_product=None,
age_by_product=None, age_by_product=None,
product_name=None,
style=None,
master_color=None, master_color=None,
season=None, price_min=req.price_min,
material_group=None, price_max=req.price_max,
fitting=None, discount_min=None,
form_neckline=None, discount_max=None,
form_sleeve=None, discovery_mode=None,
) )
...@@ -173,24 +168,19 @@ async def mock_retriever_db(req: MockRetrieverRequest): ...@@ -173,24 +168,19 @@ async def mock_retriever_db(req: MockRetrieverRequest):
logger.info(f"📍 Retriever DB started: {req.user_query}") logger.info(f"📍 Retriever DB started: {req.user_query}")
start_time = time.time() start_time = time.time()
# Xây dựng SearchItem từ request - include all required fields # Xây dựng SearchItem từ request - pass all required fields
search_item = SearchItem( search_item = SearchItem(
query=req.user_query, description=f"product_name: {req.user_query}. product_line_vn: {req.user_query}",
product_name=None,
magento_ref_code=req.magento_ref_code, magento_ref_code=req.magento_ref_code,
price_min=req.price_min,
price_max=req.price_max,
action="search",
# Metadata fields - all required with None default
gender_by_product=None, gender_by_product=None,
age_by_product=None, age_by_product=None,
product_name=None,
style=None,
master_color=None, master_color=None,
season=None, price_min=req.price_min,
material_group=None, price_max=req.price_max,
fitting=None, discount_min=None,
form_neckline=None, discount_max=None,
form_sleeve=None, discovery_mode=None,
) )
......
This diff is collapsed.
...@@ -9,7 +9,7 @@ import logging ...@@ -9,7 +9,7 @@ import logging
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from config import OPENAI_API_KEY from config import GROQ_API_KEY, OPENAI_API_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -54,8 +54,8 @@ class LLMFactory: ...@@ -54,8 +54,8 @@ class LLMFactory:
logger.debug(f"♻️ Using cached model: {clean_model}") logger.debug(f"♻️ Using cached model: {clean_model}")
return self._cache[cache_key] return self._cache[cache_key]
logger.info(f"Creating new LLM instance: {clean_model}") logger.info(f"Creating new LLM instance: {model_name}")
return self._create_instance(clean_model, streaming, json_mode, api_key) return self._create_instance(model_name, streaming, json_mode, api_key)
def _create_instance( def _create_instance(
self, self,
...@@ -77,26 +77,52 @@ class LLMFactory: ...@@ -77,26 +77,52 @@ class LLMFactory:
raise raise
def _create_openai(self, model_name: str, streaming: bool, json_mode: bool, api_key: str | None) -> BaseChatModel: def _create_openai(self, model_name: str, streaming: bool, json_mode: bool, api_key: str | None) -> BaseChatModel:
"""Create OpenAI model instance.""" """Create OpenAI-compatible model instance (OpenAI or Groq)."""
# --- Auto-detect provider ---
is_groq = any(kw in model_name.lower() for kw in ("gpt-oss", "llama", "mixtral", "gemma", "qwen", "deepseek"))
# Also detect openai/ prefix used by Groq (e.g. "openai/gpt-oss-120b")
if model_name.startswith("openai/"):
is_groq = True
if is_groq:
# Always use GROQ_API_KEY for Groq models (ignore api_key param which may be OpenAI key)
key = GROQ_API_KEY
base_url = "https://api.groq.com/openai/v1"
if not key:
raise ValueError("GROQ_API_KEY is required for Groq models")
else:
key = api_key or OPENAI_API_KEY key = api_key or OPENAI_API_KEY
base_url = None # default OpenAI
if not key: if not key:
raise ValueError("OPENAI_API_KEY is required") raise ValueError("OPENAI_API_KEY is required")
# Models that require /v1/responses API instead of /v1/chat/completions
needs_responses_api = "codex" in model_name.lower()
llm_kwargs = { llm_kwargs = {
"model": model_name, "model": model_name,
"streaming": streaming, # ← STREAMING CONFIG "streaming": streaming,
"api_key": key, "api_key": key,
"temperature": 0, "temperature": 0,
"max_tokens": 1500, "max_tokens": 1500,
} }
if base_url:
llm_kwargs["base_url"] = base_url
if needs_responses_api:
llm_kwargs["use_responses_api"] = True
logger.info(f"🔄 Using Responses API for model: {model_name}")
if json_mode: if json_mode:
llm_kwargs["model_kwargs"] = {"response_format": {"type": "json_object"}} llm_kwargs["model_kwargs"] = {"response_format": {"type": "json_object"}}
logger.info(f"⚙️ Initializing OpenAI in JSON mode: {model_name}") logger.info(f"⚙️ Initializing OpenAI in JSON mode: {model_name}")
provider = "Groq" if is_groq else "OpenAI"
logger.warning(f"🔍 DEBUG: provider={provider} | model={model_name} | base_url={base_url} | key={key[:10]}... | is_groq={is_groq}")
llm = ChatOpenAI(**llm_kwargs) llm = ChatOpenAI(**llm_kwargs)
logger.info(f"✅ Created OpenAI: {model_name} | Streaming: {streaming}") logger.info(f"✅ Created {provider}: {model_name} | Streaming: {streaming}")
return llm return llm
def _enable_json_mode(self, llm: BaseChatModel, model_name: str) -> BaseChatModel: def _enable_json_mode(self, llm: BaseChatModel, model_name: str) -> BaseChatModel:
......
"""
⚡ FastAPI Bottleneck Middleware
================================
Thêm vào server.py để tự động đo latency từng request.
Dùng:
1. Import vào server.py:
from common.profiler_middleware import ProfilerMiddleware
2. Thêm middleware:
app.add_middleware(ProfilerMiddleware)
3. Xem báo cáo:
GET /debug/profiler/stats
GET /debug/profiler/slow (các request chậm nhất)
GET /debug/profiler/reset
"""
import logging
import time
from collections import deque
from dataclasses import dataclass, field
from statistics import mean, median
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request
from starlette.responses import JSONResponse
logger = logging.getLogger("profiler.middleware")
@dataclass
class RequestMetric:
path: str
method: str
duration: float
status_code: int
timestamp: float
class ProfilerMiddleware(BaseHTTPMiddleware):
"""Middleware đo latency từng request + expose metrics endpoint."""
# Class-level storage (shared across instances)
_metrics: deque = deque(maxlen=1000) # Last 1000 requests
_slow_threshold: float = 5.0 # Seconds
async def dispatch(self, request: Request, call_next):
# Skip profiler endpoints
if request.url.path.startswith("/debug/profiler"):
return await self._handle_profiler_endpoint(request)
start = time.perf_counter()
response = await call_next(request)
duration = time.perf_counter() - start
metric = RequestMetric(
path=request.url.path,
method=request.method,
duration=duration,
status_code=response.status_code,
timestamp=time.time(),
)
self._metrics.append(metric)
# Log slow requests
if duration > self._slow_threshold:
logger.warning(
f"🐌 SLOW REQUEST: {request.method} {request.url.path} "
f"took {duration:.2f}s (>{self._slow_threshold}s)"
)
# Add timing header
response.headers["X-Response-Time"] = f"{duration:.3f}s"
return response
async def _handle_profiler_endpoint(self, request: Request) -> JSONResponse:
path = request.url.path
if path == "/debug/profiler/stats":
return self._get_stats()
elif path == "/debug/profiler/slow":
return self._get_slow_requests()
elif path == "/debug/profiler/reset":
self._metrics.clear()
return JSONResponse({"status": "reset", "message": "Metrics cleared"})
return JSONResponse({"error": "Unknown profiler endpoint"}, status_code=404)
def _get_stats(self) -> JSONResponse:
if not self._metrics:
return JSONResponse({"message": "No data yet"})
metrics = list(self._metrics)
durations = [m.duration for m in metrics]
# Group by path
path_stats = {}
for m in metrics:
key = f"{m.method} {m.path}"
if key not in path_stats:
path_stats[key] = []
path_stats[key].append(m.duration)
path_summary = {}
for path, times in sorted(path_stats.items(), key=lambda x: -max(x[1])):
path_summary[path] = {
"count": len(times),
"avg": round(mean(times), 3),
"median": round(median(times), 3),
"min": round(min(times), 3),
"max": round(max(times), 3),
}
return JSONResponse({
"total_requests": len(metrics),
"overall": {
"avg": round(mean(durations), 3),
"median": round(median(durations), 3),
"min": round(min(durations), 3),
"max": round(max(durations), 3),
},
"by_path": path_summary,
"slow_count": sum(1 for d in durations if d > self._slow_threshold),
})
def _get_slow_requests(self) -> JSONResponse:
slow = [
{
"path": m.path,
"method": m.method,
"duration": round(m.duration, 3),
"status": m.status_code,
"timestamp": m.timestamp,
}
for m in self._metrics
if m.duration > self._slow_threshold
]
slow.sort(key=lambda x: -x["duration"])
return JSONResponse({"threshold": self._slow_threshold, "slow_requests": slow[:50]})
...@@ -76,7 +76,7 @@ OPENAI_API_KEY: str | None = os.getenv("OPENAI_API_KEY") ...@@ -76,7 +76,7 @@ OPENAI_API_KEY: str | None = os.getenv("OPENAI_API_KEY")
GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY")
GROQ_API_KEY: str | None = os.getenv("GROQ_API_KEY") GROQ_API_KEY: str | None = os.getenv("GROQ_API_KEY")
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-5-nano") DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL")
# DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL") # DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL")
# ====================== JWT CONFIGURATION ====================== # ====================== JWT CONFIGURATION ======================
......
services:
# --- n8n Workflow Automation ---
n8n:
image: docker.n8n.io/n8nio/n8n:latest
container_name: canifa_n8n
ports:
- "5678:5678"
environment:
- N8N_HOST=0.0.0.0
- N8N_PORT=5678
- N8N_PROTOCOL=http
- WEBHOOK_URL=http://localhost:5678/
- GENERIC_TIMEZONE=Asia/Ho_Chi_Minh
- TZ=Asia/Ho_Chi_Minh
# Basic auth - đổi password trước khi dùng production nhé bro
- N8N_BASIC_AUTH_ACTIVE=true
- N8N_BASIC_AUTH_USER=admin
- N8N_BASIC_AUTH_PASSWORD=canifa2026
volumes:
- n8n_data:/home/node/.n8n
restart: unless-stopped
networks:
- backend_network
networks:
backend_network:
driver: bridge
ipam:
driver: default
config:
- subnet: "172.24.0.0/16"
gateway: "172.24.0.1"
volumes:
n8n_data:
driver: local
...@@ -20,3 +20,4 @@ Get-NetTCPConnection -LocalPort 5000 | ForEach-Object { Stop-Process -Id $_.Owni ...@@ -20,3 +20,4 @@ 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
# TEST STREAMING + BACKGROUND USER_INSIGHT
Write-Host "`n==== STREAMING TEST ====`n" -ForegroundColor Cyan
$query = "Ao khoac nam mua dong"
$deviceId = "test_stream_verify"
Write-Host "Sending request..." -ForegroundColor Green
$timing = Measure-Command {
$body = '{"user_query":"' + $query + '","device_id":"' + $deviceId + '"}'
$result = $body | curl.exe -s -X POST "http://localhost:5000/api/agent/chat" -H "Content-Type: application/json" --data-binary "@-"
$result | Out-Null
}
Write-Host "`nResponse Time: $($timing.TotalMilliseconds) ms" -ForegroundColor Green
Write-Host "`nCheck backend logs for:" -ForegroundColor Yellow
Write-Host " - Starting LLM streaming" -ForegroundColor Gray
Write-Host " - Regex matched product_ids" -ForegroundColor Gray
Write-Host " - BREAKING STREAM NOW" -ForegroundColor Gray
Write-Host " - Background task extraction" -ForegroundColor Gray
Write-Host "`nDone!" -ForegroundColor Green
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
"""
Create Google Sheet using Sheets API v4 directly (not gspread).
This avoids Drive quota issues by creating the spreadsheet via Sheets API.
"""
import json
import sys
from pathlib import Path
from google.oauth2.service_account import Credentials
from googleapiclient.discovery import build
CREDENTIALS_FILE = Path(__file__).parent / "google_credentials.json"
SCOPES = [
"https://www.googleapis.com/auth/spreadsheets",
"https://www.googleapis.com/auth/drive",
]
TEST_QUESTIONS = [
"Tìm cho mình chân váy màu đỏ",
"Tìm quần màu đỏ",
"Tìm áo polo nam",
"Tìm áo khoác nữ mùa đông",
"Mình muốn mua đồ đi biển, gợi ý cho mình",
"Cho mình xem áo sơ mi đi làm",
"Gợi ý outfit đi dự tiệc",
"Áo size S giá dưới 500k",
"Có khuyến mãi gì không?",
"Cách đặt hàng online",
"Cửa hàng nào gần nhất ở Hà Nội",
"Xin chào",
"Cảm ơn bạn",
"Tìm sản phẩm abc123 không tồn tại",
]
def main():
creds = Credentials.from_service_account_file(str(CREDENTIALS_FILE), scopes=SCOPES)
sheets_service = build("sheets", "v4", credentials=creds)
drive_service = build("drive", "v3", credentials=creds)
# Create spreadsheet via Sheets API
spreadsheet_body = {
"properties": {"title": "Canifa Chatbot Test Results"},
"sheets": [{
"properties": {"title": "Test Questions"},
}]
}
print("📝 Creating spreadsheet via Sheets API...")
result = sheets_service.spreadsheets().create(body=spreadsheet_body).execute()
sheet_id = result["spreadsheetId"]
sheet_url = result["spreadsheetUrl"]
print(f"✅ Created! ID: {sheet_id}")
print(f"📊 URL: {sheet_url}")
# Write headers + data
headers = ["STT", "Câu hỏi test", "Câu trả lời", "Thời gian (ms)", "Trạng thái"]
values = [headers]
for i, q in enumerate(TEST_QUESTIONS, 1):
values.append([i, q, "", "", "⏳ Đang chờ..."])
sheets_service.spreadsheets().values().update(
spreadsheetId=sheet_id,
range="Test Questions!A1:E15",
valueInputOption="RAW",
body={"values": values}
).execute()
print(f"✅ Wrote {len(TEST_QUESTIONS)} questions")
# Share with anyone (link)
try:
drive_service.permissions().create(
fileId=sheet_id,
body={"type": "anyone", "role": "writer"},
fields="id"
).execute()
print("✅ Shared as 'anyone with link can edit'")
except Exception as e:
print(f"⚠️ Could not share: {e}")
# Save sheet info
info = {"sheet_url": sheet_url, "sheet_id": sheet_id}
info_path = Path(__file__).parent / "sheet_info.json"
info_path.write_text(json.dumps(info, indent=2))
print(f"💾 Saved to {info_path}")
if __name__ == "__main__":
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