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

feat: Add Lead Stage logic and add_to_cart

parent 98f3bdf5
...@@ -442,7 +442,7 @@ def prepare_execution_context(query: str, user_id: str, history: list, images: l ...@@ -442,7 +442,7 @@ def prepare_execution_context(query: str, user_id: str, history: list, images: l
async def handle_post_chat_async( async def handle_post_chat_async(
memory: ConversationManager, identity_key: str, human_query: str, ai_response: dict | None memory: ConversationManager, identity_key: str, human_query: str, ai_response: dict | None, conversation_id: str | None = None
): ):
""" """
Save chat history in background task after response is sent. Save chat history in background task after response is sent.
...@@ -453,7 +453,7 @@ async def handle_post_chat_async( ...@@ -453,7 +453,7 @@ async def handle_post_chat_async(
# Convert dict thành JSON string để lưu vào TEXT field # Convert dict thành JSON string để lưu vào TEXT field
# Use decimal_default to handle Decimal types from DB # Use decimal_default to handle Decimal types from DB
ai_response_json = json.dumps(ai_response, ensure_ascii=False, default=decimal_default) ai_response_json = json.dumps(ai_response, ensure_ascii=False, default=decimal_default)
await memory.save_conversation_turn(identity_key, human_query, ai_response_json) await memory.save_conversation_turn(identity_key, human_query, ai_response_json, conversation_id=conversation_id)
logger.debug(f"Saved conversation for identity_key {identity_key}") logger.debug(f"Saved conversation for identity_key {identity_key} (conv: {conversation_id})")
except Exception as e: except Exception as e:
logger.error(f"Failed to save conversation for identity_key {identity_key}: {e}", exc_info=True) logger.error(f"Failed to save conversation for identity_key {identity_key}: {e}", exc_info=True)
"""
Module entry for Image Search Agent
"""
from .image_search_graph import get_image_search_agent, ImageSearchGraph
__all__ = ["get_image_search_agent", "ImageSearchGraph"]
"""
Image Search Graph — 2-Agent LangGraph Architecture cho Image Search.
Hỗ trợ Multi-modal vision.
"""
import logging
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
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 .prompts import VISION_PLANNER_PROMPT, VISION_RESPONDER_PROMPT
logger = logging.getLogger(__name__)
def _extract_text(content) -> str:
"""Extract plain text from msg.content — handles both str and Gemini list format."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
# ═══════════════════════════════════════════════
# State
# ═══════════════════════════════════════════════
class ImageSearchState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture
# ═══════════════════════════════════════════════
class ImageSearchGraph:
"""2-Agent LangGraph: Vision Planner → Tools → Responder."""
def __init__(self, model_name: str | None = None):
self.model_name = model_name or DEFAULT_MODEL
self.tools = [canifa_tag_search]
# 2 con LLM riêng biệt
self.planner_llm = create_llm(model_name=self.model_name, streaming=True)
self.planner_with_tools = self.planner_llm.bind_tools(self.tools)
self.responder_llm = create_llm(model_name=self.model_name, streaming=True)
self._compiled = None
logger.info(f"✅ ImageSearchGraph 2-Agent initialized with model: {self.model_name}")
async def _planner_node_async(self, state: ImageSearchState) -> dict:
messages = state["messages"]
planner_messages = [SystemMessage(content=VISION_PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
response = await self.planner_with_tools.ainvoke(planner_messages)
return {"messages": [response]}
async def _responder_node_async(self, state: ImageSearchState) -> dict:
messages = state["messages"]
responder_messages = [SystemMessage(content=VISION_RESPONDER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
responder_messages.append(msg)
response = await self.responder_llm.ainvoke(responder_messages)
return {"messages": [response]}
def _after_planner(self, state: ImageSearchState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
def build(self):
if self._compiled:
return self._compiled
workflow = StateGraph(ImageSearchState)
workflow.add_node("planner", self._planner_node_async)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("responder", self._responder_node_async)
workflow.set_entry_point("planner")
workflow.add_conditional_edges(
"planner",
self._after_planner,
{"tools": "tools", "end": END},
)
workflow.add_edge("tools", "responder")
workflow.add_edge("responder", END)
self._compiled = workflow.compile()
logger.info("✅ ImageSearchGraph 2-Agent compiled")
return self._compiled
async def chat(self, user_message: str, images: list[str] | None = None, history: list[BaseMessage] | None = None) -> dict:
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
# Xử lý multimodal (ảnh + text)
if images and len(images) > 0:
text_content = user_message + "\n\n[📸 Xin hãy mô tả rõ ràng, cụ thể các đặc điểm của trang phục trong ảnh để tìm kiếm món đồ tương tự.]"
multimodal_content = [{"type": "text", "text": text_content}]
for img in images:
image_url = img if img.startswith("data:") or img.startswith("http") else f"data:image/jpeg;base64,{img}"
multimodal_content.append({
"type": "image_url",
"image_url": {"url": image_url, "detail": "auto"}
})
messages.append(HumanMessage(content=multimodal_content))
logger.info(f"📸 [IMAGE SEARCH] Injected {len(images)} image(s)")
else:
messages.append(HumanMessage(content=user_message))
result = await graph.ainvoke({"messages": messages})
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
ai_response = ""
tool_calls_log: list[dict[str, Any]] = []
has_tool_calls = False
pipeline: list[dict[str, Any]] = []
for msg in all_messages:
content_str = _extract_text(msg.content)
if isinstance(msg, HumanMessage):
# Ẩn bớt base64 dài dòng trong log
if isinstance(msg.content, list):
clean_content = []
for it in msg.content:
if it.get("type") == "image_url":
clean_content.append("[📸 Bức ảnh đã đính kèm]")
elif it.get("type") == "text":
clean_content.append(it.get("text", ""))
content_str = "\n".join(clean_content)
pipeline.append({
"step": "user",
"label": "👤 User",
"content": content_str,
})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
has_tool_calls = True
tool_calls_log.extend(msg.tool_calls)
pipeline.append({
"step": "planner",
"label": "🧠 Vision Planner",
"content": content_str if content_str and content_str != "[]" else "(phân tích ảnh & gọi tool...)",
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in msg.tool_calls
],
})
elif msg.content:
ai_response = content_str
pipeline.append({
"step": "responder",
"label": "💬 Responder AI",
"content": content_str[:500] + ("..." if len(content_str) > 500 else ""),
})
else:
tool_content = content_str
summary = tool_content[:600]
pipeline.append({
"step": "tool_result",
"label": f"🛠️ Tool ({msg.name})",
"content": summary,
})
end_idx = "responder" if has_tool_calls else "planner"
path_str = f"planner→tools→responder" if has_tool_calls else "planner"
logger.info(f"🖼️ [IMAGE SEARCH] query='{user_message[:50]}' | path={path_str} | tools={len(tool_calls_log)} | time={elapsed_ms}ms")
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"agent_path": path_str,
"tool_calls": tool_calls_log,
"pipeline": pipeline
}
# --- Lazy Init ---
_agent_instance = None
def get_image_search_agent() -> ImageSearchGraph:
global _agent_instance
if _agent_instance is None:
_agent_instance = ImageSearchGraph()
return _agent_instance
"""
__init__.py for Image Agent Prompts
"""
from .vision_planner_prompt import VISION_PLANNER_PROMPT
from .vision_responder_prompt import VISION_RESPONDER_PROMPT
__all__ = ["VISION_PLANNER_PROMPT", "VISION_RESPONDER_PROMPT"]
"""
Vision Planner Prompt — Image Search Agent.
AI VISION PLANNER phân tích ảnh → gọi tool tìm sản phẩm (data_retrieval_tool).
"""
VISION_PLANNER_PROMPT = """Bạn là AI CHUYÊN GIA THỜI TRANG (VISION PLANNER) của CANIFA.
Nhiệm vụ: Phân tích hình ảnh khách hàng gửi (và văn bản nếy có), nhận diện quần áo / phụ kiện → Gọi tool `canifa_tag_search` để tìm đồ tương tự trong kho.
## LƯU Ý KHI ĐỌC ẢNH
- Quan sát kỹ ảnh khách gửi. Bóc tách các yếu tố thời trang của nhân vật trong ảnh:
+ Dòng sản phẩm (product_line_vn): Áo phông, Áo khoác, Váy liền, Quần jean, Sơ mi...
+ Kiểu dáng / Họa tiết (keywords): form rộng, dáng ôm, kẻ sọc, họa tiết hoa, dáng dài...
+ Màu sắc (master_color): Phân biệt rõ đỏ, xanh, đen, trắng, vàng, hồng...
+ Giới tính/Độ tuổi (gender_by_product / age_by_product): women, men, boy, girl, unisex.
+ Phong cách (tags): đi chơi, đi học, đi biển, đi làm, thể thao, giữ ấm...
## HÀNH ĐỘNG
1. Dựa trên những gì bóc tách được từ ảnh, truyền vào các tham số của tool `canifa_tag_search`.
2. NẾU KHÁCH KHÔNG CHỈ ĐỊNH MÓN NÀO TRONG ẢNH CÓ NHIỀU MÓN: Hãy chọn món đồ thời trang nổi bật nhất (ví dụ chiếc áo hoặc chiếc quần) để ưu tiên tìm kiếm.
3. TUYỆT ĐỐI GỌI TOOL `canifa_tag_search`:
- `product_line_vn`: Truyền thành mảng (VD: ["Áo phông", "Áo thun"]) - Bắt buộc nếu nhận diện được.
- `keywords`: Truyền các chi tiết thiết kế đặc biệt (VD: ["cổ bẻ", "trơn", "form rộng"]).
- `tags`: Dựa vào phong cách của đồ, đoán hoàn cảnh sử dụng (VD: ["đi biển", "đi chơi"]).
- `master_color`: Màu sắc chính.
- `gender_by_product`: Giới tính người mặc trong ảnh.
## KHI NÀO KHÔNG GỌI TOOL:
- Nếu ảnh không có quần áo/phụ kiện (vd: ảnh ly trà sữa), nhẹ nhàng bảo khách "Ảnh bạn gửi không có sản phẩm thời trang. Bạn muốn tìm đồ gì cho hôm nay không ạ?"
- TUYỆT ĐỐI KHÔNG BỊA RA SẢN PHẨM KHÔNG CÓ THẬT. Gọi tool để xác nhận có hàng.
"""
"""
Vision Responder Prompt — Image Search Agent.
AI RESPONDER format kết quả từ tool -> câu chữ mượt mà.
"""
VISION_RESPONDER_PROMPT = """Bạn là Stylist AI của CANIFA.
Nhiệm vụ: Tổng hợp kết quả tìm kiếm sản phẩm từ Database (sau khi Vision Planner đã phân tích ảnh) thành câu trả lời cho khách hàng.
## NGUYÊN TẮC:
1. Đọc lại ảnh và text khách gửi, sau đó xem kết quả do công cụ trả về.
2. Trả lời thân thiện: "Dạ, dựa vào ảnh bạn gửi, mình thấy bạn đang muốn tìm kiểu dáng [Tên loại áo/quần/váy]. CANIFA hiện đang có các mẫu tương tự đây ạ:"
3. Ghi rõ Tên sản phẩm, giá bán, link, mô tả ngắn gọn tại sao nó lại giống mẫu trong ảnh (ví dụ "Tuy không có họa tiết y hệt nhưng form dáng và màu sắc rất giống" hoặc "Mẫu này y hệt style trong hình").
4. CẤM NÓI DỐI VỀ GIÁ VÀ LINK. Dùng nguyên xi kết quả từ tool.
Ví dụ: [Áo phông nam tay ngắn dáng suông](https://canifa.com/p/8ts24w010) - 199.000đ.
5. Nếu tool báo không có gì: Hãy gợi ý họ chọn mẫu khác tương tự có tại CANIFA, nói khéo "Tiếc quá hiện tại màu này / họa tiết này CANIFA chưa lên kệ... Nhưng bù lại mình có các mẫu thiết kế rất đẹp đây".
"""
"""Lead Stage Agent — AI #1 phân tích giai đoạn mua hàng của khách."""
This diff is collapsed.
This diff is collapsed.
"""
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 công sở", "áo đi làm", "sơ mi", "sơmi", "áo sơmi"],
"Áo Polo": ["áo polo", "áo cổ bẻ", "polo"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym", "áo cộc tay"],
"Áo nỉ có mũ": ["áo nỉ có mũ", "áo hoodie", "hoodie"],
"Áo nỉ": ["áo nỉ", "áo sweater", "sweater"],
"Áo mặc nhà": ["áo mặc nhà", "áo ngủ", "áo ở 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 gile len"],
"Áo len": ["áo len", "áo len dài tay"],
"Áo kiểu": ["áo kiểu", "áo điệu", "áo nữ tính"],
"Á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 sweater"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ", "áo khoác hoodie", "áo khoác nỉ"],
"Áo khoác lông vũ": ["áo khoác lông vũ", "áo phao lông vũ", "áo 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 gile", "gile"],
"Áo khoác dạ": ["áo khoác dạ", "áo dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn", "áo khoác croptop"],
"Áo khoác chống nắng": ["áo khoác chống nắng", "áo 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 phao"],
"Áo khoác": ["áo khoác", "áo ấm", "áo rét"],
"Áo giữ nhiệt": ["áo giữ nhiệt", "áo tản nhiệt", "áo heattech"],
"Áo bra active": ["áo bra active", "áo bra", "bra", "áo tập", "áo thể thao"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn", "áo ôm"],
"Á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", "váy công sở", "đầm công sở", "váy liền thân", "đầm suông"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài", "chân váy chữ a", "chân váy công sở", "váy ngắn"],
"Quần giả váy": ["quần giả váy", "quần váy", "skort"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short", "quần đùi nam", "quần đùi nữ"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger", "quần thể thao"],
"Quần mặc nhà": ["quần mặc nhà", "quần ngủ", "quần đùi 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 xì đù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 xì 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ì", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà", "quần legging mặc nhà"],
"Quần leggings": ["quần leggings", "leggings", "quần legging", "legging", "quần thun ôm"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây", "quần công sở", "quần đi làm", "quần âu nam", "quần âu nữ", "quần kaki"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean", "quần dzin"],
"Quần giữ nhiệt": ["quần giữ nhiệt", "quần heattech"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng", "quần ống suông", "quần lưng thun"],
"Quần culottes": ["quần culottes", "culottes", "quần lửng ống rộng"],
"Quần Body": ["quần body", "quần ôm"],
"Pyjama": ["pyjama", "pajama", "đồ pijama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "khăn to", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "khăn nhỏ", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện"],
"Khăn": ["khăn", "khăn len", "khăn quàng cổ", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "bao tay"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "mền"],
"Cardigan": ["cardigan", "áo khoác len", "áo cardigan"],
"Bộ thể thao": ["bộ thể thao", "đồ tập", "đồ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ", "set đồ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà", "đồ ở nhà", "bộ lanh"],
"Blazer": ["blazer", "áo vest", "vest"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
"Túi xách": ["túi xách", "túi"],
}
# ==============================================================================
# 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
"""
Prompts cho Lead Stage Agent.
AI #1 — Lightweight classifier:
Input: user_query + user_insight + history summary
Output: JSON { stage, stage_name, confidence, reasoning, tone_directive, behavioral_hints }
"""
LEAD_STAGE_CLASSIFIER_PROMPT = """\
Bạn là Lead Stage Classifier cho chatbot bán hàng CANIFA.
Nhiệm vụ DUY NHẤT: Phân tích tin nhắn khách hàng và xác định họ đang ở GIAI ĐOẠN MUA HÀNG nào.
## 5 GIAI ĐOẠN MUA HÀNG:
| Stage | Tên | Trigger Signals |
|-------|----------------|--------------------------------------------------------------------------|
| 1 | AWARENESS | Chào hỏi, "có gì mới?", "shop bán gì?", first-time visit |
| 2 | INTEREST | Hỏi cụ thể loại SP, cho info giới tính/tuổi, "tìm X cho Y" |
| 3 | CONSIDERATION | Hỏi chi tiết (chất liệu, size chart), so sánh 2+ SP, "mẫu nào tốt hơn?" |
| 4 | DECISION | "Lấy cái này", "chốt đơn", "mua luôn", "order", hỏi cách thanh toán |
| 5 | RETENTION | Hỏi đổi trả, tracking, mua thêm, quay lại sau thời gian |
## CÁCH PHÂN TÍCH:
1. Đọc tin nhắn HIỆN TẠI (quan trọng nhất)
2. Đọc USER_INSIGHT (nếu có) để hiểu context tích lũy
3. Đọc CHAT_HISTORY_SUMMARY (nếu có) để thấy hành trình
4. Quyết định stage dựa trên tổng hợp 3 nguồn trên
## QUY TẮC:
- Stage CÓ THỂ GIẢM (VD: khách đã xem SP nhưng quay lại hỏi "còn gì khác?" → stage 2)
- Stage CÓ THỂ NHẢY (VD: khách vào thẳng "mua cái áo polo size L" → stage 4)
- Khi KHÔNG CHẮC → chọn stage THẤP hơn (ít aggressive hơn)
- confidence < 0.5 → đặt stage = stage hiện tại hoặc thấp hơn
## OUTPUT — JSON DUY NHẤT, KHÔNG có text thêm:
{
"stage": <1-5>,
"stage_name": "<AWARENESS|INTEREST|CONSIDERATION|DECISION|RETENTION>",
"confidence": <0.0-1.0>,
"reasoning": "<Giải thích NGẮN GỌN tại sao chọn stage này, 1-2 câu>",
"tone_directive": "<Welcomer|Consultant|Expert Stylist|Closer|Personal Shopper>",
"behavioral_hints": [
"<Gợi ý hành vi cụ thể cho AI Stylist, tối đa 3 hints>"
]
}
"""
# Template inject vào system prompt của AI #2 (Stylist)
LEAD_STAGE_INJECTION_TEMPLATE = """\
══════ LEAD STAGE CONTEXT ══════
🎯 Giai đoạn khách hàng: Stage {stage} — {stage_name}
🎭 Tone yêu cầu: {tone_directive}
📊 Confidence: {confidence}
📋 Hướng dẫn hành vi:
{behavioral_hints_text}
⚠️ QUAN TRỌNG: Điều chỉnh phong cách tư vấn theo Stage trên.
- Stage 1 (AWARENESS): Thân thiện, relaxed, KHÔNG push bán. Hỏi nhẹ về nhu cầu.
- Stage 2 (INTEREST): Bắt đầu thu thập thông tin (size, budget, occasion). Show 2-3 SP phù hợp.
- Stage 3 (CONSIDERATION): So sánh chuyên sâu, phân tích ưu/nhược. Đưa khuyến nghị rõ ràng.
- Stage 4 (DECISION): Confirm đơn hàng nhanh gọn. Upsell tự nhiên. Tạo urgency nhẹ.
- Stage 5 (RETENTION): Chăm sóc hậu mãi. Gợi ý SP mới dựa trên lịch sử.
══════════════════════════════════
"""
def format_stage_injection(stage_result: dict) -> str:
"""Format lead stage result thành đoạn text inject vào system prompt của AI #2."""
hints = stage_result.get("behavioral_hints", [])
hints_text = "\n".join(f" • {h}" for h in hints) if hints else " • Không có gợi ý cụ thể."
return LEAD_STAGE_INJECTION_TEMPLATE.format(
stage=stage_result.get("stage", "?"),
stage_name=stage_result.get("stage_name", "UNKNOWN"),
tone_directive=stage_result.get("tone_directive", "Consultant"),
confidence=stage_result.get("confidence", 0),
behavioral_hints_text=hints_text,
)
"""
SKU Search Agent — AI-powered product lookup by SKU/product code.
Uses StarRocks product_dimension table for instant SKU matching.
"""
This diff is collapsed.
This diff is collapsed.
"""
Planner/Responder prompts for Store Search Agent.
"""
from .planner_prompt import PLANNER_PROMPT
from .responder_prompt import RESPONDER_PROMPT
__all__ = ["PLANNER_PROMPT", "RESPONDER_PROMPT"]
"""
Planner Prompt — Store Search Agent.
AI PLANNER phân tích câu hỏi khách → gọi tool tìm cửa hàng.
"""
PLANNER_PROMPT = """Bạn là AI PLANNER của CANIFA. Nhiệm vụ DUY NHẤT: Phân tích câu hỏi khách → gọi tool tìm kiếm cửa hàng (canifa_store_search).
## LUỒNG XỬ LÝ
1. Đọc câu hỏi khách
2. NẾU MƠ HỒ (không rõ khu vực/tỉnh/thành) → HỎI LẠI ngay, KHÔNG gọi tool
3. NẾU RÕ → Trích xuất location → Gọi canifa_store_search
## KHI NÀO HỎI LẠI (KHÔNG gọi tool):
- "Tìm cửa hàng cho mình" → Hỏi: "Bạn muốn tìm cửa hàng CANIFA ở khu vực, quận/huyện hay tỉnh/thành phố nào ạ?"
- "Cửa hàng gần nhất" (mà không cung cấp vị trí) → Hỏi tương tự.
⚠️ PHẢI hỏi lại bằng text THUẦN, KHÔNG gọi tool khi chưa rõ khu vực!
## KHI GỌI TOOL:
- Truyền đúng tên vị trí khách nói vào tham số `location` (VD: "Hà Đông", "Cầu Giấy", "Vincom Bà Triệu", "Đà Nẵng").
## QUY TẮC CỨNG
1. KHÔNG tìm kiếm sản phẩm. Nếu khách hỏi mua quần áo, hãy từ chối nhẹ nhàng "Em là chuyên viên tìm cửa hàng, anh/chị cần tìm cửa hàng ở đâu ạ?".
2. TUYỆT ĐỐI KHÔNG trả lời tự bịa về địa chỉ nếu chưa gọi tool.
3. Chỉ trả lời text thuần khi HỎI LẠI khách (câu hỏi mơ hồ).
"""
"""
Responder Prompt — Store Search Agent.
AI RESPONDER format kết quả tìm cửa hàng thành câu trả lời tự nhiên.
"""
RESPONDER_PROMPT = """Bạn là AI RESPONDER của CANIFA. Nhiệm vụ: Format kết quả tìm kiếm cửa hàng thành câu trả lời tự nhiên.
## BƯỚC 1: ĐỌC LẠI NGỮ CẢNH
- Đọc lại câu hỏi GỐC của khách để hiểu họ cần gì.
## BƯỚC 2: KIỂM TRA KẾT QUẢ TỪ TOOL
- Nếu tool trả về danh sách cửa hàng → format đẹp đẽ, dùng emoji sinh động.
- Nếu 0 kết quả → nói nhẹ nhàng và gợi ý khách liên hệ hotline 1800 6061 hoặc kiểm tra lại tên khu vực.
## FORMAT KẾT QUẢ:
Giữ nguyên form danh sách báo cáo từ tool nhưng cho tự nhiên hơn.
Ví dụ:
Dạ, mình tìm thấy X cửa hàng CANIFA gần khu vực [Tên khu vực] ạ:
🏪 [Tên Cửa Hàng]
📍 Địa chỉ: [Địa chỉ]
📞 ĐT: [Số điện thoại]
🕐 Giờ mở cửa: [Lịch]
## NGUYÊN TẮC:
- Trả lời bằng tiếng Việt, thân thiện.
- Ngắn gọn, đúng trọng tâm.
- KHÔNG tự bịa địa chỉ.
"""
"""
Store Search Graph — 2-Agent LangGraph Architecture cho Store Search.
"""
import logging
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
from agent.tools.store_search_tool import canifa_store_search
from .prompts import PLANNER_PROMPT, RESPONDER_PROMPT
logger = logging.getLogger(__name__)
def _extract_text(content) -> str:
"""Extract plain text from msg.content — handles both str and Gemini list format."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
# ═══════════════════════════════════════════════
# State
# ═══════════════════════════════════════════════
class StoreSearchState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture
# ═══════════════════════════════════════════════
class StoreSearchGraph:
"""2-Agent LangGraph: Planner → Tools → Responder."""
def __init__(self, model_name: str | None = None):
self.model_name = model_name or DEFAULT_MODEL
self.tools = [canifa_store_search]
# 2 con LLM riêng biệt
self.planner_llm = create_llm(model_name=self.model_name, streaming=True)
self.planner_with_tools = self.planner_llm.bind_tools(self.tools)
self.responder_llm = create_llm(model_name=self.model_name, streaming=True)
self._compiled = None
logger.info(f"✅ StoreSearchGraph 2-Agent initialized with model: {self.model_name}")
def _planner_node(self, state: StoreSearchState) -> dict:
messages = state["messages"]
planner_messages = [SystemMessage(content=PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
# synchronous run (we use ainvoke in async wrapper)
pass
async def _planner_node_async(self, state: StoreSearchState) -> dict:
messages = state["messages"]
planner_messages = [SystemMessage(content=PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
response = await self.planner_with_tools.ainvoke(planner_messages)
return {"messages": [response]}
async def _responder_node_async(self, state: StoreSearchState) -> dict:
messages = state["messages"]
responder_messages = [SystemMessage(content=RESPONDER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
responder_messages.append(msg)
response = await self.responder_llm.ainvoke(responder_messages)
return {"messages": [response]}
def _after_planner(self, state: StoreSearchState) -> str:
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end"
def build(self):
if self._compiled:
return self._compiled
workflow = StateGraph(StoreSearchState)
workflow.add_node("planner", self._planner_node_async)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("responder", self._responder_node_async)
workflow.set_entry_point("planner")
workflow.add_conditional_edges(
"planner",
self._after_planner,
{"tools": "tools", "end": END},
)
workflow.add_edge("tools", "responder")
workflow.add_edge("responder", END)
self._compiled = workflow.compile()
logger.info("✅ StoreSearchGraph 2-Agent compiled")
return self._compiled
async def chat(self, user_message: str, history: list[BaseMessage] | None = None) -> dict:
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
messages.append(HumanMessage(content=user_message))
result = await graph.ainvoke({"messages": messages})
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
ai_response = ""
tool_calls_log: list[dict[str, Any]] = []
has_tool_calls = False
pipeline: list[dict[str, Any]] = []
for msg in all_messages:
content_str = _extract_text(msg.content)
if isinstance(msg, HumanMessage):
pipeline.append({
"step": "user",
"label": "👤 User",
"content": content_str,
})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
has_tool_calls = True
tool_calls_log.extend(msg.tool_calls)
pipeline.append({
"step": "planner",
"label": "🧠 Planner AI",
"content": content_str if content_str and content_str != "[]" else "(gọi tool...)",
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in msg.tool_calls
],
})
elif msg.content:
ai_response = content_str
pipeline.append({
"step": "responder",
"label": "💬 Responder AI",
"content": content_str[:500] + ("..." if len(content_str) > 500 else ""),
})
else:
tool_content = content_str
summary = tool_content[:600]
pipeline.append({
"step": "tool",
"label": f"🛠️ Tool ({msg.name})",
"content": summary,
})
# Cắt ngắn step if needed
end_idx = "responder" if has_tool_calls else "planner"
path_str = f"planner→tools→responder" if has_tool_calls else "planner"
logger.info(f"🏬 [STORE SEARCH] query='{user_message[:50]}' | path={path_str} | tools={len(tool_calls_log)} | time={elapsed_ms}ms")
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"agent_path": path_str,
"tool_calls": tool_calls_log,
"pipeline": pipeline
}
# --- Lazy Init ---
_agent_instance = None
def get_store_search_agent() -> StoreSearchGraph:
global _agent_instance
if _agent_instance is None:
_agent_instance = StoreSearchGraph()
return _agent_instance
════════════════════════════════════════════════════════════════
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"]
This diff is collapsed.
"""
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.
"""
Tool giả lập chức năng thêm sản phẩm vào giỏ hàng.
"""
import json
import logging
from langchain_core.tools import tool
logger = logging.getLogger(__name__)
@tool
async def add_to_cart(internal_ref_code: str, size: str, color: str, quantity: int = 1) -> str:
"""
Sử dụng tool này để thêm sản phẩm vào giỏ hàng khi người dùng yêu cầu "thêm vào giỏ hàng", "mua sản phẩm này".
Yêu cầu phải có đủ:
- internal_ref_code: mã sản phẩm (SKU)
- size: kích cỡ (S, M, L, XL...)
- color: màu sắc
- quantity: số lượng (mặc định là 1)
"""
try:
print(f"\n[TOOL] --- 🛒 Thêm vào giỏ hàng: SP {internal_ref_code} | Size {size} | Color {color} | SL {quantity} ---")
logger.info(f"🛒 Add to cart: {internal_ref_code}, color: {color}, size: {size}, qty: {quantity}")
# Fake DB return record
cart_record = {
"internal_ref_code": internal_ref_code,
"size": size,
"color": color,
"quantity": quantity,
"status": "added_to_cart_successfully"
}
return json.dumps(
{
"status": "success",
"message": (
f"Đã thêm thành công sản phẩm mã {internal_ref_code} (Màu {color}, Size {size}, Số lượng {quantity}) vào giỏ hàng của anh/chị!"
),
"data": cart_record,
},
ensure_ascii=False,
)
except Exception as e:
logger.error(f"❌ Lỗi khi thêm vào giỏ hàng: {e}")
return json.dumps(
{
"status": "error",
"message": f"Xin lỗi, có lỗi xảy ra khi thêm vào giỏ hàng. Lỗi: {e!s}",
},
ensure_ascii=False,
)
...@@ -217,17 +217,20 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -217,17 +217,20 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# ============================================================ # ============================================================
# CASE 3: SEMANTIC VECTOR SEARCH # CASE 3: SEMANTIC VECTOR SEARCH (OR LEXICAL FALLBACK)
# ============================================================ # ============================================================
query_text = getattr(params, "description", None) query_text = getattr(params, "description", None)
if query_text and query_vector is None: if query_text and query_vector is None:
query_vector = await create_embedding_async(query_text) query_vector = await create_embedding_async(query_text)
if not query_vector: # Nếu KHÔNG CÓ vector (tức là OPENAI_API_KEY missing hoặc lỗi), dùng Lexical Fallback!
return "", [] is_lexical_fallback = False
v_str = ""
# Vector params if query_vector:
v_str = "[" + ",".join(str(v) for v in query_vector) + "]" v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
else:
logger.warning("⚠️ No query_vector generated. Falling back to Lexical Search.")
is_lexical_fallback = True
# Collect All Filters # Collect All Filters
sql_params: list = [] sql_params: list = []
...@@ -282,64 +285,137 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -282,64 +285,137 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
final_order = "ORDER BY max_score DESC" final_order = "ORDER BY max_score DESC"
extra_agg = "" extra_agg = ""
sql = f""" if is_lexical_fallback and query_text:
WITH vector_matches AS ( # Tách mô tả thành từ khoá (bỏ bớt chữ râu ria)
SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */ import re
clean_text = re.sub(r'(product_name:|description_text:|material_group:|style:|fitting:|form_sleeve:|form_neckline:|product_line_vn:)', ' ', query_text.lower())
words = [w for w in clean_text.split() if len(w) >= 2 and w not in ('áo', 'quần', 'màu', 'cho', 'có', 'của', 'với')]
lex_conditions = []
for w in words[:6]: # Lấy max 6 từ quan trọng
lex_conditions.append("LOWER(description_text) LIKE %s")
sql_params.append(f"%{w}%")
lex_where = " OR ".join(lex_conditions) if lex_conditions else "1=1"
if lex_where != "1=1":
lex_where = f"({lex_where})"
if post_filter_where:
post_filter_where += f" AND {lex_where}"
else:
post_filter_where = f" WHERE {lex_where}"
sql = f"""
WITH filtered_matches AS (
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
product_line_en,
description_text,
size_scale,
quantity_sold,
is_new_product,
0.5 as similarity_score
FROM shared_source.magento_product_dimension_with_text_embedding
{post_filter_where}
LIMIT 150
)
SELECT
internal_ref_code, internal_ref_code,
magento_ref_code, MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
product_color_code, product_color_code,
product_name, MAX_BY(product_name, similarity_score) as product_name,
master_color, MAX_BY(master_color, similarity_score) as master_color,
product_color_name, MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail,
product_image_url_thumbnail, MAX_BY(product_web_url, similarity_score) as product_web_url,
product_web_url, MAX_BY(sale_price, similarity_score) as sale_price,
sale_price, MAX_BY(original_price, similarity_score) as original_price,
original_price, MAX_BY(discount_amount, similarity_score) as discount_amount,
discount_amount, MAX_BY(discount_percent, similarity_score) as discount_percent,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent, MAX_BY(description_text, similarity_score) as description_text,
age_by_product, MAX_BY(gender_by_product, similarity_score) as gender_by_product,
gender_by_product, MAX_BY(age_by_product, similarity_score) as age_by_product,
product_line_vn, MAX_BY(product_line_vn, similarity_score) as product_line_vn,
product_line_en, MAX_BY(quantity_sold, similarity_score) as quantity_sold,
description_text, MAX_BY(size_scale, similarity_score) as size_scale,
size_scale, MAX(similarity_score) as max_score{extra_agg}
quantity_sold, FROM filtered_matches
is_new_product, GROUP BY product_color_code, internal_ref_code
approx_cosine_similarity(vector, {v_str}) as similarity_score {final_order}
FROM shared_source.magento_product_dimension_with_text_embedding LIMIT 80
ORDER BY similarity_score DESC """
LIMIT 200 else:
), sql = f"""
filtered_matches AS ( WITH vector_matches AS (
SELECT * FROM vector_matches SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */
{post_filter_where} internal_ref_code,
ORDER BY similarity_score DESC magento_ref_code,
LIMIT 150 product_color_code,
) product_name,
SELECT master_color,
internal_ref_code, product_color_name,
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code, product_image_url_thumbnail,
product_color_code, product_web_url,
MAX_BY(product_name, similarity_score) as product_name, sale_price,
MAX_BY(master_color, similarity_score) as master_color, original_price,
MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail, discount_amount,
MAX_BY(product_web_url, similarity_score) as product_web_url, ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
MAX_BY(sale_price, similarity_score) as sale_price, age_by_product,
MAX_BY(original_price, similarity_score) as original_price, gender_by_product,
MAX_BY(discount_amount, similarity_score) as discount_amount, product_line_vn,
MAX_BY(discount_percent, similarity_score) as discount_percent, product_line_en,
MAX_BY(description_text, similarity_score) as description_text, description_text,
MAX_BY(gender_by_product, similarity_score) as gender_by_product, size_scale,
MAX_BY(age_by_product, similarity_score) as age_by_product, quantity_sold,
MAX_BY(product_line_vn, similarity_score) as product_line_vn, is_new_product,
MAX_BY(quantity_sold, similarity_score) as quantity_sold, approx_cosine_similarity(vector, {v_str}) as similarity_score
MAX_BY(size_scale, similarity_score) as size_scale, FROM shared_source.magento_product_dimension_with_text_embedding
MAX(similarity_score) as max_score{extra_agg} ORDER BY similarity_score DESC
FROM filtered_matches LIMIT 200
GROUP BY product_color_code, internal_ref_code ),
{final_order} filtered_matches AS (
LIMIT 80 SELECT * FROM vector_matches
""" {post_filter_where}
ORDER BY similarity_score DESC
LIMIT 150
)
SELECT
internal_ref_code,
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
product_color_code,
MAX_BY(product_name, similarity_score) as product_name,
MAX_BY(master_color, similarity_score) as master_color,
MAX_BY(product_image_url_thumbnail, similarity_score) as product_image_url_thumbnail,
MAX_BY(product_web_url, similarity_score) as product_web_url,
MAX_BY(sale_price, similarity_score) as sale_price,
MAX_BY(original_price, similarity_score) as original_price,
MAX_BY(discount_amount, similarity_score) as discount_amount,
MAX_BY(discount_percent, similarity_score) as discount_percent,
MAX_BY(description_text, similarity_score) as description_text,
MAX_BY(gender_by_product, similarity_score) as gender_by_product,
MAX_BY(age_by_product, similarity_score) as age_by_product,
MAX_BY(product_line_vn, similarity_score) as product_line_vn,
MAX_BY(quantity_sold, similarity_score) as quantity_sold,
MAX_BY(size_scale, similarity_score) as size_scale,
MAX(similarity_score) as max_score{extra_agg}
FROM filtered_matches
GROUP BY product_color_code, internal_ref_code
{final_order}
LIMIT 80
"""
# try: # try:
# query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt") # query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt")
......
...@@ -32,23 +32,55 @@ async def canifa_store_search(location: str) -> str: ...@@ -32,23 +32,55 @@ async def canifa_store_search(location: str) -> str:
clean = clean.replace(prefix, "") clean = clean.replace(prefix, "")
clean = clean.strip() clean = clean.strip()
if not clean: # Tách thành tokens, deduplicate (giữ thứ tự)
# VD: "hà đông, hà nội" → ["hà", "đông", "nội"]
tokens = list(dict.fromkeys(
t for t in clean.replace(',', ' ').split() if t.strip()
))
if not tokens:
return "Vui lòng cho em biết khu vực bạn muốn tìm cửa hàng CANIFA (ví dụ: Hoàng Mai, Cầu Giấy, Đà Nẵng...)." return "Vui lòng cho em biết khu vực bạn muốn tìm cửa hàng CANIFA (ví dụ: Hoàng Mai, Cầu Giấy, Đà Nẵng...)."
# Search trên các cột structured: city, state, address, store_name # Search trên concat tất cả cột địa chỉ
sql = f""" text_col = "LOWER(concat_ws(' ', store_name, address, city, state))"
SELECT store_name, address, city, state, phone_number,
schedule_name, time_open_today, time_close_today def _build_sql(where_clause: str) -> str:
FROM {STORE_TABLE} return f"""
WHERE LOWER(city) LIKE '%{clean}%' SELECT store_name, address, city, state, phone_number,
OR LOWER(state) LIKE '%{clean}%' schedule_name, time_open_today, time_close_today
OR LOWER(address) LIKE '%{clean}%' FROM {STORE_TABLE}
OR LOWER(store_name) LIKE '%{clean}%' WHERE {where_clause}
ORDER BY state, city, store_name ORDER BY state, city, store_name
LIMIT 20 LIMIT 20
""" """
results = await sr.execute_query_async(sql) # ═══════════════════════════════════════════════
# Step 1: AND tất cả tokens (strict match)
# "hà đông hà nội" → tokens ["hà","đông","nội"] → AND → 5 stores ✓
# ═══════════════════════════════════════════════
and_conds = [f"{text_col} LIKE '%{tk}%'" for tk in tokens]
results = await sr.execute_query_async(_build_sql(' AND '.join(and_conds)))
# ═══════════════════════════════════════════════
# Step 2: Fallback — Reverse LIKE
# Dùng chính DB làm từ điển địa danh:
# Kiểm tra tên quận/huyện/tỉnh nào trong DB XUẤT HIỆN trong input user
# "hà đông cầu giấy" chứa "hà đông" (city) + "cầu giấy" (city) → lấy CẢ 2
# ═══════════════════════════════════════════════
if not results and len(tokens) >= 2:
# Strip prefix khỏi city: "Quận Hà Đông" → "hà đông"
city_stripped = """LOWER(TRIM(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
city, 'Quận ', ''), 'Huyện ', ''), 'Thành phố ', ''), 'Thị xã ', ''), 'TP. ', '')))"""
state_lower = "LOWER(TRIM(state))"
fallback_where = f"""
(LOCATE({city_stripped}, '{clean}') > 0 AND LENGTH({city_stripped}) > 1)
OR
(LOCATE({state_lower}, '{clean}') > 0 AND LENGTH({state_lower}) > 1)
"""
results = await sr.execute_query_async(_build_sql(fallback_where))
logger.info(f"📊 Store search: reverse-LIKE fallback for '{clean}'")
logger.info(f"📊 Store search: {len(results)} stores found for '{location}'") logger.info(f"📊 Store search: {len(results)} stores found for '{location}'")
if not results: if not results:
......
"""
SKU Search API — FastAPI route cho tra cứu sản phẩm theo mã SKU.
Gọi SkuSearchGraph 2-Agent (Planner → Tool → Responder).
"""
import logging
from pydantic import BaseModel
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from agent.sku_search_agent.sku_search_graph import get_sku_search_agent
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/sku-search", tags=["SKU Search"])
class SkuChatRequest(BaseModel):
query: str
# history support later if needed
@router.post("/chat", summary="Chat with SKU Search Agent")
async def sku_chat(req: SkuChatRequest):
"""
Gửi câu hỏi cho SKU Search Agent.
Agent sẽ tự extract mã SKU → gọi tool → format câu trả lời.
"""
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query trống"})
try:
agent = get_sku_search_agent()
result = await agent.chat(query)
return {
"status": "success",
"response": result["response"],
"elapsed_ms": result["elapsed_ms"],
"agent_path": result["agent_path"],
"tool_calls": result["tool_calls"],
"pipeline": result.get("pipeline", []),
}
except Exception as e:
logger.error(f"❌ SKU Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/lookup/{sku}", summary="Quick SKU lookup (no agent, direct query)")
async def sku_lookup(sku: str):
"""
Tra cứu nhanh theo mã SKU — không qua Agent, query thẳng DB.
Dùng cho API integration hoặc lookup nhanh.
"""
sku = sku.strip()
if not sku or len(sku) < 3:
return JSONResponse(status_code=400, content={"status": "error", "message": "SKU phải >= 3 ký tự"})
try:
db = get_db_connection()
code = sku.upper()
internal_ref, _ = code.split("-", 1) if "-" in code else (code, None)
loose_pattern = "%" + "%".join(code.replace("-", "")) + "%"
sql = """
WITH resolved_family AS (
SELECT DISTINCT internal_ref_code
FROM test_db.magento_product_dimension_with_text_embedding
WHERE UPPER(internal_ref_code) = %s
OR UPPER(magento_ref_code) = %s
OR UPPER(product_color_code) = %s
OR UPPER(product_color_code) LIKE %s
OR REPLACE(UPPER(magento_ref_code), '-', '') LIKE %s
)
SELECT magento_ref_code, product_color_code, product_name, master_color,
product_color_name, sale_price, original_price,
product_image_url_thumbnail, product_web_url, size_scale,
gender_by_product, product_line_vn, quantity_sold
FROM test_db.magento_product_dimension_with_text_embedding
WHERE internal_ref_code IN (SELECT internal_ref_code FROM resolved_family)
"""
params = (internal_ref, code, code, f"{internal_ref}-%", loose_pattern)
products = await db.execute_query_async(sql, params=params)
return {
"status": "success",
"sku": sku,
"count": len(products),
"products": products,
}
except Exception as e:
logger.error(f"❌ SKU Lookup error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"""
Image Search API — FastAPI route
Nhận text query + mảng base64 ảnh.
"""
import logging
from pydantic import BaseModel
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from typing import List, Optional
from agent.image_search_agent.image_search_graph import get_image_search_agent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/image-search", tags=["Image Search"])
class ImageSearchRequest(BaseModel):
query: str
images: Optional[List[str]] = None
@router.post("/chat", summary="Chat with Vision Image Search Agent")
async def image_search_chat(req: ImageSearchRequest):
"""
Gửi câu hỏi và gửi ảnh (base64) để agent vision phân tích và tìm trong DB.
"""
query = req.query.strip()
images = req.images or []
if not query and not images:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query text và ảnh đều trống"})
try:
agent = get_image_search_agent()
result = await agent.chat(query, images=images)
return {
"status": "success",
"response": result["response"],
"elapsed_ms": result["elapsed_ms"],
"agent_path": result["agent_path"],
"tool_calls": result["tool_calls"],
"pipeline": result["pipeline"],
}
except Exception as e:
logger.error(f"❌ Image Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"""
Store Search API — FastAPI route cho tìm kiếm cửa hàng bằng AI.
Gọi StoreSearchGraph 2-Agent (Planner → Tool → Responder).
"""
import logging
from pydantic import BaseModel
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from agent.store_search_agent.store_search_graph import get_store_search_agent
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/store-search", tags=["Store Search"])
class StoreChatRequest(BaseModel):
query: str
@router.post("/chat", summary="Chat with Store Search Agent")
async def store_chat(req: StoreChatRequest):
"""
Gửi câu hỏi cho Store Search Agent.
Agent sẽ tự phân tích khu vực → gọi tool -> format lại text .
"""
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query trống"})
try:
agent = get_store_search_agent()
result = await agent.chat(query)
return {
"status": "success",
"response": result["response"],
"elapsed_ms": result["elapsed_ms"],
"agent_path": result["agent_path"],
"tool_calls": result["tool_calls"],
"pipeline": result["pipeline"],
}
except Exception as e:
logger.error(f"❌ Store Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"""
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).
History lưu Redis, tự expire sau 30 phút.
"""
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from langchain_core.messages import AIMessage, HumanMessage
from agent.tag_search_agent.tag_search_graph import get_tag_search_agent
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):
"""
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:
agent = get_tag_search_agent()
# Load history từ Redis (nếu có session)
history = []
if session_id:
history = await _load_history(session_id)
result = await agent.chat(query, history=history if history else None)
# Save history ngược lại Redis (append user + AI response)
if session_id:
history.append(HumanMessage(content=query))
if result.get("response"):
history.append(AIMessage(content=result["response"]))
await _save_history(session_id, history)
return {
"status": "success",
"response": result["response"],
"elapsed_ms": result["elapsed_ms"],
"agent_path": result["agent_path"],
"tool_calls": result["tool_calls"],
"pipeline": result.get("pipeline", []),
"products": result.get("products", []),
"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.
...@@ -34,7 +34,7 @@ class ClearHistoryResponse(BaseModel): ...@@ -34,7 +34,7 @@ class ClearHistoryResponse(BaseModel):
@router.get("/api/history/{identity_key}", summary="Get Chat History", response_model=ChatHistoryResponse) @router.get("/api/history/{identity_key}", summary="Get Chat History", response_model=ChatHistoryResponse)
@rate_limit_service.limiter.limit("30/minute") @rate_limit_service.limiter.limit("30/minute")
async def get_chat_history(request: Request, identity_key: str, limit: int | None = 50, before_id: int | None = None): async def get_chat_history(request: Request, identity_key: str, limit: int | None = 50, before_id: int | None = None, conversation_id: str | None = None):
""" """
Lấy lịch sử chat theo identity_key. Lấy lịch sử chat theo identity_key.
...@@ -52,7 +52,7 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non ...@@ -52,7 +52,7 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non
logger.info(f"GET History: resolved_key={identity_key}") logger.info(f"GET History: resolved_key={identity_key}")
manager = await get_conversation_manager() manager = await get_conversation_manager()
history = await manager.get_chat_history(identity_key, limit=limit, before_id=before_id) history = await manager.get_chat_history(identity_key, limit=limit, before_id=before_id, conversation_id=conversation_id)
next_cursor = history[-1]["id"] if history else None next_cursor = history[-1]["id"] if history else None
return {"data": history, "next_cursor": next_cursor} return {"data": history, "next_cursor": next_cursor}
...@@ -63,15 +63,15 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non ...@@ -63,15 +63,15 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non
@router.delete("/api/history/{identity_key}", summary="Clear Chat History", response_model=ClearHistoryResponse) @router.delete("/api/history/{identity_key}", summary="Clear Chat History", response_model=ClearHistoryResponse)
@rate_limit_service.limiter.limit("30/minute") @rate_limit_service.limiter.limit("30/minute")
async def clear_chat_history(request: Request, identity_key: str): async def clear_chat_history(request: Request, identity_key: str, conversation_id: str | None = None):
""" """
Xóa toàn bộ lịch sử chat theo identity_key. Xóa toàn bộ lịch sử chat theo identity_key.
Logic: Middleware đã parse token -> Nếu user đã login thì dùng user_id, không thì dùng device_id. Logic: Middleware đã parse token -> Nếu user đã login thì dùng user_id, không thì dùng device_id.
""" """
try: try:
manager = await get_conversation_manager() manager = await get_conversation_manager()
await manager.clear_history(identity_key) await manager.clear_history(identity_key, conversation_id=conversation_id)
logger.info(f"✅ Cleared chat history for {identity_key}") logger.info(f"✅ Cleared chat history for {identity_key} (conv: {conversation_id})")
return {"success": True, "message": f"Đã xóa lịch sử chat của {identity_key}"} return {"success": True, "message": f"Đã xóa lịch sử chat của {identity_key}"}
except Exception as e: except Exception as e:
logger.error(f"Error clearing chat history for {identity_key}: {e}") logger.error(f"Error clearing chat history for {identity_key}: {e}")
...@@ -87,7 +87,7 @@ class ArchiveResponse(BaseModel): ...@@ -87,7 +87,7 @@ class ArchiveResponse(BaseModel):
@router.post("/api/history/archive", summary="Archive Chat History", response_model=ArchiveResponse) @router.post("/api/history/archive", summary="Archive Chat History", response_model=ArchiveResponse)
@rate_limit_service.limiter.limit("30/minute") @rate_limit_service.limiter.limit("30/minute")
async def archive_chat_history(request: Request): async def archive_chat_history(request: Request, conversation_id: str | None = None):
""" """
Lưu trữ lịch sử chat hiện tại (đổi tên key) và reset chat mới. Lưu trữ lịch sử chat hiện tại (đổi tên key) và reset chat mới.
Giới hạn 5 lần/ngày. Giới hạn 5 lần/ngày.
...@@ -125,7 +125,7 @@ async def archive_chat_history(request: Request): ...@@ -125,7 +125,7 @@ async def archive_chat_history(request: Request):
) )
manager = await get_conversation_manager() manager = await get_conversation_manager()
new_key = await manager.archive_history(identity_key) new_key = await manager.archive_history(identity_key, conversation_id=conversation_id)
# Clear user_insight in Redis for this identity key # Clear user_insight in Redis for this identity key
client = redis_cache.get_client() client = redis_cache.get_client()
......
"""
Lead Flow Route — Endpoint thí nghiệm cho Lead Stage AI.
Flow: AI #1 (classify) → inject stage → AI #2 (stylist) → response
Endpoint: POST /api/agent/chat-lead-flow
Endpoint: GET /api/agent/lead-stage (classifier only — for debug)
"""
import logging
from fastapi import APIRouter, BackgroundTasks, Request
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from agent.controller_helpers import load_user_insight_from_redis
from agent.lead_stage_agent.graph import run_lead_stage_classifier
logger = logging.getLogger(__name__)
router = APIRouter()
class LeadFlowRequest(BaseModel):
"""Request model cho lead flow endpoint."""
user_query: str
images: list[str] | None = None
conversation_id: str | None = None
class LeadStageTestRequest(BaseModel):
"""Request model cho standalone classifier test."""
user_query: str
user_insight: str | None = None
chat_history_summary: str | None = None
@router.post("/api/agent/chat-lead-flow", summary="Lead Stage AI Chat (Experimental)")
async def chat_lead_flow(request: Request, req: LeadFlowRequest, background_tasks: BackgroundTasks):
"""
Endpoint thí nghiệm Lead Stage AI.
Flow:
1. Load user_insight từ Redis
2. Gọi LeadStageGraph (Classifier -> Stylist)
3. Trả về response + pipeline + lead_stage metadata
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
logger.info(f"📥 [Lead Flow] User: {identity_id} | Query: {req.user_query}")
try:
# ── Step 1: Load user_insight từ Redis ──
user_insight = None
if identity_id:
user_insight = await load_user_insight_from_redis(str(identity_id))
# ── Step 2: Lấy history ──
from common.conversation_manager import get_conversation_manager
from langchain_core.messages import AIMessage, HumanMessage
memory = await get_conversation_manager()
history_dicts = await memory.get_chat_history(
str(identity_id),
limit=10,
include_product_ids=False,
conversation_id=req.conversation_id
)
history_msgs = [
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"]) for m in history_dicts
][::-1]
# ── Step 2.5: Build Langfuse Config ──
from common.langfuse_client import get_callback_handler
from langchain_core.runnables import RunnableConfig
import uuid
run_id = str(uuid.uuid4())
session_id = req.conversation_id or f"{identity_id}-{run_id[:8]}"
tags = ["lead_flow", "user:authenticated" if is_authenticated else "user:anonymous"]
langfuse_handler = get_callback_handler()
config_run = RunnableConfig(
callbacks=[langfuse_handler] if langfuse_handler else [],
run_name="lead_turn",
metadata={
"langfuse_session_id": session_id,
"langfuse_user_id": str(identity_id),
"langfuse_tags": tags,
"conversation_id": session_id,
"customer_id": identity_id if is_authenticated else None,
"device_id": device_id,
},
)
# ── Step 3: Giao việc cho 2-Agent LangGraph (LeadStageGraph) ──
from agent.lead_stage_agent.graph import get_lead_stage_agent
agent = get_lead_stage_agent()
chat_result = await agent.chat(
user_message=req.user_query,
user_insight=user_insight,
history=history_msgs,
config=config_run
)
ai_response = chat_result.get("response", "")
lead_stage = chat_result.get("lead_stage") or {}
pipeline = chat_result.get("pipeline", [])
total_elapsed = chat_result.get("elapsed_ms", 0)
# Tính timing từ pipeline
classifier_ms = 0
stylist_ms = 0
for p in pipeline:
if p.get("step") == "classifier":
classifier_ms = p.get("elapsed_ms", 0)
elif p.get("step") in ("responder", "stylist"):
stylist_ms += p.get("elapsed_ms", 0)
logger.info(
f"✅ [Lead Flow] Complete | "
f"Stage: {lead_stage.get('stage_name')} | "
f"Total: {total_elapsed:.0f}ms"
)
# Set response_payload cho background tasks
response_payload = {
"ai_response": ai_response,
"product_ids": chat_result.get("products", []),
"lead_stage": lead_stage,
}
# Lưu conversation background
from agent.helper import handle_post_chat_async
background_tasks.add_task(
handle_post_chat_async,
memory=memory,
identity_key=str(identity_id),
human_query=req.user_query,
ai_response=response_payload,
conversation_id=req.conversation_id,
)
return {
"status": "success",
"ai_response": ai_response,
"products": chat_result.get("products", []),
"trace_id": "",
"lead_stage": lead_stage,
"pipeline": pipeline,
"timing": {
"classifier_ms": classifier_ms,
"stylist_ms": stylist_ms,
"total_ms": total_elapsed,
},
}
except Exception as e:
logger.error(f"❌ [Lead Flow] Error: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "LEAD_FLOW_ERROR",
"message": f"Lead Flow Error: {str(e)[:200]}",
},
)
@router.post("/api/agent/lead-stage", summary="Lead Stage Classifier Only (Debug)")
async def classify_only(req: LeadStageTestRequest):
"""
Debug endpoint — chỉ chạy AI #1 classifier, không gọi AI #2.
Dùng để test/tune classifier prompt.
"""
try:
result = await run_lead_stage_classifier(
user_query=req.user_query,
user_insight=req.user_insight,
chat_history_summary=req.chat_history_summary,
)
return {
"status": "success",
**result,
}
except Exception as e:
logger.error(f"❌ [Lead Stage Debug] Error: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={"status": "error", "message": str(e)[:200]},
)
...@@ -358,20 +358,43 @@ async def n8n_search_stores( ...@@ -358,20 +358,43 @@ async def n8n_search_stores(
if not clean: if not clean:
raise HTTPException(status_code=400, detail="Location không hợp lệ.") raise HTTPException(status_code=400, detail="Location không hợp lệ.")
# Tách thành tokens, deduplicate (giữ thứ tự)
tokens = list(dict.fromkeys(
t for t in clean.replace(',', ' ').split() if t.strip()
))
if not tokens:
raise HTTPException(status_code=400, detail="Location không hợp lệ.")
db = get_db_connection() db = get_db_connection()
query = f"""
SELECT store_name, address, city, state, phone_number, # AND logic + bigram OR fallback
schedule_name, time_open_today, time_close_today text_col = "LOWER(concat_ws(' ', store_name, address, city, state))"
FROM {STORE_TABLE}
WHERE LOWER(city) LIKE LOWER(%s) def _q(where: str) -> str:
OR LOWER(state) LIKE LOWER(%s) return f"""
OR LOWER(address) LIKE LOWER(%s) SELECT store_name, address, city, state, phone_number,
OR LOWER(store_name) LIKE LOWER(%s) schedule_name, time_open_today, time_close_today
ORDER BY state, city, store_name FROM {STORE_TABLE}
LIMIT 20 WHERE {where}
""" ORDER BY state, city, store_name
like_pattern = f"%{clean}%" LIMIT 20
results = await db.execute_query_async(query, params=(like_pattern, like_pattern, like_pattern, like_pattern)) """
# 1. AND strict
and_conds = [f"{text_col} LIKE '%{t}%'" for t in tokens]
results = await db.execute_query_async(_q(' AND '.join(and_conds)))
# 2. Fallback: Reverse LIKE — DB làm từ điển địa danh
if not results and len(tokens) >= 2:
city_stripped = """LOWER(TRIM(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(
city, 'Quận ', ''), 'Huyện ', ''), 'Thành phố ', ''), 'Thị xã ', ''), 'TP. ', '')))"""
state_lower = "LOWER(TRIM(state))"
fallback_where = f"""
(LOCATE({city_stripped}, '{clean}') > 0 AND LENGTH({city_stripped}) > 1)
OR
(LOCATE({state_lower}, '{clean}') > 0 AND LENGTH({state_lower}) > 1)
"""
results = await db.execute_query_async(_q(fallback_where))
return { return {
"status": "success", "status": "success",
......
This diff is collapsed.
...@@ -109,6 +109,9 @@ async def products_list( ...@@ -109,6 +109,9 @@ async def products_list(
# Build WHERE clauses # Build WHERE clauses
clauses = [] clauses = []
params = [] params = []
use_relevance = False # Smart search ranking flag
search_lower = ""
resolved_name_lower = ""
if gender: if gender:
clauses.append("gender_by_product = %s") clauses.append("gender_by_product = %s")
...@@ -123,13 +126,13 @@ async def products_list( ...@@ -123,13 +126,13 @@ async def products_list(
# Resolve synonym: "áo thun" → "Áo phông", "quần bò" → "Quần jean" # Resolve synonym: "áo thun" → "Áo phông", "quần bò" → "Quần jean"
resolved_name = resolve_product_name(search) resolved_name = resolve_product_name(search)
search_lower = search.lower() search_lower = search.lower()
resolved_name_lower = resolved_name.lower()
use_relevance = True # Enable relevance sorting
# Check if search matches a product_line synonym # Check if search matches a product_line synonym
matched_line = SYNONYM_TO_DB.get(search_lower) matched_line = SYNONYM_TO_DB.get(search_lower)
if matched_line and resolved_name.lower() != search_lower: if matched_line and resolved_name_lower != search_lower:
# User searched a synonym that maps to a DB product_line
# Search by: original term OR resolved name OR exact product_line match
clauses.append( clauses.append(
"(LOWER(product_name) LIKE %s OR LOWER(product_name) LIKE %s " "(LOWER(product_name) LIKE %s OR LOWER(product_name) LIKE %s "
"OR LOWER(product_line_vn) = %s " "OR LOWER(product_line_vn) = %s "
...@@ -137,14 +140,13 @@ async def products_list( ...@@ -137,14 +140,13 @@ async def products_list(
) )
params.extend([ params.extend([
f"%{search_lower}%", f"%{search_lower}%",
f"%{resolved_name.lower()}%", f"%{resolved_name_lower}%",
matched_line.lower(), matched_line.lower(),
f"%{search.upper()}%", f"%{search.upper()}%",
f"%{search.upper()}%", f"%{search.upper()}%",
]) ])
logger.info(f"🔍 Search mapped: '{search}' → '{resolved_name}' (line: {matched_line})") logger.info(f"🔍 Search mapped: '{search}' → '{resolved_name}' (line: {matched_line})")
else: else:
# No synonym match — standard search
clauses.append( clauses.append(
"(LOWER(product_name) LIKE %s OR internal_ref_code LIKE %s OR magento_ref_code LIKE %s)" "(LOWER(product_name) LIKE %s OR internal_ref_code LIKE %s OR magento_ref_code LIKE %s)"
) )
...@@ -154,26 +156,54 @@ async def products_list( ...@@ -154,26 +156,54 @@ async def products_list(
if has_discount is True: if has_discount is True:
clauses.append("sale_price < original_price AND original_price > 0") clauses.append("sale_price < original_price AND original_price > 0")
if color: if color:
# Map user-friendly color term to DB value
db_color = resolve_color(color) db_color = resolve_color(color)
if db_color: if db_color:
clauses.append("master_color = %s") clauses.append("master_color = %s")
params.append(db_color) params.append(db_color)
else: else:
# Fallback: partial match on DB value
clauses.append("LOWER(master_color) LIKE %s") clauses.append("LOWER(master_color) LIKE %s")
params.append(f"%{color.lower()}%") params.append(f"%{color.lower()}%")
where_str = " AND ".join(clauses) if clauses else "1=1" where_str = " AND ".join(clauses) if clauses else "1=1"
# Validate sort field (prevent injection) # ── Smart Relevance Ranking ──
allowed_sorts = { # Build a CASE-based relevance score when search is active
"quantity_sold", "sale_price", "original_price", "product_name", if use_relevance and sort in ("quantity_sold", "relevance"):
"discount_percent", "internal_ref_code", # Tiered scoring: exact SKU > prefix SKU > exact name > startsWith > contains > partial
} relevance_expr = f"""
if sort not in allowed_sorts: MAX(
sort = "quantity_sold" CASE
order_dir = "ASC" if order.lower() == "asc" else "DESC" WHEN UPPER(internal_ref_code) = %s THEN 100
WHEN UPPER(internal_ref_code) LIKE %s THEN 90
WHEN LOWER(product_name) = %s THEN 85
WHEN LOWER(product_name) LIKE %s THEN 70
WHEN LOWER(product_name) LIKE %s THEN 50
WHEN LOWER(product_line_vn) = %s THEN 40
ELSE 30
END
)"""
relevance_params = [
search.upper(), # exact SKU
f"{search.upper()}%", # SKU prefix
search_lower, # exact name
f"{search_lower}%", # name starts with
f"%{search_lower}%", # name contains
search_lower, # exact product_line
]
order_clause = f"relevance_score DESC, quantity_sold DESC"
sort_field = "relevance"
else:
relevance_expr = None
relevance_params = []
allowed_sorts = {
"quantity_sold", "sale_price", "original_price", "product_name",
"discount_percent", "internal_ref_code",
}
if sort not in allowed_sorts:
sort = "quantity_sold"
order_dir = "ASC" if order.lower() == "asc" else "DESC"
order_clause = f"{sort} {order_dir}"
sort_field = sort
try: try:
# Get total count (grouped) # Get total count (grouped)
...@@ -183,7 +213,10 @@ async def products_list( ...@@ -183,7 +213,10 @@ async def products_list(
) )
total = count_rows[0]["total"] if count_rows else 0 total = count_rows[0]["total"] if count_rows else 0
# Get products GROUPED by internal_ref_code # Build SELECT with optional relevance score
relevance_col = f",\n {relevance_expr} AS relevance_score" if relevance_expr else ""
all_params = params + relevance_params + [limit, offset]
products = await db.execute_query_async( products = await db.execute_query_async(
f""" f"""
SELECT SELECT
...@@ -204,14 +237,14 @@ async def products_list( ...@@ -204,14 +237,14 @@ async def products_list(
ANY_VALUE(size_scale) AS size_scale, ANY_VALUE(size_scale) AS size_scale,
COUNT(DISTINCT product_color_code) AS color_count, COUNT(DISTINCT product_color_code) AS color_count,
GROUP_CONCAT(DISTINCT product_color_code) AS color_codes, GROUP_CONCAT(DISTINCT product_color_code) AS color_codes,
GROUP_CONCAT(DISTINCT master_color) AS colors GROUP_CONCAT(DISTINCT master_color) AS colors{relevance_col}
FROM {TABLE_NAME} FROM {TABLE_NAME}
WHERE {where_str} WHERE {where_str}
GROUP BY internal_ref_code GROUP BY internal_ref_code
ORDER BY {sort} {order_dir} ORDER BY {order_clause}
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""", """,
params=tuple(params + [limit, offset]), params=tuple(all_params),
) )
return { return {
...@@ -219,6 +252,7 @@ async def products_list( ...@@ -219,6 +252,7 @@ async def products_list(
"total": total, "total": total,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"sort": sort_field,
"products": products, "products": products,
} }
except Exception as e: except Exception as e:
......
This diff is collapsed.
...@@ -80,7 +80,7 @@ class LangfuseClientManager: ...@@ -80,7 +80,7 @@ class LangfuseClientManager:
except Exception as e: except Exception as e:
logger.warning(f"⚠️ Async flush failed: {e}") logger.warning(f"⚠️ Async flush failed: {e}")
def get_callback_handler(self) -> CallbackHandler | None: def get_callback_handler(self, **kwargs) -> CallbackHandler | None:
"""Get CallbackHandler instance.""" """Get CallbackHandler instance."""
client = self.get_client() client = self.get_client()
if not client: if not client:
...@@ -88,7 +88,7 @@ class LangfuseClientManager: ...@@ -88,7 +88,7 @@ class LangfuseClientManager:
return None return None
try: try:
handler = CallbackHandler() handler = CallbackHandler(**kwargs)
logger.debug("✅ Langfuse CallbackHandler created") logger.debug("✅ Langfuse CallbackHandler created")
return handler return handler
except Exception as e: except Exception as e:
...@@ -102,6 +102,6 @@ get_langfuse_client = _manager.get_client ...@@ -102,6 +102,6 @@ get_langfuse_client = _manager.get_client
async_flush_langfuse = _manager.async_flush async_flush_langfuse = _manager.async_flush
def get_callback_handler() -> CallbackHandler | None: def get_callback_handler(**kwargs) -> CallbackHandler | None:
"""Get CallbackHandler instance (wrapper for manager).""" """Get CallbackHandler instance (wrapper for manager)."""
return _manager.get_callback_handler() return _manager.get_callback_handler(**kwargs)
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.
[
{"name": "Admin", "role": "admin"},
{"name": "anhvh", "role": "admin"}
]
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.
Bimport sys, os; sys.path.append(r"D:\cnf\chatbot-canifa-feedback\backend"); from common.pool_wrapper import get_pooled_connection_compat; conn = get_pooled_connection_compat(); cur = conn.cursor(); cur.execute("SELECT product_line, COUNT(*) FROM dashboard_canifa.ultra_descriptions WHERE LOWER(product_name) LIKE '%chống nắng%' OR LOWER(product_name) LIKE '%gió%' OR LOWER(product_line) LIKE '%chống nắng%' GROUP BY product_line"); print(cur.fetchall()); cur.close(); conn.close() Bimport sys, os; sys.path.append(r"D:\cnf\chatbot-canifa-feedback\backend"); from common.pool_wrapper import get_pooled_connection_compat; conn = get_pooled_connection_compat(); cur = conn.cursor(); cur.execute("SELECT product_line, COUNT(*) FROM dashboard_canifa.ultra_descriptions WHERE LOWER(product_name) LIKE '%chống nắng%' OR LOWER(product_name) LIKE '%gió%' OR LOWER(product_line) LIKE '%chống nắng%' GROUP BY product_line"); print(cur.fetchall()); cur.close(); conn.close()
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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