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)
......
"""
Tag Search API — FastAPI route cho tìm kiếm sản phẩm bằng AI tags.
Gọi TagSearchGraph 2-Agent (Planner ⇄ Tool → Responder) qua Controller.
Controller có Langfuse tracing + timing breakdown.
History lưu Redis, tự expire sau 30 phút.
"""
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
from langchain_core.messages import AIMessage, HumanMessage
# ── Controller v2 (có Langfuse tracing + timing) ──
from agent.tag_search_agent_v2.controller import tag_search_chat_controller
from common.cache import redis_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tag-search", tags=["Tag Search"])
HISTORY_KEY_PREFIX = "tagsearch:hist:"
HISTORY_TTL = 1800 # 30 phút tự chết
class TagChatRequest(BaseModel):
query: str
session_id: str | None = None
# ─── Helpers: serialize/deserialize LangChain messages ↔ Redis ───
def _serialize_messages(messages: list) -> str:
"""LangChain messages → JSON string cho Redis."""
data = []
for msg in messages:
content = msg.content if isinstance(msg.content, str) else str(msg.content)
if isinstance(msg, HumanMessage):
data.append({"role": "human", "content": content})
elif isinstance(msg, AIMessage):
data.append({"role": "ai", "content": content})
return json.dumps(data, ensure_ascii=False)
def _deserialize_messages(raw: str) -> list:
"""JSON string từ Redis → LangChain messages."""
try:
data = json.loads(raw)
except Exception:
return []
messages = []
for item in data:
if item["role"] == "human":
messages.append(HumanMessage(content=item["content"]))
elif item["role"] == "ai":
messages.append(AIMessage(content=item["content"]))
return messages
async def _load_history(session_id: str) -> list:
"""Load history từ Redis. Trả [] nếu không có hoặc Redis tắt."""
try:
client = redis_cache.get_client()
if not client:
return []
raw = await client.get(f"{HISTORY_KEY_PREFIX}{session_id}")
if raw:
msgs = _deserialize_messages(raw)
logger.debug("📜 Loaded %d history messages for session %s", len(msgs), session_id[:8])
return msgs
except Exception as e:
logger.warning("Redis load history error: %s", e)
return []
async def _save_history(session_id: str, messages: list):
"""Save history vào Redis với TTL. Tự chết sau HISTORY_TTL."""
try:
client = redis_cache.get_client()
if not client:
return
# Giới hạn 20 cặp cuối (40 messages) để không phình Redis
trimmed = messages[-40:]
raw = _serialize_messages(trimmed)
await client.setex(f"{HISTORY_KEY_PREFIX}{session_id}", HISTORY_TTL, raw)
logger.debug("💾 Saved %d messages for session %s (TTL=%ds)", len(trimmed), session_id[:8], HISTORY_TTL)
except Exception as e:
logger.warning("Redis save history error: %s", e)
async def _clear_history(session_id: str):
"""Xoá history khỏi Redis."""
try:
client = redis_cache.get_client()
if client:
await client.delete(f"{HISTORY_KEY_PREFIX}{session_id}")
logger.info("🗑️ Cleared history for session %s", session_id[:8])
except Exception as e:
logger.warning("Redis clear history error: %s", e)
# ─── Endpoints ───
@router.post("/chat", summary="Chat with Tag Search Agent")
async def tag_chat(req: TagChatRequest, background_tasks: BackgroundTasks):
"""
Gửi câu hỏi cho Tag Search Agent.
Nếu có session_id, history được load/save tự động từ Redis.
"""
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query trống"})
session_id = req.session_id
try:
# Load history từ Redis (nếu có session)
history = []
if session_id:
history = await _load_history(session_id)
# Delegate to controller — có Langfuse tracing + timing
result = await tag_search_chat_controller(
query=query,
session_id=session_id,
background_tasks=background_tasks,
history=history if history else None,
)
if result.get("status") == "error":
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": result.get("message", "Unknown error"),
"trace_id": result.get("trace_id"),
}
)
# Save history to Redis (append user + AI)
if session_id and result.get("response"):
history.append(HumanMessage(content=query))
history.append(AIMessage(content=result["response"]))
await _save_history(session_id, history)
return {
"status": "success",
"response": result["response"],
"products": result.get("products", []),
"product_ids": result.get("product_ids", []),
"product_count": result.get("product_count", 0),
"pipeline": result.get("pipeline", []),
"agent_path": result.get("agent_path", ""),
"tool_calls": result.get("tool_calls", []),
"trace_id": result.get("trace_id"),
"timing": result.get("timing", {}),
"session_id": session_id,
"history_count": len(history),
}
except Exception as e:
logger.error(f"❌ Tag Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/clear", summary="Clear chat history")
async def tag_clear(req: TagChatRequest):
"""Xoá history của session."""
if req.session_id:
await _clear_history(req.session_id)
return {"status": "success", "message": "History cleared"}
@router.get("/history", summary="Get chat history")
async def tag_history(session_id: str):
"""Load history từ Redis theo session_id cho frontend (khi f5 reload lại)."""
if not session_id:
return {"status": "success", "history": []}
history_objs = await _load_history(session_id)
formatted = []
for msg in history_objs:
if isinstance(msg, HumanMessage):
formatted.append({"role": "user", "content": msg.content})
elif isinstance(msg, AIMessage):
formatted.append({"role": "ai", "content": msg.content})
return {"status": "success", "history": formatted, "count": len(formatted)}
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 a1afa9d892df6239b4f5614d87afe5bbbef2d762
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