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

feat: add dedicated store search tool + fix typo handling

- NEW: store_search_tool.py - query dedicated store table
- NEW: store_search_tool.txt - tool prompt
- UPDATE: brand_knowledge_tool.py - pure semantic search
- UPDATE: product_mapping.py - fuzzy match for typos
- UPDATE: system_prompt.txt - section 5.5.1 canifa_store_search
- UPDATE: get_tools.py + prompt_utils.py - register new tool
- UPDATE: promotion_canifa_tool.py - new salerule table
- All prompts pushed to Langfuse
parent 25cc0aa6
...@@ -270,7 +270,8 @@ async def chat_controller( ...@@ -270,7 +270,8 @@ async def chat_controller(
raw_content = streaming_callback.accumulated_content raw_content = streaming_callback.accumulated_content
if raw_content: if raw_content:
try: try:
ai_json = json.loads(raw_content) raw_normalized = raw_content.replace("{{", "{").replace("}}", "}")
ai_json = json.loads(raw_normalized)
if isinstance(ai_json, dict): if isinstance(ai_json, dict):
ai_text_response = ai_json.get("ai_response", ai_text_response) ai_text_response = ai_json.get("ai_response", ai_text_response)
if not final_product_ids and isinstance(ai_json.get("product_ids"), list): if not final_product_ids and isinstance(ai_json.get("product_ids"), list):
...@@ -289,7 +290,8 @@ async def chat_controller( ...@@ -289,7 +290,8 @@ async def chat_controller(
# Parse JSON-wrapped ai_response # Parse JSON-wrapped ai_response
if ai_text_response and ai_text_response.lstrip().startswith("{"): if ai_text_response and ai_text_response.lstrip().startswith("{"):
try: try:
ai_json = json.loads(ai_text_response) ai_normalized = ai_text_response.replace("{{", "{").replace("}}", "}")
ai_json = json.loads(ai_normalized)
if isinstance(ai_json, dict): if isinstance(ai_json, dict):
ai_text_response = ai_json.get("ai_response", ai_text_response) ai_text_response = ai_json.get("ai_response", ai_text_response)
if not final_product_ids and isinstance(ai_json.get("product_ids"), list): if not final_product_ids and isinstance(ai_json.get("product_ids"), list):
......
...@@ -58,8 +58,11 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) -> ...@@ -58,8 +58,11 @@ async def extract_and_save_user_insight(json_content: str, identity_key: str) ->
logger.info(f"🔄 [Background] Starting user_insight extraction for {identity_key}") logger.info(f"🔄 [Background] Starting user_insight extraction for {identity_key}")
try: try:
# Normalize double braces → single (LLM sometimes outputs {{ }} per prompt instruction)
normalized = json_content.replace("{{", "{").replace("}}", "}")
# Regex match user_insight object # Regex match user_insight object
insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', json_content, re.DOTALL) insight_match = re.search(r'"user_insight"\s*:\s*(\{.*?\})\s*}?\s*$', normalized, re.DOTALL)
if insight_match: if insight_match:
insight_json_str = insight_match.group(1) insight_json_str = insight_match.group(1)
......
...@@ -5,6 +5,7 @@ Tất cả resources (LLM, Tools) khởi tạo trong __init__. ...@@ -5,6 +5,7 @@ Tất cả resources (LLM, Tools) khởi tạo trong __init__.
Sử dụng ConversationManager (Postgres) để lưu history thay vì checkpoint. Sử dụng ConversationManager (Postgres) để lưu history thay vì checkpoint.
""" """
import hashlib
import logging import logging
from datetime import datetime from datetime import datetime
from typing import Any from typing import Any
...@@ -50,14 +51,28 @@ class CANIFAGraph: ...@@ -50,14 +51,28 @@ class CANIFAGraph:
self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True) self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True)
self.cache = InMemoryCache() self.cache = InMemoryCache()
# Chain caching: avoid rebuilding ChatPromptTemplate every turn
self._cached_chain = None
self._cached_prompt_hash: str | None = None
def _build_chain(self, system_prompt_template: str): def _build_chain(self, system_prompt_template: str):
"""Build chain with dynamic system prompt (fetched from Langfuse per request).""" """Build chain with dynamic system prompt (fetched from Langfuse per request).
Caches the chain and only rebuilds when prompt content changes.
"""
# Check if prompt changed via hash comparison
prompt_hash = hashlib.md5(system_prompt_template.encode()).hexdigest()
if self._cached_chain is not None and self._cached_prompt_hash == prompt_hash:
return self._cached_chain
logger.info("🔄 Building new chain (prompt hash changed)" if self._cached_chain else "🔧 Building initial chain")
prompt_template = ChatPromptTemplate.from_messages( prompt_template = ChatPromptTemplate.from_messages(
[ [
("system", system_prompt_template), ("system", system_prompt_template),
( (
"system", "system",
"===== USER INSIGHT (TỪ TURN TRƯỚC) =====\n⚡ BẮT BUỘC: Đọc [NEXT] bên dưới và THỰC HIỆN chiến lược đã lên kế hoạch!\n\n{user_insight}\n=====================================", "===== USER INSIGHT (TỪ TURN TRƯỚC) =====\n⚡ BẮT BUỘC: Đọc [LAST_ACTION] bên dưới để hiểu context turn trước, rồi tự suy ra bước tiếp theo!\n\n{user_insight}\n=====================================",
), ),
("system", "Chat History:"), ("system", "Chat History:"),
MessagesPlaceholder(variable_name="history"), MessagesPlaceholder(variable_name="history"),
...@@ -66,7 +81,9 @@ class CANIFAGraph: ...@@ -66,7 +81,9 @@ class CANIFAGraph:
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),
] ]
) )
return prompt_template | self.llm_with_tools self._cached_chain = prompt_template | self.llm_with_tools
self._cached_prompt_hash = prompt_hash
return self._cached_chain
async def _agent_node(self, state: AgentState, config: RunnableConfig) -> dict: async def _agent_node(self, state: AgentState, config: RunnableConfig) -> dict:
"""Agent node - Chỉ việc đổ dữ liệu riêng vào khuôn đã có sẵn.""" """Agent node - Chỉ việc đổ dữ liệu riêng vào khuôn đã có sẵn."""
...@@ -97,14 +114,13 @@ class CANIFAGraph: ...@@ -97,14 +114,13 @@ class CANIFAGraph:
logger.info(f"📸 [IMAGE] Image size: {len(img)} chars") logger.info(f"📸 [IMAGE] Image size: {len(img)} chars")
user_query = HumanMessage(content=multimodal_content) user_query = HumanMessage(content=multimodal_content)
logger.info(f"📸 [IMAGE] Injected {len(transient_images)} image(s) into user_query") logger.info(f"📸 [IMAGE] Injected {len(transient_images)} image(s) into user_query")
# Invoke chain with user_query, history, and messages
# Invoke chain with history, user_query, messages (scratchpad), and user_insight # Invoke chain with history, user_query, messages (scratchpad), and user_insight
user_insight_text = ( user_insight_text = (
state.get("user_insight") state.get("user_insight")
or "⚠️ TRẠNG THÁI KHỞI TẠO: Chưa có User Insight từ lịch sử. Hãy bắt đầu thu thập thông tin mới (Nếu thiếu thông tin thì ghi 'Chưa rõ')." or "⚠️ TRẠNG THÁI KHỞI TẠO: Chưa có User Insight từ lịch sử. Hãy bắt đầu thu thập thông tin mới (Nếu thiếu thông tin thì ghi 'Chưa rõ')."
) )
# Fetch prompt from Langfuse DYNAMICALLY per request (cache_ttl=0 → 30ms) # Fetch prompt from Langfuse (cached 5min by SDK) + build chain (cached by hash)
current_date_str = datetime.now().strftime("%d/%m/%Y") current_date_str = datetime.now().strftime("%d/%m/%Y")
system_prompt_template = get_system_prompt_template() system_prompt_template = get_system_prompt_template()
chain = self._build_chain(system_prompt_template) chain = self._build_chain(system_prompt_template)
...@@ -208,3 +224,13 @@ def get_graph_manager( ...@@ -208,3 +224,13 @@ def get_graph_manager(
def reset_graph() -> None: def reset_graph() -> None:
"""Reset singleton for testing.""" """Reset singleton for testing."""
_instance[0] = None _instance[0] = None
def reset_chain_cache() -> None:
"""Reset only the cached chain (when prompt changes).
Keeps the graph/LLM/tools intact, only forces chain rebuild on next request.
"""
if _instance[0] is not None:
_instance[0]._cached_chain = None
_instance[0]._cached_prompt_hash = None
logger.info("🔄 Chain cache cleared — will rebuild on next request")
""" """
Prompt Utilities — ALL prompts from Langfuse. Prompt Utilities — ALL prompts from Langfuse.
System prompt + Tool prompts, single source of truth. System prompt + Tool prompts, single source of truth.
Uses 60-second cache TTL — auto-detects Langfuse prompt changes within 1 minute.
""" """
import logging import logging
...@@ -17,6 +18,7 @@ LANGFUSE_TOOL_PROMPT_MAP = { ...@@ -17,6 +18,7 @@ LANGFUSE_TOOL_PROMPT_MAP = {
"check_is_stock": "canifa-tool-check-stock", "check_is_stock": "canifa-tool-check-stock",
"data_retrieval_tool": "canifa-tool-data-retrieval", "data_retrieval_tool": "canifa-tool-data-retrieval",
"promotion_canifa_tool": "canifa-tool-promotion", "promotion_canifa_tool": "canifa-tool-promotion",
"store_search_tool": "canifa-tool-store-search",
} }
_langfuse_client: Langfuse | None = None _langfuse_client: Langfuse | None = None
...@@ -31,15 +33,24 @@ def _get_langfuse() -> Langfuse: ...@@ -31,15 +33,24 @@ def _get_langfuse() -> Langfuse:
def get_system_prompt() -> str: def get_system_prompt() -> str:
"""System prompt với ngày hiện tại đã inject.""" """System prompt với ngày hiện tại đã inject."""
lf = _get_langfuse() lf = _get_langfuse()
prompt = lf.get_prompt(LANGFUSE_SYSTEM_PROMPT_NAME, label="production", cache_ttl_seconds=0) prompt = lf.get_prompt(LANGFUSE_SYSTEM_PROMPT_NAME, label="production", cache_ttl_seconds=60)
return prompt.compile(date_str=datetime.now().strftime("%d/%m/%Y")) return prompt.compile(date_str=datetime.now().strftime("%d/%m/%Y"))
def get_system_prompt_template() -> str: def get_system_prompt_template() -> str:
"""Template chưa replace date_str — dùng cho ChatPromptTemplate.""" """Template chưa replace date_str — dùng cho ChatPromptTemplate.
Langfuse SDK `.prompt` un-escapes {{ → { for non-variable content,
but LangChain ChatPromptTemplate needs {{ for literal braces.
So we re-escape ALL { } first, then convert only {{date_str}} → {date_str}.
"""
lf = _get_langfuse() lf = _get_langfuse()
prompt = lf.get_prompt(LANGFUSE_SYSTEM_PROMPT_NAME, label="production", cache_ttl_seconds=0) prompt = lf.get_prompt(LANGFUSE_SYSTEM_PROMPT_NAME, label="production", cache_ttl_seconds=60)
return prompt.prompt.replace("{{date_str}}", "{date_str}") # 1) Re-escape all curly braces for LangChain (literal { → {{, } → }})
raw = prompt.prompt.replace("{", "{{").replace("}", "}}")
# 2) Convert only the date_str variable back to LangChain format
# After step 1, {{date_str}} became {{{{date_str}}}} → convert to {date_str}
return raw.replace("{{{{date_str}}}}", "{date_str}")
def read_tool_prompt(filename: str, default_prompt: str = "") -> str: def read_tool_prompt(filename: str, default_prompt: str = "") -> str:
...@@ -51,7 +62,7 @@ def read_tool_prompt(filename: str, default_prompt: str = "") -> str: ...@@ -51,7 +62,7 @@ def read_tool_prompt(filename: str, default_prompt: str = "") -> str:
return default_prompt return default_prompt
lf = _get_langfuse() lf = _get_langfuse()
prompt = lf.get_prompt(langfuse_name, label="production", cache_ttl_seconds=0) prompt = lf.get_prompt(langfuse_name, label="production", cache_ttl_seconds=60)
return prompt.prompt return prompt.prompt
...@@ -78,3 +89,27 @@ def write_tool_prompt(filename: str, content: str) -> bool: ...@@ -78,3 +89,27 @@ def write_tool_prompt(filename: str, content: str) -> bool:
def list_tool_prompts() -> list[str]: def list_tool_prompts() -> list[str]:
"""List available tool prompt names.""" """List available tool prompt names."""
return sorted(LANGFUSE_TOOL_PROMPT_MAP.keys()) return sorted(LANGFUSE_TOOL_PROMPT_MAP.keys())
def force_refresh_prompts() -> str:
"""Force refresh ALL prompt caches by fetching with cache_ttl=0.
Call this after updating prompts on Langfuse to take effect immediately.
Returns the new system prompt template (LangChain-ready).
"""
lf = _get_langfuse()
# 1) Force refresh system prompt (bypasses SDK cache)
prompt = lf.get_prompt(LANGFUSE_SYSTEM_PROMPT_NAME, label="production", cache_ttl_seconds=0)
raw = prompt.prompt.replace("{", "{{").replace("}", "}}")
new_template = raw.replace("{{{{date_str}}}}", "{date_str}")
# 2) Force refresh all tool prompts
for name_key, langfuse_name in LANGFUSE_TOOL_PROMPT_MAP.items():
try:
lf.get_prompt(langfuse_name, label="production", cache_ttl_seconds=0)
logger.info(f"🔄 Force refreshed tool prompt: {name_key}")
except Exception as e:
logger.warning(f"⚠️ Failed to refresh tool prompt '{name_key}': {e}")
logger.info(f"✅ All prompts force refreshed (version: {prompt.version})")
return new_template
...@@ -30,9 +30,9 @@ class UserInsight(BaseModel): ...@@ -30,9 +30,9 @@ class UserInsight(BaseModel):
default="Chưa có", default="Chưa có",
description="Sản phẩm vừa mới hỏi/xem gần nhất" description="Sản phẩm vừa mới hỏi/xem gần nhất"
) )
NEXT: str = Field( LAST_ACTION: str = Field(
default="Cần hỏi thêm thông tin.", default="Chưa có hành động nào.",
description="Chiến lược tiếp theo của bot" description="Hành động bot VỪA thực hiện ở turn này (FACTUAL)"
) )
SUMMARY_HISTORY: str = Field( SUMMARY_HISTORY: str = Field(
default="", default="",
......
This diff is collapsed.
Tìm kiếm cửa hàng CANIFA theo địa điểm, khu vực, quận/huyện/tỉnh/thành phố.
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước.
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response.
Sử dụng tool này khi khách hàng hỏi về:
1. CỬA HÀNG Ở ĐÂU: "Có cửa hàng ở Hoàng Mai không?", "Canifa ở Cầu Giấy địa chỉ?"
2. TÌM CỬA HÀNG GẦN: "Gần đây có shop nào?", "Mình ở Hải Phòng mua ở đâu?"
3. GIỜ MỞ CỬA: "Shop mở cửa mấy giờ?", "Giờ hoạt động cửa hàng Vincom?"
4. ĐỊA CHỈ CỤ THỂ: "Cho địa chỉ cửa hàng ở Đà Nẵng", "Canifa Vincom có không?"
5. MUA OFFLINE: "Mình ra Chùa Bộc mua được không?", "Bên quận 7 có shop không?"
--- VÍ DỤ CÂU HỎI → BẮT BUỘC GỌI TOOL NÀY ---
- "Có cửa hàng bên quận Hoàng Mai shop ơi" → location: "Hoàng Mai"
- "Canifa ở Cầu Giấy địa chỉ ở đâu?" → location: "Cầu Giấy"
- "Mình ra Chùa Bộc mua được không?" → location: "Chùa Bộc"
- "Gần Vincom Bà Triệu có shop nào?" → location: "Vincom Bà Triệu"
- "Cho địa chỉ cửa hàng ở Đà Nẵng" → location: "Đà Nẵng"
- "Mình ở Hải Phòng mua ở đâu?" → location: "Hải Phòng"
- "Shop mở cửa mấy giờ?" → location: "" (trả về tất cả)
- "Có cửa hàng ở Vĩnh Phúc không?" → location: "Vĩnh Phúc"
- "Canifa Phúc Yên ở đâu?" → location: "Phúc Yên"
⚠️ KHÔNG DÙNG TOOL NÀY KHI:
- Khách hỏi TỒN KHO tại cửa hàng: "Shop Hoàng Mai còn size M không?" → redirect hotline!
- Khách hỏi CHÍNH SÁCH, KHUYẾN MÃI → dùng canifa_knowledge_search hoặc canifa_get_promotions
- Khách hỏi SẢN PHẨM → dùng data_retrieval_tool
Tham số:
- location (bắt buộc): Tên quận/huyện/tỉnh/thành phố/địa chỉ. Bỏ prefix "quận", "huyện", "tỉnh", "tp" khi truyền vào.
...@@ -11,12 +11,12 @@ from common.starrocks_connection import get_db_connection ...@@ -11,12 +11,12 @@ from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Constants # Constants
TOP_K_RESULTS = 4 TOP_K_RESULTS = 10
class KnowledgeSearchInput(BaseModel): class KnowledgeSearchInput(BaseModel):
query: str = Field( query: str = Field(
description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: tìm cửa hàng, hỏi chính sách, tra bảng size...). KHÔNG DÙNG ĐỂ TÌM SẢN PHẨM." description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: hỏi chính sách, tra bảng size...). KHÔNG DÙNG ĐỂ TÌM SẢN PHẨM. KHÔNG DÙNG ĐỂ TÌM CỬA HÀNG (dùng canifa_store_search)."
) )
...@@ -24,11 +24,12 @@ class KnowledgeSearchInput(BaseModel): ...@@ -24,11 +24,12 @@ class KnowledgeSearchInput(BaseModel):
async def canifa_knowledge_search(query: str) -> str: async def canifa_knowledge_search(query: str) -> str:
""" """
(Placeholder docstring - Actual prompt is loaded from file) (Placeholder docstring - Actual prompt is loaded from file)
Tra cứu thông tin thương hiệu Canifa (Cửa hàng, chính sách, KHTT...). Tra cứu thông tin thương hiệu Canifa (chính sách, KHTT, bảng size...).
KHÔNG dùng để tìm cửa hàng — dùng canifa_store_search.
""" """
logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}") logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}")
try: try:
# 1. Tạo embedding cho câu hỏi (Mặc định 1536 chiều như bro yêu cầu) # 1. Tạo embedding cho câu hỏi
query_vector = await create_embedding_async(query) query_vector = await create_embedding_async(query)
if not query_vector: if not query_vector:
return "Xin lỗi, tôi gặp sự cố khi xử lý thông tin. Vui lòng thử lại sau." return "Xin lỗi, tôi gặp sự cố khi xử lý thông tin. Vui lòng thử lại sau."
......
...@@ -154,13 +154,13 @@ async def _execute_single_search( ...@@ -154,13 +154,13 @@ async def _execute_single_search(
# Debug: Log first product to see fields # Debug: Log first product to see fields
if products: if products:
first_p = products[0] first_p = products[0]
logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys())) logger.debug("🔍 [DEBUG] First product keys: %s", list(first_p.keys()))
logger.info( logger.debug(
"🔍 [DEBUG] First product price: %s, sale_price: %s", "🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"), first_p.get("original_price"),
first_p.get("sale_price"), first_p.get("sale_price"),
) )
logger.info( logger.debug(
"🔍 [DEBUG] size_scale: %r, description_text: %r", "🔍 [DEBUG] size_scale: %r, description_text: %r",
first_p.get("size_scale"), first_p.get("size_scale"),
(first_p.get("description_text") or "")[:100], (first_p.get("description_text") or "")[:100],
......
...@@ -10,11 +10,12 @@ from .customer_info_tool import collect_customer_info ...@@ -10,11 +10,12 @@ from .customer_info_tool import collect_customer_info
from .data_retrieval_tool import data_retrieval_tool from .data_retrieval_tool import data_retrieval_tool
from .promotion_canifa_tool import canifa_get_promotions from .promotion_canifa_tool import canifa_get_promotions
from .check_is_stock import check_is_stock from .check_is_stock import check_is_stock
from .store_search_tool import canifa_store_search
def get_retrieval_tools() -> list[Tool]: def get_retrieval_tools() -> list[Tool]:
"""Các tool chỉ dùng để đọc/truy vấn dữ liệu (Có thể cache)""" """Các tool chỉ dùng để đọc/truy vấn dữ liệu (Có thể cache)"""
return [data_retrieval_tool, canifa_knowledge_search, canifa_get_promotions, check_is_stock] return [data_retrieval_tool, canifa_knowledge_search, canifa_get_promotions, check_is_stock, canifa_store_search]
def get_collection_tools() -> list[Tool]: def get_collection_tools() -> list[Tool]:
......
...@@ -21,12 +21,12 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = { ...@@ -21,12 +21,12 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo khoác nỉ có mũ": ["áo khoác nỉ có 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 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 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 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 gilet": ["áo khoác gilet"],
"Áo khoác dạ": ["áo khoác dạ"], "Á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 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ố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 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 khoác": ["áo khoác"],
"Áo giữ nhiệt": ["áo giữ nhiệt"], "Áo giữ nhiệt": ["áo giữ nhiệt"],
"Áo bra active": ["áo bra active"], "Áo bra active": ["áo bra active"],
......
...@@ -39,12 +39,11 @@ async def canifa_get_promotions(check_date: str = None) -> str: ...@@ -39,12 +39,11 @@ async def canifa_get_promotions(check_date: str = None) -> str:
try: try:
sql = f""" sql = f"""
SELECT SELECT
rule_id,
name, name,
description, description,
from_date, from_date,
to_date to_date
FROM shared_source.magento_salesrule FROM shared_source.chatbot_rsa_salerule_with_text_embedding
WHERE '{target_date}' >= DATE(from_date) WHERE '{target_date}' >= DATE(from_date)
AND '{target_date}' <= DATE(to_date) AND '{target_date}' <= DATE(to_date)
ORDER BY to_date ASC ORDER BY to_date ASC
......
import logging
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.prompt_utils import read_tool_prompt
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
STORE_TABLE = "shared_source.chatbot_rsa_store_schedule_with_text_embedding"
class StoreSearchInput(BaseModel):
location: str = Field(
description="Tên quận/huyện/tỉnh/thành phố hoặc địa chỉ cụ thể mà khách hàng muốn tìm cửa hàng CANIFA. VD: 'Hoàng Mai', 'Cầu Giấy', 'Đà Nẵng', 'Vincom Bà Triệu'."
)
@tool("canifa_store_search", args_schema=StoreSearchInput)
async def canifa_store_search(location: str) -> str:
"""
Tìm kiếm cửa hàng CANIFA theo địa điểm/khu vực.
Sử dụng khi khách hàng hỏi về cửa hàng tại một khu vực cụ thể.
"""
logger.info(f"🏪 [Store Search] Location: {location}")
try:
sr = get_db_connection()
# Clean location: bỏ prefix generic
clean = location.lower().strip()
for prefix in ["quận ", "huyện ", "tỉnh ", "thành phố ", "tp. ", "tp "]:
clean = clean.replace(prefix, "")
clean = clean.strip()
if not clean:
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
sql = f"""
SELECT store_name, address, city, state, phone_number,
schedule_name, time_open_today, time_close_today
FROM {STORE_TABLE}
WHERE LOWER(city) LIKE '%{clean}%'
OR LOWER(state) LIKE '%{clean}%'
OR LOWER(address) LIKE '%{clean}%'
OR LOWER(store_name) LIKE '%{clean}%'
ORDER BY state, city, store_name
LIMIT 20
"""
results = await sr.execute_query_async(sql)
logger.info(f"📊 Store search: {len(results)} stores found for '{location}'")
if not results:
return f"Không tìm thấy cửa hàng CANIFA tại khu vực '{location}'. Khách hàng có thể liên hệ hotline 1800 6061 để được hỗ trợ tìm cửa hàng gần nhất."
# Format kết quả rõ ràng cho LLM
lines = []
for r in results:
name = r.get("store_name", "")
addr = r.get("address", "")
city = r.get("city", "")
state = r.get("state", "")
phone = r.get("phone_number", "")
schedule = r.get("schedule_name", "")
t_open = r.get("time_open_today", "")
t_close = r.get("time_close_today", "")
store_info = f"🏪 {name}\n"
store_info += f" 📍 Địa chỉ: {addr}"
if city:
store_info += f", {city}"
if state:
store_info += f", {state}"
store_info += f"\n 📞 ĐT: {phone}"
store_info += f"\n 🕐 Lịch: {schedule}"
if t_open and t_close:
store_info += f" (Hôm nay: {t_open}-{t_close})"
lines.append(store_info)
return f"Tìm thấy {len(results)} cửa hàng CANIFA tại khu vực '{location}':\n\n" + "\n\n".join(lines)
except Exception as e:
logger.error(f"❌ Error in canifa_store_search: {e}")
return "Tôi đang gặp khó khăn khi tìm kiếm cửa hàng. Bạn có thể liên hệ hotline 1800 6061 để được hỗ trợ."
canifa_store_search.__doc__ = read_tool_prompt("store_search_tool") or canifa_store_search.__doc__
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel from pydantic import BaseModel
from agent.prompt_utils import get_system_prompt_template, LANGFUSE_SYSTEM_PROMPT_NAME, _get_langfuse from agent.prompt_utils import get_system_prompt_template, force_refresh_prompts, LANGFUSE_SYSTEM_PROMPT_NAME, _get_langfuse
from agent.graph import reset_chain_cache
router = APIRouter() router = APIRouter()
...@@ -46,3 +47,21 @@ async def update_system_prompt_content(request: Request, body: PromptUpdateReque ...@@ -46,3 +47,21 @@ async def update_system_prompt_content(request: Request, body: PromptUpdateReque
} }
except Exception as e: except Exception as e:
raise HTTPException(status_code=500, detail=str(e)) raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/prompt/refresh")
async def refresh_prompt_cache(request: Request):
"""Force refresh all prompt caches immediately.
Call this after updating prompts on Langfuse to take effect right away.
"""
try:
# 1) Force refresh Langfuse SDK cache (all prompts)
force_refresh_prompts()
# 2) Clear graph chain cache so it rebuilds with new prompt
reset_chain_cache()
return {
"status": "success",
"message": "All prompt caches refreshed. Next request will use the latest prompts.",
}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# CANIFA Chatbot API Documentation
API hệ thống chatbot tư vấn thời trang CANIFA - CiCi Assistant.
## Base URL
```
http://localhost:8000
```
---
## API Endpoints
### 1. Chat với Chatbot
**Endpoint:** `POST /chat`
**Mô tả:** Gửi tin nhắn tới chatbot và nhận phản hồi tư vấn thời trang cùng danh sách sản phẩm liên quan.
#### Request Body
```json
{
"user_id": "string",
"user_query": "string"
}
```
**Parameters:**
| Field | Type | Required | Mô tả |
|-------|------|----------|-------|
| `user_id` | string | ✅ | ID định danh người dùng (dùng để lưu lịch sử chat) |
| `user_query` | string | ✅ | Nội dung tin nhắn của người dùng |
**Ví dụ Request:**
```json
{
"user_id": "user_12345",
"user_query": "Cho em xem áo sơ mi nam dưới 5a00k"
}
```
#### Response
**Success Response (200 OK):**
```json
{
"status": "success",
"ai_response": "string",
"product_ids": ["string"]
}
```
**Response Fields:**
| Field | Type | Mô tả |
|-------|------|-------|
| `status` | string | Trạng thái xử lý request (`"success"` hoặc `"error"`) |
| `ai_response` | string | Câu trả lời của chatbot (văn bản tư vấn) |
| `product_ids` | array[string] | Danh sách mã sản phẩm được đề xuất (internal_ref_code) |
**Ví dụ Response:**
```json
{
"status": "success",
"ai_response": "Em chào anh! Em đã tìm thấy một số mẫu áo sơ mi nam đẹp trong tầm giá dưới 500k:\n\n1. Áo Sơ Mi Nam Cotton - 399.000đ\n2. Áo Sơ Mi Slim Fit - 449.000đ\n\nCác sản phẩm này đều là chất liệu cotton thoáng mát, phù hợp cho mùa hè ạ!",
"product_ids": ["SM12345", "SM12346"]
}
```
**Error Response (500 Internal Server Error):**
```json
{
"status": "error",
"ai_response": "Xin lỗi, đã có lỗi xảy ra. Vui lòng thử lại sau.",
"product_ids": []
}
```
---
### 2. Lấy Lịch Sử Chat
**Endpoint:** `GET /history/{user_id}`
**Mô tả:** Lấy lịch sử chat của người dùng với phân trang cursor-based.
#### Path Parameters
| Parameter | Type | Required | Mô tả |
|-----------|------|----------|-------|
| `user_id` | string | ✅ | ID người dùng cần lấy lịch sử |
#### Query Parameters
| Parameter | Type | Required | Default | Mô tả |
|-----------|------|----------|---------|-------|
| `limit` | integer | ❌ | 50 | Số lượng tin nhắn tối đa mỗi trang (1-100) |
| `before_id` | integer | ❌ | null | ID của tin nhắn để lấy các tin nhắn trước đó (dùng cho phân trang) |
**Ví dụ Request:**
```
GET /history/user_12345?limit=20&before_id=150
```
#### Response
**Success Response (200 OK):**
```json
{
"data": [
{
"id": 149,
"user_id": "user_12345",
"message": "Cho em xem áo sơ mi nam",
"is_human": true,
"timestamp": "2025-12-25T14:30:00"
},
{
"id": 148,
"user_id": "user_12345",
"message": "Em đã tìm thấy một số mẫu áo sơ mi nam đẹp...",
"is_human": false,
"timestamp": "2025-12-25T14:30:02"
}
],
"next_cursor": 130
}
```
**Response Fields:**
| Field | Type | Mô tả |
|-------|------|-------|
| `data` | array[object] | Danh sách tin nhắn chat (sắp xếp từ mới → cũ) |
| `data[].id` | integer | ID duy nhất của tin nhắn |
| `data[].user_id` | string | ID người dùng |
| `data[].message` | string | Nội dung tin nhắn |
| `data[].is_human` | boolean | `true` = tin nhắn của người dùng, `false` = tin nhắn của bot |
| `data[].timestamp` | string | Thời gian gửi tin nhắn (ISO 8601 format) |
| `next_cursor` | integer \| null | ID của tin nhắn cuối cùng (dùng làm `before_id` cho request tiếp theo). `null` nếu hết dữ liệu |
---
## Phân Trang (Pagination)
API sử dụng **cursor-based pagination** để lấy lịch sử chat:
### Cách hoạt động:
1. **Request đầu tiên** - Lấy 20 tin nhắn mới nhất:
```
GET /history/user_12345?limit=20
```
Response:
```json
{
"data": [...], // 20 tin nhắn (ID: 200 → 181)
"next_cursor": 181
}
```
2. **Request tiếp theo** - Lấy 20 tin nhắn cũ hơn:
```
GET /history/user_12345?limit=20&before_id=181
```
Response:
```json
{
"data": [...], // 20 tin nhắn (ID: 180 → 161)
"next_cursor": 161
}
```
3. **Request cuối cùng** - Khi hết dữ liệu:
```json
{
"data": [...], // 5 tin nhắn còn lại
"next_cursor": null
}
```
### Logic phân trang:
- `next_cursor` luôn là **ID của tin nhắn cuối cùng** trong `data`
- Dùng `next_cursor` làm `before_id` cho request tiếp theo
- Khi `next_cursor = null` → đã hết dữ liệu
---
## Chat Workflow
```mermaid
graph LR
A[User gửi message] --> B[POST /chat]
B --> C{Agent xử lý}
C --> D[Tìm kiếm sản phẩm]
C --> E[Trả lời tư vấn]
D --> F[Trích xuất product_ids]
E --> F
F --> G[Response: ai_response + product_ids]
G --> H[Lưu vào PostgreSQL]
H --> I[Trả về client]
```
### Quy trình xử lý:
1. User gửi tin nhắn qua API `/chat`
2. Hệ thống agent phân tích intent
3. Nếu cần tìm sản phẩm → Gọi `data_retrieval_tool` với tham số phù hợp
4. Agent tổng hợp thông tin → Trả lời tư vấn
5. Trích xuất `product_ids` từ kết quả tìm kiếm
6. Lưu lịch sử chat vào PostgreSQL (background task)
7. Trả về JSON với `ai_response` và `product_ids`
---
## Error Handling
### Error Response Format
```json
{
"status": "error",
"ai_response": "Mô tả lỗi hoặc thông báo fallback",
"product_ids": []
}
```
### HTTP Status Codes
| Code | Ý nghĩa | Khi nào xảy ra |
|------|---------|----------------|
| 200 | OK | Request thành công |
| 400 | Bad Request | Thiếu `user_id` hoặc `user_query` |
| 500 | Internal Server Error | Lỗi hệ thống (database, LLM, ...) |
---
## Ví Dụ Sử Dụng
### Python
```python
import requests
# Chat với bot
response = requests.post("http://localhost:8000/chat", json={
"user_id": "user_12345",
"user_query": "Cho em xem váy đầm dự tiệc dưới 1 triệu"
})
data = response.json()
print(f"Bot: {data['ai_response']}")
print(f"Sản phẩm: {data['product_ids']}")
# Lấy lịch sử chat
history = requests.get("http://localhost:8000/history/user_12345?limit=10")
messages = history.json()["data"]
for msg in messages:
sender = "User" if msg["is_human"] else "Bot"
print(f"{sender}: {msg['message']}")
```
### JavaScript (Fetch API)
```javascript
// Chat với bot
const response = await fetch('http://localhost:8000/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_id: 'user_12345',
user_query: 'Cho em xem áo khoác nữ'
})
});
const data = await response.json();
console.log('Bot:', data.ai_response);
console.log('Products:', data.product_ids);
// Lấy lịch sử chat (phân trang)
let cursor = null;
const allMessages = [];
do {
const url = cursor
? `http://localhost:8000/history/user_12345?limit=50&before_id=${cursor}`
: `http://localhost:8000/history/user_12345?limit=50`;
const historyResponse = await fetch(url);
const { data: messages, next_cursor } = await historyResponse.json();
allMessages.push(...messages);
cursor = next_cursor;
} while (cursor !== null);
console.log(`Tổng số tin nhắn: ${allMessages.length}`);
```
### cURL
```bash
# Chat với bot
curl -X POST "http://localhost:8000/chat" \
-H "Content-Type: application/json" \
-d '{
"user_id": "user_12345",
"user_query": "Cho em xem giày thể thao nam"
}'
# Lấy lịch sử chat
curl "http://localhost:8000/history/user_12345?limit=20"
# Lấy trang tiếp theo
curl "http://localhost:8000/history/user_12345?limit=20&before_id=150"
```
---
## Notes
### 1. Product IDs
- `product_ids` trả về danh sách `internal_ref_code` (mã sản phẩm nội bộ)
- Frontend có thể dùng để hiển thị carousel sản phẩm hoặc link đến trang chi tiết
- Nếu không tìm thấy sản phẩm → `product_ids = []`
### 2. Conversation History
- Lịch sử chat được lưu tự động sau mỗi cuộc hội thoại (background task)
- Dữ liệu lưu trong PostgreSQL với index trên `user_id` và `id`
- Sắp xếp theo thứ tự mới nhất → cũ nhất
### 3. Rate Limiting
- Hiện tại chưa có rate limiting
- Khuyến nghị implement rate limit khi deploy production
### 4. Authentication
- Hiện tại API không yêu cầu authentication
- `user_id` do client tự generate và gửi lên
- Khuyến nghị: Tích hợp Clerk Auth hoặc JWT token cho production
---
## Environment Variables
```bash
# PostgreSQL
POSTGRES_HOST=localhost
POSTGRES_PORT=5432
POSTGRES_DB=chatbot_db
POSTGRES_USER=postgres
POSTGRES_PASSWORD=your_password
# OpenAI
OPENAI_API_KEY=sk-...
# StarRocks (Vector Database)
STARROCKS_HOST=localhost
STARROCKS_PORT=9030
STARROCKS_USER=root
STARROCKS_PASSWORD=your_password
STARROCKS_DB=chatbot_products
# Server
PORT=8000
HOST=0.0.0.0
```
---
## Testing
Truy cập `http://localhost:8000/static/index.html` để test chatbot qua UI đơn giản.
This diff is collapsed.
"""Test: Kiểm tra embedding search có match được sai chính tả không"""
import asyncio, json, sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.embedding_service import create_embedding_async
from common.starrocks_connection import get_db_connection
output_lines = []
async def search_products(query_text, sr):
v = await create_embedding_async(query_text)
if not v:
output_lines.append(" ERROR: embedding failed")
return
v_str = "[" + ",".join(str(x) for x in v) + "]"
sql = f"""
SELECT magento_ref_code, product_name, master_color, gender_by_product, age_by_product,
approx_cosine_similarity(vector, {v_str}) as score
FROM shared_source.magento_product_dimension_with_text_embedding
ORDER BY score DESC LIMIT 5
"""
rows = await sr.execute_query_async(sql)
for i, r in enumerate(rows):
line = f" #{i+1} [score={r.get('score','?'):.4f}] {r['magento_ref_code']} | {r['product_name']} | {r.get('master_color','')} | {r.get('gender_by_product','')}/{r.get('age_by_product','')}"
output_lines.append(line)
async def main():
sr = get_db_connection()
tests = [
"Ao khoac tran bong cho tre em",
"Ao khoac chan bong cho tre em",
"Ao khoac chan bong",
]
for query in tests:
output_lines.append("=" * 80)
output_lines.append(f"QUERY: '{query}'")
output_lines.append("-" * 80)
await search_products(query, sr)
output_lines.append("")
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(main())
finally:
loop.close()
# Write output with UTF-8
out_path = os.path.join(os.path.dirname(__file__), "test_output.txt")
with open(out_path, "w", encoding="utf-8") as f:
f.write("\n".join(output_lines))
print(f"Output written to {out_path}")
QUERY: 'có cửa hàng bên quận hoàng mai shop ơi' → KEYWORDS: ['hoàng', 'mai']
AND: 4 rows | OR: 19 rows
QUERY: 'cửa hàng quận Hoàng Mai' → KEYWORDS: ['hoàng', 'mai']
AND: 4 rows | OR: 19 rows
QUERY: 'Canifa ở Cầu Giấy địa chỉ ở đâu?' → KEYWORDS: ['cầu', 'giấy']
AND: 10 rows | OR: 40 rows
QUERY: 'bảng size áo nam' → KEYWORDS: ['áo', 'nam']
AND: 4 rows | OR: 76 rows
"""Check Hoàng Mai in store table"""
import asyncio, sys, os
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from common.starrocks_connection import get_db_connection
async def main():
sr = get_db_connection()
rows = await sr.execute_query_async("""
SELECT store_name, address, city, state, phone_number, schedule_name
FROM shared_source.chatbot_rsa_store_schedule_with_text_embedding
WHERE LOWER(city) LIKE '%hoàng mai%'
OR LOWER(address) LIKE '%hoàng mai%'
OR LOWER(store_name) LIKE '%hoàng mai%'
""")
print(f"Found: {len(rows)}")
for r in rows:
print(r)
# Also check Phúc Yên
print("\n=== PHÚC YÊN ===")
rows2 = await sr.execute_query_async("""
SELECT store_name, address, city, state
FROM shared_source.chatbot_rsa_store_schedule_with_text_embedding
WHERE LOWER(city) LIKE '%phúc yên%' OR LOWER(state) LIKE '%vĩnh phúc%'
""")
print(f"Found: {len(rows2)}")
for r in rows2:
print(r)
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(main())
finally:
loop.close()
================================================================================
QUERY: 'Ao khoac tran bong cho tre em'
--------------------------------------------------------------------------------
#1 [score=0.4786] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Xám/ Gray | unisex/kid
#2 [score=0.4713] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Xanh da trời/ Blue | unisex/kid
#3 [score=0.4685] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Trắng/ White | unisex/kid
#4 [score=0.4684] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Hồng/ Pink- Magenta | unisex/kid
#5 [score=0.4677] 3TS25C001 | Áo phông unisex trẻ em có hình in đứng phom | Xanh da trời/ Blue | unisex/kid
================================================================================
QUERY: 'Ao khoac chan bong cho tre em'
--------------------------------------------------------------------------------
#1 [score=0.4885] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Xám/ Gray | unisex/kid
#2 [score=0.4822] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Xanh da trời/ Blue | unisex/kid
#3 [score=0.4803] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Đen/ Black | unisex/kid
#4 [score=0.4779] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Trắng/ White | unisex/kid
#5 [score=0.4744] 3OT25W001 | Áo khoác chần bông unisex trẻ em chống thấm nước | Hồng/ Pink- Magenta | unisex/kid
================================================================================
QUERY: 'Ao khoac chan bong'
--------------------------------------------------------------------------------
#1 [score=0.5110] 8OT25W016 | Áo khoác chần bông nam chống thấm nước | Xanh da trời/ Blue | men/adult
#2 [score=0.5093] 8OT25W016 | Áo khoác chần bông nam chống thấm nước | Xám/ Gray | men/adult
#3 [score=0.5053] 8OT25W016 | Áo khoác chần bông nam chống thấm nước | Đen/ Black | men/adult
#4 [score=0.5050] 8OT25W016 | Áo khoác chần bông nam chống thấm nước | Be/ Beige | men/adult
#5 [score=0.4976] 2OT25C004-SK010 | Áo khoác chần bông bé trai chống thấm nước | Đen/ Black | boy/kid
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