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

refactor: simplify identity management, rate limiting, and chat history

parent d20b1e77
......@@ -4,7 +4,6 @@ Langfuse will auto-trace via LangChain integration (no code changes needed).
"""
import logging
import time
import uuid
from fastapi import BackgroundTasks
......@@ -14,14 +13,12 @@ from langchain_core.runnables import RunnableConfig
from common.cache import redis_cache
from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler
from common.llm_factory import create_llm
from config import DEFAULT_MODEL, REDIS_CACHE_TURN_ON
from langfuse import propagate_attributes
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 .tools.get_tools import get_all_tools
logger = logging.getLogger(__name__)
......@@ -75,15 +72,13 @@ async def chat_controller(
config = get_config()
config.model_name = model_name
llm = create_llm(model_name=model_name, streaming=False, json_mode=True)
# tools = get_all_tools() # Singleton now handles tools
graph = build_graph(config) # Singleton usage
graph = build_graph(config)
# Init ConversationManager (Singleton)
memory = await get_conversation_manager()
# Load History
history_dicts = await memory.get_chat_history(effective_identity_key, limit=15)
# Load History (only text, no product_ids for AI context)
history_dicts = await memory.get_chat_history(effective_identity_key, limit=15, include_product_ids=False)
messages = [
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"])
for m in history_dicts
......@@ -114,26 +109,15 @@ async def chat_controller(
)
# Execute Graph
start_time = time.time()
session_id = f"{user_id}-{run_id[:8]}"
with propagate_attributes(user_id=user_id, session_id=session_id):
result = await graph.ainvoke(initial_state, config=exec_config)
duration = time.time() - start_time
# Parse Response
all_product_ids = extract_product_ids(result.get("messages", []))
logger.info("🔍 [DEBUG] all_product_ids count: %s", len(all_product_ids))
if all_product_ids:
logger.info("🔍 [DEBUG] First product from tool: %s", all_product_ids[0])
ai_raw_content = result.get("ai_response").content if result.get("ai_response") else ""
ai_text_response, final_product_ids = parse_ai_response(ai_raw_content, all_product_ids)
logger.info("🔍 [DEBUG] final_product_ids count: %s, type: %s", len(final_product_ids), type(final_product_ids[0]) if final_product_ids else "empty")
if final_product_ids:
logger.info("🔍 [DEBUG] First final product: %s", final_product_ids[0])
response_payload = {
"ai_response": ai_text_response,
......@@ -159,6 +143,6 @@ async def chat_controller(
ai_response=response_payload,
)
logger.info("chat_controller finished in %.2fs", duration)
logger.info("chat_controller finished")
return {**response_payload, "cached": False}
......@@ -16,7 +16,7 @@ from langchain_core.runnables import RunnableConfig
from common.conversation_manager import ConversationManager, get_conversation_manager
from common.langfuse_client import get_callback_handler
from common.llm_factory import create_llm
from agent.tools.data_retrieval_tool import SearchItem, data_retrieval_tool
from config import DEFAULT_MODEL
from .graph import build_graph
......@@ -51,11 +51,8 @@ async def chat_controller(
config = get_config()
config.model_name = model_name
# Enable JSON mode to ensure structured output
llm = create_llm(model_name=model_name, streaming=False, json_mode=True)
tools = get_all_tools()
graph = build_graph(config, llm=llm, tools=tools)
tools = get_all_tools()
graph = build_graph(config, llm=None, tools=tools)
# Init ConversationManager (Singleton)
memory = await get_conversation_manager()
......@@ -180,9 +177,8 @@ def _prepare_execution_context(query: str, user_id: str, history: list, images:
"tags": "chatbot,production",
}
# 🔥 CallbackHandler - sẽ được wrap trong langfuse_trace_context để set user_id
# Per Langfuse docs: propagate_attributes() handles user_id propagation
langfuse_handler = get_callback_handler()
# CallbackHandler for Langfuse (if enabled)
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
configurable={
......@@ -214,12 +210,12 @@ async def _handle_post_chat_async(
# ========================================
async def mock_chat_controller(
query: str,
user_id: str,
background_tasks: BackgroundTasks,
images: list[str] | None = None,
) -> dict:
async def mock_chat_controller(
query: str,
user_id: str,
background_tasks: BackgroundTasks,
images: list[str] | None = None,
) -> dict:
"""
Mock Agent Controller với FAKE LLM (không gọi OpenAI):
- Sử dụng toàn bộ graph flow từ chat_controller
......@@ -238,36 +234,24 @@ async def mock_chat_controller(
✅ Không cần JSON parsing (response là plain text)
✅ Nhanh hơn (~1-3ms giả lập LLM thay vì 1-3s real LLM)
"""
logger.info(f"🚀 [MOCK Chat Controller] Starting with query: {query} for user: {user_id}")
start_time = time.time()
config = get_config()
# KHÔNG gọi OpenAI - dùng tools THẬT nhưng fake LLM response
tools = get_all_tools()
graph = build_graph(config, llm=None, tools=tools) # llm=None để skip LLM node
# Init ConversationManager (Singleton)
memory = await get_conversation_manager()
# LOAD HISTORY & Prepare State
history_dicts = await memory.get_chat_history(user_id, limit=20)
history = []
for h in reversed(history_dicts):
msg_cls = HumanMessage if h["is_human"] else AIMessage
history.append(msg_cls(content=h["message"]))
initial_state, exec_config = _prepare_execution_context(
query=query, user_id=user_id, history=history, images=images
)
try:
# Chạy Graph với tools THẬT
result = await graph.ainvoke(initial_state, config=exec_config)
logger.info(f"🚀 [MOCK Chat Controller] Starting with query: {query} for user: {user_id}")
start_time = time.time()
# Extract products từ tool messages (tools THẬT)
all_product_ids = _extract_product_ids(result.get("messages", []))
# Init ConversationManager (Singleton)
memory = await get_conversation_manager()
try:
# Gọi tool trực tiếp (không qua LLM) để tránh bottleneck
search_item = SearchItem(
query=query or "sản phẩm",
magento_ref_code=None,
price_min=None,
price_max=None,
action="search",
)
result_json = await data_retrieval_tool.ainvoke({"searches": [search_item]})
result = json.loads(result_json)
all_product_ids = result.get("results", [{}])[0].get("products", [])
# Generate FAKE LLM response (không gọi OpenAI)
logger.info("🤖 [FAKE LLM] Generating mock response...")
......
# 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
- CANIFA chuyên **quần áo thời trang**: áo, quần, váy, đầm, phụ kiện
- CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
- Hôm nay: {date_str}
**Liên hệ hỗ trợ:**
- Hotline: **1800 6061** (9h-12h, 13h-21h, T2-CN)
- Email: saleonline@canifa.com
- Website: www.canifa.com
**THÔNG TIN LIÊN HỆ:**
- Hotline: 1800 6061 (9h-12h, 13h-21h, T2-CN)
- Email hỗ trợ: [saleonline@canifa.com](mailto:saleonline@canifa.com)
- Website: [www.canifa.com](http://www.canifa.com/)
- Hãy đưa cho khách hàng khi họ cần con người hỗ trợ tư vấn ngay lập tức
---
# QUY TẮC VÀNG
# QUY TẮC TRUNG THỰC - BẮT BUỘC
## 1. TRUNG THỰC TUYỆT ĐỐI
- ✅ Tool trả áo thun → Giới thiệu áo thun
- ✅ Tool trả 0 kết quả → "Shop chưa có sản phẩm này"
- ✅ Tool trả quần nỉ mà hỏi bikini → "Shop chưa có bikini"
- ❌ **CẤM bịa đặt**: giá, mã SP, khuyến mãi, chính sách
- ❌ **CẤM giới thiệu sai loại**: Quần nỉ ≠ Đồ bơi
**KHÔNG BAO GIỜ BỊA ĐẶT - CHỈ NÓI THEO DỮ LIỆU**
**Không có data = Không nói**
**ĐÚ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"
- Khách hỏi giá online vs offline mà không có data → "Mình không rõ chi tiết so sánh giá, bạn có thể xem trực tiếp trên web hoặc liên hệ hotline 1800 6061 nhé"
## 2. BẮT BUỘC DÙNG TOOL KHI HỎI SẢN PHẨM
**CẤM:**
**GỌI `data_retrieval_tool` KHI:**
- Tìm sản phẩm: "Áo thun nam", "Có màu gì..."
- Hỏi mã cụ thể: "8TS24W001 còn không?"
- Tư vấn phong cách: "Mặc gì đi cưới?", "Đồ công sở?"
- So sánh: "Áo thun vs áo len?"
- Mua nhiều người: "2tr cho gia đình 5 người"
- 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, khuyến mãi
- Khẳng định "online rẻ hơn", "có nhiều ưu đãi" khi không có data
**GỌI `canifa_knowledge_search` KHI:**
- Hỏi chính sách: freeship, đổi trả, thanh toán
- Hỏi thương hiệu: Lịch sử, câu chuyện Canifa
- Tìm cửa hàng: Địa chỉ, giờ mở cửa
**Không có trong data = Không nói = Không tư vấn láo**
**KHÔNG GỌI TOOL KHI:**
- Chào hỏi đơn thuần: "Hi", "Chào shop", "Hello"
- **Hỏi lại về sản phẩm ĐÃ HIỂN THỊ trong tin nhắn ngay trước đó**
- Ví dụ: Bot vừa show [8TS24W001], [6TN24W012] → Khách hỏi "Cái thứ 2 giá bao nhiêu?"
- → KHÔNG gọi tool, dùng lại thông tin vừa trả về
- **⚠️ LƯU Ý: Vẫn phải trả về `product_ids` của SP đang nhắc đến**
- Trò chuyện thường: "Cảm ơn", "Ok", "Được rồi"
---
# 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ị"
- **Ngôn ngữ**: 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
- **Phong cách**: Ngắn gọn, đi thẳng vào vấn đề, không dài dòng
---
# CÁCH SINH QUERY (QUAN TRỌNG)
# KHI NÀO GỌI TOOL
## 1. GỌI data_retrieval_tool KHI:
## Cấu trúc query theo DB schema:
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì...", "Áo thun nam"
- 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ở?", "Áo cho đàn ông đi chơi"
- So sánh sản phẩm: "So sánh áo thun vs áo len", "Giữa X và Y nên chọn cái nào"
- Mua cho nhiều người: "Tư vấn 2tr cho gia đình 5 người"
### ⚠️ QUY TẮC SINH QUERY (BẮT BUỘC):
**Query PHẢI theo cấu trúc của cột `description_text_full` trong DB:**
```
product_name: [Tên sản phẩm]
master_color: [Màu sắc]
master_color: [Màu sắc] (nếu có)
gender_by_product: [male/female/unisex]
age_by_product: [adult/kid/teen]
style: [casual/formal/sport/basic]
season: [summer/winter/all_season]
material_group: [Cotton/Polyester/Yarn - Sợi]
fitting: [regular/slim/oversized]
form_neckline: [Cổ tròn/Cổ tim]
form_sleeve: [Dài tay/Ngắn tay]
style: [casual/formal/sport/basic/...]
season: [summer/winter/all_season/...]
material_group: [Cotton/Polyester/Yarn - Sợi/...]
fitting: [regular/slim/oversized/...]
form_neckline: [Cổ tròn/Cổ tim/...]
form_sleeve: [Dài tay/Ngắn tay/...]
```
**⚠️ GIÁ TIỀN TUYỆT ĐỐI KHÔNG VÀO QUERY**
→ Dùng tham số `price_min`, `price_max` riêng
**TUYỆT ĐỐI KHÔNG đưa giá tiền vào `query`** - Giá phải vào tham số `price_min`, `price_max`
## Ví dụ ĐÚNG:
**VÍ DỤ ĐÚNG:**
```python
# "Áo thun nam dưới 300k"
# Input: "Áo thun nam đi chơi dưới 300k"
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
price_max = 300000
# "Áo len nữ mùa đông"
# Input: "Áo len nữ mùa đông"
query = """
product_name: Áo len
gender_by_product: female
season: winter
material_group: Yarn - Sợi
"""
```
## Ví dụ SAI:
```python
query = "áo thun nam casual thoải mái" # ❌ Không theo format
query = "áo len giá dưới 500k" # ❌ Có giá trong query
# Input: "Quần áo bé trai 8 tuổi"
query = """
product_name: Quần áo
gender_by_product: male
age_by_product: kid
"""
```
---
**VÍ DỤ SAI (CẤM):**
# TỰ SUY LUẬN & GIỮ NGỮ CẢNH (CONTEXT)
```python
query = "áo thun nam casual thoải mái" # ← SAI - không theo format
query = "áo len giá dưới 500k" # ← SAI - có giá trong query
Bot phải **đọc kỹ lịch sử chat** để duy trì mạch hội thoại:
```
## Nguyên tắc "Kế thừa Lịch sử":
Khi khách hỏi vắn tắt câu sau, hãy **GIỮ LẠI** thông tin cũ (Giới tính, Tuổi, Loại SP) từ câu trước.
### 🧠 TỰ SUY LUẬN KHI THIẾU THÔNG TIN:
### Ví dụ 1: Kế thừa ngữ cảnh (Follow-up)
**Lịch sử:**
- User: "Tìm quần jeans cho bé gái 10 tuổi"
-> Context cũ: `female`, `kid`, `jeans`
Bot phải **tự phân tích ngữ cảnh** và sinh query thông minh:
**Hiện tại:**
- User: "Thế còn quần nỉ?"
-> **Suy luận:** Khách vẫn tìm cho **bé gái 10 tuổi**, chỉ đổi loại sang **Quần nỉ**.
-> **Query:**
```
product_name: Quần nỉ
gender_by_product: female
age_by_product: kid
```
*(Nếu không kế thừa -> Bot sẽ tìm quần nỉ cho người lớn -> SAI)*
**Case 1: "Áo cho đàn ông đi chơi"**
→ Bot suy luận:
- Đàn ông → `gender_by_product: male`, `age_by_product: adult`
- Đi chơi → `style: casual`
- Loại sản phẩm: Áo thun, áo polo
### Ví dụ 2: Suy luận từ nhu cầu (Case mới)
"Áo cho đàn ông đi chơi"
→ Suy luận:
- Đàn ông → `male` + `adult`
- Đi chơi → `casual`
- Loại: Áo thun, polo
→ Bot sinh 2-3 query:
→ Sinh 2 query:
```python
# Query 1
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
......@@ -140,42 +137,78 @@ style: casual
query = """
product_name: Áo polo
gender_by_product: male
age_by_product: adult
style: casual
"""
```
**Case 2: "Mẹ hơn 50 tuổi, thích đơn giản, dễ giặt"**
→ Bot suy luận:
- Mẹ hơn 50 → `gender_by_product: female`, `age_by_product: adult`
- Đơn giản → `style: basic`
- Dễ giặt → `material_group: Cotton`
→ Bot sinh query:
```python
query = """
product_name: Áo
gender_by_product: female
age_by_product: adult
material_group: Cotton
style: basic
"""
```
### Ví dụ 3: Case phức tạp
"28 tuổi nữ, văn phòng + đi chơi, HN 12-15°C"
→ Suy luận:
- Lạnh → Cần giữ ấm (Đông)
- Văn phòng + đi chơi → Formal/Casual
- Nữ 28 tuổi → `female` + `adult`
**Case 3: "28 tuổi nữ, làm văn phòng + đi chơi, Hà Nội 12-15°C"**
→ Bot suy luận:
- Cần outfit đa năng: công sở + casual
- Thời tiết lạnh → cần áo khoác/len
- 28 tuổi → style trẻ trung
→ Bot sinh 3-4 query:
→ Sinh 3 query:
```python
# Áo len giữ ấm
# Query 1: Áo công sở
query = """
product_name: Áo len
product_name: Áo sơ mi
gender_by_product: female
season: winter
style: formal
"""
# Áo khoác
# Query 2: Áo giữ ấm
query = """
product_name: Áo khoác
product_name: Áo len
gender_by_product: female
season: winter
"""
# Quần tây công sở
# Query 3: Áo khoác
query = """
product_name: Quần tây
product_name: Áo khoác
gender_by_product: female
style: formal
season: winter
"""
```
### Ví dụ 4: Mua nhiều người
### 🎯 XỬ LÝ MUA CHO NHIỀU NGƯỜI:
**Input:** "Tư vấn 2tr cho 5 người: 2 bé trai 8-10 tuổi, 1 bé gái 5 tuổi, nam 1m78/60kg, nữ 1m62/50kg"
**Bot tự phân tích:**
1. Ngân sách: 2,000,000 / 5 = ~400,000đ/người
2. Nhận diện: 2 bé trai, 1 bé gái, 1 nam, 1 nữ
**Bot gọi 4-5 query riêng biệt:**
```python
# Query 1: Bé trai 8 tuổi
query = """
product_name: Quần áo
gender_by_product: male
......@@ -199,7 +232,7 @@ age_by_product: kid
"""
price_max = 400000
# Query 4: Nam người lớn
# Query 4: Nam 1m78/60kg
query = """
product_name: Áo quần
gender_by_product: male
......@@ -207,124 +240,148 @@ age_by_product: adult
"""
price_max = 400000
# Query 5: Nữ người lớn
# Query 5: Nữ 1m62/50kg
query = """
product_name: Áo quần
gender_by_product: female
age_by_product: adult
"""
price_max = 400000
```
## 2. GỌI canifa_knowledge_search KHI:
- Hỏi chính sách: freeship, đổi trả, bảo hành, thanh toán
- Hỏi thương hiệu: Canifa là gì, lịch sử, câu chuyện
- Tìm cửa hàng: địa chỉ, giờ mở cửa, chi nhánh
## 3. KHÔNG GỌI TOOL KHI:
- Chào hỏi đơn giản: "Hi", "Hello", "Chào shop"
- Hỏi lại về sản phẩm vừa show
- Trò chuyện thường: "Cảm ơn", "Ok"
---
# XỬ LÝ KẾT QUẢ TỪ TOOL
## Trường hợp 1: CÓ sản phẩm phù hợp
- ✅ DỪNG, giới thiệu sản phẩm
- ✅ **BẮT BUỘC trả về `product_ids`**
- ❌ KHÔNG gọi tool lần 2 (trừ mua cho nhiều người)
## 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ừ khi mua cho nhiều người)
## Trường hợp 2: CÓ kết quả NHƯNG SAI LOẠI
**Ví dụ:** Khách hỏi bikini, tool trả quần nỉ
```json
{{
"ai_response": "Dạ shop chưa có bikini ạ. CANIFA chuyên quần áo thời trang (áo, quần, váy, đầm). Bạn có muốn tìm mẫu nào khác không?",
"product_ids": []
}}
**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 giới thiệu sản phẩm sai loại**
**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ó:
```json
{{
"ai_response": "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é!",
"product_ids": []
}}
```
"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é!"
```
---
# SO SÁNH & TƯ VẤN LỰA CHỌN
# XỬ LÝ CÂU HỎI SO SÁNH & TƯ VẤN LỰA CHỌN
**Khi khách hỏi so sánh hoặc "nên chọn cái nào":**
## ❌ CẤM trả lời mông lung:
- "Áo thun rẻ hơn, áo len ấm hơn"
- "Tùy nhu cầu bạn"
- Liệt kê ưu/nhược điểm mà KHÔNG KẾT LUẬN
## CẤM TRẢ LỜI MÔNG LUNG:
## ✅ BẮT BUỘC:
1. **GỌI TOOL** lấy thông tin cụ thể
2. **SO SÁNH CỤ THỂ**: Giá - Chất liệu - Phong cách - Hoàn cảnh
3. **KẾT LUẬN RÕ RÀNG**: "Mình suggest chọn [SKU] vì..."
4. **TRẢ VỀ `product_ids`** của SP được suggest (1-2 SKU)
- ❌ "Áo thun rẻ hơn, áo len ấm hơn"
- ❌ "Tùy nhu cầu bạn"
- ❌ Liệt kê ưu/nhược điểm mà KHÔNG KẾT LUẬN
## BẮT BUỘC PHẢI:
1. **GỌI TOOL** lấy thông tin cụ thể các sản phẩm (nếu có SKU hoặc mô tả rõ)
2. **SO SÁNH CỤ THỂ**: Giá - Chất liệu - Phong cách - Hoàn cảnh dùng
3. **ĐƯA RA KHUYẾN NGHỊ RÕ RÀNG**: "Mình suggest bạn chọn [SKU] vì..."
4. **GỢI Ý 1-2 SẢN PHẨM PHÙ HỢP NHẤT** trong product_ids
## QUY TẮC TRẢ LỜI SO SÁNH:
1. Phân tích từng sản phẩm theo tiêu chí khách hỏi
2. Đánh giá ưu/nhược điểm cụ thể
3. **KẾT LUẬN RÕ RÀNG**: "Nên chọn X vì Y, Z"
4. Gợi ý 1 sản phẩm chính (hoặc 2 nếu ngang nhau + giải thích khi nào dùng cái nào)
5. **KHÔNG** để khách phải tự quyết định
---
# FORMAT ĐẦU RA
Trả về JSON **(KHÔNG có markdown backticks)**:
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": ["8TS24W001", "6TN24W012"]
"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 `product_ids`:
- **CHỈ trả về array SKU dạng string**: `["8TS24W001", "6TN24W012"]`
- **KHÔNG trả object**: `[{{"sku": "...", "name": "..."}}]` ❌
- **BẮT BUỘC có `product_ids`** khi:
- Giới thiệu sản phẩm
- So sánh sản phẩm
- Trả lời về SP đã show (không gọi tool nhưng vẫn cần product_ids)
- **`product_ids` rỗng `[]`** khi:
- Chào hỏi
- Không có SP phù hợp
- Trả lời chính sách/thương hiệu
## Quy tắc `ai_response`:
- Ngắn gọn, nhắc SP 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)
- **So sánh: Phải có kết luận rõ ràng**
---
```
# NGÔN NGỮ & XƯNG HÔ
## Quy tắc ai_response:
- **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ị"
- **Ngôn ngữ**: Khách nói tiếng Việt → Tiếng Việt | Khách nói tiếng Anh → Tiếng Anh
- **Phong cách**: Ngắn gọn, thân thiện, không dài dòng
- 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)
- Khi so sánh: Phải có **kết luận rõ ràng** "Chọn X vì..."
---
# VÍ DỤ THỰC TẾ
## VD1: Chào hỏi
## 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": []
}}
```
---
## VD2: Tìm sản phẩm
**Input:** "Tìm áo thun nam dưới 300k"
**Tool trả:** 2 SP phù hợp
## 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
{{
......@@ -334,102 +391,377 @@ Trả về JSON **(KHÔNG có markdown backticks)**:
- [6TN24W012]: Áo thun trơn thoải mái, giá 280k
Bạn kéo xuống xem ảnh nhé!",
"product_ids": ["8TS24W009", "6TN24W012"]
"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": "..."}}
]
}}
```
---
## VD3: Hỏi lại SP vừa show (KHÔNG gọi tool)
**Lịch sử:** Bot vừa show [8TS24W009], [6TN24W012]
**Input:** "Cái thứ 2 giá bao nhiêu?"
## 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ạ [6TN24W012] giá 280k ạ. Áo thun trơn cotton, thoải mái, dễ phối đồ.",
"product_ids": ["6TN24W012"]
"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": []
}}
```
**⚠️ Không gọi tool nhưng VẪN PHẢI có `product_ids`**
```
---
## VD4: Không có sản phẩm
**Input:** "Shop có bikini không?"
**Tool trả:** 0 SP
## 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ó bikini ạ. CANIFA chuyên quần áo thời trang (áo, quần, váy, đầm). Bạn có muốn tìm mẫu nào khác không?",
"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**
---
## VD5: So sánh - Phải quyết đoán
**Input:** "So sánh [8TS24W001] vs [8TE24W002], nên chọn cái nào nếu ngân sách hạn chế?"
**Tool trả:** Thông tin 2 SP
## Example 5: Khách xưng anh/chị
```json
{{
"ai_response": "Dạ với ngân sách hạn chế, mình suggest **[8TS24W001]**:
**Input:** "Chào em, anh muốn tìm áo sơ mi"
**So sánh:**
- [8TS24W001] Áo thun - 250k: Giá rẻ, dễ phối, mặc cả năm, dễ giặt
- [8TE24W002] Áo len - 520k: Ấm hơn nhưng đắt gấp đôi, chỉ mùa đông
**→ Chọn áo thun vì:**
✓ Giá rẻ hơn gấp đôi
✓ Đa năng hơn (cả năm vs chỉ đông)
✓ Còn tiền mua thêm đồ khác
**Output:**
Xem ảnh bên dưới nhé!",
"product_ids": ["8TS24W001"]
```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": []
}}
```
---
## VD6: Mua cho nhiều người
**Input:** "2tr cho 5 người: 2 bé trai 8-10t, 1 bé gái 5t, nam 1m78, nữ 1m62"
**Tool:** Gọi 5 query riêng
## Example 6: Mua cho nhiều người
**Input:** "Tư vấn 2 triệu cho gia đình 5 người: 2 bé trai 8-10 tuổi, 1 bé gái 5 tuổi, nam 1m78/60kg, nữ 1m62/50kg"
**CiCi thực hiện:**
1. Phân tích: 2,000,000 / 5 = ~400,000đ/người
2. Gọi tool 5 lần riêng biệt cho từng người
3. Tổng hợp kết quả
**Output:**
```json
{{
"ai_response": "Dạ mình tư vấn combo 2tr cho cả gia đình:
"ai_response": "Dạ mình tư vấn combo 2 triệu cho cả gia đình như sau:
**Cho 2 bé trai (8-10 tuổi):**
- [8BT24S001]: Áo thun bé trai, 320k
- [8BT24S002]: Quần short bé trai, 280k
**Cho bé gái 5 tuổi:**
- [6BG24S015]: Váy cotton, 350k
- [6BG24S015]: Váy cotton bé gái, 350k
**Cho bố:**
**Cho bố (1m78/60kg):**
- [8TN24W009]: Áo thun nam, 380k
**Cho mẹ:**
**Cho mẹ (1m62/50kg):**
- [6TN24W020]: Áo thun nữ, 350k
Tổng: ~1,680,000đ (dư ~320k).
Tổng: ~1,680,000đ (còn dư ~320k để thêm phụ kiện nếu cần).
Bạn kéo xuống xem chi tiết ảnh nhé!",
"product_ids": [
{{"sku": "8BT24S001", "name": "Áo thun bé trai", "price": 320000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "8BT24S002", "name": "Quần short bé trai", "price": 280000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6BG24S015", "name": "Váy cotton bé gái", "price": 350000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "8TN24W009", "name": "Áo thun nam", "price": 380000, "url": "...", "thumbnail_image_url": "..."}},
{{"sku": "6TN24W020", "name": "Áo thun nữ", "price": 350000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 7: So sánh áo thun vs áo len - Ngân sách hạn chế
**Input:** "Áo thun unisex rẻ và dễ phối, áo len cổ tim dày hơn nhưng đắt hơn. So sánh giá - chất liệu - phối đồ - hoàn cảnh dùng để biết nên ưu tiên cái nào nếu ngân sách hạn chế?"
**Bot gọi tool:** Lấy thông tin áo thun unisex và áo len cổ tim
**Output:**
```json
{{
"ai_response": "Dạ với ngân sách hạn chế, mình suggest bạn **ưu tiên áo thun unisex [8TS24W001]** trước:
**So sánh cụ thể:**
Áo thun unisex [8TS24W001] - 250k:
✓ Giá rẻ gấp đôi áo len
✓ Dễ phối: jean, kaki, short đều ok
✓ Mặc được cả năm (layer áo khoác mùa đông)
✓ Dễ giặt, bền
Áo len cổ tim [8TE24W002] - 520k:
✓ Ấm hơn nhưng chỉ mặc mùa đông
✗ Giá cao gấp đôi
✗ Hạn chế phối đồ (cần quần tây/kaki lịch sự)
**→ Chọn áo thun vì:**
- Dùng được nhiều mùa hơn
- Giá rẻ → còn tiền mua quần hoặc phụ kiện khác
- Sau này có tiền thì mua thêm áo len
Xem ảnh bên dưới nhé!",
"product_ids": [
{{"sku": "8TS24W001", "name": "Áo thun unisex", "price": 250000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 8: So sánh áo khoác phao vs áo dạ - Hoàn cảnh cụ thể
**Input:** "So sánh áo khoác phao nhẹ nữ [6OT25W019] và áo khoác dạ mỏng [6TC25W004]: sáng đi xe máy lạnh, trưa ngồi điều hòa - loại nào linh hoạt hơn?"
**Bot gọi tool:** Lấy thông tin 2 SKU
**Output:**
```json
{{
"ai_response": "Dạ với hoàn cảnh sáng đi xe máy + trưa ngồi điều hòa, mình suggest **áo khoác dạ mỏng [6TC25W004]**:
**So sánh:**
Áo phao nhẹ [6OT25W019] - 890k:
✓ Ấm tốt khi đi xe máy
✗ Vào văn phòng điều hòa sẽ **nóng**, khó cởi/mặc lại
✗ Form phồng, treo bàn làm việc cồng kềnh
Áo dạ mỏng [6TC25W004] - 1,250k:
✓ Đủ ấm khi đi xe (không lạnh như phao nhưng ok)
✓ **Linh hoạt**: Vào VP có điều hòa vẫn mặc được, không nóng
✓ Form gọn, sang, đỡ nhàu khi treo ghế
✓ Dễ phối đồ công sở hơn
**→ Chọn áo dạ vì:**
Bạn chủ yếu ở VP (8h/ngày), chỉ đi xe ~30 phút. Áo phao ấm quá sẽ bất tiện cả ngày.
Xem ảnh bên dưới!",
"product_ids": ["8BT24S001", "8BT24S002", "6BG24S015", "8TN24W009", "6TN24W020"]
"product_ids": [
{{"sku": "6TC25W004", "name": "Áo khoác dạ mỏng nữ", "price": 1250000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
# CHECKLIST TRƯỚC KHI TRẢ LỜI
✅ Hỏi về SP → **BẮT BUỘC gọi tool**
✅ Query theo đúng format DB (không có giá trong query)
✅ Giá dùng `price_min`, `price_max` riêng
✅ Tự suy luận ngữ cảnh → Sinh query thông minh
✅ So sánh → **Phải kết luận rõ ràng**
✅ Kiểm tra tên SP trước khi giới thiệu
✅ Sai loại → Nói thẳng "shop chưa có X"
✅ Trả lời về SP → **Luôn có `product_ids`** (kể cả không gọi tool)
✅ Không có data = Không nói
\ No newline at end of file
## Example 9: So sánh 3 sản phẩm - Chọn 1 tối ưu
**Input:** "So sánh [8TE24W002], [8TW25W013], [5TW25W010]: cái nào rẻ, ấm, thoáng, dễ phối cho cả đi làm lẫn đi chơi?"
**Bot gọi tool:** Lấy thông tin 3 SKU
**Output:**
```json
{{
"ai_response": "Dạ mình so sánh 3 mẫu:
**[8TE24W002] Áo len cổ tim - 520k:**
- Ấm nhất (len dày)
- Dễ phối đi làm (formal)
- Nhưng nóng trong VP, ít linh hoạt
**[8TW25W013] Áo thun dài tay - 350k:**
- Rẻ nhất
- Thoáng, vừa ấm vừa mát
- Dễ phối cả đi làm & đi chơi
- Layer được áo khoác ngoài
**[5TW25W010] Áo nỉ có mũ - 480k:**
- Ấm vừa phải
- Nhưng style sport → khó mặc đi làm
- Chỉ phù hợp đi chơi
**→ Mình suggest [8TW25W013] vì:**
✓ Giá tốt nhất (350k)
✓ Đa năng: Đi làm smart casual + đi chơi
✓ Thoáng, không nóng trong VP
✓ Layer được với áo khoác/cardigan
Xem ảnh bên dưới!",
"product_ids": [
{{"sku": "8TW25W013", "name": "Áo thun dài tay", "price": 350000, "url": "...", "thumbnail_image_url": "..."}}
]
}}
```
---
## Example 10: Tự suy luận - "Áo cho đàn ông đi chơi"
**Input:** "Áo phù hợp cho đàn ông đi chơi"
**Bot tự suy luận:**
- Đàn ông đi chơi → 20-40 tuổi, casual, thoải mái
- Sinh 2-3 query để cover nhiều style
**Bot gọi tool:**
```python
# Query 1
query = """
product_name: Áo thun
gender_by_product: male
age_by_product: adult
style: casual
"""
# Query 2
query = """
product_name: Áo polo
gender_by_product: male
age_by_product: adult
style: casual
"""
```
**Output:**
```json
{{
"ai_response": "Dạ shop có mấy mẫu áo phù hợp cho đàn ông đi chơi:
**Áo thun:**
- [8TS24W009]: Áo thun cotton basic, 250k - Thoải mái, dễ phối
- [8TS24W015]: Áo thun họa tiết, 320k - Trẻ trung hơn
**Áo polo:**
- [8TP25A005]: Áo polo nam basic, 380k - Lịch sự hơn áo thun, vẫn casual
Tùy phong cách bạn thích nhé! Xem ảnh bên dưới.",
"product_ids": [
{{"sku": "8TS24W009", "name": "Áo thun cotton basic", "price": 250000, ...}},
{{"sku": "8TS24W015", "name": "Áo thun họa tiết", "price": 320000, ...}},
{{"sku": "8TP25A005", "name": "Áo polo nam basic", "price": 380000, ...}}
]
}}
```
## Example 11: Phân tích keywords - Thời tiết lạnh
**Input:** "Mình 28 tuổi, làm văn phòng, cuối tuần đi chơi. Thời tiết Hà Nội 12–15°C, không thích bánh bèo. Canifa có outfit vừa đi làm vừa đi chơi không?"
**Bot phân tích keywords:**
- ⚠️ **"12-15°C"** → LẠNH → Ưu tiên áo giữ ấm
- "Làm VP + đi chơi" → Đa năng
- "Không bánh bèo" → Basic, tối giản
**Bot sinh query:**
```python
# Query 1: Áo len (ưu tiên vì lạnh)
query = """
product_name: Áo len
gender_by_product: female
season: winter
style: basic
"""
# Query 2: Áo khoác
query = """
product_name: Áo khoác
gender_by_product: female
season: winter
"""
# Query 3: Quần tây công sở
query = """
product_name: Quần tây
gender_by_product: female
style: formal
"""
```
**Output:**
```json
{{
"ai_response": "Dạ với thời tiết Hà Nội 12-15°C lạnh, mình gợi ý outfit vừa đi làm vừa đi chơi:
**Áo len/Cardigan (giữ ấm):**
- [6TE25W002]: Áo len dài tay cổ tròn nữ, 520k - Ấm, basic, dễ phối
- [6TC25W001]: Cardigan len nữ, 580k - Layer được, tháo ra khi vào VP ấm
**Áo khoác:**
- [6OT25W013]: Áo khoác dạ ngắn nữ, 890k - Sang, giữ ấm tốt
**Quần:**
- [6BP25W011]: Quần tây nữ dáng suông, 450k - Lịch sự, thoải mái
**→ Gợi ý outfit:**
Áo len [6TE25W002] + Quần tây [6BP25W011] + Áo khoác [6OT25W013] bên ngoài → Vừa ấm vừa đủ lịch sự đi làm, cuối tuần bỏ áo khoác đi chơi vẫn ok.
Style tối giản, không bánh bèo như bạn yêu cầu. Xem ảnh bên dưới!",
"product_ids": [
{{"sku": "6TE25W002", "name": "Áo len dài tay cổ tròn nữ", "price": 520000, ...}},
{{"sku": "6TC25W001", "name": "Cardigan len nữ", "price": 580000, ...}},
{{"sku": "6OT25W013", "name": "Áo khoác dạ ngắn nữ", "price": 890000, ...}},
{{"sku": "6BP25W011", "name": "Quần tây nữ dáng suông", "price": 450000, ...}}
]
}}
```
# TÓM TẮT - CHECKLIST
✅ **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. Query phải theo cấu trúc DB** (product_name, gender_by_product, style,...)
✅ **4. Giá KHÔNG vào query** - Dùng price_min, price_max riêng
✅ **5. Tự suy luận ngữ cảnh** → Sinh nhiều query thông minh
✅ **6. Mua cho nhiều người** → Tính ngân sách/người → Gọi tool riêng từng người
✅ **7. So sánh phải QUYẾT ĐOÁN** - Không "tùy bạn"
✅ **8. Kiểm tra kỹ tên sản phẩm** trước khi giới thiệu
✅ **9. Sai loại** → Nói thẳng "shop chưa có X"
✅ **10. Có kết quả phù hợp** = DỪNG, không gọi tool lần 2
✅ **11. Hỏi gì chả lời nấy** = Khách hàng thời tiết lạnh, cung cấp áo dài tay, áo khoác, áo len cho khách, không cung cấp câu trả lời không phù hợp với câu hỏi
\ No newline at end of file
......@@ -10,21 +10,16 @@ import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import JSONResponse
from opentelemetry import trace
from agent.controller import chat_controller
from agent.models import QueryRequest
from common.message_limit import message_limit_service
from common.user_identity import get_user_identity
from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
tracer = trace.get_tracer(__name__)
router = APIRouter()
from common.rate_limit import rate_limit_service
@router.post("/api/agent/chat", summary="Fashion Q&A Chat (Non-streaming)")
@rate_limit_service.limiter.limit("50/minute")
async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
......@@ -33,55 +28,40 @@ async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks:
Note: Rate limit đã được check trong middleware.
"""
# 1. Xác định user identity
identity = get_user_identity(request)
user_id = identity.primary_id
# 1. Lấy user identity từ Middleware (request.state)
# Logic: Login -> User ID | Guest -> Device ID
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
# Định danh duy nhất cho Request này (Log, History, Rate Limit, Langfuse)
identity_id = user_id if is_authenticated else device_id
# Rate limit đã check trong middleware, lấy limit_info từ request.state
limit_info = getattr(request.state, 'limit_info', None)
logger.info(f"📥 [Incoming Query - NonStream] User: {user_id} | Query: {req.user_query}")
# Get current span để add logs VÀO JAEGER UI
span = trace.get_current_span()
span.set_attribute("user.id", user_id)
span.set_attribute("chat.user_query", req.user_query)
span.add_event(
"📥 User query received", attributes={"user_id": user_id, "query": req.user_query, "timestamp": "incoming"}
)
logger.info(f"📥 [Incoming Query - NonStream] User: {identity_id} | Query: {req.user_query}")
try:
# Gọi controller để xử lý logic (Non-streaming)
result = await chat_controller(
query=req.user_query,
user_id=user_id,
user_id=str(identity_id), # Langfuse User ID
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
images=req.images,
identity_key=identity.history_key, # Guest: device_id, User: user_id
identity_key=str(identity_id), # Key lưu history
)
# Log chi tiết response
logger.info(f"📤 [Outgoing Response - NonStream] User: {user_id}")
logger.info(f"📤 [Outgoing Response - NonStream] User: {identity_id}")
logger.info(f"💬 AI Response: {result['ai_response']}")
logger.info(f"🛍️ Product IDs: {result.get('product_ids', [])}")
# Add to span (hiển thị trong Jaeger UI)
span.set_attribute("chat.ai_response", result["ai_response"][:200]) # Giới hạn 200 ký tự
span.set_attribute("chat.product_count", len(result.get("product_ids", [])))
span.add_event(
"💬 AI response generated",
attributes={
"ai_response_preview": result["ai_response"][:100],
"product_count": len(result.get("product_ids", [])),
"product_ids": str(result.get("product_ids", [])[:5]), # First 5 IDs
},
)
# Increment message count SAU KHI chat thành công
usage_info = await message_limit_service.increment(
identity_key=identity.rate_limit_key,
is_authenticated=identity.is_authenticated,
identity_key=identity_id,
is_authenticated=is_authenticated,
)
return {
......
"""
Chat History API Routes
- GET /api/history/{identity_key} - Lấy lịch sử chat (có product_ids)
- GET /api/history/{identity_key} - Lấy 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)
......@@ -12,7 +12,6 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from common.conversation_manager import get_conversation_manager
from common.user_identity import get_user_identity
router = APIRouter(tags=["Chat History"])
logger = logging.getLogger(__name__)
......@@ -40,17 +39,17 @@ async def get_chat_history(request: Request, identity_key: str, limit: int | Non
(identity_key trong URL chỉ là fallback)
"""
try:
# Tự động resolve identity từ middleware
identity = get_user_identity(request)
# Resolve identity từ middleware (request.state)
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", identity_key)
is_authenticated = getattr(request.state, "is_authenticated", False)
# Nếu đã login -> Dùng user_id
if identity.is_authenticated:
resolved_key = identity.history_key
else:
# Nếu chưa login (Guest) -> Dùng identity_key từ URL
resolved_key = identity_key
logger.info(f"GET History: URL key={identity_key} -> Resolved key={resolved_key}")
# Log chi tiết để debug
logger.info(f"GET History: auth={is_authenticated} | user_id={user_id} | device_id={device_id}")
# Nếu đã login -> Dùng user_id, không thì dùng device_id
resolved_key = user_id if is_authenticated else device_id
logger.info(f"GET History: resolved_key={resolved_key}")
manager = await get_conversation_manager()
history = await manager.get_chat_history(resolved_key, limit=limit, before_id=before_id)
......@@ -100,10 +99,13 @@ async def archive_chat_history(request: Request):
Giới hạn 5 lần/ngày.
"""
try:
identity = get_user_identity(request)
# Resolve identity từ middleware (request.state)
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "")
is_authenticated = getattr(request.state, "is_authenticated", False)
# Chỉ dành cho User đã đăng nhập
if not identity.is_authenticated:
if not is_authenticated:
return JSONResponse(
status_code=401,
content={
......@@ -114,7 +116,7 @@ async def archive_chat_history(request: Request):
}
)
identity_key = identity.history_key
identity_key = user_id
# Check reset limit
can_reset, usage, remaining = await reset_limit_service.check_limit(identity_key)
......
......@@ -7,6 +7,7 @@ from fastapi import APIRouter, BackgroundTasks, HTTPException
from pydantic import BaseModel
from agent.tools.data_retrieval_tool import SearchItem, data_retrieval_tool
from agent.mock_controller import mock_chat_controller
logger = logging.getLogger(__name__)
router = APIRouter()
......@@ -31,6 +32,7 @@ class MockQueryRequest(BaseModel):
user_query: str
user_id: str | None = "test_user"
session_id: str | None = None
images: list[str] | None = None
class MockDBRequest(BaseModel):
......@@ -62,10 +64,6 @@ MOCK_AI_RESPONSES = [
# --- ENDPOINTS ---
from agent.mock_controller import mock_chat_controller
@router.post("/api/mock/agent/chat", summary="Mock Agent Chat (Real Tools + Fake LLM)")
async def mock_chat(req: MockQueryRequest, background_tasks: BackgroundTasks):
"""
......@@ -82,6 +80,7 @@ async def mock_chat(req: MockQueryRequest, background_tasks: BackgroundTasks):
query=req.user_query,
user_id=req.user_id or "test_user",
background_tasks=background_tasks,
images=req.images,
)
return {
......@@ -146,9 +145,9 @@ async def mock_db_search(req: MockDBRequest):
raise HTTPException(status_code=500, detail=f"DB Search Error: {e!s}")
@router.post("/api/mock/retrieverdb", summary="Real Embedding + Real DB Vector Search")
@router.post("/api/mock/retriverdb", summary="Real Embedding + Real DB Vector Search (Legacy)")
async def mock_retriever_db(req: MockRetrieverRequest):
@router.post("/api/mock/retrieverdb", summary="Real Embedding + Real DB Vector Search")
@router.post("/api/mock/retriverdb", summary="Real Embedding + Real DB Vector Search (Legacy)")
async def mock_retriever_db(req: MockRetrieverRequest):
"""
API thực tế để test Retriever + DB Search (dùng agent tool):
- Lấy query từ user
......
......@@ -45,6 +45,7 @@ async def get_system_prompt_content(request: Request):
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/agent/system-prompt")
@rate_limit_service.limiter.limit("10/minute")
async def update_system_prompt_content(request: Request, body: PromptUpdateRequest):
......
......@@ -10,7 +10,27 @@ import httpx
logger = logging.getLogger(__name__)
CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
# CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
CANIFA_CUSTOMER_API = "https://canifa.com/v1/magento/customer"
_http_client: httpx.AsyncClient | None = None
def _get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient(timeout=10.0)
return _http_client
async def close_http_client() -> None:
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
CANIFA_QUERY_BODY = [
{
......@@ -43,23 +63,23 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None:
}
try:
async with httpx.AsyncClient(timeout=10.0) as client:
response = await client.post(CANIFA_CUSTOMER_API, json=CANIFA_QUERY_BODY, headers=headers)
if response.status_code == 200:
data = response.json()
logger.debug(f"Canifa API Raw Response: {data}")
client = _get_http_client()
response = await client.post(CANIFA_CUSTOMER_API, json=CANIFA_QUERY_BODY, headers=headers)
# Response format: {"data": {"customer": {...}}, "loading": false, ...}
if isinstance(data, dict):
# Trả về toàn bộ data để extract_user_id xử lý
return data
if response.status_code == 200:
data = response.json()
logger.debug(f"Canifa API Raw Response: {data}")
# Nếu Canifa trả list (batch request)
# Response format: {"data": {"customer": {...}}, "loading": false, ...}
if isinstance(data, dict):
# Trả về toàn bộ data để extract_user_id xử lý
return data
logger.warning(f"Canifa API Failed: {response.status_code} - {response.text}")
return None
# Nếu Canifa trả list (batch request)
return data
logger.warning(f"Canifa API Failed: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Error calling Canifa API: {e}")
......
......@@ -109,12 +109,15 @@ class ConversationManager:
raise
async def get_chat_history(
self, identity_key: str, limit: int | None = None, before_id: int | None = None
self, identity_key: str, limit: int | None = None, before_id: int | None = None,
include_product_ids: bool = True
) -> list[dict[str, Any]]:
"""
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.
Uses cached graph for performance.
Args:
include_product_ids: True for API (frontend needs product cards),
False for AI context (only text needed)
"""
max_retries = 3
for attempt in range(max_retries):
......@@ -166,15 +169,17 @@ class ConversationManager:
# User message - text thuần
entry["message"] = message_content
else:
# AI message - parse JSON để lấy ai_response + product_ids
# AI message - parse JSON
try:
parsed = json.loads(message_content)
entry["message"] = parsed.get("ai_response", message_content)
entry["product_ids"] = parsed.get("product_ids", [])
if include_product_ids:
entry["product_ids"] = parsed.get("product_ids", [])
except (json.JSONDecodeError, TypeError):
# Fallback nếu không phải JSON (data cũ)
entry["message"] = message_content
entry["product_ids"] = []
if include_product_ids:
entry["product_ids"] = []
history.append(entry)
......
import logging
import hashlib
import json
import logging
from openai import AsyncOpenAI, OpenAI
......@@ -91,7 +93,7 @@ async def create_embedding_async(text: str) -> list[float]:
return []
async def create_embeddings_async(texts: list[str]) -> list[list[float]]:
async def create_embeddings_async(texts: list[str]) -> list[list[float]]:
"""
Batch async embedding generation with per-item Layer 2 Cache.
"""
......@@ -99,18 +101,28 @@ async def create_embeddings_async(texts: list[str]) -> list[list[float]]:
if not texts:
return []
results = [[] for _ in texts]
missed_indices = []
missed_texts = []
# 1. Check Cache for each text
for i, text in enumerate(texts):
cached = await redis_cache.get_embedding(text)
if cached:
results[i] = cached
else:
missed_indices.append(i)
missed_texts.append(text)
results = [[] for _ in texts]
missed_indices = []
missed_texts = []
client = redis_cache.get_client()
if client:
keys = []
for text in texts:
text_hash = hashlib.md5(text.strip().lower().encode()).hexdigest()
keys.append(f"emb_cache:{text_hash}")
cached_values = await client.mget(keys)
for i, cached in enumerate(cached_values):
if cached:
results[i] = json.loads(cached)
else:
missed_indices.append(i)
missed_texts.append(texts[i])
else:
# Fallback: no redis client, treat all as miss
missed_indices = list(range(len(texts)))
missed_texts = texts
# 2. Call OpenAI for missed texts
if missed_texts:
......
import logging
import uuid
import httpx
import httpx
from config import CONV_SUPABASE_KEY, CONV_SUPABASE_URL
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
_http_client: httpx.AsyncClient | None = None
def _get_http_client() -> httpx.AsyncClient:
global _http_client
if _http_client is None:
_http_client = httpx.AsyncClient()
return _http_client
async def close_http_client() -> None:
global _http_client
if _http_client is not None:
await _http_client.aclose()
_http_client = None
class ImageStorageService:
"""
......@@ -51,16 +67,16 @@ class ImageStorageService:
headers = {"Authorization": f"Bearer {self.key}", "apikey": self.key, "Content-Type": content_type}
async with httpx.AsyncClient() as client:
response = await client.post(upload_url, content=file_content, headers=headers)
if response.status_code == 200:
# Lấy public URL (Giả định bucket là public)
public_url = f"{self.url}/storage/v1/object/public/{self.bucket_name}/{filename}"
logger.info(f"✅ Uploaded image successfully: {public_url}")
return public_url
logger.error(f"❌ Failed to upload image: {response.status_code} - {response.text}")
return None
client = _get_http_client()
response = await client.post(upload_url, content=file_content, headers=headers)
if response.status_code == 200:
# Lấy public URL (Giả định bucket là public)
public_url = f"{self.url}/storage/v1/object/public/{self.bucket_name}/{filename}"
logger.info(f"✅ Uploaded image successfully: {public_url}")
return public_url
logger.error(f"❌ Failed to upload image: {response.status_code} - {response.text}")
return None
except Exception as e:
logger.error(f"Error uploading image to Supabase: {e}")
......
......@@ -17,11 +17,6 @@ from config import RATE_LIMIT_GUEST, RATE_LIMIT_USER
logger = logging.getLogger(__name__)
# =============================================================================
# CONFIGURATION (from config.py)
# =============================================================================
# Redis key prefix
MESSAGE_COUNT_PREFIX = "msg_limit:"
class MessageLimitService:
......@@ -92,6 +87,7 @@ class MessageLimitService:
today = self._get_today_key()
return f"{MESSAGE_COUNT_PREFIX}{today}:{identity_key}"
def _get_seconds_until_midnight(self) -> int:
"""
Get seconds until next midnight (00:00).
......@@ -104,6 +100,7 @@ class MessageLimitService:
return int((midnight - now).total_seconds())
def _reset_memory_if_new_day(self) -> None:
"""Reset in-memory storage nếu qua ngày mới."""
today = self._get_today_key()
......@@ -112,10 +109,10 @@ class MessageLimitService:
self._memory_date = today
logger.debug(f"🔄 Memory storage reset for new day: {today}")
# =========================================================================
# REDIS OPERATIONS
# =========================================================================
async def _get_counts_from_redis(self, identity_key: str) -> dict[str, int] | None:
"""
Get all counts (guest, user) từ Redis Hash.
......@@ -143,6 +140,7 @@ class MessageLimitService:
logger.warning(f"Redis get counts error: {e}")
return None
async def _increment_in_redis(self, identity_key: str, field: str) -> int | None:
"""
Increment specific field ('guest' or 'user') trong Redis Hash.
......@@ -171,10 +169,10 @@ class MessageLimitService:
logger.warning(f"Redis increment error: {e}")
return None
# =========================================================================
# PUBLIC METHODS
# =========================================================================
# =========================================================================
async def check_limit(
self,
identity_key: str,
......
......@@ -69,135 +69,83 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
return await call_next(request)
# Skip public endpoints
if path in PUBLIC_PATHS:
return await call_next(request)
# Skip public path prefixes
if any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES):
if path in PUBLIC_PATHS or any(path.startswith(prefix) for prefix in PUBLIC_PATH_PREFIXES):
return await call_next(request)
# =====================================================================
# STEP 1: AUTHENTICATION (Canifa API)
# STEP 1: AUTHENTICATION & IDENTITY
# =====================================================================
try:
auth_header = request.headers.get("Authorization")
# --- Device ID from Body ---
device_id = ""
if method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
async def receive_wrapper():
return {"type": "http.request", "body": body_bytes}
request._receive = receive_wrapper
if body_bytes:
try:
body_json = json.loads(body_bytes)
device_id = body_json.get("device_id", "")
except json.JSONDecodeError:
pass
except Exception as e:
logger.warning(f"Error reading device_id from body: {e}")
# Fallback: Nếu không có trong body, tìm trong header -> IP
if not device_id:
device_id = request.headers.get("device_id", "")
if not device_id:
device_id = f"unknown_{request.client.host}" if request.client else "unknown"
# ========== DEV MODE: Bypass auth ==========
dev_user_id = request.headers.get("X-Dev-User-Id")
if dev_user_id:
logger.warning(f"⚠️ DEV MODE: Using X-Dev-User-Id={dev_user_id}")
request.state.user = {"customer_id": dev_user_id}
request.state.user_id = dev_user_id
request.state.is_authenticated = True
request.state.device_id = device_id or dev_user_id
return await call_next(request)
request.state.is_authenticated = False
request.state.user = None
request.state.user_id = None
request.state.device_id = None
# --- TRƯỜNG HỢP 1: KHÔNG CÓ TOKEN -> GUEST ---
if not auth_header or not auth_header.startswith("Bearer "):
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
request.state.device_id = device_id
else:
# --- TRƯỜNG HỢP 2: CÓ TOKEN -> GỌI CANIFA VERIFY ---
# 1. Thử xác thực qua Header trước (Bearer Token)
if auth_header and auth_header.startswith("Bearer "):
token = auth_header.replace("Bearer ", "")
from common.canifa_api import verify_canifa_token, extract_user_id_from_canifa_response
try:
user_data = await verify_canifa_token(token)
user_id = await extract_user_id_from_canifa_response(user_data)
if user_id:
request.state.user = user_data
request.state.user_id = user_id
request.state.token = token
request.state.is_authenticated = True
request.state.device_id = device_id
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
logger.debug(f"✅ Auth Success: User {user_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
request.state.device_id = device_id
logger.error(f"❌ Auth Error: {e}")
# 2. Lấy Device ID (Để tracking hoặc dùng cho Guest)
# Lấy từ Body nếu là POST/PUT...
device_id = ""
if method in ["POST", "PUT", "PATCH"]:
try:
body_bytes = await request.body()
request._receive = lambda: {"type": "http.request", "body": body_bytes}
if body_bytes:
try:
data = json.loads(body_bytes)
device_id = data.get("device_id", "")
except: pass
except: pass
# Nếu không có trong body, lấy từ Header hoặc IP
if not device_id:
device_id = request.headers.get("device_id") or (f"unknown_{request.client.host}" if request.client else "unknown")
request.state.device_id = device_id
except Exception as e:
logger.error(f"❌ Middleware Auth Error: {e}")
request.state.user = None
request.state.user_id = None
request.state.is_authenticated = False
request.state.device_id = ""
logger.error(f"❌ Middleware Error: {e}")
# =====================================================================
# STEP 2: RATE LIMIT CHECK (Chỉ cho các path cần limit)
# STEP 2: RATE LIMIT CHECK
# =====================================================================
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
# Identify User for Rate Limit: Ưu tiên user_id (nếu đã login), không thì dùng device_id
identity_key = request.state.user_id if request.state.is_authenticated else request.state.device_id
if rate_limit_key:
if identity_key:
can_send, limit_info = await message_limit_service.check_limit(
identity_key=rate_limit_key,
is_authenticated=is_authenticated,
identity_key=identity_key,
is_authenticated=request.state.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']}"
)
logger.warning(f"⚠️ Rate Limit: {identity_key} | {limit_info['used']}/{limit_info['limit']}")
return JSONResponse(
status_code=429,
content={
"status": "error",
"error_code": limit_info.get("error_code") or "MESSAGE_LIMIT_EXCEEDED",
"error_code": "MESSAGE_LIMIT_EXCEEDED",
"message": limit_info["message"],
"require_login": limit_info["require_login"],
"limit_info": {
......@@ -205,23 +153,20 @@ class CanifaAuthMiddleware(BaseHTTPMiddleware):
"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)
# =============================================================================
# MIDDLEWARE MANAGER - Singleton to manage all middlewares
# =============================================================================
class MiddlewareManager:
"""
Middleware Manager - Singleton Pattern
......@@ -311,6 +256,7 @@ class MiddlewareManager:
self._auth_enabled = True
logger.info("✅ Canifa Auth middleware enabled")
def _setup_rate_limit(self, app: FastAPI) -> None:
"""Setup rate limiting."""
from common.rate_limit import rate_limit_service
......
"""
Rate Limiting Service - Singleton Pattern
Sử dụng SlowAPI với Redis backend (production) hoặc Memory (dev)
Sử dụng SlowAPI với Memory backend
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from fastapi import Request
from fastapi.responses import JSONResponse
from slowapi import Limiter
from slowapi.errors import RateLimitExceeded
from slowapi.middleware import SlowAPIMiddleware
from slowapi.util import get_remote_address
from fastapi.responses import JSONResponse
if TYPE_CHECKING:
from fastapi import FastAPI
......@@ -27,17 +25,8 @@ class RateLimitService:
Rate Limiting Service - Singleton Pattern
Usage:
# Trong server.py
from common.rate_limit import RateLimitService
rate_limiter = RateLimitService()
rate_limiter.setup(app)
# Trong route
from common.rate_limit import RateLimitService
@router.post("/chat")
@RateLimitService().limiter.limit("10/minute")
@rate_limit_service.limiter.limit("50/minute")
async def chat(request: Request):
...
"""
......@@ -45,68 +34,47 @@ class RateLimitService:
_instance: RateLimitService | None = None
_initialized: bool = False
# =========================================================================
# SINGLETON PATTERN
# =========================================================================
def __new__(cls) -> RateLimitService:
if cls._instance is None:
cls._instance = super().__new__(cls)
return cls._instance
def __init__(self) -> None:
# Chỉ init một lần
if RateLimitService._initialized:
return
# Configuration
self.storage_uri = os.getenv("RATE_STORAGE_URI", "memory://")
self.default_limits = ["100/hour", "30/minute"]
self.block_duration_minutes = int(os.getenv("RATE_LIMIT_BLOCK_MINUTES", "5"))
# Paths không áp dụng rate limit
self.exempt_paths = {
"/",
"/health",
"/docs",
"/openapi.json",
"/redoc",
}
self.exempt_prefixes = ["/static", "/mock", "/api/mock"]
self.exempt_prefixes = ["/mock", "/api/mock", "/health", "/docs", "/openapi.json", "/redoc"]
# In-memory blocklist (có thể chuyển sang Redis)
self._blocklist: dict[str, datetime] = {}
# Create limiter instance
# Create limiter instance (memory storage)
self.limiter = Limiter(
key_func=self._get_client_identifier,
storage_uri=self.storage_uri,
default_limits=self.default_limits,
storage_uri="memory://",
)
RateLimitService._initialized = True
logger.info(f"✅ RateLimitService initialized (storage: {self.storage_uri})")
# =========================================================================
# CLIENT IDENTIFIER
# =========================================================================
logger.info("✅ RateLimitService initialized")
@staticmethod
def _get_client_identifier(request: Request) -> str:
def _get_client_identifier(self, request: Request) -> str:
"""
Lấy client identifier cho rate limiting.
Ưu tiên: user_id (authenticated) > device_id > IP address
Ưu tiên: user_id > device_id > IP
Trả về 'exempt' cho paths không rate limit
"""
# 1. Nếu đã authenticated → dùng user_id
# Check exempt paths
path = request.url.path
if any(path.startswith(prefix) for prefix in self.exempt_prefixes):
return "exempt" # SlowAPI sẽ không limit key này
# 1. User đã login
if hasattr(request.state, "user_id") and request.state.user_id:
return f"user:{request.state.user_id}"
# 2. Nếu có device_id trong header → dùng device_id
device_id = request.headers.get("device_id")
if device_id:
return f"device:{device_id}"
# 2. Device ID
if hasattr(request.state, "device_id") and request.state.device_id:
return f"device:{request.state.device_id}"
# 3. Fallback → IP address
# 3. IP address
try:
return f"ip:{get_remote_address(request)}"
except Exception:
......@@ -114,125 +82,28 @@ class RateLimitService:
return f"ip:{request.client.host}"
return "unknown"
# =========================================================================
# BLOCKLIST MANAGEMENT
# =========================================================================
def is_blocked(self, key: str) -> tuple[bool, int]:
"""
Check if client is blocked.
Returns: (is_blocked, retry_after_seconds)
"""
now = datetime.utcnow()
blocked_until = self._blocklist.get(key)
if blocked_until:
if blocked_until > now:
retry_after = int((blocked_until - now).total_seconds())
return True, retry_after
else:
# Block expired
self._blocklist.pop(key, None)
return False, 0
def block_client(self, key: str) -> int:
"""
Block client for configured duration.
Returns: retry_after_seconds
"""
self._blocklist[key] = datetime.utcnow() + timedelta(minutes=self.block_duration_minutes)
return self.block_duration_minutes * 60
def unblock_client(self, key: str) -> None:
"""Unblock client manually."""
self._blocklist.pop(key, None)
# =========================================================================
# PATH CHECKING
# =========================================================================
def is_exempt(self, path: str) -> bool:
"""Check if path is exempt from rate limiting."""
if path in self.exempt_paths:
return True
return any(path.startswith(prefix) for prefix in self.exempt_prefixes)
# =========================================================================
# SETUP FOR FASTAPI APP
# =========================================================================
def setup(self, app: FastAPI) -> None:
"""
Setup rate limiting cho FastAPI app.
Gọi trong server.py sau khi tạo app.
"""
# Attach limiter to app state
"""Setup rate limiting cho FastAPI app."""
app.state.limiter = self.limiter
app.state.rate_limit_service = self
# Register middleware
self._register_block_middleware(app)
self._register_exception_handler(app)
# Add SlowAPI middleware (PHẢI thêm SAU custom middlewares)
# Add SlowAPI middleware
app.add_middleware(SlowAPIMiddleware)
logger.info("✅ Rate limiting middleware registered")
def _register_block_middleware(self, app: FastAPI) -> None:
"""Register middleware to check blocklist."""
@app.middleware("http")
async def rate_limit_block_middleware(request: Request, call_next):
path = request.url.path
# Skip exempt paths
if self.is_exempt(path):
return await call_next(request)
# Bypass header cho testing
if request.headers.get("X-Bypass-RateLimit") == "1":
return await call_next(request)
# Check blocklist
key = self._get_client_identifier(request)
is_blocked, retry_after = self.is_blocked(key)
if is_blocked:
return JSONResponse(
status_code=429,
content={
"detail": "Quá số lượt cho phép. Vui lòng thử lại sau.",
"retry_after_seconds": retry_after,
},
headers={"Retry-After": str(retry_after)},
)
return await call_next(request)
def _register_exception_handler(self, app: FastAPI) -> None:
"""Register exception handler for rate limit exceeded."""
# Exception handler
@app.exception_handler(RateLimitExceeded)
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
key = self._get_client_identifier(request)
retry_after = self.block_client(key)
logger.warning(f"⚠️ Rate limit exceeded for {key}, blocked for {self.block_duration_minutes} minutes")
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
logger.warning(f"⚠️ Rate limit exceeded: {self._get_client_identifier(request)}")
return JSONResponse(
status_code=429,
content={
"detail": "Quá số lượt cho phép. Vui lòng thử lại sau.",
"retry_after_seconds": retry_after,
"status": "error",
"error_code": "RATE_LIMIT_EXCEEDED",
"message": "Quá số lượt cho phép. Vui lòng thử lại sau.",
},
headers={"Retry-After": str(retry_after)},
)
logger.info("✅ Rate limiting middleware registered")
# =============================================================================
# SINGLETON INSTANCE - Import trực tiếp để dùng
# =============================================================================
# Singleton instance
rate_limit_service = RateLimitService()
"""
Reset Limit Service - Chỉ dành cho User đã login
Không giới hạn số lần reset (archive chat)
"""
import logging
from datetime import datetime
from common.cache import redis_cache
logger = logging.getLogger(__name__)
class ResetLimitService:
def __init__(self, limit: int = 5):
self.limit = limit
self.expiration_seconds = 86400 # 1 day
"""
Service quản lý việc reset (archive) chat.
Chỉ dành cho user đã đăng nhập, không giới hạn số lần.
"""
async def check_limit(self, identity_key: str) -> tuple[bool, int, int]:
"""
Check if user can reset chat.
Luôn cho phép reset (không giới hạn).
Returns (can_reset, current_usage, remaining)
"""
redis_client = redis_cache.get_client()
if not redis_client:
# Fallback if Redis is down: allow reset
return True, 0, self.limit
today = datetime.now().strftime("%Y-%m-%d")
key = f"reset_limit:{identity_key}:{today}"
try:
count = await redis_client.get(key)
if count is None:
return True, 0, self.limit
current_usage = int(count)
remaining = self.limit - current_usage
if current_usage >= self.limit:
return False, current_usage, 0
return True, current_usage, remaining
except Exception as e:
logger.error(f"Error checking reset limit: {e}")
return True, 0, self.limit
# Không giới hạn - luôn cho phép
return True, 0, 999
async def increment(self, identity_key: str):
redis_client = redis_cache.get_client()
if not redis_client:
return
"""
Không cần track usage nữa vì không giới hạn.
"""
pass
today = datetime.now().strftime("%Y-%m-%d")
key = f"reset_limit:{identity_key}:{today}"
try:
pipe = redis_client.pipeline()
pipe.incr(key)
pipe.expire(key, self.expiration_seconds)
await pipe.execute()
except Exception as e:
logger.error(f"Error incrementing reset limit: {e}")
reset_limit_service = ResetLimitService(limit=5)
reset_limit_service = ResetLimitService()
......@@ -3,8 +3,9 @@ StarRocks Database Connection Utility
Based on chatbot-rsa pattern
"""
import asyncio
import logging
import asyncio
import logging
import os
from typing import Any
import aiomysql
......@@ -156,17 +157,19 @@ class StarRocksConnection:
async with StarRocksConnection._pool_lock:
if StarRocksConnection._shared_pool is None:
logger.info(f"🔌 Creating Async Pool to {self.host}:{self.port}...")
StarRocksConnection._shared_pool = await aiomysql.create_pool(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
db=self.database,
charset="utf8mb4",
cursorclass=aiomysql.DictCursor,
minsize=2, # Giảm minsize để đỡ tốn tài nguyên idle
maxsize=80,
connect_timeout=10,
minsize = int(os.getenv("STARROCKS_POOL_MINSIZE", "2"))
maxsize = int(os.getenv("STARROCKS_POOL_MAXSIZE", "80"))
StarRocksConnection._shared_pool = await aiomysql.create_pool(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
db=self.database,
charset="utf8mb4",
cursorclass=aiomysql.DictCursor,
minsize=minsize, # Giảm minsize để đỡ tốn tài nguyên idle
maxsize=maxsize,
connect_timeout=10,
# --- CHỈNH SỬA QUAN TRỌNG Ở ĐÂY ---
pool_recycle=280, # Recycle sau 4 phút rưỡi (tránh timeout 5 phút của Windows/Firewall)
# ----------------------------------
......
"""
User Identity Helper
Xác định user identity từ request
Design:
- Có user_id: Langfuse User ID = user_id, metadata = {device_id: "xxx", is_authenticated: true}
- Không user_id: Langfuse User ID = device_id, metadata = {device_id: "xxx", is_authenticated: false}
"""
from __future__ import annotations
import logging
from dataclasses import dataclass, field
from datetime import datetime
from fastapi import Request
logger = logging.getLogger(__name__)
@dataclass
class UserIdentity:
"""User identity với các thông tin cần thiết"""
# ID chính dùng cho Langfuse, history, rate limit
primary_id: str
# Device ID (luôn có)
device_id: str
# User ID từ token (chỉ có khi đã login)
user_id: str | None
# Đã login hay chưa
is_authenticated: bool
@property
def langfuse_user_id(self) -> str:
"""User ID cho Langfuse tracking"""
return self.primary_id
@property
def langfuse_session_id(self) -> str:
"""Session ID cho Langfuse (theo device + ngày)"""
today = datetime.now().strftime("%Y%m%d")
return f"{self.device_id}-{today}"
@property
def langfuse_metadata(self) -> dict:
"""Metadata cho Langfuse"""
return {
"device_id": self.device_id,
"is_authenticated": self.is_authenticated,
}
@property
def langfuse_tags(self) -> list[str]:
"""Tags cho Langfuse"""
tags = ["chatbot", "production"]
tags.append("customer" if self.is_authenticated else "guest")
return tags
@property
def history_key(self) -> str:
"""
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
@property
def rate_limit_key(self) -> str:
"""
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
def get_user_identity(request: Request) -> UserIdentity:
"""
Extract user identity từ request.
Logic:
- Có user_id (từ token) → primary_id = user_id
- Không có → primary_id = device_id
Args:
request: FastAPI Request object
Returns:
UserIdentity object
"""
# 1. Lấy device_id ưu tiên từ request.state (do middleware parse từ body), sau đó mới tới header
device_id = ""
if hasattr(request.state, "device_id") and request.state.device_id:
device_id = request.state.device_id
if not device_id:
device_id = request.headers.get("device_id", "")
if not device_id:
device_id = f"unknown_{request.client.host}" if request.client else "unknown"
# 2. Lấy user_id từ token (middleware đã parse)
user_id = None
is_authenticated = False
if hasattr(request.state, "user_id") and request.state.user_id:
user_id = request.state.user_id
is_authenticated = True
# 3. Primary ID - LUÔN LUÔN LÀ device_id
primary_id = device_id
identity = UserIdentity(
primary_id=primary_id,
device_id=device_id,
user_id=user_id,
is_authenticated=is_authenticated,
)
logger.debug(
f"UserIdentity: langfuse_user_id={identity.langfuse_user_id}, "
f"metadata={identity.langfuse_metadata}"
)
return identity
import asyncio
import logging
import os
import platform
if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
import logging
import uvicorn
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router
from api.conservation_route import router as conservation_router
from api.prompt_route import router as prompt_router
from api.mock_api_route import router as mock_router
from common.cache import redis_cache
from common.langfuse_client import get_langfuse_client
from common.middleware import middleware_manager
from config import PORT
# Configure LoggingP
if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Configure Logging
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
......@@ -29,11 +31,7 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
langfuse_client = get_langfuse_client()
if langfuse_client:
logger.info("✅ Langfuse client ready (lazy loading)")
else:
logger.warning("⚠️ Langfuse client not available (missing keys or disabled)")
app = FastAPI(
title="Contract AI Service",
......@@ -42,62 +40,41 @@ app = FastAPI(
)
# =============================================================================
# STARTUP EVENT - Initialize Redis Cache
# =============================================================================
@app.on_event("startup")
async def startup_event():
"""Initialize Redis cache on startup."""
await redis_cache.initialize()
logger.info("✅ Redis cache initialized for message limit")
logger.info("✅ Redis cache initialized")
# =============================================================================
# MIDDLEWARE SETUP - Gom Auth + RateLimit + CORS vào một chỗ
# =============================================================================
middleware_manager.setup(
app,
enable_auth=True, # 👈 Bật lại Auth để test logic Guest/User
enable_rate_limit=True, # 👈 Bật lại SlowAPI theo yêu cầu
enable_cors=True, # 👈 Bật CORS
cors_origins=["*"], # 👈 Trong production nên limit origins
enable_auth=True,
enable_rate_limit=True,
enable_cors=True,
cors_origins=["*"],
)
# api include
app.include_router(conservation_router)
app.include_router(chatbot_router)
app.include_router(prompt_router)
from api.mock_api_route import router as mock_router
app.include_router(mock_router)
print("✅ Mock API Router mounted at /api/mock")
# --- MOCK API FOR LOAD TESTING ---
try:
from api.mock_api_route import router as mock_router
app.include_router(mock_router, prefix="/api")
print("✅ Mock API Router mounted at /api/mock")
except ImportError:
print("⚠️ Mock Router not found, skipping...")
# ==========================================
# 🟢 ĐOẠN MOUNT STATIC HTML CỦA BRO ĐÂY 🟢
# ==========================================
try:
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
# Mount thư mục static để chạy file index.html
app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static")
print(f"✅ Static files mounted at /static (Dir: {static_dir})")
except Exception as e:
print(f"⚠️ Failed to mount static files: {e}")
from fastapi.responses import RedirectResponse
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
......
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