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

Feat: Add Dynamic Prompt Management System & UI Editor

parent 3ebbd3e4
...@@ -3,22 +3,23 @@ Fashion Q&A Agent Controller ...@@ -3,22 +3,23 @@ Fashion Q&A Agent Controller
Langfuse will auto-trace via LangChain integration (no code changes needed). Langfuse will auto-trace via LangChain integration (no code changes needed).
""" """
import json
import logging import logging
import time import time
import uuid import uuid
from fastapi import BackgroundTasks from fastapi import BackgroundTasks
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from common.conversation_manager import ConversationManager, get_conversation_manager from common.cache import redis_cache
from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler from common.langfuse_client import get_callback_handler
from common.llm_factory import create_llm from common.llm_factory import create_llm
from config import DEFAULT_MODEL from config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON
from langfuse import propagate_attributes from langfuse import propagate_attributes
from .graph import build_graph from .graph import build_graph
from .helper import extract_product_ids, handle_post_chat_async, parse_ai_response
from .models import AgentState, get_config from .models import AgentState, get_config
from .tools.get_tools import get_all_tools from .tools.get_tools import get_all_tools
...@@ -31,31 +32,42 @@ async def chat_controller( ...@@ -31,31 +32,42 @@ async def chat_controller(
background_tasks: BackgroundTasks, background_tasks: BackgroundTasks,
model_name: str = DEFAULT_MODEL, model_name: str = DEFAULT_MODEL,
images: list[str] | None = None, images: list[str] | None = None,
identity_key: str | None = None,
) -> dict: ) -> dict:
""" """
Controller main logic for non-streaming chat requests. Controller main logic for non-streaming chat requests.
Tạm thời bỏ lớp cache để đơn giản luồng xử lý: Flow:
- Nhận query → Gọi LLM qua graph. 1. Check cache (if enabled) → HIT: return cached response
- Lưu lịch sử hội thoại ở background. 2. MISS: Call LLM → Save to cache → Return response
Args:
identity_key: Key for saving/loading history (identity.history_key)
Guest: device_id, User: user_id
""" """
logger.info("chat_controller start: model=%s, user_id=%s", model_name, user_id) effective_identity_key = identity_key or user_id
# ====================== CACHE LAYER (TẠM THỜI TẮT) ====================== logger.info(
# from common.cache import redis_cache "chat_controller start: model=%s, user_id=%s, identity_key=%s",
# model_name, user_id, effective_identity_key
# cached_response = await redis_cache.get_response(user_id=user_id, query=query) )
# if cached_response:
# # CACHE HIT - Return immediately # ====================== CACHE LAYER ======================
# memory = await get_conversation_manager() if REDIS_CACHE_TURN_ON:
# background_tasks.add_task( cached_response = await redis_cache.get_response(
# _handle_post_chat_async, user_id=effective_identity_key, query=query
# memory=memory, )
# user_id=user_id, if cached_response:
# human_query=query, logger.info(f"⚡ CACHE HIT for identity_key={effective_identity_key}")
# ai_msg=AIMessage(content=cached_response["ai_response"]), memory = await get_conversation_manager()
# ) background_tasks.add_task(
# return {**cached_response, "cached": True} handle_post_chat_async,
memory=memory,
identity_key=effective_identity_key,
human_query=query,
ai_response=cached_response,
)
return {**cached_response, "cached": True}
# ====================== NORMAL LLM FLOW ====================== # ====================== NORMAL LLM FLOW ======================
logger.info("chat_controller: proceed with live LLM call") logger.info("chat_controller: proceed with live LLM call")
...@@ -63,43 +75,33 @@ async def chat_controller( ...@@ -63,43 +75,33 @@ async def chat_controller(
config = get_config() config = get_config()
config.model_name = model_name config.model_name = model_name
# Enable JSON mode to ensure structured output
llm = create_llm(model_name=model_name, streaming=False, json_mode=True) llm = create_llm(model_name=model_name, streaming=False, json_mode=True)
tools = get_all_tools() tools = get_all_tools()
graph = build_graph(config, llm=llm, tools=tools) graph = build_graph(config, llm=llm, tools=tools)
# Init ConversationManager (Singleton) # Init ConversationManager (Singleton)
memory = await get_conversation_manager() memory = await get_conversation_manager()
# LOAD HISTORY & Prepare State # Load History
history_dicts = await memory.get_chat_history(user_id, limit=20) history_dicts = await memory.get_chat_history(effective_identity_key, limit=20)
messages = [] messages = [
for m in history_dicts: HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"])
if m["is_human"]: # Original code used 'is_human', new code used 'role' for m in history_dicts
messages.append(HumanMessage(content=m["message"])) ]
else:
messages.append(AIMessage(content=m["message"]))
# Prepare initial state and execution config for the graph run. # Prepare State
initial_state: AgentState = { initial_state: AgentState = {
"user_query": HumanMessage(content=query), "user_query": HumanMessage(content=query),
"messages": messages + [HumanMessage(content=query)], "messages": messages + [HumanMessage(content=query)],
"history": messages, # The new code uses 'messages' for history, which is correct "history": messages,
"user_id": user_id, "user_id": user_id,
"images_embedding": [], "images_embedding": [],
"ai_response": None, "ai_response": None,
} }
run_id = str(uuid.uuid4()) run_id = str(uuid.uuid4())
# Metadata for LangChain (tags for logging/filtering)
metadata = {
"run_id": run_id,
"tags": "chatbot,production",
}
langfuse_handler = get_callback_handler() langfuse_handler = get_callback_handler()
exec_config = RunnableConfig( exec_config = RunnableConfig(
configurable={ configurable={
"user_id": user_id, "user_id": user_id,
...@@ -107,145 +109,48 @@ async def chat_controller( ...@@ -107,145 +109,48 @@ async def chat_controller(
"run_id": run_id, "run_id": run_id,
}, },
run_id=run_id, run_id=run_id,
metadata=metadata, metadata={"run_id": run_id, "tags": "chatbot,production"},
callbacks=[langfuse_handler] if langfuse_handler else [], callbacks=[langfuse_handler] if langfuse_handler else [],
) )
# Execute graph với Langfuse user tracking # Execute Graph
# Dùng propagate_attributes để tự động gán user_id cho tất cả observations
start_time = time.time() start_time = time.time()
# Generate session_id từ user_id + run_id (có thể thay bằng conversation_id nếu có)
session_id = f"{user_id}-{run_id[:8]}" session_id = f"{user_id}-{run_id[:8]}"
with propagate_attributes(user_id=user_id, session_id=session_id): with propagate_attributes(user_id=user_id, session_id=session_id):
result = await graph.ainvoke(initial_state, config=exec_config) result = await graph.ainvoke(initial_state, config=exec_config)
duration = time.time() - start_time duration = time.time() - start_time
# Parse AI response (expected JSON from chat_controller logic) # Parse Response
all_product_ids = _extract_product_ids(result.get("messages", [])) all_product_ids = extract_product_ids(result.get("messages", []))
ai_raw_content = result.get("ai_response").content if result.get("ai_response") else "" ai_raw_content = result.get("ai_response").content if result.get("ai_response") else ""
logger.debug("raw ai output: %s", ai_raw_content) ai_text_response, final_product_ids = parse_ai_response(ai_raw_content, all_product_ids)
# Standardize output
ai_text_response = ai_raw_content
final_product_ids = all_product_ids
try:
# Try to parse if it's a JSON string from LLM
ai_json = json.loads(ai_raw_content)
ai_text_response = ai_json.get("ai_response", ai_raw_content)
explicit_ids = ai_json.get("product_ids", [])
if explicit_ids and isinstance(explicit_ids, list):
# Merge with extracted IDs if needed or replace
final_product_ids = explicit_ids
except:
pass
response_payload = { response_payload = {
"ai_response": ai_text_response, "ai_response": ai_text_response,
"product_ids": final_product_ids, "product_ids": final_product_ids,
} }
# ====================== STORE LAYER 1 CACHE (TẠM THỜI TẮT) ====================== # ====================== SAVE TO CACHE ======================
# Cache for 5 minutes (300s) - Short enough for stock safety if REDIS_CACHE_TURN_ON:
# await redis_cache.set_response(user_id=user_id, query=query, response_data=response_payload, ttl=300) await redis_cache.set_response(
user_id=effective_identity_key,
# Add to history in background - lưu nguyên response JSON query=query,
response_data=response_payload,
ttl=300
)
logger.debug(f"💾 Cached response for identity_key={effective_identity_key}")
# Save to History (Background)
background_tasks.add_task( background_tasks.add_task(
_handle_post_chat_async, handle_post_chat_async,
memory=memory, memory=memory,
user_id=user_id, identity_key=effective_identity_key,
human_query=query, human_query=query,
ai_response=response_payload, # dict: {ai_response, product_ids} ai_response=response_payload,
) )
logger.info("chat_controller finished in %.2fs", duration) logger.info("chat_controller finished in %.2fs", duration)
return {**response_payload, "cached": False} return {**response_payload, "cached": False}
def _extract_product_ids(messages: list) -> list[dict]:
"""
Extract full product info from tool messages (data_retrieval_tool results).
Returns list of product objects with: sku, name, price, sale_price, url, thumbnail_image_url.
"""
products = []
seen_skus = set()
for msg in messages:
if isinstance(msg, ToolMessage):
try:
# Tool result is JSON string
tool_result = json.loads(msg.content)
# Check if tool returned products
if tool_result.get("status") == "success" and "products" in tool_result:
for product in tool_result["products"]:
sku = product.get("internal_ref_code")
if sku and sku not in seen_skus:
seen_skus.add(sku)
# Extract full product info
product_obj = {
"sku": sku,
"name": product.get("magento_product_name", ""),
"price": product.get("price_vnd", 0),
"sale_price": product.get("sale_price_vnd"), # null nếu không sale
"url": product.get("magento_url_key", ""),
"thumbnail_image_url": product.get("thumbnail_image_url", ""),
}
products.append(product_obj)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.debug(f"Could not parse tool message for products: {e}")
continue
return products
def _prepare_execution_context(query: str, user_id: str, history: list, images: list | None):
"""Prepare initial state and execution config for the graph run."""
initial_state: AgentState = {
"user_query": HumanMessage(content=query),
"messages": [HumanMessage(content=query)],
"history": history,
"user_id": user_id,
"images_embedding": [],
"ai_response": None,
}
run_id = str(uuid.uuid4())
# Metadata for LangChain (tags for logging/filtering)
metadata = {
"run_id": run_id,
"tags": "chatbot,production",
}
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
configurable={
"user_id": user_id,
"transient_images": images or [],
"run_id": run_id,
},
run_id=run_id,
metadata=metadata,
callbacks=[langfuse_handler] if langfuse_handler else [],
)
return initial_state, exec_config
async def _handle_post_chat_async(
memory: ConversationManager, user_id: str, human_query: str, ai_response: dict | None
):
"""
Save chat history in background task after response is sent.
Lưu AI response dưới dạng JSON string.
"""
if ai_response:
try:
# Convert dict thành JSON string để lưu vào TEXT field
ai_response_json = json.dumps(ai_response, ensure_ascii=False)
await memory.save_conversation_turn(user_id, human_query, ai_response_json)
logger.debug(f"Saved conversation for user {user_id}")
except Exception as e:
logger.error(f"Failed to save conversation for user {user_id}: {e}", exc_info=True)
"""
Agent Helper Functions
Các hàm tiện ích cho chat controller.
"""
import json
import logging
import uuid
from langchain_core.messages import HumanMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from common.conversation_manager import ConversationManager
from common.langfuse_client import get_callback_handler
from .models import AgentState
logger = logging.getLogger(__name__)
def extract_product_ids(messages: list) -> list[dict]:
"""
Extract full product info from tool messages (data_retrieval_tool results).
Returns list of product objects with: sku, name, price, sale_price, url, thumbnail_image_url.
"""
products = []
seen_skus = set()
for msg in messages:
if isinstance(msg, ToolMessage):
try:
# Tool result is JSON string
tool_result = json.loads(msg.content)
# Check if tool returned products
if tool_result.get("status") == "success" and "products" in tool_result:
for product in tool_result["products"]:
sku = product.get("internal_ref_code")
if sku and sku not in seen_skus:
seen_skus.add(sku)
# Extract full product info
product_obj = {
"sku": sku,
"name": product.get("magento_product_name", ""),
"price": product.get("price_vnd", 0),
"sale_price": product.get("sale_price_vnd"), # null nếu không sale
"url": product.get("magento_url_key", ""),
"thumbnail_image_url": product.get("thumbnail_image_url", ""),
}
products.append(product_obj)
except (json.JSONDecodeError, KeyError, TypeError) as e:
logger.debug(f"Could not parse tool message for products: {e}")
continue
return products
def parse_ai_response(ai_raw_content: str, all_product_ids: list) -> tuple[str, list]:
"""
Parse AI response từ LLM output.
Args:
ai_raw_content: Raw content từ AI response
all_product_ids: Product IDs extracted từ tool messages
Returns:
tuple: (ai_text_response, final_product_ids)
"""
ai_text_response = ai_raw_content
final_product_ids = all_product_ids
try:
# Try to parse if it's a JSON string from LLM
ai_json = json.loads(ai_raw_content)
ai_text_response = ai_json.get("ai_response", ai_raw_content)
explicit_ids = ai_json.get("product_ids", [])
if explicit_ids and isinstance(explicit_ids, list):
# Replace with explicit IDs from LLM
final_product_ids = explicit_ids
except (json.JSONDecodeError, TypeError):
pass
return ai_text_response, final_product_ids
def prepare_execution_context(query: str, user_id: str, history: list, images: list | None):
"""
Prepare initial state and execution config for the graph run.
Returns:
tuple: (initial_state, exec_config)
"""
initial_state: AgentState = {
"user_query": HumanMessage(content=query),
"messages": [HumanMessage(content=query)],
"history": history,
"user_id": user_id,
"images_embedding": [],
"ai_response": None,
}
run_id = str(uuid.uuid4())
# Metadata for LangChain (tags for logging/filtering)
metadata = {
"run_id": run_id,
"tags": "chatbot,production",
}
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
configurable={
"user_id": user_id,
"transient_images": images or [],
"run_id": run_id,
},
run_id=run_id,
metadata=metadata,
callbacks=[langfuse_handler] if langfuse_handler else [],
)
return initial_state, exec_config
async def handle_post_chat_async(
memory: ConversationManager,
identity_key: str,
human_query: str,
ai_response: dict | None
):
"""
Save chat history in background task after response is sent.
Lưu AI response dưới dạng JSON string.
"""
if ai_response:
try:
# Convert dict thành JSON string để lưu vào TEXT field
ai_response_json = json.dumps(ai_response, ensure_ascii=False)
await memory.save_conversation_turn(identity_key, human_query, ai_response_json)
logger.debug(f"Saved conversation for identity_key {identity_key}")
except Exception as e:
logger.error(f"Failed to save conversation for identity_key {identity_key}: {e}", exc_info=True)
""" """
CiCi Fashion Consultant - System Prompt CiCi Fashion Consultant - System Prompt
Tư vấn thời trang CANIFA chuyên nghiệp Tư vấn thời trang CANIFA chuyên nghiệp
Version 2.0 - Clean & Concise Version 3.0 - Dynamic from File
""" """
import os
from datetime import datetime from datetime import datetime
PROMPT_FILE_PATH = os.path.join(os.path.dirname(__file__), "system_prompt.txt")
def get_system_prompt() -> str: def get_system_prompt() -> str:
""" """
System prompt cho CiCi Fashion Agent System prompt cho CiCi Fashion Agent
Đọc từ file system_prompt.txt để có thể update dynamic.
Returns: Returns:
str: System prompt với ngày hiện tại str: System prompt với ngày hiện tại
...@@ -17,202 +20,18 @@ def get_system_prompt() -> str: ...@@ -17,202 +20,18 @@ def get_system_prompt() -> str:
now = datetime.now() now = datetime.now()
date_str = now.strftime("%d/%m/%Y") date_str = now.strftime("%d/%m/%Y")
prompt = """# VAI TRÒ try:
if os.path.exists(PROMPT_FILE_PATH):
with open(PROMPT_FILE_PATH, "r", encoding="utf-8") as f:
prompt_template = f.read()
return prompt_template.replace("{date_str}", date_str)
except Exception as e:
print(f"Error reading system prompt file: {e}")
# Fallback default prompt if file error
return f"""# VAI TRÒ
Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA. Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- Nhiệt tình, thân thiện, chuyên nghiệp Hôm nay: {date_str}
- CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
- Hôm nay: {date_str}
--- KHÔNG BAO GIỜ BỊA ĐẶT. TRẢ LỜI NGẮN GỌN.
"""
# QUY TẮC TRUNG THỰC - BẮT BUỘC \ No newline at end of file
KHÔNG BAO GIỜ BỊA ĐẶT - CHỈ NÓI THEO DỮ LIỆU
**ĐÚNG:**
- Tool trả về áo thun → Giới thiệu áo thun
- Tool trả về 0 sản phẩm → Nói "Shop chưa có sản phẩm này"
- Tool trả về quần nỉ mà khách hỏi bikini → Nói "Shop chưa có bikini"
**CẤM:**
- Tool trả về quần nỉ → Gọi là "đồ bơi"
- Tool trả về 0 kết quả → Nói "shop có sản phẩm X"
- Tự bịa mã sản phẩm, giá tiền, chính sách
Không có trong data = Không nói = Không tư vấn láo
---
# NGÔN NGỮ & XƯNG HÔ
- Mặc định: Xưng "mình" - gọi "bạn"
- Khi khách xưng anh/chị: Xưng "em" - gọi "anh/chị"
- Khách nói tiếng Việt → Trả lời tiếng Việt
- Khách nói tiếng Anh → Trả lời tiếng Anh
- Ngắn gọn, đi thẳng vào vấn đề
---
# KHI NÀO GỌI TOOL
**Gọi data_retrieval_tool khi:**
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì..."
- Khách hỏi sản phẩm cụ thể: "Mã 8TS24W001 có không?"
- Tư vấn phong cách: "Mặc gì đi cưới?", "Đồ công sở?"
**⚠️ QUY TẮC SINH QUERY (BẮT BUỘC):**
- **Query chỉ chứa MÔ TẢ SẢN PHẨM** (tên, chất liệu, màu, phong cách).
- **TUYỆT ĐỐI KHÔNG đưa giá tiền vào chuỗi `query`**.
- Giá tiền phải đưa vào tham số riêng: `price_min`, `price_max`.
Ví dụ ĐÚNG:
- Query: "Áo thun nam cotton thoáng mát basic"
- Price_max: 300000
Ví dụ SAI (Cấm):
- Query: "Áo thun nam giá dưới 300k" (SAI vì có giá trong query)
**Gọi canifa_knowledge_search khi:**
- Hỏi chính sách: freeship, đổi trả, bảo hành
- Hỏi thương hiệu: Canifa là gì, lịch sử
- Tìm cửa hàng: địa chỉ, giờ mở cửa
**Không gọi tool khi:**
- Chào hỏi đơn giản: "Hi", "Hello"
- Hỏi lại về sản phẩm vừa show
---
# XỬ LÝ KẾT QUẢ TỪ TOOL
## Sau khi gọi tool, kiểm tra kết quả:
**Trường hợp 1: CÓ sản phẩm phù hợp (đúng loại, đúng yêu cầu)**
- DỪNG LẠI, giới thiệu sản phẩm
- KHÔNG GỌI TOOL LẦN 2
**Trường hợp 2: CÓ kết quả NHƯNG SAI LOẠI**
Ví dụ: Khách hỏi bikini, tool trả về quần nỉ
→ Trả lời thẳng:
"Dạ shop chưa có bikini ạ. Shop chuyên về quần áo thời trang (áo, quần, váy). Bạn có muốn tìm sản phẩm nào khác không?"
CẤM TUYỆT ĐỐI:
- Giới thiệu quần nỉ như thể nó là bikini
- Nói "shop có đồ bơi này bạn tham khảo" khi thực tế là áo/quần thường
**Trường hợp 3: KHÔNG CÓ kết quả (count = 0)**
- Thử lại 1 LẦN với filter rộng hơn
- Nếu vẫn không có:
"Dạ shop chưa có sản phẩm [X] ạ. Bạn có thể tham khảo [loại gần nhất] hoặc ghé shop sau nhé!"
---
# FORMAT ĐẦU RA
Trả về JSON (KHÔNG có markdown backticks):
```json
{{
"ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]",
"product_ids": [
{{
"sku": "8TS24W001",
"name": "Áo thun nam basic",
"price": 200000,
"sale_price": 160000,
"url": "https://canifa.com/...",
"thumbnail_image_url": "https://..."
}}
]
}}
```
**Quy tắc ai_response:**
- Mô tả ngắn gọn, nhắc sản phẩm bằng [SKU]
- Nói qua giá, chất liệu, điểm nổi bật
- KHÔNG tạo bảng markdown
- KHÔNG đưa link, ảnh (frontend tự render)
---
# VÍ DỤ
## Example 1: Chào hỏi
Input: "Chào shop"
Output:
```json
{{
"ai_response": "Chào bạn! Mình là CiCi, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"product_ids": []
}}
```
## Example 2: Tìm sản phẩm CÓ
Input: "Tìm áo thun nam dưới 300k"
Tool trả về: 2 sản phẩm áo thun phù hợp
Output:
```json
{{
"ai_response": "Shop có 2 mẫu áo thun nam giá dưới 300k:\n\n- [8TS24W009]: Áo thun cotton basic, giá 250k đang sale 200k\n- [6TN24W012]: Áo thun trơn thoải mái, giá 280k\n\nBạn kéo xuống xem ảnh nhé!",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, "sale_price": 200000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W012", "name": "Áo thun trơn", "price": 280000, "sale_price": null, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
## Example 3: Khách hỏi KHÔNG CÓ trong kho
Input: "Shop có bikini không?"
Tool trả về: 0 sản phẩm
Output:
```json
{{
"ai_response": "Dạ shop chưa có bikini ạ. CANIFA chuyên về quần áo thời trang như áo, quần, váy, đầm. Bạn có muốn tìm mẫu nào khác không?",
"product_ids": []
}}
```
## Example 4: Tool trả về SAI LOẠI
Input: "Cho tôi xem đồ bơi"
Tool trả về: Quần nỉ, áo nỉ (SAI HOÀN TOÀN so với đồ bơi)
Output:
```json
{{
"ai_response": "Dạ shop chưa có đồ bơi ạ. Shop chuyên bán quần áo thời trang (áo, quần, váy, áo khoác). Bạn có muốn tìm loại sản phẩm nào khác không?",
"product_ids": []
}}
```
TUYỆT ĐỐI KHÔNG giới thiệu sản phẩm sai loại
## Example 5: Khách xưng anh/chị
Input: "Chào em, anh muốn tìm áo sơ mi"
Output:
```json
{{
"ai_response": "Chào anh ạ! Em là CiCi. Anh đang tìm áo sơ mi dài tay hay ngắn tay ạ? Để em tư vấn mẫu phù hợp nhất cho anh nhé!",
"product_ids": []
}}
```
---
# TÓM TẮT
1. CANIFA bán quần áo (áo, quần, váy, đầm, phụ kiện)
2. Không có trong data = Không nói
3. Kiểm tra kỹ tên sản phẩm trước khi giới thiệu
4. Nếu sai loại → Nói thẳng "shop chưa có X"
5. Không bịa giá, mã sản phẩm, chính sách
6. Có kết quả phù hợp = DỪNG, không gọi tool lần 2
7. Trả lời ngắn gọn, dựa 100% vào dữ liệu tool trả về
---
Luôn thành thật, khéo léo, và chuyên nghiệp."""
return prompt.replace("{date_str}", date_str)
\ No newline at end of file
# VAI TRÒ
Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- Nhiệt tình, thân thiện, chuyên nghiệp
- CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
- Hôm nay: {date_str}
---
# QUY TẮC TRUNG THỰC - BẮT BUỘC
KHÔNG BAO GIỜ BỊA ĐẶT - CHỈ NÓI THEO DỮ LIỆU
**ĐÚNG:**
- Tool trả về áo thun → Giới thiệu áo thun
- Tool trả về 0 sản phẩm → Nói "Shop chưa có sản phẩm này"
- Tool trả về quần nỉ mà khách hỏi bikini → Nói "Shop chưa có bikini"
**CẤM:**
- Tool trả về quần nỉ → Gọi là "đồ bơi"
- Tool trả về 0 kết quả → Nói "shop có sản phẩm X"
- Tự bịa mã sản phẩm, giá tiền, chính sách
Không có trong data = Không nói = Không tư vấn láo
---
# NGÔN NGỮ & XƯNG HÔ
- Mặc định: Xưng "mình" - gọi "bạn"
- Khi khách xưng anh/chị: Xưng "em" - gọi "anh/chị"
- Khách nói tiếng Việt → Trả lời tiếng Việt
- Khách nói tiếng Anh → Trả lời tiếng Anh
- Ngắn gọn, đi thẳng vào vấn đề
---
# KHI NÀO GỌI TOOL
**Gọi data_retrieval_tool khi:**
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì..."
- Khách hỏi sản phẩm cụ thể: "Mã 8TS24W001 có không?"
- Tư vấn phong cách: "Mặc gì đi cưới?", "Đồ công sở?"
**⚠️ QUY TẮC SINH QUERY (BẮT BUỘC):**
- **Query chỉ chứa MÔ TẢ SẢN PHẨM** (tên, chất liệu, màu, phong cách).
- **TUYỆT ĐỐI KHÔNG đưa giá tiền vào chuỗi `query`**.
- Giá tiền phải đưa vào tham số riêng: `price_min`, `price_max`.
Ví dụ ĐÚNG:
- Query: "Áo thun nam cotton thoáng mát basic"
- Price_max: 300000
Ví dụ SAI (Cấm):
- Query: "Áo thun nam giá dưới 300k" (SAI vì có giá trong query)
**Gọi canifa_knowledge_search khi:**
- Hỏi chính sách: freeship, đổi trả, bảo hành
- Hỏi thương hiệu: Canifa là gì, lịch sử
- Tìm cửa hàng: địa chỉ, giờ mở cửa
**Không gọi tool khi:**
- Chào hỏi đơn giản: "Hi", "Hello"
- Hỏi lại về sản phẩm vừa show
---
# XỬ LÝ KẾT QUẢ TỪ TOOL
## Sau khi gọi tool, kiểm tra kết quả:
**Trường hợp 1: CÓ sản phẩm phù hợp (đúng loại, đúng yêu cầu)**
- DỪNG LẠI, giới thiệu sản phẩm
- KHÔNG GỌI TOOL LẦN 2
**Trường hợp 2: CÓ kết quả NHƯNG SAI LOẠI**
Ví dụ: Khách hỏi bikini, tool trả về quần nỉ
→ Trả lời thẳng:
"Dạ shop chưa có bikini ạ. Shop chuyên về quần áo thời trang (áo, quần, váy). Bạn có muốn tìm sản phẩm nào khác không?"
CẤM TUYỆT ĐỐI:
- Giới thiệu quần nỉ như thể nó là bikini
- Nói "shop có đồ bơi này bạn tham khảo" khi thực tế là áo/quần thường
**Trường hợp 3: KHÔNG CÓ kết quả (count = 0)**
- Thử lại 1 LẦN với filter rộng hơn
- Nếu vẫn không có:
"Dạ shop chưa có sản phẩm [X] ạ. Bạn có thể tham khảo [loại gần nhất] hoặc ghé shop sau nhé!"
---
# FORMAT ĐẦU RA
Trả về JSON (KHÔNG có markdown backticks):
```json
{{
"ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]",
"product_ids": [
{{
"sku": "8TS24W001",
"name": "Áo thun nam basic",
"price": 200000,
"sale_price": 160000,
"url": "https://canifa.com/...",
"thumbnail_image_url": "https://..."
}}
]
}}
```
**Quy tắc ai_response:**
- Mô tả ngắn gọn, nhắc sản phẩm bằng [SKU]
- Nói qua giá, chất liệu, điểm nổi bật
- KHÔNG tạo bảng markdown
- KHÔNG đưa link, ảnh (frontend tự render)
---
# VÍ DỤ
## Example 1: Chào hỏi
Input: "Chào shop"
Output:
```json
{{
"ai_response": "Chào bạn! Mình là CiCi, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"product_ids": []
}}
```
## Example 2: Tìm sản phẩm CÓ
Input: "Tìm áo thun nam dưới 300k"
Tool trả về: 2 sản phẩm áo thun phù hợp
Output:
```json
{{
"ai_response": "Shop có 2 mẫu áo thun nam giá dưới 300k:
- [8TS24W009]: Áo thun cotton basic, giá 250k đang sale 200k
- [6TN24W012]: Áo thun trơn thoải mái, giá 280k
Bạn kéo xuống xem ảnh nhé!",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, "sale_price": 200000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W012", "name": "Áo thun trơn", "price": 280000, "sale_price": null, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
## Example 3: Khách hỏi KHÔNG CÓ trong kho
Input: "Shop có bikini không?"
Tool trả về: 0 sản phẩm
Output:
```json
{{
"ai_response": "Dạ shop chưa có bikini ạ. CANIFA chuyên về quần áo thời trang như áo, quần, váy, đầm. Bạn có muốn tìm mẫu nào khác không?",
"product_ids": []
}}
```
## Example 4: Tool trả về SAI LOẠI
Input: "Cho tôi xem đồ bơi"
Tool trả về: Quần nỉ, áo nỉ (SAI HOÀN TOÀN so với đồ bơi)
Output:
```json
{{
"ai_response": "Dạ shop chưa có đồ bơi ạ. Shop chuyên bán quần áo thời trang (áo, quần, váy, áo khoác). Bạn có muốn tìm loại sản phẩm nào khác không?",
"product_ids": []
}}
```
TUYỆT ĐỐI KHÔNG giới thiệu sản phẩm sai loại
## Example 5: Khách xưng anh/chị
Input: "Chào em, anh muốn tìm áo sơ mi"
Output:
```json
{{
"ai_response": "Chào anh ạ! Em là CiCi. Anh đang tìm áo sơ mi dài tay hay ngắn tay ạ? Để em tư vấn mẫu phù hợp nhất cho anh nhé!",
"product_ids": []
}}
```
---
# TÓM TẮT
1. CANIFA bán quần áo (áo, quần, váy, đầm, phụ kiện)
2. Không có trong data = Không nói
3. Kiểm tra kỹ tên sản phẩm trước khi giới thiệu
4. Nếu sai loại → Nói thẳng "shop chưa có X"
5. Không bịa giá, mã sản phẩm, chính sách
6. Có kết quả phù hợp = DỪNG, không gọi tool lần 2
7. Trả lời ngắn gọn, dựa 100% vào dữ liệu tool trả về
---
Luôn thành thật, khéo léo, và chuyên nghiệp.
\ No newline at end of file
...@@ -3,9 +3,7 @@ Fashion Q&A Agent Router ...@@ -3,9 +3,7 @@ Fashion Q&A Agent Router
FastAPI endpoints cho Fashion Q&A Agent service. FastAPI endpoints cho Fashion Q&A Agent service.
Router chỉ chứa định nghĩa API, logic nằm ở controller. Router chỉ chứa định nghĩa API, logic nằm ở controller.
Message Limit: Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware)
- Guest (không login): 10 tin/ngày theo device_id
- User đã login: 100 tin/ngày theo user_id
""" """
import logging import logging
...@@ -29,37 +27,14 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: ...@@ -29,37 +27,14 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
""" """
Endpoint chat không stream - trả về response JSON đầy đủ một lần. Endpoint chat không stream - trả về response JSON đầy đủ một lần.
Message Limit: Note: Rate limit đã được check trong middleware.
- Guest: 10 tin nhắn/ngày (theo device_id)
- User đã login: 100 tin nhắn/ngày (theo user_id)
""" """
# 1. Xác định user identity # 1. Xác định user identity
identity = get_user_identity(request) identity = get_user_identity(request)
user_id = identity.primary_id user_id = identity.primary_id
# 2. Check message limit TRƯỚC khi xử lý # Rate limit đã check trong middleware, lấy limit_info từ request.state
can_send, limit_info = await message_limit_service.check_limit( limit_info = getattr(request.state, 'limit_info', None)
identity_key=identity.rate_limit_key,
is_authenticated=identity.is_authenticated,
)
if not can_send:
logger.warning(
f"⚠️ Message limit exceeded: {identity.rate_limit_key} | "
f"used={limit_info['used']}/{limit_info['limit']}"
)
return {
"status": "error",
"error_code": "MESSAGE_LIMIT_EXCEEDED",
"message": limit_info["message"],
"require_login": limit_info["require_login"],
"limit_info": {
"limit": limit_info["limit"],
"used": limit_info["used"],
"remaining": limit_info["remaining"],
"reset_seconds": limit_info["reset_seconds"],
},
}
logger.info(f"📥 [Incoming Query - NonStream] User: {user_id} | Query: {req.user_query}") logger.info(f"📥 [Incoming Query - NonStream] User: {user_id} | Query: {req.user_query}")
...@@ -79,6 +54,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: ...@@ -79,6 +54,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
background_tasks=background_tasks, background_tasks=background_tasks,
model_name=DEFAULT_MODEL, model_name=DEFAULT_MODEL,
images=req.images, images=req.images,
identity_key=identity.history_key, # Guest: device_id, User: user_id
) )
# Log chi tiết response # Log chi tiết response
...@@ -98,7 +74,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: ...@@ -98,7 +74,7 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
}, },
) )
# 3. Increment message count SAU KHI chat thành công # Increment message count SAU KHI chat thành công
usage_info = await message_limit_service.increment( usage_info = await message_limit_service.increment(
identity_key=identity.rate_limit_key, identity_key=identity.rate_limit_key,
is_authenticated=identity.is_authenticated, is_authenticated=identity.is_authenticated,
......
""" """
Chat History API Routes Chat History API Routes
- GET /api/history/{user_id} - Lấy lịch sử chat (có product_ids) - GET /api/history/{identity_key} - Lấy lịch sử chat (có product_ids)
- DELETE /api/history/{user_id} - Xóa lịch sử chat - DELETE /api/history/{identity_key} - Xóa lịch sử chat
Note: identity_key có thể là device_id (guest) hoặc user_id (đã login)
""" """
import logging import logging
...@@ -26,10 +28,14 @@ class ClearHistoryResponse(BaseModel): ...@@ -26,10 +28,14 @@ class ClearHistoryResponse(BaseModel):
message: str message: str
@router.get("/api/history/{user_id}", summary="Get Chat History", response_model=ChatHistoryResponse) @router.get("/api/history/{identity_key}", summary="Get Chat History", response_model=ChatHistoryResponse)
async def get_chat_history(user_id: str, limit: int | None = 50, before_id: int | None = None): async def get_chat_history(identity_key: str, limit: int | None = 50, before_id: int | None = None):
""" """
Lấy lịch sử chat của user. Lấy lịch sử chat theo identity_key.
identity_key:
- Guest: device_id
- User đã login: user_id (customer_id từ Canifa)
Response bao gồm: Response bao gồm:
- message: Nội dung tin nhắn - message: Nội dung tin nhắn
...@@ -40,7 +46,7 @@ async def get_chat_history(user_id: str, limit: int | None = 50, before_id: int ...@@ -40,7 +46,7 @@ async def get_chat_history(user_id: str, limit: int | None = 50, before_id: int
""" """
try: try:
manager = await get_conversation_manager() manager = await get_conversation_manager()
history = await manager.get_chat_history(user_id, limit=limit, before_id=before_id) history = await manager.get_chat_history(identity_key, limit=limit, before_id=before_id)
next_cursor = None next_cursor = None
if history and len(history) > 0: if history and len(history) > 0:
...@@ -48,20 +54,21 @@ async def get_chat_history(user_id: str, limit: int | None = 50, before_id: int ...@@ -48,20 +54,21 @@ async def get_chat_history(user_id: str, limit: int | None = 50, before_id: int
return {"data": history, "next_cursor": next_cursor} return {"data": history, "next_cursor": next_cursor}
except Exception as e: except Exception as e:
logger.error(f"Error fetching chat history for user {user_id}: {e}") logger.error(f"Error fetching chat history for {identity_key}: {e}")
raise HTTPException(status_code=500, detail="Failed to fetch chat history") raise HTTPException(status_code=500, detail="Failed to fetch chat history")
@router.delete("/api/history/{user_id}", summary="Clear Chat History", response_model=ClearHistoryResponse) @router.delete("/api/history/{identity_key}", summary="Clear Chat History", response_model=ClearHistoryResponse)
async def clear_chat_history(user_id: str): async def clear_chat_history(identity_key: str):
""" """
Xóa toàn bộ lịch sử chat của user. Xóa toàn bộ lịch sử chat theo identity_key.
""" """
try: try:
manager = await get_conversation_manager() manager = await get_conversation_manager()
await manager.clear_history(user_id) await manager.clear_history(identity_key)
logger.info(f"✅ Cleared chat history for user {user_id}") logger.info(f"✅ Cleared chat history for {identity_key}")
return {"success": True, "message": f"Đã xóa lịch sử chat của user {user_id}"} 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 user {user_id}: {e}") logger.error(f"Error clearing chat history for {identity_key}: {e}")
raise HTTPException(status_code=500, detail="Failed to clear chat history") raise HTTPException(status_code=500, detail="Failed to clear chat history")
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
import os
from agent.graph import reset_graph
router = APIRouter()
PROMPT_FILE_PATH = os.path.join(os.path.dirname(__file__), "../agent/system_prompt.txt")
class PromptUpdateRequest(BaseModel):
content: str
@router.get("/api/agent/system-prompt")
async def get_system_prompt_content():
"""Get current system prompt content"""
# ... existing code ...
try:
if os.path.exists(PROMPT_FILE_PATH):
with open(PROMPT_FILE_PATH, "r", encoding="utf-8") as f:
content = f.read()
return {"status": "success", "content": content}
else:
return {"status": "error", "message": "Prompt file not found"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/agent/system-prompt")
async def update_system_prompt_content(request: PromptUpdateRequest):
"""Update system prompt content"""
try:
# 1. Update file
with open(PROMPT_FILE_PATH, "w", encoding="utf-8") as f:
f.write(request.content)
# 2. Reset Graph Singleton to force reload prompt
reset_graph()
return {"status": "success", "message": "System prompt updated successfully. Graph reloaded."}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
...@@ -52,15 +52,14 @@ async def verify_canifa_token(token: str) -> Optional[Dict[str, Any]]: ...@@ -52,15 +52,14 @@ async def verify_canifa_token(token: str) -> Optional[Dict[str, Any]]:
if response.status_code == 200: if response.status_code == 200:
data = response.json() data = response.json()
logger.debug(f"Canifa API Raw Response: {data}")
# Check nếu response là lỗi (Magento thường trả 200 kèm body lỗi đôi khi) # Response format: {"data": {"customer": {...}}, "loading": false, ...}
if isinstance(data, dict): if isinstance(data, dict):
if data.get("code") != 200: # Trả về toàn bộ data để extract_user_id xử lý
logger.warning(f"Canifa API Business Error: {data.get('code')} - {data.get('result')}") return data
return None
return data.get("result", {})
# Nếu Canifa trả list (đôi khi batch request trả về list) # Nếu Canifa trả list (batch request)
return data return data
else: else:
......
import json import json
import logging import logging
import asyncio
from datetime import datetime, date from datetime import datetime, date
from typing import Any from typing import Any
import psycopg
from psycopg_pool import AsyncConnectionPool from psycopg_pool import AsyncConnectionPool
from config import CHECKPOINT_POSTGRES_URL from config import CHECKPOINT_POSTGRES_URL
...@@ -23,7 +25,15 @@ class ConversationManager: ...@@ -23,7 +25,15 @@ class ConversationManager:
async def _get_pool(self) -> AsyncConnectionPool: async def _get_pool(self) -> AsyncConnectionPool:
"""Get or create async connection pool.""" """Get or create async connection pool."""
if self._pool is None: if self._pool is None:
self._pool = AsyncConnectionPool(self.connection_url, open=False) self._pool = AsyncConnectionPool(
self.connection_url,
min_size=1,
max_size=20,
max_lifetime=600, # Recycle connections every 10 mins
max_idle=300, # Close idle connections after 5 mins
open=False,
kwargs={"autocommit": True}
)
await self._pool.open() await self._pool.open()
return self._pool return self._pool
...@@ -36,7 +46,7 @@ class ConversationManager: ...@@ -36,7 +46,7 @@ class ConversationManager:
await cursor.execute(f""" await cursor.execute(f"""
CREATE TABLE IF NOT EXISTS {self.table_name} ( CREATE TABLE IF NOT EXISTS {self.table_name} (
id SERIAL PRIMARY KEY, id SERIAL PRIMARY KEY,
user_id VARCHAR(255) NOT NULL, identity_key VARCHAR(255) NOT NULL,
message TEXT NOT NULL, message TEXT NOT NULL,
is_human BOOLEAN NOT NULL, is_human BOOLEAN NOT NULL,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
...@@ -44,8 +54,8 @@ class ConversationManager: ...@@ -44,8 +54,8 @@ class ConversationManager:
""") """)
await cursor.execute(f""" await cursor.execute(f"""
CREATE INDEX IF NOT EXISTS idx_{self.table_name}_user_timestamp CREATE INDEX IF NOT EXISTS idx_{self.table_name}_identity_timestamp
ON {self.table_name} (user_id, timestamp) ON {self.table_name} (identity_key, timestamp)
""") """)
await conn.commit() await conn.commit()
logger.info(f"Table {self.table_name} initialized successfully") logger.info(f"Table {self.table_name} initialized successfully")
...@@ -53,62 +63,84 @@ class ConversationManager: ...@@ -53,62 +63,84 @@ class ConversationManager:
logger.error(f"Error initializing table: {e}") logger.error(f"Error initializing table: {e}")
raise raise
async def save_conversation_turn(self, user_id: str, human_message: str, ai_message: str): async def save_conversation_turn(self, identity_key: str, human_message: str, ai_message: str):
"""Save both human and AI messages in a single atomic transaction.""" """Save both human and AI messages in a single atomic transaction with retry logic."""
try: max_retries = 3
pool = await self._get_pool() for attempt in range(max_retries):
timestamp = datetime.now() try:
async with pool.connection() as conn: pool = await self._get_pool()
async with conn.cursor() as cursor: timestamp = datetime.now()
await cursor.execute( async with pool.connection() as conn:
f"""INSERT INTO {self.table_name} (user_id, message, is_human, timestamp) async with conn.cursor() as cursor:
VALUES (%s, %s, %s, %s), (%s, %s, %s, %s)""", await cursor.execute(
( f"""INSERT INTO {self.table_name} (identity_key, message, is_human, timestamp)
user_id, VALUES (%s, %s, %s, %s), (%s, %s, %s, %s)""",
human_message, (
True, identity_key,
timestamp, human_message,
user_id, True,
ai_message, timestamp,
False, identity_key,
timestamp, ai_message,
), False,
) timestamp,
await conn.commit() ),
logger.debug(f"Saved conversation turn for user {user_id}") )
except Exception as e: # With autocommit=True in pool, and context manager, transactions are handled.
logger.error(f"Failed to save conversation for user {user_id}: {e}", exc_info=True) # Explicit commit can be safer but might be redundant if autocommit is on.
raise # Let's keep existing logic but be mindful of autocommit.
# Actually if autocommit=True, we don't need conn.commit().
# But if we want atomic transaction for 2 inserts, we should NOT use autocommit=True for the pool globally,
# OR we start a transaction block.
# But psycopg3 connection `async with pool.connection() as conn` actually starts a transaction by default if autocommit is False.
# Let's revert pool autocommit=True and handle it normally which is safer for atomicity.
await conn.commit()
logger.debug(f"Saved conversation turn for identity_key {identity_key}")
return # Success
except psycopg.OperationalError as e:
logger.warning(f"Database connection error (attempt {attempt+1}/{max_retries}): {e}")
if attempt == max_retries - 1:
logger.error(f"Failed to save conversation after {max_retries} attempts: {e}")
raise
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Failed to save conversation for identity_key {identity_key}: {e}", exc_info=True)
raise
async def get_chat_history( async def get_chat_history(
self, user_id: str, limit: int | None = None, before_id: int | None = None self, identity_key: str, limit: int | None = None, before_id: int | None = None
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
""" """
Retrieve chat history for a user using cursor-based pagination. Retrieve chat history for an identity (user_id or device_id) using cursor-based pagination.
AI messages được parse từ JSON string để lấy product_ids. AI messages được parse từ JSON string để lấy product_ids.
""" """
try: max_retries = 3
query = f""" for attempt in range(max_retries):
SELECT message, is_human, timestamp, id try:
FROM {self.table_name} query = f"""
WHERE user_id = %s SELECT message, is_human, timestamp, id
""" FROM {self.table_name}
params = [user_id] WHERE identity_key = %s
"""
if before_id: params = [identity_key]
query += " AND id < %s"
params.append(before_id) if before_id:
query += " AND id < %s"
query += " ORDER BY id DESC" params.append(before_id)
if limit: query += " ORDER BY id DESC"
query += " LIMIT %s"
params.append(limit) if limit:
query += " LIMIT %s"
pool = await self._get_pool() params.append(limit)
async with pool.connection() as conn, conn.cursor() as cursor:
await cursor.execute(query, tuple(params)) pool = await self._get_pool()
results = await cursor.fetchall() async with pool.connection() as conn, conn.cursor() as cursor:
await cursor.execute(query, tuple(params))
results = await cursor.fetchall()
history = [] history = []
for row in results: for row in results:
...@@ -138,37 +170,45 @@ class ConversationManager: ...@@ -138,37 +170,45 @@ class ConversationManager:
history.append(entry) history.append(entry)
return history return history
except Exception as e:
logger.error(f"Error retrieving chat history: {e}")
return []
async def clear_history(self, user_id: str): except psycopg.OperationalError as e:
"""Clear all chat history for a user""" logger.warning(f"Database connection error in get_chat_history (attempt {attempt+1}/{max_retries}): {e}")
if attempt == max_retries - 1:
logger.error(f"Failed to get chat history after {max_retries} attempts: {e}")
raise
await asyncio.sleep(0.5)
except Exception as e:
logger.error(f"Error retrieving chat history: {e}")
return []
async def clear_history(self, identity_key: str):
"""Clear all chat history for an identity"""
try: try:
pool = await self._get_pool() pool = await self._get_pool()
async with pool.connection() as conn: async with pool.connection() as conn:
async with conn.cursor() as cursor: async with conn.cursor() as cursor:
await cursor.execute(f"DELETE FROM {self.table_name} WHERE user_id = %s", (user_id,)) await cursor.execute(f"DELETE FROM {self.table_name} WHERE identity_key = %s", (identity_key,))
await conn.commit() await conn.commit()
logger.info(f"Cleared chat history for user {user_id}") logger.info(f"Cleared chat history for identity_key {identity_key}")
except Exception as e: except Exception as e:
logger.error(f"Error clearing chat history: {e}") logger.error(f"Error clearing chat history: {e}")
async def get_user_count(self) -> int: async def get_user_count(self) -> int:
"""Get total number of unique users""" """Get total number of unique identities"""
try: try:
pool = await self._get_pool() pool = await self._get_pool()
async with pool.connection() as conn, conn.cursor() as cursor: async with pool.connection() as conn, conn.cursor() as cursor:
await cursor.execute(f"SELECT COUNT(DISTINCT user_id) FROM {self.table_name}") await cursor.execute(f"SELECT COUNT(DISTINCT identity_key) FROM {self.table_name}")
result = await cursor.fetchone() result = await cursor.fetchone()
return result[0] if result else 0 return result[0] if result else 0
except Exception as e: except Exception as e:
logger.error(f"Error getting user count: {e}") logger.error(f"Error getting user count: {e}")
return 0 return 0
async def get_message_count_today(self, user_id: str) -> int: async def get_message_count_today(self, identity_key: str) -> int:
""" """
Đếm số tin nhắn của user trong ngày hôm nay (cho rate limiting). Đếm số tin nhắn của identity trong ngày hôm nay (cho rate limiting).
Chỉ đếm human messages (is_human = true). Chỉ đếm human messages (is_human = true).
""" """
try: try:
...@@ -177,16 +217,16 @@ class ConversationManager: ...@@ -177,16 +217,16 @@ class ConversationManager:
await cursor.execute( await cursor.execute(
f""" f"""
SELECT COUNT(*) FROM {self.table_name} SELECT COUNT(*) FROM {self.table_name}
WHERE user_id = %s WHERE identity_key = %s
AND is_human = true AND is_human = true
AND DATE(timestamp) = CURRENT_DATE AND DATE(timestamp) = CURRENT_DATE
""", """,
(user_id,), (identity_key,),
) )
result = await cursor.fetchone() result = await cursor.fetchone()
return result[0] if result else 0 return result[0] if result else 0
except Exception as e: except Exception as e:
logger.error(f"Error counting messages for {user_id}: {e}") logger.error(f"Error counting messages for {identity_key}: {e}")
return 0 return 0
async def close(self): async def close(self):
......
...@@ -88,6 +88,7 @@ class LLMFactory: ...@@ -88,6 +88,7 @@ class LLMFactory:
"streaming": streaming, "streaming": streaming,
"api_key": key, "api_key": key,
"temperature": 0, "temperature": 0,
"max_tokens": 1000,
} }
# Nếu bật json_mode, tiêm trực tiếp vào constructor # Nếu bật json_mode, tiêm trực tiếp vào constructor
......
""" """
Message Limit Service Message Limit Service
Giới hạn số tin nhắn theo ngày: Giới hạn số tin nhắn theo ngày:
- Guest (không login): 10 tin/ngày theo device_id - Guest (không login): RATE_LIMIT_GUEST tin/ngày theo device_id
- User đã login: 100 tin/ngày theo user_id - User đã login: RATE_LIMIT_USER tin/ngày theo user_id
Lưu trữ: Redis (dùng chung với cache.py) Lưu trữ: Redis (dùng chung với cache.py)
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import os
from datetime import datetime from datetime import datetime
from common.cache import redis_cache from common.cache import redis_cache
from config import RATE_LIMIT_GUEST, RATE_LIMIT_USER
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ============================================================================= # =============================================================================
# CONFIGURATION # CONFIGURATION (from config.py)
# ============================================================================= # =============================================================================
GUEST_LIMIT_PER_DAY = int(os.getenv("MESSAGE_LIMIT_GUEST", "3")) # Tạm set 3 để test
USER_LIMIT_PER_DAY = int(os.getenv("MESSAGE_LIMIT_USER", "100"))
# Redis key prefix # Redis key prefix
MESSAGE_COUNT_PREFIX = "msg_limit:" MESSAGE_COUNT_PREFIX = "msg_limit:"
...@@ -32,6 +29,10 @@ class MessageLimitService: ...@@ -32,6 +29,10 @@ class MessageLimitService:
Service quản lý giới hạn tin nhắn theo ngày. Service quản lý giới hạn tin nhắn theo ngày.
Dùng Redis để lưu trữ, tự động reset mỗi ngày. Dùng Redis để lưu trữ, tự động reset mỗi ngày.
Limits:
- Guest (device_id): RATE_LIMIT_GUEST (default: 10)
- User (user_id): RATE_LIMIT_USER (default: 100)
Usage: Usage:
from common.message_limit import message_limit_service from common.message_limit import message_limit_service
...@@ -60,18 +61,18 @@ class MessageLimitService: ...@@ -60,18 +61,18 @@ class MessageLimitService:
if MessageLimitService._initialized: if MessageLimitService._initialized:
return return
# Fallback in-memory storage: { "device_id": {"guest": 0, "user": 0} } # Fallback in-memory storage: { "identity_key": count }
self._memory_storage: dict[str, dict[str, int]] = {} self._memory_storage: dict[str, int] = {}
self._memory_date: str = "" self._memory_date: str = ""
# Limits # Limits from config
self.guest_limit = 3 # Test limit self.guest_limit = RATE_LIMIT_GUEST # Default: 10
self.total_limit = 5 # Test limit self.user_limit = RATE_LIMIT_USER # Default: 100
MessageLimitService._initialized = True MessageLimitService._initialized = True
logger.info( logger.info(
f"✅ MessageLimitService initialized " f"✅ MessageLimitService initialized "
f"(Guest Limit: {self.guest_limit}, Total Limit: {self.total_limit})" f"(Guest Limit: {self.guest_limit}, User Limit: {self.user_limit})"
) )
# ========================================================================= # =========================================================================
...@@ -200,19 +201,19 @@ class MessageLimitService: ...@@ -200,19 +201,19 @@ class MessageLimitService:
# 2. Logic Checking # 2. Logic Checking
can_send = True can_send = True
limit_display = self.total_limit limit_display = self.user_limit
message = "" message = ""
require_login = False require_login = False
# Check Total Limit (Hard limit cho device) # Check User Limit (Hard limit cho identity)
if total_used >= self.total_limit: if total_used >= self.user_limit:
can_send = False can_send = False
# Thông báo khi hết tổng quota (dù là user hay guest) # Thông báo khi hết tổng quota (dù là user hay guest)
if is_authenticated: if is_authenticated:
message = f"Bạn đã sử dụng hết {self.total_limit} tin nhắn hôm nay. Quay lại vào ngày mai nhé!" message = f"Bạn đã sử dụng hết {self.user_limit} tin nhắn hôm nay. Quay lại vào ngày mai nhé!"
else: else:
# Guest dùng hết 100 tin (hiếm, vì guest bị chặn ở 10 rồi, trừ khi login rồi logout) # Guest dùng hết user_limit tin (hiếm, vì guest bị chặn ở guest_limit rồi)
message = f"Thiết bị này đã đạt giới hạn {self.total_limit} tin nhắn hôm nay." message = f"Thiết bị này đã đạt giới hạn {self.user_limit} tin nhắn hôm nay."
# Check Guest Limit (nếu chưa login và chưa bị chặn bởi total) # Check Guest Limit (nếu chưa login và chưa bị chặn bởi total)
elif not is_authenticated: elif not is_authenticated:
...@@ -222,19 +223,19 @@ class MessageLimitService: ...@@ -222,19 +223,19 @@ class MessageLimitService:
require_login = True require_login = True
message = ( message = (
f"Bạn đã dùng hết {self.guest_limit} tin nhắn miễn phí. " f"Bạn đã dùng hết {self.guest_limit} tin nhắn miễn phí. "
f"Đăng nhập ngay để dùng tiếp (tối đa {self.total_limit} tin/ngày)!" f"Đăng nhập ngay để dùng tiếp (tối đa {self.user_limit} tin/ngày)!"
) )
# 3. Build Remaining Info # 3. Build Remaining Info
# Nếu là guest: remaining = min(guest_remaining, total_remaining) # Nếu là guest: remaining = min(guest_remaining, user_remaining)
# Thực ra guest chỉ care guest_remaining vì guest < total # Thực ra guest chỉ care guest_remaining vì guest < user
if is_authenticated: if is_authenticated:
remaining = max(0, self.total_limit - total_used) remaining = max(0, self.user_limit - total_used)
else: else:
# Guest bị chặn bởi guest_limit hoặc total_limit (trường hợp login rồi logout) # Guest bị chặn bởi guest_limit hoặc user_limit (trường hợp login rồi logout)
guest_remaining = max(0, self.guest_limit - guest_used) guest_remaining = max(0, self.guest_limit - guest_used)
total_remaining = max(0, self.total_limit - total_used) user_remaining = max(0, self.user_limit - total_used)
remaining = min(guest_remaining, total_remaining) remaining = min(guest_remaining, user_remaining)
info = { info = {
"limit": limit_display, "limit": limit_display,
......
...@@ -38,18 +38,24 @@ PUBLIC_PATH_PREFIXES = [ ...@@ -38,18 +38,24 @@ PUBLIC_PATH_PREFIXES = [
# ============================================================================= # =============================================================================
# AUTH MIDDLEWARE CLASS # AUTH + RATE LIMIT MIDDLEWARE CLASS
# ============================================================================= # =============================================================================
class ClerkAuthMiddleware(BaseHTTPMiddleware): # Paths that need rate limit check
RATE_LIMITED_PATHS = [
"/api/agent/chat",
]
class CanifaAuthMiddleware(BaseHTTPMiddleware):
""" """
Clerk Authentication Middleware Canifa Authentication + Rate Limit Middleware
Flow: Flow:
1. Frontend gửi request với Authorization: Bearer <clerk_token> 1. Frontend gửi request với Authorization: Bearer <canifa_token>
2. Middleware verify token và extract user_id 2. Middleware verify token với Canifa API → extract customer_id
3. Attach user info vào request.state.user và request.state.user_id 3. Check message rate limit (Guest: 10, User: 100)
4. Routes lấy trực tiếp từ request.state (không cần Depends) 4. Attach user info vào request.state
5. Routes lấy trực tiếp từ request.state
""" """
async def dispatch(self, request: Request, call_next: Callable): async def dispatch(self, request: Request, call_next: Callable):
...@@ -68,9 +74,12 @@ class ClerkAuthMiddleware(BaseHTTPMiddleware): ...@@ -68,9 +74,12 @@ class ClerkAuthMiddleware(BaseHTTPMiddleware):
if any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES): if any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES):
return await call_next(request) return await call_next(request)
# ✅ Authentication Process # =====================================================================
# STEP 1: AUTHENTICATION (Canifa API)
# =====================================================================
try: try:
auth_header = request.headers.get("Authorization") auth_header = request.headers.get("Authorization")
device_id = request.headers.get("device_id", "")
# ========== DEV MODE: Bypass auth ========== # ========== DEV MODE: Bypass auth ==========
dev_user_id = request.headers.get("X-Dev-User-Id") dev_user_id = request.headers.get("X-Dev-User-Id")
...@@ -79,56 +88,105 @@ class ClerkAuthMiddleware(BaseHTTPMiddleware): ...@@ -79,56 +88,105 @@ class ClerkAuthMiddleware(BaseHTTPMiddleware):
request.state.user = {"customer_id": dev_user_id} request.state.user = {"customer_id": dev_user_id}
request.state.user_id = dev_user_id request.state.user_id = dev_user_id
request.state.is_authenticated = True request.state.is_authenticated = True
request.state.device_id = device_id or dev_user_id
return await call_next(request) return await call_next(request)
# --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST --- # --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST ---
if not auth_header or not auth_header.startswith("Bearer "): if not auth_header or not auth_header.startswith("Bearer "):
# Guest Mode (Không User ID, Không Auth)
# logger.debug(f"ℹ️ Guest access (no token) for {path}")
request.state.user = None request.state.user = None
request.state.user_id = None request.state.user_id = None
request.state.is_authenticated = False request.state.is_authenticated = False
return await call_next(request) request.state.device_id = device_id
else:
# --- TRƯỜNG HỢP 2: CÓ TOKEN -> GỌI CANIFA VERIFY --- # --- TRƯỜNG HỢP 2: CÓ TOKEN -> GỌI CANIFA VERIFY ---
token = auth_header.replace("Bearer ", "") token = auth_header.replace("Bearer ", "")
# Import Lazy để tránh circular import nếu có
from common.canifa_api import verify_canifa_token, extract_user_id_from_canifa_response
try:
# 1. Gọi API Canifa
user_data = await verify_canifa_token(token)
# 2. Lấy User ID from common.canifa_api import verify_canifa_token, extract_user_id_from_canifa_response
user_id = await extract_user_id_from_canifa_response(user_data)
try:
if user_id: user_data = await verify_canifa_token(token)
# ✅ VERIFY THÀNH CÔNG -> USER VIP user_id = await extract_user_id_from_canifa_response(user_data)
request.state.user = user_data
request.state.user_id = user_id if user_id:
request.state.token = token request.state.user = user_data
request.state.is_authenticated = True request.state.user_id = user_id
logger.debug(f"✅ Auth Success: User {user_id}") request.state.token = token
else: request.state.is_authenticated = True
# ❌ VERIFY FAILED -> GUEST request.state.device_id = device_id
logger.warning(f"⚠️ Invalid Canifa Token (No ID found) -> Guest Mode") logger.debug(f"✅ Canifa Auth Success: User {user_id}")
else:
logger.warning(f"⚠️ Invalid Canifa Token -> Guest Mode")
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
request.state.device_id = device_id
except Exception as e:
logger.error(f"❌ Canifa Auth Error: {e} -> Guest Mode")
request.state.user = None request.state.user = None
request.state.user_id = None request.state.user_id = None
request.state.is_authenticated = False request.state.is_authenticated = False
request.state.device_id = device_id
except Exception as e:
logger.error(f"❌ Canifa Auth Error: {e} -> Guest Mode")
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
except Exception as e: except Exception as e:
logger.error(f"❌ Middleware Unexpected Error: {e}") logger.error(f"❌ Middleware Auth Error: {e}")
# Fallback an toàn: Guest mode
request.state.user = None request.state.user = None
request.state.user_id = None request.state.user_id = None
request.state.is_authenticated = False request.state.is_authenticated = False
request.state.device_id = request.headers.get("device_id", "")
# =====================================================================
# STEP 2: RATE LIMIT CHECK (Chỉ cho các path cần limit)
# =====================================================================
if path in RATE_LIMITED_PATHS:
try:
from common.message_limit import message_limit_service
from fastapi.responses import JSONResponse
# Lấy identity_key làm rate limit key
# Guest: device_id → limit 10
# User: user_id → limit 100
is_authenticated = request.state.is_authenticated
if is_authenticated and request.state.user_id:
rate_limit_key = request.state.user_id
else:
rate_limit_key = request.state.device_id
if rate_limit_key:
can_send, limit_info = await message_limit_service.check_limit(
identity_key=rate_limit_key,
is_authenticated=is_authenticated,
)
# Lưu limit_info vào request.state để route có thể dùng
request.state.limit_info = limit_info
if not can_send:
logger.warning(
f"⚠️ Rate Limit Exceeded: {rate_limit_key} | "
f"used={limit_info['used']}/{limit_info['limit']}"
)
return JSONResponse(
status_code=429,
content={
"status": "error",
"error_code": "MESSAGE_LIMIT_EXCEEDED",
"message": limit_info["message"],
"require_login": limit_info["require_login"],
"limit_info": {
"limit": limit_info["limit"],
"used": limit_info["used"],
"remaining": limit_info["remaining"],
"reset_seconds": limit_info["reset_seconds"],
},
},
)
else:
logger.warning(f"⚠️ No identity_key for rate limiting")
except Exception as e:
logger.error(f"❌ Rate Limit Check Error: {e}")
# Cho phép request tiếp tục nếu lỗi rate limit
return await call_next(request) return await call_next(request)
...@@ -181,7 +239,7 @@ class MiddlewareManager: ...@@ -181,7 +239,7 @@ class MiddlewareManager:
Args: Args:
app: FastAPI application app: FastAPI application
enable_auth: Bật Clerk authentication middleware enable_auth: Bật Canifa authentication middleware
enable_rate_limit: Bật rate limiting enable_rate_limit: Bật rate limiting
enable_cors: Bật CORS middleware enable_cors: Bật CORS middleware
cors_origins: List origins cho CORS (default: ["*"]) cors_origins: List origins cho CORS (default: ["*"])
...@@ -221,10 +279,10 @@ class MiddlewareManager: ...@@ -221,10 +279,10 @@ class MiddlewareManager:
logger.info(f"✅ CORS middleware enabled (origins: {origins})") logger.info(f"✅ CORS middleware enabled (origins: {origins})")
def _setup_auth(self, app: FastAPI) -> None: def _setup_auth(self, app: FastAPI) -> None:
"""Setup Clerk auth middleware.""" """Setup Canifa auth middleware."""
app.add_middleware(ClerkAuthMiddleware) app.add_middleware(CanifaAuthMiddleware)
self._auth_enabled = True self._auth_enabled = True
logger.info("✅ Clerk Auth middleware enabled") logger.info("✅ Canifa Auth middleware enabled")
def _setup_rate_limit(self, app: FastAPI) -> None: def _setup_rate_limit(self, app: FastAPI) -> None:
"""Setup rate limiting.""" """Setup rate limiting."""
......
...@@ -48,7 +48,7 @@ class UserIdentity: ...@@ -48,7 +48,7 @@ class UserIdentity:
def langfuse_metadata(self) -> dict: def langfuse_metadata(self) -> dict:
"""Metadata cho Langfuse""" """Metadata cho Langfuse"""
return { return {
"device_id": self.device_id, "device_id": self.device_id,
"is_authenticated": self.is_authenticated, "is_authenticated": self.is_authenticated,
} }
...@@ -61,12 +61,24 @@ class UserIdentity: ...@@ -61,12 +61,24 @@ class UserIdentity:
@property @property
def history_key(self) -> str: def history_key(self) -> str:
"""Key để lưu/load chat history (theo device_id)""" """
Key để lưu/load chat history.
- Guest (chưa login): device_id
- User (đã login): user_id (customer_id từ Canifa)
"""
if self.is_authenticated and self.user_id:
return self.user_id
return self.device_id return self.device_id
@property @property
def rate_limit_key(self) -> str: def rate_limit_key(self) -> str:
"""Key cho rate limiting (luôn theo device_id, limit tùy login status)""" """
Key cho rate limiting.
- Guest (chưa login): device_id → limit 10
- User (đã login): user_id → limit 100
"""
if self.is_authenticated and self.user_id:
return self.user_id
return self.device_id return self.device_id
...@@ -97,8 +109,8 @@ def get_user_identity(request: Request) -> UserIdentity: ...@@ -97,8 +109,8 @@ def get_user_identity(request: Request) -> UserIdentity:
user_id = request.state.user_id user_id = request.state.user_id
is_authenticated = True is_authenticated = True
# 3. Primary ID # 3. Primary ID - LUÔN LUÔN LÀ device_id
primary_id = user_id if user_id else device_id primary_id = device_id
identity = UserIdentity( identity = UserIdentity(
primary_id=primary_id, primary_id=primary_id,
......
...@@ -53,6 +53,8 @@ __all__ = [ ...@@ -53,6 +53,8 @@ __all__ = [
"STARROCKS_PORT", "STARROCKS_PORT",
"STARROCKS_USER", "STARROCKS_USER",
"USE_MONGO_CONVERSATION", "USE_MONGO_CONVERSATION",
"RATE_LIMIT_GUEST",
"RATE_LIMIT_USER",
] ]
# ====================== SUPABASE CONFIGURATION ====================== # ====================== SUPABASE CONFIGURATION ======================
...@@ -134,3 +136,8 @@ OTEL_EXPORTER_JAEGER_AGENT_PORT = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_PORT") ...@@ -134,3 +136,8 @@ OTEL_EXPORTER_JAEGER_AGENT_PORT = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_PORT")
OTEL_SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME") OTEL_SERVICE_NAME = os.getenv("OTEL_SERVICE_NAME")
OTEL_TRACES_EXPORTER = os.getenv("OTEL_TRACES_EXPORTER") OTEL_TRACES_EXPORTER = os.getenv("OTEL_TRACES_EXPORTER")
OTEL_EXPORTER_JAEGER_AGENT_SPLIT_OVERSIZED_BATCHES = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_SPLIT_OVERSIZED_BATCHES") OTEL_EXPORTER_JAEGER_AGENT_SPLIT_OVERSIZED_BATCHES = os.getenv("OTEL_EXPORTER_JAEGER_AGENT_SPLIT_OVERSIZED_BATCHES")
RATE_LIMIT_GUEST: int = int(os.getenv("RATE_LIMIT_GUEST", "10"))
RATE_LIMIT_USER: int = int(os.getenv("RATE_LIMIT_USER", "100"))
...@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles ...@@ -14,6 +14,7 @@ from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router from api.chatbot_route import router as chatbot_router
from api.conservation_route import router as conservation_router from api.conservation_route import router as conservation_router
from api.prompt_route import router as prompt_router
from common.cache import redis_cache from common.cache import redis_cache
from common.langfuse_client import get_langfuse_client from common.langfuse_client import get_langfuse_client
from common.middleware import middleware_manager from common.middleware import middleware_manager
...@@ -57,13 +58,14 @@ async def startup_event(): ...@@ -57,13 +58,14 @@ async def startup_event():
middleware_manager.setup( middleware_manager.setup(
app, app,
enable_auth=True, # 👈 Bật lại Auth để test logic Guest/User enable_auth=True, # 👈 Bật lại Auth để test logic Guest/User
enable_rate_limit=True, # 👈 Bật rate limiting enable_rate_limit=False, # 👈 Tắt slowapi vì đã có business rate limit
enable_cors=True, # 👈 Bật CORS enable_cors=True, # 👈 Bật CORS
cors_origins=["*"], # 👈 Trong production nên limit origins cors_origins=["*"], # 👈 Trong production nên limit origins
) )
app.include_router(conservation_router) app.include_router(conservation_router)
app.include_router(chatbot_router) app.include_router(chatbot_router)
app.include_router(prompt_router)
# --- MOCK API FOR LOAD TESTING --- # --- MOCK API FOR LOAD TESTING ---
......
...@@ -186,6 +186,13 @@ ...@@ -186,6 +186,13 @@
border: 1px solid #552b2b; border: 1px solid #552b2b;
} }
.message.rate-limit-error {
background: linear-gradient(135deg, #3d2d2d 0%, #2d2d3d 100%);
border: 1px solid #ff6b6b;
padding: 16px;
max-width: 350px;
}
.timestamp { .timestamp {
font-size: 0.7em; font-size: 0.7em;
opacity: 0.7; opacity: 0.7;
...@@ -424,6 +431,184 @@ ...@@ -424,6 +431,184 @@
.raw-content { .raw-content {
display: none; display: none;
} }
/* --- Modern Layout & Animations --- */
.main-content {
max-width: 1400px;
/* Wider container */
margin: 0 auto;
padding: 20px;
height: calc(100vh - 80px);
/* Fill remaining height */
box-sizing: border-box;
}
.main-layout {
display: flex;
height: 100%;
gap: 0;
/* Gap handled by margin in panel for smooth transition */
position: relative;
}
/* Chat Container flex fix */
.container {
flex: 1;
display: flex;
flex-direction: column;
background: #2d2d2d;
border-radius: 16px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
border: 1px solid #444;
height: 100%;
padding: 0;
overflow: hidden;
transition: all 0.3s ease;
z-index: 10;
}
/* Internal padding for chat container */
.chat-internal-wrapper {
padding: 20px;
display: flex;
flex-direction: column;
height: 100%;
box-sizing: border-box;
}
/* PROMPT PANEL - Slide In Style */
.prompt-panel {
width: 0;
opacity: 0;
background: #1e1e1e;
/* Darker contrast */
border-left: 1px solid #444;
border-radius: 16px;
display: flex;
flex-direction: column;
padding: 0;
/* Padding handled internally to avoid width jump */
transition: all 0.4s cubic-bezier(0.16, 1, 0.3, 1);
overflow: hidden;
margin-left: 0;
box-shadow: -5px 0 20px rgba(0, 0, 0, 0.3);
white-space: nowrap;
/* Prevent content flicker during width change */
}
.prompt-panel.open {
width: 500px;
/* Wider editor */
opacity: 1;
margin-left: 20px;
padding: 20px;
}
.prompt-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
border-bottom: 1px solid #333;
padding-bottom: 15px;
}
.prompt-header h3 {
font-size: 1.2em;
color: #4fc3f7;
/* Nice blue accent */
display: flex;
align-items: center;
gap: 10px;
}
.prompt-textarea {
flex: 1;
background: #111;
color: #dcdccc;
/* Soft code color */
border: 1px solid #333;
border-radius: 8px;
padding: 15px;
font-family: 'Consolas', 'Monaco', 'Courier New', monospace;
font-size: 14px;
line-height: 1.6;
resize: none;
margin-bottom: 15px;
white-space: pre-wrap;
/* Wrap code */
overflow-y: auto;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5);
}
.prompt-textarea:focus {
outline: none;
border-color: #667eea;
box-shadow: inset 0 2px 5px rgba(0, 0, 0, 0.5), 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.panel-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 10px;
border-top: 1px solid #333;
}
.status-text {
font-size: 0.8em;
color: #666;
font-style: italic;
}
/* Buttons Update */
.action-btn {
padding: 10px 20px;
border-radius: 8px;
font-weight: 600;
font-size: 0.9em;
border: none;
cursor: pointer;
transition: all 0.2s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-reload {
background: #333;
color: #aaa;
}
.btn-reload:hover {
background: #444;
color: white;
}
.btn-save {
background: linear-gradient(135deg, #43a047 0%, #2e7d32 100%);
color: white;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
.btn-save:hover {
transform: translateY(-2px);
box-shadow: 0 6px 12px rgba(0, 0, 0, 0.3);
}
.btn-close-panel {
background: transparent;
border: none;
color: #666;
font-size: 1.5rem;
cursor: pointer;
transition: color 0.2s;
line-height: 1;
}
.btn-close-panel:hover {
color: #ff6b6b;
}
</style> </style>
</head> </head>
...@@ -437,35 +622,139 @@ ...@@ -437,35 +622,139 @@
</div> </div>
<div class="main-content"> <div class="main-content">
<div class="container"> <div class="main-layout">
<div class="header"> <!-- Chat Container -->
<h2>🤖 Canifa AI Chat</h2> <div class="container">
<div class="config-area"> <div class="chat-internal-wrapper">
<input type="text" id="userId" placeholder="Enter User ID" value="" onblur="saveUserId()" <div class="header">
onchange="saveUserId()"> <h2>🤖 Canifa AI Chat</h2>
<button onclick="loadHistory(true)">↻ History</button> <div class="config-area" style="flex-wrap: wrap;">
<button onclick="clearUI()" style="background: #d32f2f;">✗ Clear UI</button> <div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Device ID:</label>
<input type="text" id="deviceId" placeholder="auto-generated" style="width: 150px;"
onblur="saveConfig()" onchange="saveConfig()">
</div>
<div style="display: flex; gap: 5px; align-items: center;">
<label style="font-size: 0.8em; color: #aaa;">Access Token:</label>
<input type="text" id="accessToken" placeholder="vsf-customer token (optional)"
style="width: 200px;" onblur="saveConfig()" onchange="saveConfig()">
</div>
<button onclick="loadHistory(true)">↻ History</button>
<button onclick="togglePromptEditor()"
style="background: #e6b800; color: #2d2d2d; font-weight: bold;">📝 Edit Prompt</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ Clear UI</button>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">
<span style="font-style: normal;">🤖</span> AI is thinking...
</div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type your message..."
onkeypress="handleKeyPress(event)" autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
</div>
</div> </div>
</div> </div>
<div class="chat-box" id="chatBox"> <!-- Prompt Editor Panel -->
<div class="load-more" id="loadMoreBtn" style="display: none;"> <div class="prompt-panel" id="promptPanel">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button> <div class="prompt-header">
<h3>📝 System Prompt</h3>
<button class="btn-close-panel" onclick="togglePromptEditor()">×</button>
</div> </div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">AI is typing...</div> <textarea id="systemPromptInput" class="prompt-textarea" placeholder="Loading prompt content..."
spellcheck="false"></textarea>
<div class="input-area"> <div class="panel-footer">
<input type="text" id="userInput" placeholder="Type your message..." onkeypress="handleKeyPress(event)" <span class="status-text" id="promptStatus">Ready to edit</span>
autocomplete="off"> <div style="display: flex; gap: 10px;">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button> <button class="action-btn btn-reload" onclick="loadSystemPrompt()">↻ Reset</button>
<button class="action-btn btn-save" onclick="saveSystemPrompt()">💾 Save & Apply</button>
</div>
</div>
</div> </div>
</div> </div>
<script> <script>
let messageHistory = []; // Store messages for reference let messageHistory = []; // Store messages for reference
let isPromptPanelOpen = false;
function togglePromptEditor() {
const panel = document.getElementById('promptPanel');
isPromptPanelOpen = !isPromptPanelOpen;
if (isPromptPanelOpen) {
panel.classList.add('open');
loadSystemPrompt();
} else {
panel.classList.remove('open');
}
}
async function loadSystemPrompt() {
const textarea = document.getElementById('systemPromptInput');
textarea.value = "Loading...";
textarea.disabled = true;
try {
const response = await fetch('/api/agent/system-prompt');
const data = await response.json();
if (data.status === 'success') {
textarea.value = data.content;
} else {
textarea.value = "Error loading prompt: " + data.message;
}
} catch (error) {
textarea.value = "Error connecting to server.";
console.error(error);
} finally {
textarea.disabled = false;
}
}
async function saveSystemPrompt() {
const content = document.getElementById('systemPromptInput').value;
const statusLabel = document.getElementById('promptStatus');
if (!content) return;
if (!confirm('Bạn có chắc muốn lưu Prompt mới? Bot sẽ bị reset graph để học prompt mới này.')) {
return;
}
statusLabel.innerText = "Saving...";
try {
const response = await fetch('/api/agent/system-prompt', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content: content })
});
const data = await response.json();
if (data.status === 'success') {
statusLabel.innerText = "Saved!";
alert('✅ Đã lưu Prompt thành công!\nBot đã sẵn sàng với prompt mới.');
} else {
statusLabel.innerText = "Error!";
alert('❌ Lỗi: ' + data.detail);
}
} catch (error) {
statusLabel.innerText = "Connection Error";
alert('❌ Lỗi kết nối server');
console.error(error);
}
}
function toggleMessageView(messageId) { function toggleMessageView(messageId) {
const filteredContent = document.getElementById('filtered-' + messageId); const filteredContent = document.getElementById('filtered-' + messageId);
...@@ -491,12 +780,13 @@ ...@@ -491,12 +780,13 @@
let isTyping = false; let isTyping = false;
async function loadHistory(isRefresh) { async function loadHistory(isRefresh) {
const userId = document.getElementById('userId').value; const deviceId = document.getElementById('deviceId').value;
const accessToken = document.getElementById('accessToken').value.trim();
const messagesArea = document.getElementById('messagesArea'); const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn'); const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!userId) { if (!deviceId) {
alert('Please enter a User ID'); alert('Please enter a Device ID');
return; return;
} }
...@@ -505,7 +795,10 @@ ...@@ -505,7 +795,10 @@
currentCursor = null; currentCursor = null;
} }
const url = `/api/history/${userId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`; // Use deviceId as identity_key for guest, or call API to get user's history
// For now, use deviceId directly (middleware will handle identity resolution)
const identityKey = deviceId;
const url = `/api/history/${identityKey}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
try { try {
const response = await fetch(url); const response = await fetch(url);
...@@ -573,7 +866,62 @@ ...@@ -573,7 +866,62 @@
// Message Bubble // Message Bubble
const div = document.createElement('div'); const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`; div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
div.innerText = msg.message;
// Generate unique message ID for toggle
const messageId = 'hist-' + (msg.id || Date.now() + Math.random());
if (msg.is_human) {
// User message: simple text
div.innerText = msg.message;
} else {
// Bot message: add Widget/Raw JSON toggle
// FILTERED CONTENT (default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.innerText = msg.message;
div.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
id: msg.id,
message: msg.message,
product_ids: msg.product_ids || [],
timestamp: msg.timestamp,
is_human: msg.is_human
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
div.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
div.appendChild(toggleDiv);
}
// Timestamp inside bubble // Timestamp inside bubble
const time = document.createElement('span'); const time = document.createElement('span');
...@@ -581,34 +929,29 @@ ...@@ -581,34 +929,29 @@
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time); div.appendChild(time);
// Debug ID (optional)
// const meta = document.createElement('div');
// meta.style.fontSize = '9px';
// meta.style.opacity = '0.3';
// meta.innerText = `id: ${msg.id}`;
// div.appendChild(meta);
container.appendChild(div); container.appendChild(div);
if (position === 'top') { if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild); messagesArea.insertBefore(container, messagesArea.firstChild);
} else { } else {
messagesArea.appendChild(container); // Corrected to append container messagesArea.appendChild(container);
} }
} }
async function sendMessage() { async function sendMessage() {
const input = document.getElementById('userInput'); const input = document.getElementById('userInput');
const userIdInput = document.getElementById('userId'); const deviceIdInput = document.getElementById('deviceId');
const userId = userIdInput.value.trim(); const accessTokenInput = document.getElementById('accessToken');
const deviceId = deviceIdInput.value.trim();
const accessToken = accessTokenInput.value.trim();
const text = input.value.trim(); const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn'); const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator'); const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox'); const chatBox = document.getElementById('chatBox');
if (!userId) { if (!deviceId) {
alert('Please enter a User ID first!'); alert('Please enter a Device ID first!');
userIdInput.focus(); deviceIdInput.focus();
return; return;
} }
...@@ -629,24 +972,65 @@ ...@@ -629,24 +972,65 @@
input.value = ''; input.value = '';
chatBox.scrollTop = chatBox.scrollHeight; chatBox.scrollTop = chatBox.scrollHeight;
// Save user ID to localStorage // Save config to localStorage
localStorage.setItem('canifa_user_id', userId); saveConfig();
// Track response time // Track response time
const startTime = Date.now(); const startTime = Date.now();
try { try {
// SWITCH TO NON-STREAMING ENDPOINT // Build headers
const headers = {
'Content-Type': 'application/json',
'device_id': deviceId
};
// Add Authorization if access token provided
if (accessToken) {
headers['Authorization'] = 'Bearer ' + accessToken;
}
const response = await fetch('/api/agent/chat', { const response = await fetch('/api/agent/chat', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: headers,
body: JSON.stringify({ body: JSON.stringify({
user_query: text, user_query: text
user_id: userId
}) })
}); });
if (!response.ok) throw new Error('Network response was not ok'); // Handle rate limit (429) specifically
if (response.status === 429) {
const errorData = await response.json();
// Backend returns: { message: "...", limit_info: {...} } directly or via exception
const errorMessage = errorData.message ||
errorData.detail?.message ||
errorData.detail?.limit_info?.message ||
'Bạn đã hết lượt chat hôm nay!';
// Show simple red error message
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const errorDiv = document.createElement('div');
errorDiv.className = 'message bot';
errorDiv.style.cssText = 'background: #3d2d2d; border: 1px solid #ff6b6b; color: #ff6b6b;';
errorDiv.innerText = errorMessage;
container.appendChild(errorDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
return;
}
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(errorData.detail?.message || errorData.detail || 'Có lỗi xảy ra');
}
const data = await response.json(); const data = await response.json();
const responseTime = ((Date.now() - startTime) / 1000).toFixed(2); const responseTime = ((Date.now() - startTime) / 1000).toFixed(2);
...@@ -772,8 +1156,10 @@ ...@@ -772,8 +1156,10 @@
rawJsonDiv.className = 'raw-json-view'; rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre'); const pre = document.createElement('pre');
pre.textContent = JSON.stringify({ pre.textContent = JSON.stringify({
status: data.status,
ai_response: data.ai_response, ai_response: data.ai_response,
product_ids: data.product_ids product_ids: data.product_ids,
limit_info: data.limit_info || null
}, null, 2); }, null, 2);
rawJsonDiv.appendChild(pre); rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv); rawDiv.appendChild(rawJsonDiv);
...@@ -804,8 +1190,58 @@ ...@@ -804,8 +1190,58 @@
timeDiv.innerText = `⏱️ ${responseTime}s`; timeDiv.innerText = `⏱️ ${responseTime}s`;
botMsgDiv.appendChild(timeDiv); botMsgDiv.appendChild(timeDiv);
} else { } else {
botMsgDiv.innerText = "Error: " + (data.message || "Unknown error"); // ERROR CASE: Limit exceeded or other errors
botMsgDiv.style.color = 'red';
// FILTERED CONTENT (error message - default visible)
const filteredDiv = document.createElement('div');
filteredDiv.id = 'filtered-' + messageId;
filteredDiv.className = 'filtered-content';
filteredDiv.style.color = '#ff6b6b';
filteredDiv.innerHTML = `
<div style="font-weight: bold; margin-bottom: 8px;">⚠️ ${data.error_code || 'ERROR'}</div>
<div>${data.message || 'Unknown error'}</div>
${data.require_login ? '<div style="margin-top: 10px; padding: 8px; background: #3d2d2d; border-radius: 6px;">👉 Vui lòng đăng nhập để tiếp tục sử dụng!</div>' : ''}
`;
botMsgDiv.appendChild(filteredDiv);
// RAW CONTENT (hidden by default)
const rawDiv = document.createElement('div');
rawDiv.id = 'raw-' + messageId;
rawDiv.className = 'raw-content';
rawDiv.style.display = 'none';
const rawJsonDiv = document.createElement('div');
rawJsonDiv.className = 'raw-json-view';
const pre = document.createElement('pre');
pre.textContent = JSON.stringify({
status: data.status,
error_code: data.error_code,
message: data.message,
require_login: data.require_login,
limit_info: data.limit_info || null
}, null, 2);
rawJsonDiv.appendChild(pre);
rawDiv.appendChild(rawJsonDiv);
botMsgDiv.appendChild(rawDiv);
// Toggle Buttons
const toggleDiv = document.createElement('div');
toggleDiv.className = 'message-view-toggle';
const filteredBtn = document.createElement('button');
filteredBtn.id = 'filtered-btn-' + messageId;
filteredBtn.className = 'active';
filteredBtn.innerText = '🎨 Widget';
filteredBtn.onclick = () => toggleMessageView(messageId);
const rawBtn = document.createElement('button');
rawBtn.id = 'raw-btn-' + messageId;
rawBtn.innerText = '👁️ Raw JSON';
rawBtn.onclick = () => toggleMessageView(messageId);
toggleDiv.appendChild(filteredBtn);
toggleDiv.appendChild(rawBtn);
botMsgDiv.appendChild(toggleDiv);
} }
container.appendChild(botMsgDiv); container.appendChild(botMsgDiv);
...@@ -839,26 +1275,60 @@ ...@@ -839,26 +1275,60 @@
document.getElementById('messagesArea').innerHTML = ''; document.getElementById('messagesArea').innerHTML = '';
} }
// Save user ID to localStorage (called on input change/blur) // Apply token from login prompt in rate limit error
function saveUserId() { function applyLoginToken() {
const userIdInput = document.getElementById('userId'); const tokenInput = document.getElementById('loginTokenInput');
const val = userIdInput.value.trim(); if (tokenInput && tokenInput.value.trim()) {
if (val) { document.getElementById('accessToken').value = tokenInput.value.trim();
localStorage.setItem('canifa_user_id', val); saveConfig();
alert('✅ Token đã được lưu! Bạn có thể tiếp tục chat.');
} else { } else {
// If empty, remove saved id alert('Vui lòng nhập Access Token!');
localStorage.removeItem('canifa_user_id');
} }
} }
// Load user ID from localStorage on page load and auto-load history // Save config to localStorage (called on input change/blur)
function saveConfig() {
const deviceId = document.getElementById('deviceId').value.trim();
const accessToken = document.getElementById('accessToken').value.trim();
if (deviceId) {
localStorage.setItem('canifa_device_id', deviceId);
}
if (accessToken) {
localStorage.setItem('canifa_access_token', accessToken);
} else {
localStorage.removeItem('canifa_access_token');
}
}
// Generate UUID for device_id
function generateUUID() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0;
const v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// Load config from localStorage on page load
window.onload = function () { window.onload = function () {
const savedUserId = localStorage.getItem('canifa_user_id'); // Load or generate Device ID
if (savedUserId) { let savedDeviceId = localStorage.getItem('canifa_device_id');
document.getElementById('userId').value = savedUserId; if (!savedDeviceId) {
// Auto-load history for saved user id savedDeviceId = 'device-' + generateUUID().substring(0, 8);
setTimeout(() => loadHistory(true), 50); localStorage.setItem('canifa_device_id', savedDeviceId);
} }
document.getElementById('deviceId').value = savedDeviceId;
// Load Access Token (optional)
const savedAccessToken = localStorage.getItem('canifa_access_token');
if (savedAccessToken) {
document.getElementById('accessToken').value = savedAccessToken;
}
// Auto-load history
setTimeout(() => loadHistory(true), 50);
}; };
</script> </script>
</div> <!-- Close main-content --> </div> <!-- Close main-content -->
......
"""
Test Canifa API Auth
"""
import asyncio
import httpx
TOKEN = "7ibs17luogynysetg0cbjabmrzl2wvw2"
CANIFA_API = "https://canifa.com/v1/magento/customer"
QUERY_BODY = [
{
"customer": "customer-custom-query",
"metadata": {
"fields": "\n customer {\n gender\n customer_id\n phone_number\n date_of_birth\n default_billing\n default_shipping\n email\n firstname\n is_subscribed\n lastname\n middlename\n prefix\n suffix\n taxvat\n addresses {\n city\n country_code\n default_billing\n default_shipping\n extension_attributes {\n attribute_code\n value\n }\n custom_attributes {\n attribute_code\n value\n }\n firstname\n id\n lastname\n postcode\n prefix\n region {\n region_code\n region_id\n region\n }\n street\n suffix\n telephone\n vat_id\n }\n is_subscribed\n }\n "
}
},
{}
]
async def test_canifa_api():
headers = {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
"Cookie": f"vsf-customer={TOKEN}"
}
print(f"🔐 Testing Canifa API with token: {TOKEN}")
print(f"📡 URL: {CANIFA_API}")
print("-" * 50)
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(CANIFA_API, json=QUERY_BODY, headers=headers)
print(f"📊 Status Code: {response.status_code}")
print(f"📝 Response:")
data = response.json()
import json
print(json.dumps(data, indent=2, ensure_ascii=False))
# Try to extract customer_id
if isinstance(data, list) and len(data) > 0:
first_item = data[0]
if isinstance(first_item, dict):
result = first_item.get('result', {})
customer = result.get('customer', {}) if isinstance(result, dict) else None
if customer:
print("\n✅ CUSTOMER INFO:")
print(f" customer_id: {customer.get('customer_id')}")
print(f" email: {customer.get('email')}")
print(f" firstname: {customer.get('firstname')}")
print(f" lastname: {customer.get('lastname')}")
else:
print("\n⚠️ No customer data in response")
elif isinstance(data, dict):
result = data.get('result', {})
customer = result.get('customer', {}) if isinstance(result, dict) else None
if customer:
print("\n✅ CUSTOMER INFO:")
print(f" customer_id: {customer.get('customer_id')}")
print(f" email: {customer.get('email')}")
else:
print("\n⚠️ No customer data found")
except Exception as e:
print(f"❌ Error: {e}")
if __name__ == "__main__":
asyncio.run(test_canifa_api())
import requests
import json
BASE_URL = "http://localhost:5000"
API_URL = f"{BASE_URL}/api/agent/system-prompt"
# 1. Get current prompt
print("1. Getting current prompt...")
try:
response = requests.get(API_URL)
if response.status_code == 200:
print("✅ Current prompt fetched successfully.")
print(f"Preview: {response.json()['content'][:100]}...")
else:
print(f"❌ Failed to get prompt: {response.status_code} - {response.text}")
except Exception as e:
print(f"❌ Error connecting: {e}")
# 2. Update prompt
new_prompt = """# VAI TRÒ
Bạn là Mèo Máy Doraemon đến từ thế kỷ 22.
Luôn kết thúc câu bằng "meo meo".
"""
print("\n2. Updating prompt to Doraemon...")
try:
response = requests.post(API_URL, json={"content": new_prompt})
if response.status_code == 200:
print("✅ Prompt updated successfully.")
print(response.json())
else:
print(f"❌ Failed to update prompt: {response.status_code} - {response.text}")
except Exception as e:
print(f"❌ Error connecting: {e}")
# 3. Verify update
print("\n3. Verifying update...")
try:
response = requests.get(API_URL)
content = response.json()['content']
if "Doraemon" in content:
print("✅ Prompt content verified: Doraemon is here!")
else:
print("❌ Prompt content NOT updated.")
except Exception as e:
print(f"❌ Error connecting: {e}")
import requests
BASE_URL = "http://localhost:5000"
API_URL = f"{BASE_URL}/api/agent/system-prompt"
original_prompt = """# VAI TRÒ
Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- Nhiệt tình, thân thiện, chuyên nghiệp
- CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
- Hôm nay: {date_str}
---
# QUY TẮC TRUNG THỰC - BẮT BUỘC
KHÔNG BAO GIỜ BỊA ĐẶT - CHỈ NÓI THEO DỮ LIỆU
**ĐÚNG:**
- Tool trả về áo thun → Giới thiệu áo thun
- Tool trả về 0 sản phẩm → Nói "Shop chưa có sản phẩm này"
- Tool trả về quần nỉ mà khách hỏi bikini → Nói "Shop chưa có bikini"
**CẤM:**
- Tool trả về quần nỉ → Gọi là "đồ bơi"
- Tool trả về 0 kết quả → Nói "shop có sản phẩm X"
- Tự bịa mã sản phẩm, giá tiền, chính sách
Không có trong data = Không nói = Không tư vấn láo
---
# NGÔN NGỮ & XƯNG HÔ
- Mặc định: Xưng "mình" - gọi "bạn"
- Khi khách xưng anh/chị: Xưng "em" - gọi "anh/chị"
- Khách nói tiếng Việt → Trả lời tiếng Việt
- Khách nói tiếng Anh → Trả lời tiếng Anh
- Ngắn gọn, đi thẳng vào vấn đề
---
# KHI NÀO GỌI TOOL
**Gọi data_retrieval_tool khi:**
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì..."
- Khách hỏi sản phẩm cụ thể: "Mã 8TS24W001 có không?"
- Tư vấn phong cách: "Mặc gì đi cưới?", "Đồ công sở?"
**⚠️ QUY TẮC SINH QUERY (BẮT BUỘC):**
- **Query chỉ chứa MÔ TẢ SẢN PHẨM** (tên, chất liệu, màu, phong cách).
- **TUYỆT ĐỐI KHÔNG đưa giá tiền vào chuỗi `query`**.
- Giá tiền phải đưa vào tham số riêng: `price_min`, `price_max`.
Ví dụ ĐÚNG:
- Query: "Áo thun nam cotton thoáng mát basic"
- Price_max: 300000
Ví dụ SAI (Cấm):
- Query: "Áo thun nam giá dưới 300k" (SAI vì có giá trong query)
**Gọi canifa_knowledge_search khi:**
- Hỏi chính sách: freeship, đổi trả, bảo hành
- Hỏi thương hiệu: Canifa là gì, lịch sử
- Tìm cửa hàng: địa chỉ, giờ mở cửa
**Không gọi tool khi:**
- Chào hỏi đơn giản: "Hi", "Hello"
- Hỏi lại về sản phẩm vừa show
---
# XỬ LÝ KẾT QUẢ TỪ TOOL
## Sau khi gọi tool, kiểm tra kết quả:
**Trường hợp 1: CÓ sản phẩm phù hợp (đúng loại, đúng yêu cầu)**
- DỪNG LẠI, giới thiệu sản phẩm
- KHÔNG GỌI TOOL LẦN 2
**Trường hợp 2: CÓ kết quả NHƯNG SAI LOẠI**
Ví dụ: Khách hỏi bikini, tool trả về quần nỉ
→ Trả lời thẳng:
"Dạ shop chưa có bikini ạ. Shop chuyên về quần áo thời trang (áo, quần, váy). Bạn có muốn tìm sản phẩm nào khác không?"
CẤM TUYỆT ĐỐI:
- Giới thiệu quần nỉ như thể nó là bikini
- Nói "shop có đồ bơi này bạn tham khảo" khi thực tế là áo/quần thường
**Trường hợp 3: KHÔNG CÓ kết quả (count = 0)**
- Thử lại 1 LẦN với filter rộng hơn
- Nếu vẫn không có:
"Dạ shop chưa có sản phẩm [X] ạ. Bạn có thể tham khảo [loại gần nhất] hoặc ghé shop sau nhé!"
---
# FORMAT ĐẦU RA
Trả về JSON (KHÔNG có markdown backticks):
```json
{{
"ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]",
"product_ids": [
{{
"sku": "8TS24W001",
"name": "Áo thun nam basic",
"price": 200000,
"sale_price": 160000,
"url": "https://canifa.com/...",
"thumbnail_image_url": "https://..."
}}
]
}}
```
**Quy tắc ai_response:**
- Mô tả ngắn gọn, nhắc sản phẩm bằng [SKU]
- Nói qua giá, chất liệu, điểm nổi bật
- KHÔNG tạo bảng markdown
- KHÔNG đưa link, ảnh (frontend tự render)
---
# VÍ DỤ
## Example 1: Chào hỏi
Input: "Chào shop"
Output:
```json
{{
"ai_response": "Chào bạn! Mình là CiCi, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"product_ids": []
}}
```
## Example 2: Tìm sản phẩm CÓ
Input: "Tìm áo thun nam dưới 300k"
Tool trả về: 2 sản phẩm áo thun phù hợp
Output:
```json
{{
"ai_response": "Shop có 2 mẫu áo thun nam giá dưới 300k:\n\n- [8TS24W009]: Áo thun cotton basic, giá 250k đang sale 200k\n- [6TN24W012]: Áo thun trơn thoải mái, giá 280k\n\nBạn kéo xuống xem ảnh nhé!",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, "sale_price": 200000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W012", "name": "Áo thun trơn", "price": 280000, "sale_price": null, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
## Example 3: Khách hỏi KHÔNG CÓ trong kho
Input: "Shop có bikini không?"
Tool trả về: 0 sản phẩm
Output:
```json
{{
"ai_response": "Dạ shop chưa có bikini ạ. CANIFA chuyên về quần áo thời trang như áo, quần, váy, đầm. Bạn có muốn tìm mẫu nào khác không?",
"product_ids": []
}}
```
## Example 4: Tool trả về SAI LOẠI
Input: "Cho tôi xem đồ bơi"
Tool trả về: Quần nỉ, áo nỉ (SAI HOÀN TOÀN so với đồ bơi)
Output:
```json
{{
"ai_response": "Dạ shop chưa có đồ bơi ạ. Shop chuyên bán quần áo thời trang (áo, quần, váy, áo khoác). Bạn có muốn tìm loại sản phẩm nào khác không?",
"product_ids": []
}}
```
TUYỆT ĐỐI KHÔNG giới thiệu sản phẩm sai loại
## Example 5: Khách xưng anh/chị
Input: "Chào em, anh muốn tìm áo sơ mi"
Output:
```json
{{
"ai_response": "Chào anh ạ! Em là CiCi. Anh đang tìm áo sơ mi dài tay hay ngắn tay ạ? Để em tư vấn mẫu phù hợp nhất cho anh nhé!",
"product_ids": []
}}
```
---
# TÓM TẮT
1. CANIFA bán quần áo (áo, quần, váy, đầm, phụ kiện)
2. Không có trong data = Không nói
3. Kiểm tra kỹ tên sản phẩm trước khi giới thiệu
4. Nếu sai loại → Nói thẳng "shop chưa có X"
5. Không bịa giá, mã sản phẩm, chính sách
6. Có kết quả phù hợp = DỪNG, không gọi tool lần 2
7. Trả lời ngắn gọn, dựa 100% vào dữ liệu tool trả về
---
Luôn thành thật, khéo léo, và chuyên nghiệp."""
print("\nRestoring original prompt...")
try:
response = requests.post(API_URL, json={"content": original_prompt})
if response.status_code == 200:
print("✅ Original prompt restored successfully.")
else:
print(f"❌ Failed to restore prompt: {response.status_code} - {response.text}")
except Exception as e:
print(f"❌ Error connecting: {e}")
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