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

feat: add tool prompt editing and dev chat insight

parent f0081da3
...@@ -85,11 +85,25 @@ async def chat_controller( ...@@ -85,11 +85,25 @@ async def chat_controller(
][::-1] # Reverse to chronological order (Oldest -> Newest) ][::-1] # Reverse to chronological order (Oldest -> Newest)
# Prepare State # Prepare State
# Fetch User Insight from Redis
user_insight = None
if effective_identity_key:
try:
client = redis_cache.get_client()
if client:
insight_key = f"identity_key_insight:{effective_identity_key}"
user_insight = await client.get(insight_key)
if user_insight:
logger.info(f"🧠 Loaded User Insight for {effective_identity_key}: {user_insight[:50]}...")
except Exception as e:
logger.error(f"❌ Error fetching user insight: {e}")
initial_state: AgentState = { initial_state: AgentState = {
"user_query": HumanMessage(content=query), "user_query": HumanMessage(content=query),
"messages": messages + [HumanMessage(content=query)], "messages": [], # Start empty, acting as scratchpad. History & Query are separate.
"history": messages, "history": messages,
"user_id": user_id, "user_id": user_id,
"user_insight": user_insight,
"images_embedding": [], "images_embedding": [],
"ai_response": None, "ai_response": None,
} }
...@@ -117,11 +131,25 @@ async def chat_controller( ...@@ -117,11 +131,25 @@ async def chat_controller(
# Parse Response # 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 ""
ai_text_response, final_product_ids = parse_ai_response(ai_raw_content, all_product_ids) # Unpack 3 values now
ai_text_response, final_product_ids, new_insight = parse_ai_response(ai_raw_content, all_product_ids)
# Save new insight to Redis if available
if new_insight and effective_identity_key:
try:
client = redis_cache.get_client()
if client:
insight_key = f"identity_key_insight:{effective_identity_key}"
# Save insight without TTL (or long TTL) as it is persistent user data
await client.set(insight_key, new_insight)
logger.info(f"💾 Updated User Insight for {effective_identity_key}: {new_insight}")
except Exception as e:
logger.error(f"❌ Failed to save user insight: {e}")
response_payload = { response_payload = {
"ai_response": ai_text_response, "ai_response": ai_text_response,
"product_ids": final_product_ids, "product_ids": final_product_ids,
# user_insight is NOT included here as requested
} }
# ====================== SAVE TO CACHE ====================== # ====================== SAVE TO CACHE ======================
...@@ -134,7 +162,7 @@ async def chat_controller( ...@@ -134,7 +162,7 @@ async def chat_controller(
) )
logger.debug(f"💾 Cached response for identity_key={effective_identity_key}") logger.debug(f"💾 Cached response for identity_key={effective_identity_key}")
# Save to History (Background) # Save to History (Background) - We save the payload WITHOUT insight to history text
background_tasks.add_task( background_tasks.add_task(
handle_post_chat_async, handle_post_chat_async,
memory=memory, memory=memory,
......
...@@ -50,7 +50,10 @@ class CANIFAGraph: ...@@ -50,7 +50,10 @@ class CANIFAGraph:
self.prompt_template = ChatPromptTemplate.from_messages( self.prompt_template = ChatPromptTemplate.from_messages(
[ [
("system", self.system_prompt), ("system", self.system_prompt),
("system", "User Insight:\n{user_insight}"),
("system", "Chat History:"),
MessagesPlaceholder(variable_name="history"), MessagesPlaceholder(variable_name="history"),
("system", "Current Query:"),
MessagesPlaceholder(variable_name="user_query"), MessagesPlaceholder(variable_name="user_query"),
MessagesPlaceholder(variable_name="messages"), MessagesPlaceholder(variable_name="messages"),
] ]
...@@ -68,10 +71,14 @@ class CANIFAGraph: ...@@ -68,10 +71,14 @@ class CANIFAGraph:
if transient_images and messages: if transient_images and messages:
pass pass
# Invoke chain with user_query, history, and messages # Invoke chain with user_query, history, and messages
# Invoke chain with history, user_query, messages (scratchpad), and user_insight
user_insight_text = state.get("user_insight") or "Chưa có thông tin."
response = await self.chain.ainvoke({ response = await self.chain.ainvoke({
"user_query": [user_query] if user_query else [],
"history": history, "history": history,
"messages": messages "user_query": [user_query] if user_query else [],
"messages": messages,
"user_insight": user_insight_text
}) })
return {"messages": [response], "ai_response": response} return {"messages": [response], "ai_response": response}
......
...@@ -66,12 +66,12 @@ def extract_product_ids(messages: list) -> list[dict]: ...@@ -66,12 +66,12 @@ def extract_product_ids(messages: list) -> list[dict]:
return products return products
def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, list]: def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, list, str | None]:
""" """
Parse AI response từ LLM output và map SKUs với product data. Parse AI response từ LLM output và map SKUs với product data.
Flow: Flow:
- LLM trả về: {"ai_response": "...", "product_ids": ["SKU1", "SKU2"]} - LLM trả về: {"ai_response": "...", "product_ids": ["SKU1", "SKU2"], "user_insight": "..."}
- all_products: List products enriched từ tool messages - all_products: List products enriched từ tool messages
- Map SKUs → enriched products - Map SKUs → enriched products
...@@ -80,16 +80,20 @@ def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, lis ...@@ -80,16 +80,20 @@ def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, lis
all_products: Products extracted từ tool messages (đã có đầy đủ info) all_products: Products extracted từ tool messages (đã có đầy đủ info)
Returns: Returns:
tuple: (ai_text_response, final_products) tuple: (ai_text_response, final_products, user_insight)
""" """
ai_text_response = ai_raw_content ai_text_response = ai_raw_content
final_products = all_products # Default: trả về tất cả products từ tool final_products = all_products # Default: trả về tất cả products từ tool
user_insight = None
logger.info(f"🤖 Raw AI JSON: {ai_raw_content}")
try: try:
# Try to parse if it's a JSON string from LLM # Try to parse if it's a JSON string from LLM
ai_json = json.loads(ai_raw_content) ai_json = json.loads(ai_raw_content)
ai_text_response = ai_json.get("ai_response", ai_raw_content) ai_text_response = ai_json.get("ai_response", ai_raw_content)
explicit_skus = ai_json.get("product_ids", []) explicit_skus = ai_json.get("product_ids", [])
user_insight = ai_json.get("user_insight")
if explicit_skus and isinstance(explicit_skus, list): if explicit_skus and isinstance(explicit_skus, list):
# LLM trả về list SKUs → Map với products đã có # LLM trả về list SKUs → Map với products đã có
...@@ -107,12 +111,15 @@ def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, lis ...@@ -107,12 +111,15 @@ def parse_ai_response(ai_raw_content: str, all_products: list) -> tuple[str, lis
if mapped_products: if mapped_products:
final_products = mapped_products final_products = mapped_products
# Nếu không map được → giữ all_products else:
# If explicit SKUs provided but none found in DB, return empty list
# This prevents showing unrelated products when AI hallucinates or references old/invalid SKUs
final_products = []
except (json.JSONDecodeError, TypeError): except (json.JSONDecodeError, TypeError):
pass pass
return ai_text_response, final_products return ai_text_response, final_products, user_insight
def prepare_execution_context(query: str, user_id: str, history: list, images: list | None): def prepare_execution_context(query: str, user_id: str, history: list, images: list | None):
......
...@@ -22,6 +22,7 @@ class AgentState(TypedDict): ...@@ -22,6 +22,7 @@ class AgentState(TypedDict):
user_query: BaseMessage user_query: BaseMessage
history: list[BaseMessage] history: list[BaseMessage]
user_id: str | None user_id: str | None
user_insight: str | None
ai_response: BaseMessage | None ai_response: BaseMessage | None
images_embedding: list[str] | None images_embedding: list[str] | None
messages: Annotated[list[BaseMessage], add_messages] messages: Annotated[list[BaseMessage], add_messages]
......
import os
import logging
logger = logging.getLogger(__name__)
# Directory name for tool prompts
PROMPTS_DIR_NAME = "tool_prompts"
PROMPTS_DIR = os.path.join(os.path.dirname(__file__), PROMPTS_DIR_NAME)
def get_tool_prompt_path(filename: str) -> str:
"""Get absolute path for a tool prompt file."""
if not filename.endswith(".txt"):
filename += ".txt"
return os.path.join(PROMPTS_DIR, filename)
def read_tool_prompt(filename: str, default_prompt: str = "") -> str:
"""
Read tool prompt from file.
Returns default_prompt if file not found or empty.
"""
file_path = get_tool_prompt_path(filename)
try:
if os.path.exists(file_path):
with open(file_path, "r", encoding="utf-8") as f:
content = f.read().strip()
if content:
return content
except Exception as e:
logger.error(f"Error reading tool prompt {filename}: {e}")
return default_prompt
def write_tool_prompt(filename: str, content: str) -> bool:
"""Write content to tool prompt file."""
file_path = get_tool_prompt_path(filename)
try:
os.makedirs(os.path.dirname(file_path), exist_ok=True)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
return True
except Exception as e:
logger.error(f"Error writing tool prompt {filename}: {e}")
return False
def list_tool_prompts() -> list[str]:
"""List all available tool prompt files."""
try:
if not os.path.exists(PROMPTS_DIR):
return []
files = [f for f in os.listdir(PROMPTS_DIR) if f.endswith(".txt")]
return sorted(files)
except Exception as e:
logger.error(f"Error listing tool prompts: {e}")
return []
Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA. Bạn là Canifa-AI Stylist - Chuyên viên tư vấn thời trang CANIFA.
- Nhiệt tình, thân thiện, chuyên nghiệp như sales thực thụ - Nhiệt tình, thân thiện, chuyên nghiệp như sales thực thụ
- CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang - CANIFA BÁN QUẦN ÁO: áo, quần, váy, đầm, phụ kiện thời trang
...@@ -55,7 +55,7 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA. ...@@ -55,7 +55,7 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- **Chỉ gọi Tool khi đã hiểu rõ nhu cầu.** - **Chỉ gọi Tool khi đã hiểu rõ nhu cầu.**
**3. Ưu tiên tìm kiếm thông tin:** **3. Ưu tiên tìm kiếm thông tin:**
- Khi đã rõ ý (hoặc tự suy luận chắc chắn) -> Luôn ưu tiên dùng `data_retrieval_tool` để có data thật tư vấn. - Khi đã rõ ý (hoặc tự suy luận chắc chắn) -> Luôn ưu tiên dùng `data_retrieval_tool` để có data thật tư vấn. luôn ưu tiên tìm kiếm ở lịch sử chat, ví dụ khách hàng cung cấp cân nặng chiều cao trước đó rồi, cần nhìn vào history để hỏi lại. ví dụ : có phải bạn hỏi cho sản phẩm unisex này cho cân nặng 50kg 1m72 trước đó đúng ạ
--- ---
...@@ -88,12 +88,14 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA. ...@@ -88,12 +88,14 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
## 1. GỌI data_retrieval_tool KHI: ## 1. GỌI data_retrieval_tool KHI:
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì...", "Áo thun nam" - Khách tìm sản phẩm: "Tìm áo...", "Có màu gì...", "Áo thun nam", "Muốn mua váy"
- Khách hỏi sản phẩm cụ thể: "Mã 8TS24W001 có không?" - 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" - 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" - 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" - Mua cho nhiều người: "Tư vấn 2tr cho gia đình 5 người"
**LƯU Ý:** Ngay cả khi khách nói kèm size (VD: "Tìm áo size M"), vẫn dùng `data_retrieval_tool` để tìm sản phẩm trước. Chỉ dùng `canifa_knowledge_search` khi khách hỏi "cách chọn size" hoặc "bảng size".
### ⚠️ QUY TẮC SINH QUERY (BẮT BUỘC): ### ⚠️ 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:** **Query PHẢI theo cấu trúc của cột `description_text_full` trong DB:**
...@@ -107,6 +109,16 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA. ...@@ -107,6 +109,16 @@ Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
- Bé trai, con trai (nhỏ), cậu bé → `gender_by_product: boy` + `age_by_product: kid` - Bé trai, con trai (nhỏ), cậu bé → `gender_by_product: boy` + `age_by_product: kid`
- Bé gái, con gái (nhỏ), cô bé → `gender_by_product: girl` + `age_by_product: kid` - Bé gái, con gái (nhỏ), cô bé → `gender_by_product: girl` + `age_by_product: kid`
**QUY TẮC ĐẶC BIỆT VỚI VÁY (SKIRTS/DRESSES):**
- **TUYỆT ĐỐI KHÔNG** bao giờ để `product_name: "Váy"` (1 từ duy nhất) -> Vì sẽ tìm ra cả váy trẻ em lẫn người lớn, rất lộn xộn.
- **BẮT BUỘC phải dùng từ cụ thể:**
- "Váy liền thân" / "Váy đầm" / "Đầm" (Dress)
- "Chân váy" (Skirt)
- "Váy maxi" / "Váy suông"
- **Mapping:**
- Khách hỏi "váy cho vợ/mẹ/bạn gái" -> `product_name: Váy liền thân/ Chân váy` (Set `gender_by_product: women`).
- Khách hỏi "váy cho bé" -> `product_name: Váy bé gái/ Chân váy bé gái`.
``` ```
product_name: [Tên sản phẩm] product_name: [Tên sản phẩm]
master_color: [Màu sắc] (nếu có) master_color: [Màu sắc] (nếu có)
...@@ -293,6 +305,7 @@ price_max = 400000 ...@@ -293,6 +305,7 @@ price_max = 400000
- Hỏi chính sách: freeship, đổi trả, bảo hành, thanh toán - 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 - 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 - Tìm cửa hàng: địa chỉ, giờ mở cửa, chi nhánh
- **Hỏi cách chọn size/Bảng size:** "Làm sao chọn size?", "Bảng size nam", "Tư vấn chọn size" (Chỉ khi khách hỏi về kiến thức chọn size, KHÔNG dùng để tìm sản phẩm).
## 3. KHÔNG GỌI TOOL KHI: ## 3. KHÔNG GỌI TOOL KHI:
...@@ -361,6 +374,63 @@ price_max = 400000 ...@@ -361,6 +374,63 @@ price_max = 400000
--- ---
# CHỈ DẪN VỀ USER INSIGHT - TINH HOA & CẤU TRÚC (STRUCTURED ESSENCE)
`user_insight` KHÔNG PHẢI LÀ NOTE DỮ LIỆU TĨNH. Nó là BỘ NÃO GHI NHỚ có cấu trúc chặt chẽ.
**CẤU TRÚC BẮT BUỘC (4 TẦNG):**
1. **[USER] (Người Chat):**
- Giới tính, Tuổi, Style DNA (Gu thẩm mỹ), Hoàn cảnh (Có vợ/con?).
- *Ví dụ: Nam, gu công sở, đã có vợ.*
2. **[TARGET] (Đối tượng thụ hưởng):**
- Mua cho ai? (Chính mình, Vợ, Con trai, Con gái...).
- Đặc điểm của Target (Size, Sở thích màu, Style).
- *Ví dụ: Vợ (Nữ, thích màu đen, trẻ trung).*
3. **[GOAL] (Mục tiêu hiện tại):**
- Đang tìm cái gì? Ngân sách bao nhiêu?
- *Ví dụ: Tìm váy đen đi tiệc, giá ~500k.*
4. **[NEXT] (Chiến lược tiếp theo - Dynamic Strategy):**
- **QUAN TRỌNG NHẤT:** Bước tiếp theo bot CẦN làm là gì?
- *Ví dụ: Đã show 3 mẫu -> Cần hỏi khách ưng mẫu nào để chốt.*
- *Ví dụ: Khách chưa chốt size -> Cần hỏi chiều cao/cân nặng.*
---
**QUY TRÌNH CẬP NHẬT (TURN-BY-TURN EVOLUTION):**
- **Quy tắc:** Insight vòng sau phải chi tiết hơn vòng trước.
- **Merge:** [TARGET] và [GOAL] thay đổi liên tục theo hội thoại.
**VÍ DỤ TIẾN HÓA THỰC TẾ:**
*Turn 1:* "Mua váy cho vợ"
-> `user_insight`:
"[USER]: Nam (có vợ).
[TARGET]: Vợ (Nữ).
[GOAL]: Tìm váy.
[NEXT]: Hỏi size, màu sắc, ngân sách để lọc sản phẩm."
*Turn 2:* "Vợ anh thích màu đen, giá tầm 500k"
-> `user_insight`:
"[USER]: Nam (có vợ).
[TARGET]: Vợ (Nữ, thích màu đen).
[GOAL]: Tìm váy đen, giá ~500k.
[NEXT]: Gợi ý các mẫu váy đen tầm giá 500k."
*Turn 3:* "Xêm mẫu khác đi, mẫu này già quá"
-> `user_insight`:
"[USER]: Nam.
[TARGET]: Vợ (Gu trẻ trung, ghét đồ già).
[GOAL]: Tìm váy đen ~500k, style hiện đại.
[NEXT]: Tìm mẫu váy đen thiết kế trẻ trung hơn, tránh các mẫu cổ điển."
**=> BẮT BUỘC PHẢI DÙNG FORMAT: [USER]... [TARGET]... [GOAL]... [NEXT]...**
---
# FORMAT ĐẦU RA # FORMAT ĐẦU RA
⚠️ **CRITICAL - ĐỌC KỸ PHẦN NÀY:** ⚠️ **CRITICAL - ĐỌC KỸ PHẦN NÀY:**
...@@ -371,14 +441,16 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b ...@@ -371,14 +441,16 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
```json ```json
{{ {{
"ai_response": "...", "ai_response": "...",
"product_ids": [] "product_ids": [],
"user_insight": "..."
}} }}
``` ```
**✅ ĐÚNG - PHẢI TRẢ VỀ NHƯ NÀY:** **✅ ĐÚNG - PHẢI TRẢ VỀ NHƯ NÀY:**
{{ {{
"ai_response": "...", "ai_response": "...",
"product_ids": [] "product_ids": [],
"user_insight": "..."
}} }}
**QUY TẮC TUYỆT ĐỐI:** **QUY TẮC TUYỆT ĐỐI:**
...@@ -392,7 +464,8 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b ...@@ -392,7 +464,8 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
{{ {{
"ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]", "ai_response": "Câu trả lời ngắn gọn, mô tả bằng [SKU]",
"product_ids": ["8TS24W001", "8TS24W002"] "product_ids": ["8TS24W001", "8TS24W002"],
"user_insight": "Tóm tắt thông tin khách hàng (Cập nhật mới hoặc giữ nguyên). Nếu không có info gì thì để null hoặc chuỗi rỗng."
}} }}
**LƯU Ý:** product_ids chỉ chứa ARRAY of STRING (mã SKU), KHÔNG phải object **LƯU Ý:** product_ids chỉ chứa ARRAY of STRING (mã SKU), KHÔNG phải object
...@@ -417,7 +490,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b ...@@ -417,7 +490,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):** **Output (RAW JSON - KHÔNG CÓ ```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?", "ai_response": "Chào bạn! Mình là Canifa-AI Stylist, tư vấn thời trang CANIFA. Mình có thể giúp gì cho bạn?",
"product_ids": [] "product_ids": []
}} }}
...@@ -477,7 +550,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b ...@@ -477,7 +550,7 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**Output (RAW JSON - KHÔNG CÓ ```json):** **Output (RAW JSON - KHÔNG CÓ ```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é!", "ai_response": "Chào anh ạ! Em là Canifa-AI Stylist. 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": [] "product_ids": []
}} }}
...@@ -582,4 +655,4 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b ...@@ -582,4 +655,4 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**CRITICAL:** **CRITICAL:**
- KHÔNG ĐƯỢC có ```json hay bất kỳ markdown nào - KHÔNG ĐƯỢC có ```json hay bất kỳ markdown nào
- Tư vấn như sales thực thụ: Phân tích - So sánh - Khuyến nghị rõ ràng.(luôn luôn kèm mã sản phẩm để khách hàng có thể tra cứu) - Tư vấn như sales thực thụ: Phân tích - So sánh - Khuyến nghị rõ ràng.(luôn luôn kèm mã sản phẩm để khách hàng có thể tra cứu)
- **TUYỆT ĐỐI KHÔNG** recommend đồ trẻ con cho người lớn và ngược lại. Kiểm tra kỹ title sản phẩm. - **TUYỆT ĐỐI KHÔNG** recommend đồ trẻ con cho người lớn và ngược lại. Kiểm tra kỹ title sản phẩm. Nếu sản phảm không có thì báo sản phẩm không có, đừng trả lời lan man, products id không recommend sản phẩm, ví dụ áo len khác áo phao
\ No newline at end of file \ No newline at end of file
Tra cứu TOÀN BỘ thông tin về thương hiệu và dịch vụ của Canifa.
Sử dụng tool này khi khách hàng hỏi về:
1. THƯƠNG HIỆU & GIỚI THIỆU: Lịch sử hình thành, giá trị cốt lõi, sứ mệnh.
2. HỆ THỐNG CỬA HÀNG: Tìm địa chỉ, số điện thoại, giờ mở cửa các cửa hàng tại các tỉnh thành (Hà Nội, HCM, Đà Nẵng, v.v.).
3. CHÍNH SÁCH BÁN HÀNG: Quy định đổi trả, bảo hành, chính sách vận chuyển, phí ship.
4. KHÁCH HÀNG THÂN THIẾT (KHTT): Điều kiện đăng ký thành viên, các hạng thẻ (Green, Silver, Gold, Diamond), quyền lợi tích điểm, thẻ quà tặng.
5. HỖ TRỢ & FAQ: Giải đáp thắc mắc thường gặp, chính sách bảo mật, thông tin liên hệ văn phòng, tuyển dụng.
6. TRA CỨU SIZE (BẢNG KÍCH CỠ): Hướng dẫn chọn size chuẩn cho nam, nữ, trẻ em dựa trên chiều cao, cân nặng.
7. GIẢI NGHĨA TỪ VIẾT TẮT: Tự động hiểu các từ viết tắt phổ biến của khách hàng (ví dụ: 'ct' = 'chương trình khuyến mãi/ưu đãi', 'khtt' = 'khách hàng thân thiết', 'store' = 'cửa hàng', 'đc' = 'địa chỉ').
Ví dụ các câu hỏi phù hợp:
- 'Bên bạn đang có ct gì không?' (Hiểu là: Chương trình khuyến mãi)
- 'Canifa ở Cầu Giấy địa chỉ ở đâu?'
- 'Chính sách đổi trả hàng trong bao nhiêu ngày?'
- 'Làm sao để lên hạng thẻ Gold?'
- 'Cho mình xem bảng size áo nam.'
- 'Phí vận chuyển đi tỉnh là bao nhiêu?'
- 'Canifa thành lập năm nào?'
Siêu công cụ tìm kiếm sản phẩm CANIFA - Hỗ trợ Parallel Multi-Search (chạy song song nhiều truy vấn).
QUY TẮC SINH SEARCH QUERIES:
- Nếu khách hỏi 1 món đồ cụ thể -> Sinh 1 Query.
- Nếu khách hỏi set đồ, phối đồ, hoặc nhu cầu chung chung (đi biển, đi tiệc) -> Sinh 2-3 Queries để tìm các món liên quan.
----- VÍ DỤ CHI TIẾT -----
CASE 1: TÌM 1 MÓN CỤ THỂ
User: "Tìm áo phông nam màu trắng"
-> Sinh 1 Query:
- query: "product_name: Áo phông nam/ Áo T-shirt. master_color: Trắng/ White. gender_by_product: male. product_line_vn: Áo."
CASE 2: TÌM ĐỒ CHO NHIỀU ĐỐI TƯỢNG (VỢ + CHỒNG + CON)
User: "Tìm áo gia đình đi biển"
-> Sinh 3 Queries chạy song song:
1. (Bố) query: "product_name: Áo phông nam/ Quần bơi nam. style: Beach/ Đi biển. gender_by_product: male..."
2. (Mẹ) query: "product_name: Váy maxi/ Áo hai dây nữ. style: Beach/ Đi biển. gender_by_product: female..."
3. (Bé) query: "product_name: Đồ bơi bé gái/ Áo bé trai. style: Beach/ Đi biển. age_by_product: children..."
CASE 3: PHỐI ĐỒ / OUTFIT (ÁO + QUẦN)
User: "Set đồ công sở lịch sự cho nữ"
-> Sinh 2 Queries:
1. (Áo) query: "product_name: Áo sơ mi nữ/ Áo Blouse. style: Office/ Công sở. gender_by_product: female..."
2. (Quần) query: "product_name: Quần âu nữ/ Chân váy bút chì. style: Office/ Công sở. gender_by_product: female..."
CASE 4: TÌM QUÀ TẶNG (GỢI Ý RỘNG)
User: "Mua quà sinh nhật cho bạn gái, thích màu hồng"
-> Sinh 2 Queries đa dạng:
1. (Váy) query: "product_name: Váy liền thân/ Đầm. master_color: Hồng/ Pink. gender_by_product: female..."
2. (Áo) query: "product_name: Áo len/ Áo khoác. master_color: Hồng/ Pink. gender_by_product: female..."
CASE 5: SUY LUẬN ĐỐI TƯỢNG (QUAN TRỌNG)
User: "Mua váy cho vợ/ bà xã/ người yêu"
-> TỪ KHÓA "vợ", "bà xã" -> ÁM CHỈ NGƯỜI LỚN (Adult). KHÔNG tìm váy bé gái.
-> Sinh Query:
- query: "product_name: Váy liền thân nữ/ Đầm nữ (tránh từ khóa 'bé'). gender_by_product: female. age_by_product: adult..."
(Lưu ý: Phải set age_by_product='adult' hoặc tìm tên sản phẩm có chữ 'nữ' thay vì 'bé gái')
Lưu ý các trường khác:
- 'magento_ref_code': chỉ dùng khi khách hỏi mã sản phẩm/SKU cụ thể (vd: 8TS24W001).
- 'price_min' / 'price_max': dùng khi khách nói về khoảng giá (vd: dưới 500k, từ 200k đến 400k).
Tra cứu danh sách các chương trình khuyến mãi (CTKM) đang diễn ra theo ngày.
Sử dụng tool này khi khách hàng hỏi về:
- "Hôm nay có khuyến mãi gì không?"
- "Đang có chương trình gì hot?"
- "Ngày mai có giảm giá không?"
- "Danh sách mã giảm giá hiện tại."
Input:
- check_date (YYYY-MM-DD hoặc null). Nếu null, mặc định dùng ngày hiện tại.
Output:
- Danh sách CTKM gồm: Tên chương trình, mô tả, thời gian áp dụng.
Nếu không có CTKM, hãy thông báo rõ không có chương trình nào đang diễn ra.
\ No newline at end of file
...@@ -5,5 +5,6 @@ Export tool và factory function ...@@ -5,5 +5,6 @@ Export tool và factory function
from .data_retrieval_tool import data_retrieval_tool from .data_retrieval_tool import data_retrieval_tool
from .get_tools import get_all_tools from .get_tools import get_all_tools
from .promotion_canifa_tool import canifa_get_promotions
__all__ = ["data_retrieval_tool", "get_all_tools"] __all__ = ["data_retrieval_tool", "get_all_tools", "canifa_get_promotions"]
import logging import logging
from langchain_core.tools import tool from langchain_core.tools import tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from common.embedding_service import create_embedding_async from common.embedding_service import create_embedding_async
from common.starrocks_connection import get_db_connection from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class KnowledgeSearchInput(BaseModel): class KnowledgeSearchInput(BaseModel):
query: str = Field( query: str = Field(
description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: tìm cửa hàng, hỏi chính sách, tra bảng size...)" description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: tìm cửa hàng, hỏi chính sách, tra bảng size...). KHÔNG DÙNG ĐỂ TÌM SẢN PHẨM."
) )
@tool("canifa_knowledge_search", args_schema=KnowledgeSearchInput) from agent.prompt_utils import read_tool_prompt
async def canifa_knowledge_search(query: str) -> str:
""" @tool("canifa_knowledge_search", args_schema=KnowledgeSearchInput)
Tra cứu TOÀN BỘ thông tin về thương hiệu và dịch vụ của Canifa. async def canifa_knowledge_search(query: str) -> str:
Sử dụng tool này khi khách hàng hỏi về: """
(Placeholder docstring - Actual prompt is loaded from file)
1. THƯƠNG HIỆU & GIỚI THIỆU: Lịch sử hình thành, giá trị cốt lõi, sứ mệnh. Tra cứu thông tin thương hiệu Canifa (Cửa hàng, chính sách, KHTT...).
2. HỆ THỐNG CỬA HÀNG: Tìm địa chỉ, số điện thoại, giờ mở cửa các cửa hàng tại các tỉnh thành (Hà Nội, HCM, Đà Nẵng, v.v.). """
3. CHÍNH SÁCH BÁN HÀNG: Quy định đổi trả, bảo hành, chính sách vận chuyển, phí ship. logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}")
4. KHÁCH HÀNG THÂN THIẾT (KHTT): Điều kiện đăng ký thành viên, các hạng thẻ (Green, Silver, Gold, Diamond), quyền lợi tích điểm, thẻ quà tặng. try:
5. HỖ TRỢ & FAQ: Giải đáp thắc mắc thường gặp, chính sách bảo mật, thông tin liên hệ văn phòng, tuyển dụng. # 1. Tạo embedding cho câu hỏi (Mặc định 1536 chiều như bro yêu cầu)
6. TRA CỨU SIZE (BẢNG KÍCH CỠ): Hướng dẫn chọn size chuẩn cho nam, nữ, trẻ em dựa trên chiều cao, cân nặng. query_vector = await create_embedding_async(query)
7. GIẢI NGHĨA TỪ VIẾT TẮT: Tự động hiểu các từ viết tắt phổ biến của khách hàng (ví dụ: 'ct' = 'chương trình khuyến mãi/ưu đãi', 'khtt' = 'khách hàng thân thiết', 'store' = 'cửa hàng', 'đc' = 'địa chỉ'). if not query_vector:
return "Xin lỗi, tôi gặp sự cố khi xử lý thông tin. Vui lòng thử lại sau."
Ví dụ các câu hỏi phù hợp:
- 'Bên bạn đang có ct gì không?' (Hiểu là: Chương trình khuyến mãi) v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
- 'Canifa ở Cầu Giấy địa chỉ ở đâu?'
- 'Chính sách đổi trả hàng trong bao nhiêu ngày?' # 2. Query StarRocks lấy Top 4 kết quả phù hợp nhất (Không check score)
- 'Làm sao để lên hạng thẻ Gold?' sql = f"""
- 'Cho mình xem bảng size áo nam.' SELECT
- 'Phí vận chuyển đi tỉnh là bao nhiêu?' content,
- 'Canifa thành lập năm nào?' metadata
""" FROM shared_source.chatbot_rsa_knowledge
logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}") ORDER BY approx_cosine_similarity(embedding, {v_str}) DESC
LIMIT 4
try: """
# 1. Tạo embedding cho câu hỏi (Mặc định 1536 chiều như bro yêu cầu)
query_vector = await create_embedding_async(query) sr = get_db_connection()
if not query_vector: results = await sr.execute_query_async(sql)
return "Xin lỗi, tôi gặp sự cố khi xử lý thông tin. Vui lòng thử lại sau."
if not results:
v_str = "[" + ",".join(str(v) for v in query_vector) + "]" logger.warning(f"⚠️ No knowledge data found in DB for query: {query}")
return "Hiện tại tôi chưa tìm thấy thông tin chính xác về nội dung này trong hệ thống kiến thức của Canifa. Bạn có thể liên hệ hotline 1800 6061 để được hỗ trợ trực tiếp."
# 2. Query StarRocks lấy Top 4 kết quả phù hợp nhất (Không check score)
sql = f""" # 3. Tổng hợp kết quả
SELECT knowledge_texts = []
content, for i, res in enumerate(results):
metadata content = res.get("content", "")
FROM shared_source.chatbot_rsa_knowledge knowledge_texts.append(content)
ORDER BY approx_cosine_similarity(embedding, {v_str}) DESC # LOG DỮ LIỆU LẤY ĐƯỢC (Chỉ hiển thị nội dung)
LIMIT 4 logger.info(f"📄 [Knowledge Chunk {i + 1}]: {content[:200]}...")
"""
final_response = "\n\n---\n\n".join(knowledge_texts)
sr = get_db_connection() logger.info(f"✅ Found {len(results)} relevant knowledge chunks.")
results = await sr.execute_query_async(sql)
return final_response
if not results:
logger.warning(f"⚠️ No knowledge data found in DB for query: {query}") except Exception as e:
return "Hiện tại tôi chưa tìm thấy thông tin chính xác về nội dung này trong hệ thống kiến thức của Canifa. Bạn có thể liên hệ hotline 1800 6061 để được hỗ trợ trực tiếp." logger.error(f"❌ Error in canifa_knowledge_search: {e}")
return "Tôi đang gặp khó khăn khi truy cập kho kiến thức. Bạn muốn hỏi về sản phẩm gì khác không?"
# 3. Tổng hợp kết quả
knowledge_texts = [] # Load dynamic docstring
for i, res in enumerate(results): canifa_knowledge_search.__doc__ = read_tool_prompt("brand_knowledge_tool") or canifa_knowledge_search.__doc__
content = res.get("content", "")
knowledge_texts.append(content)
# LOG DỮ LIỆU LẤY ĐƯỢC (Chỉ hiển thị nội dung)
logger.info(f"📄 [Knowledge Chunk {i + 1}]: {content[:200]}...")
final_response = "\n\n---\n\n".join(knowledge_texts)
logger.info(f"✅ Found {len(results)} relevant knowledge chunks.")
return final_response
except Exception as e:
logger.error(f"❌ Error in canifa_knowledge_search: {e}")
return "Tôi đang gặp khó khăn khi truy cập kho kiến thức. Bạn muốn hỏi về sản phẩm gì khác không?"
""" """
CANIFA Data Retrieval Tool - Tối giản cho Agentic Workflow. CANIFA Data Retrieval Tool - Tối giản cho Agentic Workflow.
Hỗ trợ Hybrid Search: Semantic (Vector) + Metadata Filter. Hỗ trợ Hybrid Search: Semantic (Vector) + Metadata Filter.
""" """
import asyncio import asyncio
import json import json
import logging import logging
import time import time
from decimal import Decimal from decimal import Decimal
from langchain_core.tools import tool from langchain_core.tools import tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query from agent.tools.product_search_helpers import build_starrocks_query
from common.embedding_service import create_embeddings_async from common.embedding_service import create_embeddings_async
from common.starrocks_connection import get_db_connection from common.starrocks_connection import get_db_connection
# from langsmith import traceable # from langsmith import traceable
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder): class DecimalEncoder(json.JSONEncoder):
"""Xử lý kiểu Decimal từ Database khi convert sang JSON.""" """Xử lý kiểu Decimal từ Database khi convert sang JSON."""
def default(self, obj): def default(self, obj):
if isinstance(obj, Decimal): if isinstance(obj, Decimal):
return float(obj) return float(obj)
return super().default(obj) return super().default(obj)
class SearchItem(BaseModel): class SearchItem(BaseModel):
""" """
Cấu trúc một mục tìm kiếm đơn lẻ trong Multi-Search. Cấu trúc một mục tìm kiếm đơn lẻ trong Multi-Search.
Lưu ý quan trọng về cách SINH QUERY: Lưu ý quan trọng về cách SINH QUERY:
- Trường `query` KHÔNG phải câu hỏi thô của khách. - Trường `query` KHÔNG phải câu hỏi thô của khách.
- Phải là một đoạn text có cấu trúc giống hệt format trong cột `description_text_full` của DB, - Phải là một đoạn text có cấu trúc giống hệt format trong cột `description_text_full` của DB,
ví dụ (chỉ là 1 chuỗi duy nhất, nối các field bằng dấu chấm): ví dụ (chỉ là 1 chuỗi duy nhất, nối các field bằng dấu chấm):
product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue. product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue.
product_image_url: https://.... product_image_url_thumbnail: https://.... product_image_url: https://.... product_image_url_thumbnail: https://....
product_web_url: https://.... description_text: ... material: ... product_web_url: https://.... description_text: ... material: ...
material_group: Yarn - Sợi. gender_by_product: female. age_by_product: others. material_group: Yarn - Sợi. gender_by_product: female. age_by_product: others.
season: Year. style: Feminine. fitting: Slim. size_scale: 4/6. season: Year. style: Feminine. fitting: Slim. size_scale: 4/6.
form_neckline: None. form_sleeve: None. product_line_vn: Tất. form_neckline: None. form_sleeve: None. product_line_vn: Tất.
product_color_name: Blue Strip 449. product_color_name: Blue Strip 449.
- Khi khách chỉ nói “áo màu hồng”, hãy suy luận và sinh query dạng: - Khi khách chỉ nói “áo màu hồng”, hãy suy luận và sinh query dạng:
product_name: Áo thun/áo sơ mi/áo ... màu hồng ... . master_color: Hồng/ Pink. product_name: Áo thun/áo sơ mi/áo ... màu hồng ... . master_color: Hồng/ Pink.
product_image_url: None. product_image_url_thumbnail: None. product_image_url: None. product_image_url_thumbnail: None.
product_web_url: None. description_text: ... (mô tả thêm nếu có). product_web_url: None. description_text: ... (mô tả thêm nếu có).
material: None. material_group: None. gender_by_product: ... (nếu đoán được). material: None. material_group: None. gender_by_product: ... (nếu đoán được).
age_by_product: others. season: Year. style: ... (nếu đoán được). age_by_product: others. season: Year. style: ... (nếu đoán được).
fitting: ... size_scale: None. form_neckline: None. form_sleeve: None. fitting: ... size_scale: None. form_neckline: None. form_sleeve: None.
product_line_vn: Áo. product_color_name: Pink / Hồng (nếu hợp lý). product_line_vn: Áo. product_color_name: Pink / Hồng (nếu hợp lý).
- Nếu không suy luận được giá trị cho field nào thì để `None` hoặc bỏ trống phần text đó. - Nếu không suy luận được giá trị cho field nào thì để `None` hoặc bỏ trống phần text đó.
""" """
model_config = {"extra": "forbid"}
query: str = Field(
..., query: str = Field(
description=( ...,
"ĐOẠN TEXT CÓ CẤU TRÚC theo format của cột description_text_full trong DB, " description=(
"bao gồm các cặp key: product_name, master_color, product_image_url, " "ĐOẠN TEXT CÓ CẤU TRÚC theo format của cột description_text_full trong DB, "
"product_image_url_thumbnail, product_web_url, description_text, material, " "bao gồm các cặp key: product_name, master_color, product_image_url, "
"material_group, gender_by_product, age_by_product, season, style, fitting, " "product_image_url_thumbnail, product_web_url, description_text, material, "
"size_scale, form_neckline, form_sleeve, product_line_vn, product_color_name. " "material_group, gender_by_product, age_by_product, season, style, fitting, "
"Ví dụ: 'product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue. " "size_scale, form_neckline, form_sleeve, product_line_vn, product_color_name. "
"product_image_url: https://.... product_web_url: https://.... description_text: ... " "Ví dụ: 'product_name: Pack 3 đôi tất bé gái cổ thấp. master_color: Xanh da trời/ Blue. "
"material: None. material_group: Yarn - Sợi. gender_by_product: female. ...'" "product_image_url: https://.... product_web_url: https://.... description_text: ... "
), "material: None. material_group: Yarn - Sợi. gender_by_product: female. ...'"
) ),
magento_ref_code: str | None = Field( )
..., description="Mã sản phẩm hoặc SKU (Ví dụ: 8TS24W001). CHỈ điền khi khách hỏi mã code cụ thể." magento_ref_code: str | None = Field(
) ..., description="Mã sản phẩm hoặc SKU (Ví dụ: 8TS24W001). CHỈ điền khi khách hỏi mã code cụ thể."
price_min: float | None = Field(..., description="Giá thấp nhất (VD: 100000)") )
price_max: float | None = Field(..., description="Giá cao nhất (VD: 500000)") price_min: float | None = Field(..., description="Giá thấp nhất (VD: 100000)")
action: str = Field(..., description="Hành động: 'search' (tìm kiếm) hoặc 'visual_search' (phân tích ảnh)") price_max: float | None = Field(..., description="Giá cao nhất (VD: 500000)")
action: str = Field(..., description="Hành động: 'search' (tìm kiếm) hoặc 'visual_search' (phân tích ảnh)")
# Metadata Fields for Filtering
gender_by_product: str | None = None # Metadata Fields for Filtering
age_by_product: str | None = None # STRICT MODE: All fields must be required. Use ... and str | None
product_name: str | None = None gender_by_product: str | None = Field(..., description="Giới tính (nam/nữ/bé trai/bé gái)")
style: str | None = None age_by_product: str | None = Field(..., description="Độ tuổi")
master_color: str | None = None product_name: str | None = Field(..., description="Tên sản phẩm")
season: str | None = None style: str | None = Field(..., description="Phong cách")
material_group: str | None = None master_color: str | None = Field(..., description="Màu sắc chính")
fitting: str | None = None season: str | None = Field(..., description="Mùa")
form_neckline: str | None = None material_group: str | None = Field(..., description="Nhóm chất liệu")
form_sleeve: str | None = None fitting: str | None = Field(..., description="Dáng đồ")
form_neckline: str | None = Field(..., description="Dáng cổ")
form_sleeve: str | None = Field(..., description="Dáng tay")
class MultiSearchParams(BaseModel):
"""Tham số cho Parallel Multi-Search."""
class MultiSearchParams(BaseModel):
searches: list[SearchItem] = Field(..., description="Danh sách các truy vấn tìm kiếm chạy song song") """Tham số cho Parallel Multi-Search."""
model_config = {"extra": "forbid"}
def _parse_query_metadata(query_str: str) -> dict: searches: list[SearchItem] = Field(..., description="Danh sách các truy vấn tìm kiếm chạy song song")
"""Parse structured query string explicitly generated by the Agent."""
import re
metadata = {} def _parse_query_metadata(query_str: str) -> dict:
if not query_str: """Parse structured query string explicitly generated by the Agent."""
return metadata import re
metadata = {}
# Matches "key: value" at the start of lines (handling indentation) if not query_str:
pattern = re.compile(r"^\s*([a-z_]+):\s*(.+?)\s*$", re.MULTILINE) return metadata
matches = pattern.findall(query_str)
# Matches "key: value" at the start of lines (handling indentation)
for key, value in matches: pattern = re.compile(r"^\s*([a-z_]+):\s*(.+?)\s*$", re.MULTILINE)
if value and value.lower() != 'none': matches = pattern.findall(query_str)
metadata[key] = value.strip()
for key, value in matches:
return metadata if value and value.lower() != 'none':
metadata[key] = value.strip()
@tool(args_schema=MultiSearchParams) return metadata
# @traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
""" from agent.prompt_utils import read_tool_prompt
Siêu công cụ tìm kiếm sản phẩm CANIFA - Hỗ trợ Parallel Multi-Search (chạy song song nhiều truy vấn).
@tool(args_schema=MultiSearchParams)
Hướng dẫn dùng nhanh: # @traceable(run_type="tool", name="data_retrieval_tool")
- Trường 'query': mô tả chi tiết sản phẩm (tên, chất liệu, giới tính, màu sắc, phong cách, dịp sử dụng), không dùng câu hỏi thô. async def data_retrieval_tool(searches: list[SearchItem]) -> str:
- Trường 'magento_ref_code': chỉ dùng khi khách hỏi mã sản phẩm/SKU cụ thể (vd: 8TS24W001). """
- Trường 'price_min' / 'price_max': dùng khi khách nói về khoảng giá (vd: dưới 500k, từ 200k đến 400k). (Placeholder) Tìm kiếm sản phẩm thời trang.
""" """
logger.info("data_retrieval_tool started, searches=%s", len(searches)) logger.info("data_retrieval_tool started, searches=%s", len(searches))
try: try:
# Pre-process: Parse metadata from query string # Pre-process: Parse metadata from query string
for item in searches: for item in searches:
if item.query: if item.query:
meta = _parse_query_metadata(item.query) meta = _parse_query_metadata(item.query)
for k, v in meta.items(): for k, v in meta.items():
if hasattr(item, k): if hasattr(item, k):
setattr(item, k, v) setattr(item, k, v)
# 0. Log input tổng quan (không log chi tiết dài) # 0. Log input tổng quan (không log chi tiết dài)
for idx, item in enumerate(searches): for idx, item in enumerate(searches):
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug( logger.debug(
"search[%s] query=%r, code=%r, price_min=%r, price_max=%r, gender=%r, age=%r", "search[%s] query=%r, code=%r, price_min=%r, price_max=%r, gender=%r, age=%r",
idx, idx,
short_query, short_query,
item.magento_ref_code, item.magento_ref_code,
item.price_min, item.price_min,
item.price_max, item.price_max,
item.gender_by_product, item.gender_by_product,
item.age_by_product item.age_by_product
) )
queries_to_embed = [s.query for s in searches if s.query] queries_to_embed = [s.query for s in searches if s.query]
all_vectors = [] all_vectors = []
if queries_to_embed: if queries_to_embed:
logger.info("batch embedding %s queries", len(queries_to_embed)) logger.info("batch embedding %s queries", len(queries_to_embed))
emb_batch_start = time.time() emb_batch_start = time.time()
all_vectors = await create_embeddings_async(queries_to_embed) all_vectors = await create_embeddings_async(queries_to_embed)
logger.info( logger.info(
"batch embedding done in %.2f ms", "batch embedding done in %.2f ms",
(time.time() - emb_batch_start) * 1000, (time.time() - emb_batch_start) * 1000,
) )
# 2. Get DB connection (singleton) # 2. Get DB connection (singleton)
db = get_db_connection() db = get_db_connection()
tasks = [] tasks = []
vector_idx = 0 vector_idx = 0
for item in searches: for item in searches:
current_vector = None current_vector = None
if item.query: if item.query:
if vector_idx < len(all_vectors): if vector_idx < len(all_vectors):
current_vector = all_vectors[vector_idx] current_vector = all_vectors[vector_idx]
vector_idx += 1 vector_idx += 1
tasks.append(_execute_single_search(db, item, query_vector=current_vector)) tasks.append(_execute_single_search(db, item, query_vector=current_vector))
results = await asyncio.gather(*tasks) results = await asyncio.gather(*tasks)
# 3. Tổng hợp kết quả # 3. Tổng hợp kết quả
combined_results = [] combined_results = []
for i, products in enumerate(results): for i, products in enumerate(results):
combined_results.append( combined_results.append(
{ {
"search_index": i, "search_index": i,
"search_criteria": searches[i].dict(exclude_none=True), "search_criteria": searches[i].dict(exclude_none=True),
"count": len(products), "count": len(products),
"products": products, "products": products,
} }
) )
logger.info("data_retrieval_tool finished, results=%s", len(combined_results)) logger.info("data_retrieval_tool finished, results=%s", len(combined_results))
return json.dumps( return json.dumps(
{"status": "success", "results": combined_results}, {"status": "success", "results": combined_results},
ensure_ascii=False, ensure_ascii=False,
cls=DecimalEncoder, cls=DecimalEncoder,
) )
except Exception as e: except Exception as e:
logger.exception("Error in Multi-Search data_retrieval_tool: %s", e) logger.exception("Error in Multi-Search data_retrieval_tool: %s", e)
return json.dumps({"status": "error", "message": str(e)}) return json.dumps({"status": "error", "message": str(e)})
async def _execute_single_search(db, item: SearchItem, query_vector: list[float] | None = None) -> list[dict]: async def _execute_single_search(db, item: SearchItem, query_vector: list[float] | None = None) -> list[dict]:
"""Thực thi một search query đơn lẻ (Async).""" """Thực thi một search query đơn lẻ (Async)."""
try: try:
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug( logger.debug(
"_execute_single_search started, query=%r, code=%r", "_execute_single_search started, query=%r, code=%r",
short_query, short_query,
item.magento_ref_code, item.magento_ref_code,
) )
# Timer: build query (sử dụng vector đã có hoặc build mới) # Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time() query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector) sql, params = await build_starrocks_query(item, query_vector=query_vector)
query_build_time = (time.time() - query_build_start) * 1000 # Convert to ms query_build_time = (time.time() - query_build_start) * 1000 # Convert to ms
logger.debug("SQL built, length=%s, build_time_ms=%.2f", len(sql), query_build_time) logger.debug("SQL built, length=%s, build_time_ms=%.2f", len(sql), query_build_time)
if not sql: if not sql:
return [] return []
# Timer: execute DB query # Timer: execute DB query
db_start = time.time() db_start = time.time()
products = await db.execute_query_async(sql, params=params) products = await db.execute_query_async(sql, params=params)
db_time = (time.time() - db_start) * 1000 # Convert to ms db_time = (time.time() - db_start) * 1000 # Convert to ms
logger.info( logger.info(
"_execute_single_search done, products=%s, build_ms=%.2f, db_ms=%.2f, total_ms=%.2f", "_execute_single_search done, products=%s, build_ms=%.2f, db_ms=%.2f, total_ms=%.2f",
len(products), len(products),
query_build_time, query_build_time,
db_time, db_time,
query_build_time + db_time, query_build_time + db_time,
) )
# Debug: Log first product to see fields # Debug: Log first product to see fields
if products: if products:
first_p = products[0] first_p = products[0]
logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys())) logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys()))
logger.info("🔍 [DEBUG] First product price: %s, sale_price: %s", logger.info("🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"), first_p.get("sale_price")) first_p.get("original_price"), first_p.get("sale_price"))
return _format_product_results(products) return _format_product_results(products)
except Exception as e: except Exception as e:
logger.exception("Single search error for item %r: %s", item, e) logger.exception("Single search error for item %r: %s", item, e)
return [] return []
def _format_product_results(products: list[dict]) -> list[dict]: def _format_product_results(products: list[dict]) -> list[dict]:
"""Lọc và format kết quả trả về cho Agent - Parse description_text_full thành structured fields.""" """Lọc và format kết quả trả về cho Agent - Parse description_text_full thành structured fields."""
max_items = 15 max_items = 15
formatted: list[dict] = [] formatted: list[dict] = []
for p in products[:max_items]: for p in products[:max_items]:
desc_full = p.get("description_text_full", "") desc_full = p.get("description_text_full", "")
# Parse các field từ description_text_full # Parse các field từ description_text_full
parsed = _parse_description_text(desc_full) parsed = _parse_description_text(desc_full)
formatted.append( formatted.append(
{ {
"sku": p.get("internal_ref_code"), "sku": p.get("internal_ref_code"),
"name": parsed.get("product_name", ""), "name": parsed.get("product_name", ""),
"price": p.get("original_price") or 0, "price": p.get("original_price") or 0,
"sale_price": p.get("sale_price") or 0, "sale_price": p.get("sale_price") or 0,
"description": p.get("description_text_full", ""), "description": p.get("description_text_full", ""),
"url": parsed.get("product_web_url", ""), "url": parsed.get("product_web_url", ""),
"thumbnail_image_url": parsed.get("product_image_url_thumbnail", ""), "thumbnail_image_url": parsed.get("product_image_url_thumbnail", ""),
"discount_amount": p.get("discount_amount") or 0, "discount_amount": p.get("discount_amount") or 0,
"max_score": p.get("max_score") or 0, "max_score": p.get("max_score") or 0,
} }
) )
return formatted return formatted
def _parse_description_text(desc: str) -> dict: def _parse_description_text(desc: str) -> dict:
""" """
Parse description_text_full thành dict các field. Parse description_text_full thành dict các field.
Format: "product_name: X. master_color: Y. product_web_url: https://canifa.com/... ..." Format: "product_name: X. master_color: Y. product_web_url: https://canifa.com/... ..."
""" """
import re import re
result = {} result = {}
if not desc: if not desc:
return result return result
# Extract product_name: từ đầu đến ". master_color:" hoặc ". product_image_url:" # Extract product_name: từ đầu đến ". master_color:" hoặc ". product_image_url:"
name_match = re.search(r"product_name:\s*(.+?)\.(?:\s+master_color:|$)", desc) name_match = re.search(r"product_name:\s*(.+?)\.(?:\s+master_color:|$)", desc)
if name_match: if name_match:
result["product_name"] = name_match.group(1).strip() result["product_name"] = name_match.group(1).strip()
# Extract product_image_url_thumbnail: từ field name đến ". product_web_url:" # Extract product_image_url_thumbnail: từ field name đến ". product_web_url:"
thumb_match = re.search(r"product_image_url_thumbnail:\s*(https?://[^\s]+?)\.(?:\s+product_web_url:|$)", desc) thumb_match = re.search(r"product_image_url_thumbnail:\s*(https?://[^\s]+?)\.(?:\s+product_web_url:|$)", desc)
if thumb_match: if thumb_match:
result["product_image_url_thumbnail"] = thumb_match.group(1).strip() result["product_image_url_thumbnail"] = thumb_match.group(1).strip()
# Extract product_web_url: từ field name đến ". description_text:" # Extract product_web_url: từ field name đến ". description_text:"
url_match = re.search(r"product_web_url:\s*(https?://[^\s]+?)\.(?:\s+description_text:|$)", desc) url_match = re.search(r"product_web_url:\s*(https?://[^\s]+?)\.(?:\s+description_text:|$)", desc)
if url_match: if url_match:
result["product_web_url"] = url_match.group(1).strip() result["product_web_url"] = url_match.group(1).strip()
# Extract master_color: từ field name đến ". product_image_url:" # Extract master_color: từ field name đến ". product_image_url:"
color_match = re.search(r"master_color:\s*(.+?)\.(?:\s+product_image_url:|$)", desc) color_match = re.search(r"master_color:\s*(.+?)\.(?:\s+product_image_url:|$)", desc)
if color_match: if color_match:
result["master_color"] = color_match.group(1).strip() result["master_color"] = color_match.group(1).strip()
return result return result
# Load dynamic docstring
data_retrieval_tool.__doc__ = read_tool_prompt("data_retrieval_tool") or data_retrieval_tool.__doc__
...@@ -8,11 +8,12 @@ from langchain_core.tools import Tool ...@@ -8,11 +8,12 @@ from langchain_core.tools import Tool
from .brand_knowledge_tool import canifa_knowledge_search from .brand_knowledge_tool import canifa_knowledge_search
from .customer_info_tool import collect_customer_info from .customer_info_tool import collect_customer_info
from .data_retrieval_tool import data_retrieval_tool from .data_retrieval_tool import data_retrieval_tool
from .promotion_canifa_tool import canifa_get_promotions
def get_retrieval_tools() -> list[Tool]: def get_retrieval_tools() -> list[Tool]:
"""Các tool chỉ dùng để đọc/truy vấn dữ liệu (Có thể cache)""" """Các tool chỉ dùng để đọc/truy vấn dữ liệu (Có thể cache)"""
return [data_retrieval_tool, canifa_knowledge_search] return [data_retrieval_tool, canifa_knowledge_search, canifa_get_promotions]
def get_collection_tools() -> list[Tool]: def get_collection_tools() -> list[Tool]:
......
import logging
from datetime import datetime
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from agent.prompt_utils import read_tool_prompt
logger = logging.getLogger(__name__)
class PromotionInput(BaseModel):
check_date: str | None = Field(
...,
description="Ngày cần kiểm tra khuyến mãi (định dạng YYYY-MM-DD). Nếu muốn kiểm tra ngày hiện tại thì truyền null."
)
model_config = {"extra": "forbid"}
@tool("canifa_get_promotions", args_schema=PromotionInput)
async def canifa_get_promotions(check_date: str = None) -> str:
"""
Tra cứu danh sách các chương trình khuyến mãi (CTKM) đang diễn ra theo ngày.
Sử dụng tool này khi khách hàng hỏi về:
- "Hôm nay có khuyến mãi gì không?"
- "Đang có chương trình gì hot?"
- "Ngày mai có giảm giá không?"
- "Danh sách mã giảm giá hiện tại."
Trả về: Tên chương trình, mô tả, thời gian áp dụng.
"""
target_date = check_date
if not target_date:
target_date = datetime.now().strftime("%Y-%m-%d")
logger.info(f"🎁 [Promotion Search] Checking for date: {target_date}")
try:
sql = f"""
SELECT
rule_id,
name,
description,
from_date,
to_date
FROM shared_source.magento_salesrule
WHERE '{target_date}' >= DATE(from_date)
AND '{target_date}' <= DATE(to_date)
ORDER BY to_date ASC
LIMIT 10
"""
sr = get_db_connection()
results = await sr.execute_query_async(sql)
if not results:
return f"Hiện tại (ngày {target_date}) không có chương trình khuyến mãi nào đang diễn ra trên hệ thống."
lines = []
for res in results:
name = res.get("name", "CTKM")
desc = res.get("description", "")
f_date = res.get("from_date", "")
t_date = res.get("to_date", "")
lines.append(f"- **{name}**\n {desc}\n (Từ {f_date} đến {t_date})")
return "\n\n".join(lines)
except Exception as e:
logger.error(f"❌ Error in canifa_get_promotions: {e}")
return "Xin lỗi, tôi không thể lấy danh sách khuyến mãi lúc này."
# Load dynamic docstring
canifa_get_promotions.__doc__ = read_tool_prompt("promotion_canifa_tool") or canifa_get_promotions.__doc__
""" """
Fashion Q&A Agent Router 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.
Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware) Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware)
""" """
import logging import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from agent.controller import chat_controller from agent.controller import chat_controller
from agent.models import QueryRequest from agent.models import QueryRequest
from common.message_limit import message_limit_service from common.message_limit import message_limit_service
from common.rate_limit import rate_limit_service from common.cache import redis_cache
from config import DEFAULT_MODEL from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
logger = logging.getLogger(__name__)
router = APIRouter() logger = logging.getLogger(__name__)
router = APIRouter()
@router.post("/api/agent/chat", summary="Fashion Q&A Chat (Non-streaming)")
@rate_limit_service.limiter.limit("50/minute") @router.post("/api/agent/chat", summary="Fashion Q&A Chat (Non-streaming)")
async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: BackgroundTasks): @rate_limit_service.limiter.limit("50/minute")
""" async def fashion_qa_chat(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
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.
Note: Rate limit đã được check trong middleware.
""" Note: Rate limit đã được check trong middleware.
# 1. Lấy user identity từ Middleware (request.state) """
# Logic: Login -> User ID | Guest -> Device ID # 1. Lấy user identity từ Middleware (request.state)
user_id = getattr(request.state, "user_id", None) # Logic: Login -> User ID | Guest -> Device ID
device_id = getattr(request.state, "device_id", "unknown") user_id = getattr(request.state, "user_id", None)
is_authenticated = getattr(request.state, "is_authenticated", False) 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 # Đị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) # 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: {identity_id} | Query: {req.user_query}")
logger.info(f"📥 [Incoming Query - NonStream] User: {identity_id} | Query: {req.user_query}")
try:
# Gọi controller để xử lý logic (Non-streaming) try:
result = await chat_controller( # Gọi controller để xử lý logic (Non-streaming)
query=req.user_query, result = await chat_controller(
user_id=str(identity_id), # Langfuse User ID query=req.user_query,
background_tasks=background_tasks, user_id=str(identity_id), # Langfuse User ID
model_name=DEFAULT_MODEL, background_tasks=background_tasks,
images=req.images, model_name=DEFAULT_MODEL,
identity_key=str(identity_id), # Key lưu history images=req.images,
) identity_key=str(identity_id), # Key lưu history
)
# Log chi tiết response
logger.info(f"📤 [Outgoing Response - NonStream] User: {identity_id}") # Log chi tiết response
logger.info(f"💬 AI Response: {result['ai_response']}") logger.info(f"📤 [Outgoing Response - NonStream] User: {identity_id}")
logger.info(f"🛍️ Product IDs: {result.get('product_ids', [])}") logger.info(f"💬 AI Response: {result['ai_response']}")
logger.info(f"🛍️ Product IDs: {result.get('product_ids', [])}")
# Increment message count SAU KHI chat thành công
usage_info = await message_limit_service.increment( # Increment message count SAU KHI chat thành công
identity_key=identity_id, usage_info = await message_limit_service.increment(
is_authenticated=is_authenticated, identity_key=identity_id,
) is_authenticated=is_authenticated,
)
return {
"status": "success", return {
"ai_response": result["ai_response"], "status": "success",
"product_ids": result.get("product_ids", []), "ai_response": result["ai_response"],
"limit_info": { "product_ids": result.get("product_ids", []),
"limit": usage_info["limit"], "limit_info": {
"used": usage_info["used"], "limit": usage_info["limit"],
"remaining": usage_info["remaining"], "used": usage_info["used"],
}, "remaining": usage_info["remaining"],
} },
except Exception as e: }
logger.error(f"Error in fashion_qa_chat: {e}", exc_info=True) except Exception as e:
# Trả về lỗi dạng JSON chuẩn với error_code="SYSTEM_ERROR" logger.error(f"Error in fashion_qa_chat: {e}", exc_info=True)
return JSONResponse( # Trả về lỗi dạng JSON chuẩn với error_code="SYSTEM_ERROR"
status_code=500, return JSONResponse(
content={ status_code=500,
"status": "error", content={
"error_code": "SYSTEM_ERROR", "status": "error",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát." "error_code": "SYSTEM_ERROR",
} "message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát."
) }
)
@router.post("/api/agent/chat-dev", summary="Fashion Q&A Chat (Dev - includes user_insight)")
@rate_limit_service.limiter.limit("50/minute")
async def fashion_qa_chat_dev(request: Request, req: QueryRequest, background_tasks: BackgroundTasks):
"""
Endpoint chat dành cho DEV - trả về đầy đủ user_insight.
Note: Rate limit đã được check trong middleware.
"""
user_id = getattr(request.state, "user_id", None)
device_id = getattr(request.state, "device_id", "unknown")
is_authenticated = getattr(request.state, "is_authenticated", False)
identity_id = user_id if is_authenticated else device_id
limit_info = getattr(request.state, 'limit_info', None)
logger.info(f"📥 [Incoming Query - Dev] User: {identity_id} | Query: {req.user_query}")
try:
result = await chat_controller(
query=req.user_query,
user_id=str(identity_id),
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
images=req.images,
identity_key=str(identity_id),
)
usage_info = await message_limit_service.increment(
identity_key=identity_id,
is_authenticated=is_authenticated,
)
user_insight = None
try:
client = redis_cache.get_client()
if client:
insight_key = f"identity_key_insight:{identity_id}"
user_insight = await client.get(insight_key)
except Exception as insight_error:
logger.error(f"Error fetching user_insight for dev endpoint: {insight_error}")
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"user_insight": user_insight,
"limit_info": {
"limit": usage_info["limit"],
"used": usage_info["used"],
"remaining": usage_info["remaining"],
},
}
except Exception as e:
logger.error(f"Error in fashion_qa_chat_dev: {e}", exc_info=True)
return JSONResponse(
status_code=500,
content={
"status": "error",
"error_code": "SYSTEM_ERROR",
"message": "Oops 😥 Hiện Canifa-AI chưa thể xử lý yêu cầu của bạn ngay lúc này, vui lòng quay lại trong giây lát."
}
)
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from typing import List
from agent.prompt_utils import list_tool_prompts, read_tool_prompt, write_tool_prompt
from agent.graph import reset_graph
from common.rate_limit import rate_limit_service
router = APIRouter()
class ToolPromptUpdateRequest(BaseModel):
content: str
@router.get("/api/agent/tool-prompts")
async def get_tool_prompts_list(request: Request):
"""List all available tool prompt files."""
try:
files = list_tool_prompts()
return {"status": "success", "files": files}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/agent/tool-prompts/{filename}")
async def get_tool_prompt_content(filename: str, request: Request):
"""Get content of a specific tool prompt file."""
try:
content = read_tool_prompt(filename)
if not content:
# Try appending .txt if not present
if not filename.endswith(".txt"):
content = read_tool_prompt(filename + ".txt")
return {"status": "success", "content": content}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/agent/tool-prompts/{filename}")
@rate_limit_service.limiter.limit("10/minute")
async def update_tool_prompt_content(filename: str, request: Request, body: ToolPromptUpdateRequest):
"""Update content of a tool prompt file and reset graph."""
try:
# Ensure filename is safe (basic check)
if ".." in filename or "/" in filename or "\\" in filename:
raise HTTPException(status_code=400, detail="Invalid filename")
success = write_tool_prompt(filename, body.content)
if not success:
raise HTTPException(status_code=500, detail="Failed to write file")
# Reset Graph to reload tools with new prompts
reset_graph()
return {
"status": "success",
"message": f"Tool prompt {filename} updated successfully. Graph reloaded."
}
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
import hashlib import hashlib
import json import json
import logging import logging
from openai import AsyncOpenAI, OpenAI from openai import AsyncOpenAI, OpenAI
...@@ -75,8 +75,11 @@ async def create_embedding_async(text: str) -> list[float]: ...@@ -75,8 +75,11 @@ async def create_embedding_async(text: str) -> list[float]:
try: try:
# 1. Try Layer 2 Cache # 1. Try Layer 2 Cache
cached = await redis_cache.get_embedding(text) cached = await redis_cache.get_embedding(text)
if cached: # Validate dimension (must be 1536 for text-embedding-3-small)
if cached and len(cached) == 1536:
return cached return cached
elif cached:
logger.warning(f"⚠️ Cached embedding has wrong dimension ({len(cached)}). Regenerating...")
# 2. Call OpenAI # 2. Call OpenAI
client = get_async_embedding_client() client = get_async_embedding_client()
...@@ -93,7 +96,7 @@ async def create_embedding_async(text: str) -> list[float]: ...@@ -93,7 +96,7 @@ async def create_embedding_async(text: str) -> list[float]:
return [] 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. Batch async embedding generation with per-item Layer 2 Cache.
""" """
...@@ -101,28 +104,36 @@ async def create_embeddings_async(texts: list[str]) -> list[list[float]]: ...@@ -101,28 +104,36 @@ async def create_embeddings_async(texts: list[str]) -> list[list[float]]:
if not texts: if not texts:
return [] return []
results = [[] for _ in texts] results = [[] for _ in texts]
missed_indices = [] missed_indices = []
missed_texts = [] missed_texts = []
client = redis_cache.get_client() client = redis_cache.get_client()
if client: if client:
keys = [] keys = []
for text in texts: for text in texts:
text_hash = hashlib.md5(text.strip().lower().encode()).hexdigest() text_hash = hashlib.md5(text.strip().lower().encode()).hexdigest()
keys.append(f"emb_cache:{text_hash}") keys.append(f"emb_cache:{text_hash}")
cached_values = await client.mget(keys) cached_values = await client.mget(keys)
for i, cached in enumerate(cached_values): for i, cached in enumerate(cached_values):
if cached: use_cache = False
results[i] = json.loads(cached) if cached:
else: loaded_emb = json.loads(cached)
missed_indices.append(i) # Validate dimension
missed_texts.append(texts[i]) if len(loaded_emb) == 1536:
else: results[i] = loaded_emb
# Fallback: no redis client, treat all as miss use_cache = True
missed_indices = list(range(len(texts))) else:
missed_texts = texts logger.warning(f"⚠️ Cached embedding for item {i} has wrong dimension ({len(loaded_emb)}). Ignoring.")
if not use_cache:
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 # 2. Call OpenAI for missed texts
if missed_texts: if missed_texts:
......
import asyncio import asyncio
import logging import logging
import os import os
import platform import platform
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from fastapi.staticfiles import StaticFiles 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 api.tool_prompt_route import router as tool_prompt_router
from api.mock_api_route import router as mock_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.cache import redis_cache
from common.middleware import middleware_manager from common.langfuse_client import get_langfuse_client
from config import PORT from common.middleware import middleware_manager
from config import PORT
if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...") if platform.system() == "Windows":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Configure Logging
logging.basicConfig( # Configure Logging
level=logging.INFO, logging.basicConfig(
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", level=logging.INFO,
handlers=[logging.StreamHandler()], format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
) handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Contract AI Service", app = FastAPI(
description="API for Contract AI Service", title="Contract AI Service",
version="1.0.0", description="API for Contract AI Service",
) version="1.0.0",
)
@app.on_event("startup")
async def startup_event(): @app.on_event("startup")
"""Initialize Redis cache on startup.""" async def startup_event():
await redis_cache.initialize() """Initialize Redis cache on startup."""
logger.info("✅ Redis cache initialized") await redis_cache.initialize()
logger.info("✅ Redis cache initialized")
middleware_manager.setup(
app, middleware_manager.setup(
enable_auth=True, app,
enable_rate_limit=True, enable_auth=True,
enable_cors=True, enable_rate_limit=True,
cors_origins=["*"], enable_cors=True,
) cors_origins=["*"],
)
# api include
app.include_router(conservation_router) # api include
app.include_router(chatbot_router) app.include_router(conservation_router)
app.include_router(prompt_router) app.include_router(chatbot_router)
app.include_router(mock_router) app.include_router(prompt_router)
app.include_router(tool_prompt_router) # Register new router
app.include_router(mock_router)
try:
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir): try:
os.makedirs(static_dir) static_dir = os.path.join(os.path.dirname(__file__), "static")
app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static") if not os.path.exists(static_dir):
print(f"✅ Static files mounted at /static (Dir: {static_dir})") os.makedirs(static_dir)
except Exception as e: app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static")
print(f"⚠️ Failed to mount static files: {e}") print(f"✅ Static files mounted at /static (Dir: {static_dir})")
except Exception as e:
print(f"⚠️ Failed to mount static files: {e}")
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html") @app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...") if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}") print("🚀 Contract AI Service Starting...")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html") print("=" * 60)
print(f"📚 API Docs: http://localhost:{PORT}/docs") print(f"📡 REST API: http://localhost:{PORT}")
print("=" * 60) print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
ENABLE_RELOAD = False print("=" * 60)
print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
ENABLE_RELOAD = False
reload_dirs = ["common", "api", "agent"] print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
if ENABLE_RELOAD: reload_dirs = ["common", "api", "agent"]
os.environ["PYTHONUNBUFFERED"] = "1"
if ENABLE_RELOAD:
uvicorn.run( os.environ["PYTHONUNBUFFERED"] = "1"
"server:app",
host="0.0.0.0", uvicorn.run(
port=PORT, "server:app",
reload=ENABLE_RELOAD, host="0.0.0.0",
reload_dirs=reload_dirs, port=PORT,
log_level="info", reload=ENABLE_RELOAD,
) reload_dirs=reload_dirs,
log_level="info",
)
This source diff could not be displayed because it is too large. You can view the blob instead.
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