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

feat: faq simulator, db sync, lead logic update

parent 906e5cc3
Subproject commit 44af4fbec40276b94e54a275162e2ae803e5e13c
......@@ -15,7 +15,7 @@ from langgraph.prebuilt import ToolNode
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
from agent.tag_search_agent.tag_search_tool import tag_search_tool as canifa_tag_search
from agent.lead_stage_agent.lead_search_tool import lead_search_tool as canifa_tag_search
from .prompts import VISION_PLANNER_PROMPT, VISION_RESPONDER_PROMPT
logger = logging.getLogger(__name__)
......
......@@ -96,7 +96,7 @@ class ClassifierOutput(BaseModel):
# ── TH 2: EARLY EXIT ──
ai_response: str | None = Field(default=None, description="Cau tra loi cho khach. CHI nha ra khi tool_name=null. Neu goi tool, bat buoc de null.")
product_ids: list[str] = Field(default_factory=list, description="Ma SKU (neu khach hoi trung ma).")
product_ids: list[str] | None = Field(default_factory=list, description="Ma SKU (neu khach hoi trung ma).")
class InsightJSON(BaseModel):
......
════════════════════════════════════════════════════════════════
TAG SEARCH AGENT — Kiến trúc tìm kiếm sản phẩm CANIFA
════════════════════════════════════════════════════════════════
📅 Cập nhật: 2026-04-05
═══ 1. TỔNG QUAN ═══
Tag Search Agent là hệ thống tìm kiếm sản phẩm thông minh cho chatbot
CANIFA. AI nhận câu hỏi tự nhiên từ khách → phân tích → gọi tool SQL
→ trả kết quả sản phẩm phù hợp.
Flow: Khách hỏi → LLM phân tích → tag_search_tool → SQL → Postgres → Kết quả
═══ 2. CÁC FILE CHÍNH ═══
┌─────────────────────────┬────────────────────────────────────────┐
│ File │ Vai trò │
├─────────────────────────┼────────────────────────────────────────┤
│ tag_search_graph.py │ LangGraph graph + System Prompt cho AI │
│ tag_search_tool.py │ Pydantic schema + SQL builder + Tool │
│ __init__.py │ Export module │
└─────────────────────────┴────────────────────────────────────────┘
═══ 3. SEARCH ARCHITECTURE: 3-TẦNG FALLBACK ═══
Tầng 1: KEYWORDS + TAGS + FIXED FILTERS
↓ (0 kết quả?)
Tầng 2: TAGS + FIXED FILTERS (bỏ keywords)
↓ (0 kết quả?)
Tầng 3: CHỈ FIXED FILTERS (bỏ cả tags)
FIXED FILTERS = product_type + color + gender + price (luôn giữ, không bỏ)
═══ 4. INPUT SCHEMA (TagSearchInput) ═══
① KEYWORDS (list[str]) — từ khoá lấy từ mô tả SP hoặc câu hỏi khách
Ưu tiên: từ đặc biệt như "30/4", "cờ đỏ sao vàng", "cotton"...
Tối đa 2 keywords.
② TAGS (list[str]) — AI suy luận từ ngữ cảnh
DANH SÁCH CỐ ĐỊNH (CHỈ 12 GIÁ TRỊ, KHÔNG TỰ NGHĨ RA):
đi tiệc, đi học, đi chơi, dạo phố, mặc nhà, đi ngủ,
thể thao, đi dã ngoại, đi biển, đi làm, giữ ấm, thoáng mát
⚠️ "đi ăn nhà hàng" ❌ → "đi tiệc" ✅
⚠️ "đi picnic" ❌ → "đi dã ngoại" ✅
③ PRODUCT_TYPE (list[str]) — CLOSED LIST, CHỈ 14 GIÁ TRỊ:
┌──────────────────────────┐
│ Áo phông │
│ Áo len │
│ Áo nỉ │
│ Áo Polo │
│ Áo Sơ mi │
│ Áo kiểu │
│ Áo khoác chống nắng │
│ Cardigan │
│ Váy liền │
│ Chân váy │
│ Quần nỉ │
│ Quần dài │
│ Quần soóc │
│ Pyjama │
└──────────────────────────┘
⚠️ AI KHÔNG ĐƯỢC tự nghĩ ra loại mới!
"đồ đông" ❌ → ["Áo len", "Áo nỉ", "Quần nỉ"] ✅
"đồ hè" ❌ → ["Áo phông", "Quần soóc"] ✅
"bộ thể thao" ❌ → ["Áo phông", "Quần soóc"] ✅
⚠️ AUTO-EXPAND từ khái quát:
"áo" → ["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
"quần" → ["Quần dài", "Quần soóc", "Quần nỉ"]
"váy" → ["Váy liền", "Chân váy"]
❌ TUYỆT ĐỐI KHÔNG để product_type=[] khi khách đã nhắc loại đồ!
④ CÁC FILTER KHÁC:
- gender_by_product: women, men, boy, girl, unisex, newborn
- master_color: Màu sắc (AI suy luận: "30/4" → đỏ)
- price_min / price_max: đơn vị VND
- discovery_mode: "new" | "best_seller"
- magento_ref_code: mã SKU cụ thể
═══ 5. QUY TẮC QUAN TRỌNG ═══
✅ Tags + Product_type CHỈ chọn từ danh sách cố định
✅ Nếu khách nói "áo" → auto-expand thành nhiều loại áo
✅ Nếu khách hỏi mơ hồ ("cả gia đình", "cho mọi người")
→ HỎI LẠI: "Bạn muốn tìm cho ai ạ?" TRƯỚC khi gọi tool
❌ KHÔNG tự nghĩ ra tags mới (VD: "đi ăn nhà hàng")
❌ KHÔNG tự nghĩ ra product_type mới (VD: "đồ đông")
❌ KHÔNG để product_type=[] khi khách đã nhắc loại đồ
═══ 6. VÍ DỤ MAPPING ═══
Câu hỏi khách → AI gửi tool_call
─────────────────────────────────────────────────────────────
"Váy đi tiệc cho bé" → tags=["đi tiệc"]
product_type=["Váy liền", "Chân váy"]
gender="girl"
"Áo mặc 30/4" → keywords=["30/4"]
tags=["đi chơi"]
product_type=["Áo phông", "Áo kiểu"]
color="đỏ"
"Bộ thể thao nam" → tags=["thể thao"]
product_type=["Áo phông", "Quần soóc"]
gender="men"
"Đồ mùa đông cho bé gái" → tags=["giữ ấm"]
product_type=["Áo len", "Áo nỉ", "Quần nỉ"]
gender="girl"
"Hàng mới bán chạy" → product_type=[]
discovery_mode="best_seller"
"Áo đi ăn nhà hàng cho nữ" → tags=["đi tiệc"] (KHÔNG phải "đi ăn nhà hàng")
product_type=["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
gender="women"
"Có áo cho cả gia đình k?" → HỎI LẠI: "Bạn muốn tìm cho ai ạ?"
═══ 6. SQL GENERATION ═══
product_type là list → sinh OR clauses:
WHERE (
(LOWER(product_name) LIKE '%áo len%' OR LOWER(ultra_description_text) LIKE '%áo len%')
OR
(LOWER(product_name) LIKE '%áo nỉ%' OR LOWER(ultra_description_text) LIKE '%áo nỉ%')
OR
(LOWER(product_name) LIKE '%quần nỉ%' OR LOWER(ultra_description_text) LIKE '%quần nỉ%')
)
AND gender_by_product IN ('female', 'unisex')
AND ...
═══ 7. API ENDPOINTS ═══
POST /api/tag-search/chat → Chat với AI agent
GET /api/tag-search/inventory → Thống kê kho hàng (count, giá, màu, tags)
═══ 8. TECH STACK ═══
- Backend: FastAPI + LangGraph + LangChain
- LLM: GPT-4.1-nano (OpenAI)
- Database: PostgreSQL (law_bot)
- Schema: Pydantic v2
- Frontend: Vanilla HTML/JS/CSS
════════════════════════════════════════════════════════════════
END OF DOCUMENTATION
════════════════════════════════════════════════════════════════
"""
Tag Search Agent — AI-powered product search using tag selection.
No vector search. Pure SQL tag matching.
"""
"""
Product Line Mapping
Key = DB product_line_vn (chính xác)
Value = list các từ khách hàng hay dùng (synonym)
"""
# DB value → [các từ khách hàng hay gọi]
PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo Sơ mi": ["áo sơ mi"],
"Áo Polo": ["áo polo", "áo cổ bẻ"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym"],
"Áo nỉ có mũ": ["áo nỉ có mũ"],
"Áo nỉ": ["áo nỉ"],
"Áo mặc nhà": ["áo mặc nhà"],
"Áo lót": ["áo lót", "áo ngực", "áo quây", "áo lót nữ", "áo lót nam", "áo lót trẻ em"],
"Áo len gilet": ["áo len gilet"],
"Áo len": ["áo len"],
"Áo kiểu": ["áo kiểu"],
"Áo khoác sợi": ["áo khoác sợi"],
"Áo khoác nỉ không mũ": ["áo khoác nỉ không mũ"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ"],
"Áo khoác lông vũ": ["áo khoác lông vũ"],
"Áo khoác gió": ["áo khoác gió", "áo gió", "áo khoác mỏng"],
"Áo khoác gilet chần bông": ["áo khoác gilet chần bông", "áo khoác gilet trần bông", "áo gilet chần bông", "áo gilet trần bông"],
"Áo khoác gilet": ["áo khoác gilet"],
"Áo khoác dạ": ["áo khoác dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn"],
"Áo khoác chống nắng": ["áo khoác chống nắng"],
"Áo khoác chần bông": ["áo khoác chần bông", "áo khoác trần bông", "áo chần bông", "áo trần bông"],
"Áo khoác": ["áo khoác"],
"Áo giữ nhiệt": ["áo giữ nhiệt"],
"Áo bra active": ["áo bra active", "áo bra", "bra"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn"],
"Áo ba lỗ": ["áo ba lỗ", "áo sát nách", "tanktop", "tank top", "áo dây", "áo 2 dây", "áo hai dây"],
"Váy liền": ["váy liền", "đầm"],
"Tất": ["tất", "vớ"],
"Túi xách": ["túi xách"],
"Quần giả váy": ["quần giả váy", "quần váy"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger"],
"Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer", "boxer", "sịp đùi"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief", "brief", "sịp tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings", "leggings"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean"],
"Quần giữ nhiệt": ["quần giữ nhiệt"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng"],
"Quần culottes": ["quần culottes"],
"Quần Body": ["quần body"],
"Pyjama": ["pyjama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "phụ kiện Canifa", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "phụ kiện Canifa", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện Canifa", "phụ kiện"],
"Khăn": ["khăn", "phụ kiện Canifa", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "phụ kiện Canifa", "phụ kiện"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "phụ kiện Canifa", "phụ kiện"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài"],
"Cardigan": ["cardigan"],
"Bộ thể thao": ["bộ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà"],
"Blazer": ["blazer"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
}
# ==============================================================================
# AUTO-GENERATE reverse lookup: synonym → DB value
# "áo thun" → "Áo phông", "quần bò" → "Quần jean", ...
# ==============================================================================
SYNONYM_TO_DB: dict[str, str] = {}
for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_product_name(raw_name: str) -> str:
"""
Resolve synonym trong product_name → tên DB thật.
Dùng longest-match-first để tránh match sai.
VD:
"áo cổ bẻ khaki" → "Áo Polo khaki"
"áo thun disney" → "Áo phông disney"
"quần bò ống rộng" → "Quần jean ống rộng"
"áo polo khaki" → "Áo Polo khaki" (giữ nguyên nếu đã đúng)
"""
name_lower = raw_name.lower().strip()
for synonym in _SORTED_SYNONYMS:
if name_lower.startswith(synonym):
db_value = SYNONYM_TO_DB[synonym]
remainder = name_lower[len(synonym):].strip()
return f"{db_value} {remainder}".strip() if remainder else db_value
return raw_name
def resolve_product_line(raw_value: str) -> list[str]:
"""
Lookup keyword → DB product_line_vn.
Hỗ trợ '/' separator (VD: "Quần/ Váy").
Không tìm thấy → giữ nguyên (prefix match ở SQL).
"""
parts = [p.strip() for p in raw_value.split("/") if p.strip()]
resolved = []
for part in parts:
mapped = SYNONYM_TO_DB.get(part.lower())
if mapped:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
"""Tag Search Agent — Prompts package."""
from .planner_prompt import PLANNER_PROMPT
from .responder_prompt import RESPONDER_PROMPT
__all__ = ["PLANNER_PROMPT", "RESPONDER_PROMPT"]
"""
Responder Prompt — Tag Search Agent.
AI RESPONDER format kết quả tìm kiếm thành câu trả lời tự nhiên.
"""
RESPONDER_PROMPT = """Bạn là AI RESPONDER của CANIFA. Nhiệm vụ DUY NHẤT: Format kết quả tìm kiếm từ tool thành câu trả lời tự nhiên cho khách.
## QUY TẮC #1 — TRÌNH BÀY KẾT QUẢ, KHÔNG PHÁN XÉT
⚠️ BẮT BUỘC: Nếu tool trả về sản phẩm → BẠN PHẢI TRÌNH BÀY chúng cho khách.
KHÔNG ĐƯỢC tự ý:
- Phán "sản phẩm này không phù hợp với dịp X" rồi giấu kết quả
- Nói "tiếc, chưa có" khi tool ĐÃ TRẢ VỀ sản phẩm
- Tự đánh giá sản phẩm "không chuyên dụng" hay "không phù hợp"
- Từ chối hiển thị kết quả dựa trên suy đoán của bạn
Việc của bạn là GIỚI THIỆU, không phải KIỂM DUYỆT. Khách tự quyết định mua gì.
## KHI NÀO ĐƯỢC NÓI "KHÔNG CÓ":
CHỈ KHI:
1. Tool trả về 0 kết quả (count = 0)
2. Kết quả sai GIỚI TÍNH rõ ràng (user hỏi "nữ" nhưng toàn sản phẩm "nam")
3. Kết quả sai ĐỘ TUỔI rõ ràng (user hỏi "người lớn" nhưng toàn "bé gái")
Ngoài 3 trường hợp trên → PHẢI trình bày sản phẩm, ĐƯỢC PHÉP thêm gợi ý nhẹ.
## FORMAT MỖI SẢN PHẨM:
- **Tên sản phẩm** (Màu)
- Giá: xxx₫ ~~(giá gốc)~~ giảm xx%
- 🔗 [Xem và mua](link)
## NGUYÊN TẮC:
- Tiếng Việt, thân thiện, ngắn gọn.
- KHÔNG tự bịa thông tin. Chỉ dùng data từ tool.
- Cuối cùng: "Bạn muốn xem thêm mẫu khác không?"
- ĐƯỢC PHÉP thêm gợi ý bổ sung SAU KHI đã trình bày kết quả: "Ngoài ra, bạn có thể tham khảo thêm..."
"""
This diff is collapsed.
This diff is collapsed.
════════════════════════════════════════════════════════════════
TAG SEARCH AGENT — Kiến trúc tìm kiếm sản phẩm CANIFA
════════════════════════════════════════════════════════════════
📅 Cập nhật: 2026-04-05
═══ 1. TỔNG QUAN ═══
Tag Search Agent là hệ thống tìm kiếm sản phẩm thông minh cho chatbot
CANIFA. AI nhận câu hỏi tự nhiên từ khách → phân tích → gọi tool SQL
→ trả kết quả sản phẩm phù hợp.
Flow: Khách hỏi → LLM phân tích → tag_search_tool → SQL → Postgres → Kết quả
═══ 2. CÁC FILE CHÍNH ═══
┌─────────────────────────┬────────────────────────────────────────┐
│ File │ Vai trò │
├─────────────────────────┼────────────────────────────────────────┤
│ tag_search_graph.py │ LangGraph graph + System Prompt cho AI │
│ tag_search_tool.py │ Pydantic schema + SQL builder + Tool │
│ __init__.py │ Export module │
└─────────────────────────┴────────────────────────────────────────┘
═══ 3. SEARCH ARCHITECTURE: 3-TẦNG FALLBACK ═══
Tầng 1: KEYWORDS + TAGS + FIXED FILTERS
↓ (0 kết quả?)
Tầng 2: TAGS + FIXED FILTERS (bỏ keywords)
↓ (0 kết quả?)
Tầng 3: CHỈ FIXED FILTERS (bỏ cả tags)
FIXED FILTERS = product_type + color + gender + price (luôn giữ, không bỏ)
═══ 4. INPUT SCHEMA (TagSearchInput) ═══
① KEYWORDS (list[str]) — từ khoá lấy từ mô tả SP hoặc câu hỏi khách
Ưu tiên: từ đặc biệt như "30/4", "cờ đỏ sao vàng", "cotton"...
Tối đa 2 keywords.
② TAGS (list[str]) — AI suy luận từ ngữ cảnh
DANH SÁCH CỐ ĐỊNH (CHỈ 12 GIÁ TRỊ, KHÔNG TỰ NGHĨ RA):
đi tiệc, đi học, đi chơi, dạo phố, mặc nhà, đi ngủ,
thể thao, đi dã ngoại, đi biển, đi làm, giữ ấm, thoáng mát
⚠️ "đi ăn nhà hàng" ❌ → "đi tiệc" ✅
⚠️ "đi picnic" ❌ → "đi dã ngoại" ✅
③ PRODUCT_TYPE (list[str]) — CLOSED LIST, CHỈ 14 GIÁ TRỊ:
┌──────────────────────────┐
│ Áo phông │
│ Áo len │
│ Áo nỉ │
│ Áo Polo │
│ Áo Sơ mi │
│ Áo kiểu │
│ Áo khoác chống nắng │
│ Cardigan │
│ Váy liền │
│ Chân váy │
│ Quần nỉ │
│ Quần dài │
│ Quần soóc │
│ Pyjama │
└──────────────────────────┘
⚠️ AI KHÔNG ĐƯỢC tự nghĩ ra loại mới!
"đồ đông" ❌ → ["Áo len", "Áo nỉ", "Quần nỉ"] ✅
"đồ hè" ❌ → ["Áo phông", "Quần soóc"] ✅
"bộ thể thao" ❌ → ["Áo phông", "Quần soóc"] ✅
⚠️ AUTO-EXPAND từ khái quát:
"áo" → ["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
"quần" → ["Quần dài", "Quần soóc", "Quần nỉ"]
"váy" → ["Váy liền", "Chân váy"]
❌ TUYỆT ĐỐI KHÔNG để product_type=[] khi khách đã nhắc loại đồ!
④ CÁC FILTER KHÁC:
- gender_by_product: women, men, boy, girl, unisex, newborn
- master_color: Màu sắc (AI suy luận: "30/4" → đỏ)
- price_min / price_max: đơn vị VND
- discovery_mode: "new" | "best_seller"
- magento_ref_code: mã SKU cụ thể
═══ 5. QUY TẮC QUAN TRỌNG ═══
✅ Tags + Product_type CHỈ chọn từ danh sách cố định
✅ Nếu khách nói "áo" → auto-expand thành nhiều loại áo
✅ Nếu khách hỏi mơ hồ ("cả gia đình", "cho mọi người")
→ HỎI LẠI: "Bạn muốn tìm cho ai ạ?" TRƯỚC khi gọi tool
❌ KHÔNG tự nghĩ ra tags mới (VD: "đi ăn nhà hàng")
❌ KHÔNG tự nghĩ ra product_type mới (VD: "đồ đông")
❌ KHÔNG để product_type=[] khi khách đã nhắc loại đồ
═══ 6. VÍ DỤ MAPPING ═══
Câu hỏi khách → AI gửi tool_call
─────────────────────────────────────────────────────────────
"Váy đi tiệc cho bé" → tags=["đi tiệc"]
product_type=["Váy liền", "Chân váy"]
gender="girl"
"Áo mặc 30/4" → keywords=["30/4"]
tags=["đi chơi"]
product_type=["Áo phông", "Áo kiểu"]
color="đỏ"
"Bộ thể thao nam" → tags=["thể thao"]
product_type=["Áo phông", "Quần soóc"]
gender="men"
"Đồ mùa đông cho bé gái" → tags=["giữ ấm"]
product_type=["Áo len", "Áo nỉ", "Quần nỉ"]
gender="girl"
"Hàng mới bán chạy" → product_type=[]
discovery_mode="best_seller"
"Áo đi ăn nhà hàng cho nữ" → tags=["đi tiệc"] (KHÔNG phải "đi ăn nhà hàng")
product_type=["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
gender="women"
"Có áo cho cả gia đình k?" → HỎI LẠI: "Bạn muốn tìm cho ai ạ?"
═══ 6. SQL GENERATION ═══
product_type là list → sinh OR clauses:
WHERE (
(LOWER(product_name) LIKE '%áo len%' OR LOWER(ultra_description_text) LIKE '%áo len%')
OR
(LOWER(product_name) LIKE '%áo nỉ%' OR LOWER(ultra_description_text) LIKE '%áo nỉ%')
OR
(LOWER(product_name) LIKE '%quần nỉ%' OR LOWER(ultra_description_text) LIKE '%quần nỉ%')
)
AND gender_by_product IN ('female', 'unisex')
AND ...
═══ 7. API ENDPOINTS ═══
POST /api/tag-search/chat → Chat với AI agent
GET /api/tag-search/inventory → Thống kê kho hàng (count, giá, màu, tags)
═══ 8. TECH STACK ═══
- Backend: FastAPI + LangGraph + LangChain
- LLM: GPT-4.1-nano (OpenAI)
- Database: PostgreSQL (law_bot)
- Schema: Pydantic v2
- Frontend: Vanilla HTML/JS/CSS
════════════════════════════════════════════════════════════════
END OF DOCUMENTATION
════════════════════════════════════════════════════════════════
"""
Tag Search Agent — AI-powered product search using tag selection.
No vector search. Pure SQL tag matching with 3-tier cascading fallback.
"""
from .controller import tag_search_chat_controller
from .tag_search_graph import get_tag_search_agent
__all__ = ["tag_search_chat_controller", "get_tag_search_agent"]
"""
Tag Search Agent Controller — Entry point with Langfuse tracing.
Wraps TagSearchGraph (Planner ⇄ Tools → Responder) with:
- Langfuse trace + span context (timing: planner_ms, responder_ms, tool_ms)
- Session-based Redis history (via ai_tag_search Redis helpers)
- Background flush for Langfuse
Every call creates a named Langfuse trace "tag-search-chat" so the full
Planner → Tool → Responder pipeline is visible in the Langfuse dashboard.
"""
import logging
import time
import uuid
from fastapi import BackgroundTasks
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langfuse import Langfuse, get_client as get_langfuse
from common.langfuse_client import async_flush_langfuse, get_callback_handler
from .tag_search_graph import get_tag_search_agent
logger = logging.getLogger(__name__)
async def tag_search_chat_controller(
*,
query: str,
session_id: str | None = None,
background_tasks: BackgroundTasks,
model_name: str | None = None,
history: list | None = None, # pre-loaded LangChain messages (optional)
user_id: str | None = None,
) -> dict:
"""
Controller cho Tag Search Agent — full Langfuse tracing.
Flow:
1. Build Langfuse trace context
2. Run TagSearchGraph (Planner ⇄ Tools → Responder)
3. Extract timing breakdown từ pipeline diagnostics
4. Update Langfuse trace output
5. Flush Langfuse in background
Returns:
dict with: status, response, products, product_ids, pipeline, timing, trace_id
"""
start_time = time.time()
run_id = str(uuid.uuid4())
trace_id = Langfuse.create_trace_id()
effective_session = session_id or f"ts-{run_id[:8]}"
effective_user = user_id or effective_session
logger.info(
"📥 [TagSearch Controller] session=%s | query=%.80s",
effective_session[:12], query,
)
# ═══ 1. BUILD LANGFUSE TRACE ═══
tags = ["tag_search", "experiment"]
langfuse = get_langfuse()
observation_ctx = None
if langfuse:
try:
observation_ctx = langfuse.start_as_current_observation(
as_type="span",
name="tag-search-chat",
trace_context={"trace_id": trace_id},
)
except Exception as e:
logger.warning("⚠️ Langfuse span init failed: %s", e)
span = None
if observation_ctx:
try:
span = observation_ctx.__enter__()
span.update_trace(
name="tag-search-chat",
user_id=effective_user,
session_id=effective_session,
tags=tags,
input={"query": query},
metadata={
"model": model_name,
"session_id": effective_session,
"history_turns": len(history) if history else 0,
},
)
except Exception as e:
logger.warning("⚠️ Langfuse trace update failed: %s", e)
# Langfuse callback handler — tracks each LLM call inside the graph
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
callbacks=[langfuse_handler] if langfuse_handler else [],
run_name="tag-search-graph",
run_id=run_id,
metadata={
"langfuse_session_id": effective_session,
"langfuse_user_id": effective_user,
"langfuse_tags": tags,
"trace_id": trace_id,
},
)
# ═══ 2. RUN TAG SEARCH GRAPH ═══
try:
agent = get_tag_search_agent(model_name)
chat_result = await agent.chat(
user_message=query,
history=history or [],
config=exec_config,
)
ai_response = chat_result.get("response", "")
products = chat_result.get("products", [])
pipeline = chat_result.get("pipeline", [])
agent_path = chat_result.get("agent_path", "unknown")
total_elapsed = chat_result.get("elapsed_ms", 0)
# ─── Extract product_ids (SKU array) ───
product_ids = [p["sku"] for p in products if p.get("sku")]
# ─── Timing breakdown from pipeline diagnostics ───
planner_ms = 0
responder_ms = 0
tool_ms = 0
for step in pipeline:
step_type = step.get("step", "")
ms = step.get("elapsed_ms", 0) or 0
if "planner" in step_type:
planner_ms += ms
elif step_type == "responder":
responder_ms += ms
# tool_ms không có elapsed_ms in pipeline (tool timing tracked by tool itself)
wall_ms = round((time.time() - start_time) * 1000)
# ─── Log timing breakdown ───
logger.info(
"✅ [TagSearch Controller] path=%s | products=%d | "
"planner=%dms | responder=%dms | total=%dms | wall=%dms",
agent_path, len(products),
planner_ms, responder_ms, round(total_elapsed), wall_ms,
)
# ═══ 3. UPDATE LANGFUSE TRACE OUTPUT ═══
if span:
try:
span.update_trace(
output={
"ai_response": (ai_response or "")[:500],
"agent_path": agent_path,
"product_count": len(products),
"product_ids": product_ids[:10], # top 10 for trace
"elapsed_ms": round(total_elapsed),
"planner_ms": planner_ms,
"responder_ms": responder_ms,
},
)
except Exception:
pass
# ═══ 4. RETURN ═══
return {
"status": "success",
"response": ai_response,
"products": products,
"product_ids": product_ids,
"product_count": len(products),
"pipeline": pipeline,
"agent_path": agent_path,
"tool_calls": chat_result.get("tool_calls", []),
"trace_id": trace_id,
"session_id": effective_session,
"timing": {
"planner_ms": planner_ms,
"responder_ms": responder_ms,
"tool_ms": tool_ms,
"total_ms": round(total_elapsed),
"wall_ms": wall_ms,
},
}
except Exception as e:
elapsed = round((time.time() - start_time) * 1000)
logger.error(
"❌ [TagSearch Controller] Error after %dms: %s", elapsed, e,
exc_info=True,
)
if span:
try:
span.update_trace(
output={"error": str(e)[:500], "elapsed_ms": elapsed},
level="ERROR",
)
except Exception:
pass
return {
"status": "error",
"error_code": "TAG_SEARCH_ERROR",
"message": f"Tag Search Error: {str(e)[:200]}",
"trace_id": trace_id,
}
finally:
if observation_ctx:
try:
observation_ctx.__exit__(None, None, None)
except Exception:
pass
# Async flush Langfuse in background — non-blocking
if background_tasks:
background_tasks.add_task(async_flush_langfuse)
"""
Product Line Mapping
Key = DB product_line_vn (chính xác)
Value = list các từ khách hàng hay dùng (synonym)
"""
# DB value → [các từ khách hàng hay gọi]
PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo Sơ mi": ["áo sơ mi"],
"Áo Polo": ["áo polo", "áo cổ bẻ"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym"],
"Áo nỉ có mũ": ["áo nỉ có mũ"],
"Áo nỉ": ["áo nỉ"],
"Áo mặc nhà": ["áo mặc nhà"],
"Áo lót": ["áo lót", "áo ngực", "áo quây", "áo lót nữ", "áo lót nam", "áo lót trẻ em"],
"Áo len gilet": ["áo len gilet"],
"Áo len": ["áo len"],
"Áo kiểu": ["áo kiểu"],
"Áo khoác sợi": ["áo khoác sợi"],
"Áo khoác nỉ không mũ": ["áo khoác nỉ không mũ"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ"],
"Áo khoác lông vũ": ["áo khoác lông vũ"],
"Áo khoác gió": ["áo khoác gió", "áo gió", "áo khoác mỏng"],
"Áo khoác gilet chần bông": ["áo khoác gilet chần bông", "áo khoác gilet trần bông", "áo gilet chần bông", "áo gilet trần bông"],
"Áo khoác gilet": ["áo khoác gilet"],
"Áo khoác dạ": ["áo khoác dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn"],
"Áo khoác chống nắng": ["áo khoác chống nắng"],
"Áo khoác chần bông": ["áo khoác chần bông", "áo khoác trần bông", "áo chần bông", "áo trần bông"],
"Áo khoác": ["áo khoác"],
"Áo giữ nhiệt": ["áo giữ nhiệt"],
"Áo bra active": ["áo bra active", "áo bra", "bra"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn"],
"Áo ba lỗ": ["áo ba lỗ", "áo sát nách", "tanktop", "tank top", "áo dây", "áo 2 dây", "áo hai dây"],
"Váy liền": ["váy liền", "đầm"],
"Tất": ["tất", "vớ"],
"Túi xách": ["túi xách"],
"Quần giả váy": ["quần giả váy", "quần váy"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger"],
"Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer", "boxer", "sịp đùi"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief", "brief", "sịp tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings", "leggings"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean"],
"Quần giữ nhiệt": ["quần giữ nhiệt"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng"],
"Quần culottes": ["quần culottes"],
"Quần Body": ["quần body"],
"Pyjama": ["pyjama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "phụ kiện Canifa", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "phụ kiện Canifa", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện Canifa", "phụ kiện"],
"Khăn": ["khăn", "phụ kiện Canifa", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "phụ kiện Canifa", "phụ kiện"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "phụ kiện Canifa", "phụ kiện"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài"],
"Cardigan": ["cardigan"],
"Bộ thể thao": ["bộ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà"],
"Blazer": ["blazer"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
}
# ==============================================================================
# AUTO-GENERATE reverse lookup: synonym → DB value
# "áo thun" → "Áo phông", "quần bò" → "Quần jean", ...
# ==============================================================================
SYNONYM_TO_DB: dict[str, str] = {}
for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_product_name(raw_name: str) -> str:
"""
Resolve synonym trong product_name → tên DB thật.
Dùng longest-match-first để tránh match sai.
VD:
"áo cổ bẻ khaki" → "Áo Polo khaki"
"áo thun disney" → "Áo phông disney"
"quần bò ống rộng" → "Quần jean ống rộng"
"áo polo khaki" → "Áo Polo khaki" (giữ nguyên nếu đã đúng)
"""
name_lower = raw_name.lower().strip()
for synonym in _SORTED_SYNONYMS:
if name_lower.startswith(synonym):
db_value = SYNONYM_TO_DB[synonym]
remainder = name_lower[len(synonym):].strip()
return f"{db_value} {remainder}".strip() if remainder else db_value
return raw_name
def resolve_product_line(raw_value: str) -> list[str]:
"""
Lookup keyword → DB product_line_vn.
Hỗ trợ '/' separator (VD: "Quần/ Váy").
Không tìm thấy → giữ nguyên (prefix match ở SQL).
"""
parts = [p.strip() for p in raw_value.split("/") if p.strip()]
resolved = []
for part in parts:
mapped = SYNONYM_TO_DB.get(part.lower())
if mapped:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
"""Tag Search Agent — Prompts package."""
from .planner_prompt import PLANNER_PROMPT
from .responder_prompt import RESPONDER_PROMPT
__all__ = ["PLANNER_PROMPT", "RESPONDER_PROMPT"]
"""
Responder Prompt — Tag Search Agent.
AI RESPONDER nhận kết quả tool → format câu trả lời tự nhiên, thân thiện như stylist.
"""
RESPONDER_PROMPT = """Bạn là AI STYLIST của CANIFA — người tư vấn thời trang thân thiện, am hiểu sản phẩm.
Nhiệm vụ: Nhận kết quả tìm kiếm từ tool → trình bày cho khách một cách tự nhiên, hấp dẫn.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## NGUYÊN TẮC CỐT LÕI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### ✅ PHẢI LÀM:
- **Mở đầu bằng 1 câu dẫn ngắn** phù hợp ngữ cảnh câu hỏi → tạo cảm giác được tư vấn thật sự
VD: "Để đi biển mùa hè, CANIFA có mấy mẫu này khá ổn nè bạn 🌊"
VD: "Mình tìm được vài mẫu áo polo nam phù hợp, bạn xem thử nhé!"
- **Trình bày sản phẩm đúng format** (xem bên dưới)
- **Kết thúc bằng follow-up tự nhiên** — gợi ý lọc thêm hoặc hỏi nhu cầu thêm
- Dùng **emoji** nhẹ nhàng, không lạm dụng (1-2 cái là đủ)
- Tiếng Việt, tự nhiên như nhắn tin
### ❌ KHÔNG ĐƯỢC:
- Tự đánh giá sản phẩm "không phù hợp", "không chuyên dụng", rồi giấu kết quả
- Nói "tiếc, chưa có" khi tool ĐÃ TRẢ VỀ sản phẩm
- Bịa thêm thông tin không có trong data (màu, giá, tính năng)
- Từ chối hiển thị kết quả dựa trên suy đoán cá nhân
- Liệt kê quá 6 sản phẩm trong 1 lần (chọn highlight những cái đặc sắc nhất)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## FORMAT SẢN PHẨM (BẮT BUỘC)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Mỗi sản phẩm trình bày như sau:
**[Tên sản phẩm]** _(màu nếu có)_
💰 [giá bán]₫ ~~[giá gốc]~~ (-[% giảm]%)
🔗 [Xem & mua ngay](link)
---
Nếu không có giảm giá → chỉ hiện giá bán, bỏ qua dòng giảm.
Nếu không có link → bỏ qua dòng link.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## XỬ LÝ KẾT QUẢ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### Khi có sản phẩm (count > 0):
1. Câu dẫn ngắn liên quan đến nhu cầu khách
2. Highlight 3-6 sản phẩm đặc sắc nhất (nếu có nhiều → chọn cái đa dạng nhất)
3. Gợi ý follow-up: lọc thêm màu/size/giá, hoặc hỏi nhu cầu cụ thể hơn
Ví dụ follow-up tốt:
- "Bạn thích màu gì để mình lọc chính xác hơn? 😊"
- "Tầm giá bao nhiêu là phù hợp với bạn nhỉ?"
- "Muốn xem thêm mẫu khác không, mình tìm tiếp cho bạn nhé!"
- "Bạn cần size nào để mình check hàng còn không?"
### Khi KHÔNG có sản phẩm (count = 0):
- Thông cảm ngắn gọn, KHÔNG phân tích dài dòng
- Gợi ý tìm thay thế hoặc mở rộng tiêu chí
VD: "Hm, CANIFA chưa có đúng mẫu đó bạn ơi 😅 Mình thử tìm theo hướng khác nhé — bạn có thể bỏ bớt điều kiện nào không?"
### Khi kết quả sai gender/age rõ ràng:
- Thông báo nhẹ nhàng rằng filter chưa khớp
- Gợi ý tìm lại với gender đúng
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## VÍ DỤ TONE & STYLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
❌ Robot:
"Dưới đây là danh sách sản phẩm phù hợp với yêu cầu của bạn:"
✅ Thân thiện:
"Đây bạn! Mình tìm được mấy mẫu áo sơ mi đi làm khá oke 👔"
❌ Robot:
"Sản phẩm 1: Áo Sơ mi Nam. Giá: 399,000. Link: ..."
✅ Thân thiện:
**Áo Sơ mi Nam Kẻ Ngang** _(Màu xanh navy)_
💰 399.000₫ ~~499.000₫~~ (-20%)
🔗 [Xem & mua ngay](link)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## LƯU Ý CUỐI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- KHÔNG tự bịa thêm tính năng, chất liệu, hay thông tin không có trong data tool trả về
- Data từ tool là nguồn duy nhất — chỉ dùng những gì tool cung cấp
- Tối đa 6 sản phẩm/lần, ưu tiên sản phẩm có ảnh + link đầy đủ
"""
This diff is collapsed.
......@@ -8,10 +8,14 @@ import os
import platform
import sys
import time
import json
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common.pool_wrapper import get_pooled_connection_compat
from config import (
AI_SUPABASE_URL,
......@@ -45,6 +49,85 @@ _SERVER_START_TIME = time.time()
APP_VERSION = "2.5.0"
APP_CODENAME = "Canifa AI Stylist"
def _ensure_links_table():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS dashboard_links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
category TEXT,
icon TEXT,
pinned INTEGER DEFAULT 0
)
""")
except Exception as e:
logger.error(f"Error creating dashboard_links table: {e}")
_ensure_links_table()
DEFAULT_TOOL_LINKS = [
{"id": "tool_stress_test", "title": "Stress Test Dashboard", "url": "/static/stress-test.html",
"description": "Bắn N request đồng thời — đo RPS, latency p95, error rate", "category": "tool", "icon": "🔥"},
{"id": "tool_regression_test", "title": "Regression Test", "url": "/static/regression-test.html",
"description": "Chạy bộ test cases qua bất kỳ chatbot URL — con người đọc kết quả", "category": "tool", "icon": "🧪"},
{"id": "tool_user_simulator", "title": "User Simulator", "url": "/static/user-simulator.html",
"description": "AI đóng vai persona test chatbot — đánh giá từ góc user thật", "category": "tool", "icon": "🎭"},
{"id": "tool_prompt_optimizer", "title": "Prompt Optimizer", "url": "/static/prompt-optimizer.html",
"description": "AI Judge chấm điểm chatbot + tự động gợi ý cải thiện prompt", "category": "tool", "icon": "⚡"},
{"id": "tool_competitor_research", "title": "Competitor Research", "url": "/static/competitor-research.html",
"description": "Nghiên cứu đối thủ — phân tích Zalando, YaMe, H&M", "category": "doc", "icon": "🔍"},
]
def _seed_default_links():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id FROM dashboard_links")
existing_ids = {r["id"] for r in cur.fetchall()}
for link in DEFAULT_TOOL_LINKS:
if link["id"] not in existing_ids:
cur.execute("""
INSERT INTO dashboard_links (id, title, url, description, category, icon, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (link["id"], link["title"], link["url"], link["description"], link["category"], link["icon"], 0))
except Exception as e:
logger.error(f"Error seeding dashboard_links: {e}")
def _load_links():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id, title, url, description, category, icon, pinned FROM dashboard_links")
rows = cur.fetchall()
return [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"description": r["description"] or "",
"category": r["category"] or "tool",
"icon": r["icon"] or "",
"pinned": bool(r["pinned"])
} for r in rows
]
except Exception as e:
logger.error(f"Error loading dashboard_links: {e}")
return []
class LinkItem(BaseModel):
title: str
url: str
description: str = ""
category: str = "tool"
icon: str = ""
pinned: bool = False
def _mask_key(key: str | None) -> str:
"""Mask API key for safe display."""
......@@ -181,3 +264,87 @@ async def dashboard_info():
"routes": routes,
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
}
# =====================================================================
# Dashboard Links API
# =====================================================================
@router.get("/api/dashboard/links", summary="Get all dashboard links")
async def get_dashboard_links():
_seed_default_links()
links = _load_links()
return {"links": links}
@router.post("/api/dashboard/links", summary="Create a new link")
async def create_dashboard_link(item: LinkItem):
link_id = str(uuid.uuid4())
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
INSERT INTO dashboard_links (id, title, url, description, category, icon, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (link_id, item.title, item.url, item.description, item.category, item.icon, 1 if item.pinned else 0))
except Exception as e:
logger.error(f"Error creating dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
new_link = item.dict()
new_link["id"] = link_id
return {"status": "success", "link": new_link}
@router.put("/api/dashboard/links/{link_id}", summary="Update a link")
async def update_dashboard_link(link_id: str, item: LinkItem):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
UPDATE dashboard_links
SET title = ?, url = ?, description = ?, category = ?, icon = ?, pinned = ?
WHERE id = ?
""", (item.title, item.url, item.description, item.category, item.icon, 1 if item.pinned else 0, link_id))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Link not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
updated_link = item.dict()
updated_link["id"] = link_id
return {"status": "success", "link": updated_link}
@router.delete("/api/dashboard/links/{link_id}", summary="Delete a link")
async def delete_dashboard_link(link_id: str):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("DELETE FROM dashboard_links WHERE id = ?", (link_id,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Link not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
return {"status": "success"}
@router.patch("/api/dashboard/links/{link_id}/pin", summary="Toggle pin status")
async def toggle_pin_dashboard_link(link_id: str):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT pinned FROM dashboard_links WHERE id = ?", (link_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Link not found")
new_pin = 0 if row["pinned"] else 1
cur.execute("UPDATE dashboard_links SET pinned = ? WHERE id = ?", (new_pin, link_id))
return {"status": "success", "pinned": bool(new_pin)}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error toggling pin on dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
This diff is collapsed.
This diff is collapsed.
......@@ -9,8 +9,6 @@ from api.common.feedback_route import router as feedback_router
from api.feedback_agent.feedback_agent_route import router as feedback_agent_router
from api.diagram_agent.ai_diagram_route import router as diagram_router
from api.sku_search.ai_answer_sku import router as sku_search_router
from api.tag_search.ai_tag_search import router as tag_search_router
# History & Auth
from api.history.check_history_route import router as check_history_router
from api.history.conservation_route import router as conservation_router
......@@ -38,6 +36,7 @@ from api.api_sql.user_insight_route import router as user_insight_router
# Dashboards & Monitoring
from api.common.dashboard_route import router as dashboard_router
from api.common.faq_route import router as faq_router
from api.cache.cache_route import router as cache_router
from api.live_monitor.live_monitor_route import router as live_monitor_router
from api.ai_report.report_html_route import router as report_html_router
......@@ -61,6 +60,7 @@ from api.regression_test.regression_test_route import router as regression_test_
from api.stress_test.stress_test_route import router as stress_test_router
from api.reaction_simulator.reaction_simulator_route import router as reaction_simulator_router
from api.lead_flow.lead_flow_route import router as lead_flow_router
from api.lead_flow.simulate_route import router as lead_flow_simulate_router
from api.limit.limit_route import router as limit_router
# -------------------------------------------------------------------------
......@@ -77,8 +77,6 @@ api_router.include_router(feedback_router)
api_router.include_router(feedback_agent_router)
api_router.include_router(diagram_router)
api_router.include_router(sku_search_router)
api_router.include_router(tag_search_router)
# History & Auth
api_router.include_router(check_history_router)
api_router.include_router(conservation_router)
......@@ -106,6 +104,7 @@ api_router.include_router(user_insight_router)
# Dashboards & Monitoring
api_router.include_router(dashboard_router)
api_router.include_router(faq_router)
api_router.include_router(cache_router)
api_router.include_router(live_monitor_router)
api_router.include_router(report_html_router)
......@@ -129,4 +128,5 @@ api_router.include_router(regression_test_router)
api_router.include_router(stress_test_router)
api_router.include_router(reaction_simulator_router)
api_router.include_router(lead_flow_router)
api_router.include_router(lead_flow_simulate_router)
api_router.include_router(limit_router)
......@@ -27,25 +27,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/product-desc", tags=["n8n Ultra Description"])
# ── Groq config (reuse tu agent) ──
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_idx = 0
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
WRITER_MODEL = "openai/gpt-oss-120b"
# ── FreeLLMAPI config ──
FREE_LLM_API_KEY = "freellmapi-a858c30561987b8aa2a58343c7fe48a6a52e65cadb9df6d5"
def _next_key():
global _groq_idx
key = GROQ_API_KEYS[_groq_idx % len(GROQ_API_KEYS)]
_groq_idx += 1
return key
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
WRITER_MODEL = "openai/gpt-oss-20b"
# ── Helpers ──
......@@ -83,7 +69,7 @@ def _extract_json(raw) -> dict | None:
async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
temperature: float = 0.7, is_vision: bool = False) -> str:
"""Round-robin Groq call voi fallback chain."""
"""FreeLLMAPI call voi fallback chain."""
fallbacks = [model]
if not is_vision:
if model != "openai/gpt-oss-120b":
......@@ -92,13 +78,12 @@ async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
fallbacks.append("openai/gpt-oss-20b")
for m in fallbacks:
for _ in range(len(GROQ_API_KEYS)):
key = _next_key()
for _ in range(2): # 2 retries
try:
async with httpx.AsyncClient(timeout=45) as c:
resp = await c.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {key}",
"http://172.16.2.210:3002/v1/chat/completions",
headers={"Authorization": f"Bearer {FREE_LLM_API_KEY}",
"Content-Type": "application/json"},
json={"model": m, "max_tokens": max_tokens,
"temperature": temperature, "messages": messages},
......@@ -108,15 +93,16 @@ async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
if "error" not in data:
return data["choices"][0]["message"]["content"]
elif resp.status_code == 429:
continue # rotate key
await asyncio.sleep(1)
continue # retry
else:
break # loi khac -> thu model tiep
except Exception as e:
logger.warning(f"Groq error ({m}): {e}")
logger.warning(f"FreeLLMAPI error ({m}): {e}")
break
await asyncio.sleep(2)
raise RuntimeError("All Groq models/keys exhausted")
raise RuntimeError("All FreeLLMAPI models/retries exhausted")
# ═══════════════════════════════════════════════════════════════
......
......@@ -513,7 +513,11 @@ async def product_desc_list(
p["desc_status"] = code_status_map[magento_code] # 0 = pending, 1 = approved
row = UltraDescriptionDB.get_by_magento_code(magento_code)
if row:
p["tags"] = row.get("tags") or []
tags_raw = row.get("tags") or []
if isinstance(tags_raw, str):
try: tags_raw = json.loads(tags_raw)
except: tags_raw = []
p["tags"] = tags_raw
dd = row.get("description_data", {})
if isinstance(dd, str):
try: dd = json.loads(dd)
......
This diff is collapsed.
import unicodedata
import difflib
from typing import List, Dict
def normalize_text(text: str) -> str:
"""Loại bỏ dấu tiếng Việt, chuyển chữ thường, bỏ ký tự đặc biệt."""
if not text:
return ""
text = str(text).lower()
# Remove accents
text = unicodedata.normalize('NFD', text).encode('ascii', 'ignore').decode('utf-8')
# Keep only alphanumeric and space
text = "".join(c for c in text if c.isalnum() or c.isspace())
return " ".join(text.split())
def calculate_similarity(query: str, document: str) -> float:
"""Tính điểm tương đồng kết hợp Keyword Overlap (70%) và Fuzzy Match (30%)."""
norm_q = normalize_text(query)
norm_d = normalize_text(document)
if not norm_q or not norm_d:
return 0.0
# 1. Fuzzy Match (bù trừ sai chính tả dính liền, gõ tắt)
fuzzy_ratio = difflib.SequenceMatcher(None, norm_q, norm_d).ratio()
# 2. Word Overlap
q_words = set(norm_q.split())
d_words = set(norm_d.split())
if not q_words:
return 0.0
intersection = q_words.intersection(d_words)
overlap_score = len(intersection) / len(q_words) # % từ của query xuất hiện trong document
# Tính điểm
final_score = (overlap_score * 0.7) + (fuzzy_ratio * 0.3)
return round(final_score * 100, 2)
def find_best_matches(query: str, faq_list: List[Dict], top_k: int = 3) -> List[Dict]:
"""Tìm Top K FAQ khớp nhất. faq_list = [{"id": ..., "question": ..., "answer": ...}, ...]"""
results = []
for faq in faq_list:
q_text = faq.get("question", "")
if not q_text:
continue
score = calculate_similarity(query, q_text)
# Nếu điểm > 0, đưa vào ds
if score > 5.0: # Ngưỡng thấp để lọc
results.append({
"id": faq.get("id"),
"question": q_text,
"answer": faq.get("answer", ""),
"category": faq.get("category", ""),
"is_variant": faq.get("is_variant", 0),
"score": score
})
# Sort giảm dần theo điểm
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_k]
......@@ -36,7 +36,7 @@ class UltraDescriptionDB:
cur = conn.cursor()
if USE_LOCAL_SQLITE:
# SQLite: Create full table at once with all columns
# SQLite: execute each statement separately (SQLite only allows one at a time)
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
......@@ -54,11 +54,11 @@ class UltraDescriptionDB:
ai_matches TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ud_internal ON {TABLE} (internal_ref_code);
CREATE INDEX IF NOT EXISTS idx_ud_magento ON {TABLE} (magento_ref_code);
CREATE INDEX IF NOT EXISTS idx_ud_base ON {TABLE} (base_ref_code);
)
""")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_internal ON {TABLE} (internal_ref_code)")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_magento ON {TABLE} (magento_ref_code)")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_base ON {TABLE} (base_ref_code)")
else:
# PostgreSQL: Original logic
cur.execute(f"""
......@@ -403,7 +403,7 @@ class UltraDescriptionDB:
COUNT(*) AS total,
COUNT(CASE WHEN status = 1 THEN 1 END) AS approved,
COUNT(CASE WHEN status = 0 THEN 1 END) AS pending,
COUNT(CASE WHEN clean_description IS NOT NULL AND BTRIM(clean_description) != '' THEN 1 END) AS has_clean_desc,
COUNT(CASE WHEN clean_description IS NOT NULL AND TRIM(clean_description) != '' THEN 1 END) AS has_clean_desc,
COUNT(CASE WHEN phase = 'enriched' THEN 1 END) AS enriched,
COUNT(CASE WHEN phase = 'raw' THEN 1 END) AS raw_only,
MIN(created_at) AS first_created,
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Subproject commit a1afa9d892df6239b4f5614d87afe5bbbef2d762
import os
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
def create_doc():
doc = Document()
# Title
title = doc.add_heading('Canifa AI Stylist - Lead Search Tool Architecture', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.add_paragraph('Bản vẽ chi tiết luồng hoạt động & Kiến trúc tối ưu Token (Cập nhật T4/2026)', style='Subtitle')
# Section 1
doc.add_heading('1. Tổng quan (Overview)', level=1)
doc.add_paragraph('Lead Search Tool là trung tâm truy xuất dữ liệu sản phẩm của Canifa AI Stylist. Sau đợt tái cấu trúc, hệ thống đã loại bỏ hoàn toàn OpenAI Embedding, thay thế bằng cơ chế Cascading Search 7 tầng (NGRAMBF + BITMAP Tags), mang lại tốc độ truy vấn < 300ms. Đồng thời, cấu trúc dữ liệu trả về được tối ưu hóa để giảm thiểu Token tiêu thụ cho LLM.')
# Section 2
doc.add_heading('2. Kiến trúc Tối ưu Token (Token-Saving Architecture)', level=1)
p = doc.add_paragraph()
p.add_run('Thay vì trả về 15-20 sản phẩm chi tiết (gây tràn Context Window), hệ thống hiện tại sử dụng chiến lược: ').bold = True
p.add_run('"Lấy ít sản phẩm chính, móc nhiều sản phẩm gợi ý".')
doc.add_paragraph('• Main Products (Top 3): Trả về tối đa 3 sản phẩm match nhất với đầy đủ thông tin chi tiết (Size, Giá, Mô tả ngắn <200 ký tự, Tồn kho thực tế, Lý do phối đồ AI).', style='List Bullet')
doc.add_paragraph('• Suggest Items (Phối cùng): Cho mỗi sản phẩm chính, lấy thêm tối đa 2 sản phẩm phối cùng (chỉ gồm SKU, Tên, Giá).', style='List Bullet')
doc.add_paragraph('• Similar Items (Tương tự): Cho mỗi sản phẩm chính, lấy thêm tối đa 3 sản phẩm thay thế (chỉ gồm SKU, Tên, Giá).', style='List Bullet')
doc.add_paragraph('Kết quả: LLM vẫn có 3 lựa chọn chính và 15 lựa chọn phụ để tư vấn, nhưng số lượng Token giảm xuống 75%.')
# Section 3
doc.add_heading('3. Luồng dữ liệu (Data Flow)', level=1)
flow_steps = [
("Bước 1: Phân tích Ý định (Input parsing)", "Nhận các tham số từ Agent: keywords, tags, color, size, price_min, price_max."),
("Bước 2: Cascading Search (7 Tiers)", "Truy xuất StarRocks qua 7 tầng rớt mạng. Tầng 1 (Keywords + Hard Filters), Tầng 2 (Tags + Filters), Tầng 5-6 (Nới giá 1.5x - 2.0x)."),
("Bước 3: Lọc Tồn Kho (Stock API)", "Gửi các mã sản phẩm sang Canifa Stock API qua httpx. Chỉ giữ lại những mã đang CÒN HÀNG, kèm theo danh sách chi tiết các size còn trống."),
("Bước 4: Bổ sung Styling (SQLite 123.db)", "Đọc file SQLite local để lấy `ai_description` và `ai_matches` (lý do phối đồ), cung cấp 'bộ não' thời trang cho LLM chốt sale."),
("Bước 5: Định dạng Đầu ra (Output Formatter)", "Gom nhóm dữ liệu, cắt ngắn mô tả, fetch suggest/similar items và trả về JSON nhẹ nhất cho AI.")
]
for step, desc in flow_steps:
p = doc.add_paragraph(style='List Number')
p.add_run(step + ": ").bold = True
p.add_run(desc)
# Section 4
doc.add_heading('4. Đầu ra của hệ thống (JSON Format)', level=1)
doc.add_paragraph('Dưới đây là cấu trúc minh hoạ cho 1 sản phẩm chính sau khi được format gọn nhẹ:')
json_example = """{
"sku": "8TP24S001-SB060",
"name": "Áo polo nam phom regular",
"price": 349000,
"in_stock": true,
"stock": [{"size": "M", "qty": 10}, {"size": "L", "qty": 14}],
"ai_matches": {
"phoi_voi": ["Quần khaki nam"],
"ly_do": "Màu áo hợp quần khaki tạo vẻ thanh lịch..."
},
"suggest_items": [
{"sku": "8QK24S002", "name": "Quần khaki nam", "price": 450000}
],
"similar_items": [
{"sku": "8TP24S003", "name": "Áo polo sọc", "price": 399000}
]
}"""
code_p = doc.add_paragraph(json_example)
code_p.style.font.name = 'Courier New'
code_p.style.font.size = Pt(9)
# Save document
os.makedirs(r"D:\cnf\chatbot-canifa-feedback\backend\static\lead_flow", exist_ok=True)
out_path = r"D:\cnf\chatbot-canifa-feedback\backend\static\lead_flow\lead_search_logic.docx"
doc.save(out_path)
print(f"Created docx at {out_path}")
if __name__ == "__main__":
create_doc()
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Subproject commit 44af4fbec40276b94e54a275162e2ae803e5e13c
This diff is collapsed.
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