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(
][::-1] # Reverse to chronological order (Oldest -> Newest)
# 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 = {
"user_query": HumanMessage(content=query),
"messages": messages + [HumanMessage(content=query)],
"messages": [], # Start empty, acting as scratchpad. History & Query are separate.
"history": messages,
"user_id": user_id,
"user_insight": user_insight,
"images_embedding": [],
"ai_response": None,
}
......@@ -117,11 +131,25 @@ async def chat_controller(
# Parse Response
all_product_ids = extract_product_ids(result.get("messages", []))
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 = {
"ai_response": ai_text_response,
"product_ids": final_product_ids,
# user_insight is NOT included here as requested
}
# ====================== SAVE TO CACHE ======================
......@@ -134,7 +162,7 @@ async def chat_controller(
)
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(
handle_post_chat_async,
memory=memory,
......
......@@ -50,7 +50,10 @@ class CANIFAGraph:
self.prompt_template = ChatPromptTemplate.from_messages(
[
("system", self.system_prompt),
("system", "User Insight:\n{user_insight}"),
("system", "Chat History:"),
MessagesPlaceholder(variable_name="history"),
("system", "Current Query:"),
MessagesPlaceholder(variable_name="user_query"),
MessagesPlaceholder(variable_name="messages"),
]
......@@ -68,10 +71,14 @@ class CANIFAGraph:
if transient_images and messages:
pass
# 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({
"user_query": [user_query] if user_query else [],
"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}
......
......@@ -66,12 +66,12 @@ def extract_product_ids(messages: list) -> list[dict]:
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.
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
- Map SKUs → enriched products
......@@ -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)
Returns:
tuple: (ai_text_response, final_products)
tuple: (ai_text_response, final_products, user_insight)
"""
ai_text_response = ai_raw_content
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 to parse if it's a JSON string from LLM
ai_json = json.loads(ai_raw_content)
ai_text_response = ai_json.get("ai_response", ai_raw_content)
explicit_skus = ai_json.get("product_ids", [])
user_insight = ai_json.get("user_insight")
if explicit_skus and isinstance(explicit_skus, list):
# 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
if 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):
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):
......
......@@ -22,6 +22,7 @@ class AgentState(TypedDict):
user_query: BaseMessage
history: list[BaseMessage]
user_id: str | None
user_insight: str | None
ai_response: BaseMessage | None
images_embedding: list[str] | None
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ụ
- 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.
- **Chỉ gọi Tool khi đã hiểu rõ nhu cầu.**
**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.
## 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?"
- 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"
**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):
**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.
- 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`
**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]
master_color: [Màu sắc] (nếu có)
......@@ -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 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
- **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:
......@@ -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
⚠️ **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
```json
{{
"ai_response": "...",
"product_ids": []
"product_ids": [],
"user_insight": "..."
}}
```
**✅ ĐÚNG - PHẢI TRẢ VỀ NHƯ NÀY:**
{{
"ai_response": "...",
"product_ids": []
"ai_response": "...",
"product_ids": [],
"user_insight": "..."
}}
**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
{{
"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
......@@ -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):**
{{
"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": []
}}
......@@ -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):**
{{
"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": []
}}
......@@ -582,4 +655,4 @@ Bạn PHẢI trả về JSON thuần túy, KHÔNG ĐƯỢC wrap trong markdown b
**CRITICAL:**
- 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)
- **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.
\ No newline at end of file
- **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
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
from .data_retrieval_tool import data_retrieval_tool
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
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.embedding_service import create_embedding_async
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
class KnowledgeSearchInput(BaseModel):
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...)"
)
@tool("canifa_knowledge_search", args_schema=KnowledgeSearchInput)
async def canifa_knowledge_search(query: str) -> str:
"""
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?'
"""
logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}")
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)
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_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# 2. Query StarRocks lấy Top 4 kết quả phù hợp nhất (Không check score)
sql = f"""
SELECT
content,
metadata
FROM shared_source.chatbot_rsa_knowledge
ORDER BY approx_cosine_similarity(embedding, {v_str}) DESC
LIMIT 4
"""
sr = get_db_connection()
results = await sr.execute_query_async(sql)
if not results:
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."
# 3. Tổng hợp kết quả
knowledge_texts = []
for i, res in enumerate(results):
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?"
import logging
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.embedding_service import create_embedding_async
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
class KnowledgeSearchInput(BaseModel):
query: str = Field(
description="Câu hỏi hoặc nhu cầu tìm kiếm thông tin phi sản phẩm của khách hàng (ví dụ: tìm cửa hàng, hỏi chính sách, tra bảng size...). KHÔNG DÙNG ĐỂ TÌM SẢN PHẨM."
)
from agent.prompt_utils import read_tool_prompt
@tool("canifa_knowledge_search", args_schema=KnowledgeSearchInput)
async def canifa_knowledge_search(query: str) -> str:
"""
(Placeholder docstring - Actual prompt is loaded from file)
Tra cứu thông tin thương hiệu Canifa (Cửa hàng, chính sách, KHTT...).
"""
logger.info(f"🔍 [Semantic Search] Brand Knowledge query: {query}")
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)
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_str = "[" + ",".join(str(v) for v in query_vector) + "]"
# 2. Query StarRocks lấy Top 4 kết quả phù hợp nhất (Không check score)
sql = f"""
SELECT
content,
metadata
FROM shared_source.chatbot_rsa_knowledge
ORDER BY approx_cosine_similarity(embedding, {v_str}) DESC
LIMIT 4
"""
sr = get_db_connection()
results = await sr.execute_query_async(sql)
if not results:
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."
# 3. Tổng hợp kết quả
knowledge_texts = []
for i, res in enumerate(results):
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?"
# Load dynamic docstring
canifa_knowledge_search.__doc__ = read_tool_prompt("brand_knowledge_tool") or canifa_knowledge_search.__doc__
"""
CANIFA Data Retrieval Tool - Tối giản cho Agentic Workflow.
Hỗ trợ Hybrid Search: Semantic (Vector) + Metadata Filter.
"""
import asyncio
import json
import logging
import time
from decimal import Decimal
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query
from common.embedding_service import create_embeddings_async
from common.starrocks_connection import get_db_connection
# from langsmith import traceable
logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder):
"""Xử lý kiểu Decimal từ Database khi convert sang JSON."""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
class SearchItem(BaseModel):
"""
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:
- 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,
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_image_url: https://.... product_image_url_thumbnail: https://....
product_web_url: https://.... description_text: ... material: ...
material_group: Yarn - Sợi. gender_by_product: female. age_by_product: others.
season: Year. style: Feminine. fitting: Slim. size_scale: 4/6.
form_neckline: None. form_sleeve: None. product_line_vn: Tất.
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:
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_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).
age_by_product: others. season: Year. style: ... (nếu đoán được).
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ý).
- Nếu không suy luận được giá trị cho field nào thì để `None` hoặc bỏ trống phần text đó.
"""
query: str = Field(
...,
description=(
"ĐOẠN TEXT CÓ CẤU TRÚC theo format của cột description_text_full trong DB, "
"bao gồm các cặp key: product_name, master_color, product_image_url, "
"product_image_url_thumbnail, product_web_url, description_text, material, "
"material_group, gender_by_product, age_by_product, season, style, fitting, "
"size_scale, form_neckline, form_sleeve, product_line_vn, product_color_name. "
"Ví dụ: '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_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ể."
)
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)")
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
age_by_product: str | None = None
product_name: str | None = None
style: str | None = None
master_color: str | None = None
season: str | None = None
material_group: str | None = None
fitting: str | None = None
form_neckline: str | None = None
form_sleeve: str | None = None
class MultiSearchParams(BaseModel):
"""Tham số cho Parallel Multi-Search."""
searches: list[SearchItem] = Field(..., description="Danh sách các truy vấn tìm kiếm chạy song song")
def _parse_query_metadata(query_str: str) -> dict:
"""Parse structured query string explicitly generated by the Agent."""
import re
metadata = {}
if not query_str:
return metadata
# Matches "key: value" at the start of lines (handling indentation)
pattern = re.compile(r"^\s*([a-z_]+):\s*(.+?)\s*$", re.MULTILINE)
matches = pattern.findall(query_str)
for key, value in matches:
if value and value.lower() != 'none':
metadata[key] = value.strip()
return metadata
@tool(args_schema=MultiSearchParams)
# @traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"""
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).
Hướng dẫn dùng nhanh:
- 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ô.
- 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).
"""
logger.info("data_retrieval_tool started, searches=%s", len(searches))
try:
# Pre-process: Parse metadata from query string
for item in searches:
if item.query:
meta = _parse_query_metadata(item.query)
for k, v in meta.items():
if hasattr(item, k):
setattr(item, k, v)
# 0. Log input tổng quan (không log chi tiết dài)
for idx, item in enumerate(searches):
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"search[%s] query=%r, code=%r, price_min=%r, price_max=%r, gender=%r, age=%r",
idx,
short_query,
item.magento_ref_code,
item.price_min,
item.price_max,
item.gender_by_product,
item.age_by_product
)
queries_to_embed = [s.query for s in searches if s.query]
all_vectors = []
if queries_to_embed:
logger.info("batch embedding %s queries", len(queries_to_embed))
emb_batch_start = time.time()
all_vectors = await create_embeddings_async(queries_to_embed)
logger.info(
"batch embedding done in %.2f ms",
(time.time() - emb_batch_start) * 1000,
)
# 2. Get DB connection (singleton)
db = get_db_connection()
tasks = []
vector_idx = 0
for item in searches:
current_vector = None
if item.query:
if vector_idx < len(all_vectors):
current_vector = all_vectors[vector_idx]
vector_idx += 1
tasks.append(_execute_single_search(db, item, query_vector=current_vector))
results = await asyncio.gather(*tasks)
# 3. Tổng hợp kết quả
combined_results = []
for i, products in enumerate(results):
combined_results.append(
{
"search_index": i,
"search_criteria": searches[i].dict(exclude_none=True),
"count": len(products),
"products": products,
}
)
logger.info("data_retrieval_tool finished, results=%s", len(combined_results))
return json.dumps(
{"status": "success", "results": combined_results},
ensure_ascii=False,
cls=DecimalEncoder,
)
except Exception as e:
logger.exception("Error in Multi-Search data_retrieval_tool: %s", 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]:
"""Thực thi một search query đơn lẻ (Async)."""
try:
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"_execute_single_search started, query=%r, code=%r",
short_query,
item.magento_ref_code,
)
# Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector)
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)
if not sql:
return []
# Timer: execute DB query
db_start = time.time()
products = await db.execute_query_async(sql, params=params)
db_time = (time.time() - db_start) * 1000 # Convert to ms
logger.info(
"_execute_single_search done, products=%s, build_ms=%.2f, db_ms=%.2f, total_ms=%.2f",
len(products),
query_build_time,
db_time,
query_build_time + db_time,
)
# Debug: Log first product to see fields
if products:
first_p = products[0]
logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys()))
logger.info("🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"), first_p.get("sale_price"))
return _format_product_results(products)
except Exception as e:
logger.exception("Single search error for item %r: %s", item, e)
return []
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."""
max_items = 15
formatted: list[dict] = []
for p in products[:max_items]:
desc_full = p.get("description_text_full", "")
# Parse các field từ description_text_full
parsed = _parse_description_text(desc_full)
formatted.append(
{
"sku": p.get("internal_ref_code"),
"name": parsed.get("product_name", ""),
"price": p.get("original_price") or 0,
"sale_price": p.get("sale_price") or 0,
"description": p.get("description_text_full", ""),
"url": parsed.get("product_web_url", ""),
"thumbnail_image_url": parsed.get("product_image_url_thumbnail", ""),
"discount_amount": p.get("discount_amount") or 0,
"max_score": p.get("max_score") or 0,
}
)
return formatted
def _parse_description_text(desc: str) -> dict:
"""
Parse description_text_full thành dict các field.
Format: "product_name: X. master_color: Y. product_web_url: https://canifa.com/... ..."
"""
import re
result = {}
if not desc:
return result
# 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)
if name_match:
result["product_name"] = name_match.group(1).strip()
# 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)
if thumb_match:
result["product_image_url_thumbnail"] = thumb_match.group(1).strip()
# 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)
if url_match:
result["product_web_url"] = url_match.group(1).strip()
# Extract master_color: từ field name đến ". product_image_url:"
color_match = re.search(r"master_color:\s*(.+?)\.(?:\s+product_image_url:|$)", desc)
if color_match:
result["master_color"] = color_match.group(1).strip()
return result
"""
CANIFA Data Retrieval Tool - Tối giản cho Agentic Workflow.
Hỗ trợ Hybrid Search: Semantic (Vector) + Metadata Filter.
"""
import asyncio
import json
import logging
import time
from decimal import Decimal
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query
from common.embedding_service import create_embeddings_async
from common.starrocks_connection import get_db_connection
# from langsmith import traceable
logger = logging.getLogger(__name__)
class DecimalEncoder(json.JSONEncoder):
"""Xử lý kiểu Decimal từ Database khi convert sang JSON."""
def default(self, obj):
if isinstance(obj, Decimal):
return float(obj)
return super().default(obj)
class SearchItem(BaseModel):
"""
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:
- 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,
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_image_url: https://.... product_image_url_thumbnail: https://....
product_web_url: https://.... description_text: ... material: ...
material_group: Yarn - Sợi. gender_by_product: female. age_by_product: others.
season: Year. style: Feminine. fitting: Slim. size_scale: 4/6.
form_neckline: None. form_sleeve: None. product_line_vn: Tất.
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:
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_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).
age_by_product: others. season: Year. style: ... (nếu đoán được).
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ý).
- 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(
...,
description=(
"ĐOẠN TEXT CÓ CẤU TRÚC theo format của cột description_text_full trong DB, "
"bao gồm các cặp key: product_name, master_color, product_image_url, "
"product_image_url_thumbnail, product_web_url, description_text, material, "
"material_group, gender_by_product, age_by_product, season, style, fitting, "
"size_scale, form_neckline, form_sleeve, product_line_vn, product_color_name. "
"Ví dụ: '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_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ể."
)
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)")
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
# STRICT MODE: All fields must be required. Use ... and str | None
gender_by_product: str | None = Field(..., description="Giới tính (nam/nữ/bé trai/bé gái)")
age_by_product: str | None = Field(..., description="Độ tuổi")
product_name: str | None = Field(..., description="Tên sản phẩm")
style: str | None = Field(..., description="Phong cách")
master_color: str | None = Field(..., description="Màu sắc chính")
season: str | None = Field(..., description="Mùa")
material_group: str | None = Field(..., description="Nhóm chất liệu")
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."""
model_config = {"extra": "forbid"}
searches: list[SearchItem] = Field(..., description="Danh sách các truy vấn tìm kiếm chạy song song")
def _parse_query_metadata(query_str: str) -> dict:
"""Parse structured query string explicitly generated by the Agent."""
import re
metadata = {}
if not query_str:
return metadata
# Matches "key: value" at the start of lines (handling indentation)
pattern = re.compile(r"^\s*([a-z_]+):\s*(.+?)\s*$", re.MULTILINE)
matches = pattern.findall(query_str)
for key, value in matches:
if value and value.lower() != 'none':
metadata[key] = value.strip()
return metadata
from agent.prompt_utils import read_tool_prompt
@tool(args_schema=MultiSearchParams)
# @traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"""
(Placeholder) Tìm kiếm sản phẩm thời trang.
"""
logger.info("data_retrieval_tool started, searches=%s", len(searches))
try:
# Pre-process: Parse metadata from query string
for item in searches:
if item.query:
meta = _parse_query_metadata(item.query)
for k, v in meta.items():
if hasattr(item, k):
setattr(item, k, v)
# 0. Log input tổng quan (không log chi tiết dài)
for idx, item in enumerate(searches):
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"search[%s] query=%r, code=%r, price_min=%r, price_max=%r, gender=%r, age=%r",
idx,
short_query,
item.magento_ref_code,
item.price_min,
item.price_max,
item.gender_by_product,
item.age_by_product
)
queries_to_embed = [s.query for s in searches if s.query]
all_vectors = []
if queries_to_embed:
logger.info("batch embedding %s queries", len(queries_to_embed))
emb_batch_start = time.time()
all_vectors = await create_embeddings_async(queries_to_embed)
logger.info(
"batch embedding done in %.2f ms",
(time.time() - emb_batch_start) * 1000,
)
# 2. Get DB connection (singleton)
db = get_db_connection()
tasks = []
vector_idx = 0
for item in searches:
current_vector = None
if item.query:
if vector_idx < len(all_vectors):
current_vector = all_vectors[vector_idx]
vector_idx += 1
tasks.append(_execute_single_search(db, item, query_vector=current_vector))
results = await asyncio.gather(*tasks)
# 3. Tổng hợp kết quả
combined_results = []
for i, products in enumerate(results):
combined_results.append(
{
"search_index": i,
"search_criteria": searches[i].dict(exclude_none=True),
"count": len(products),
"products": products,
}
)
logger.info("data_retrieval_tool finished, results=%s", len(combined_results))
return json.dumps(
{"status": "success", "results": combined_results},
ensure_ascii=False,
cls=DecimalEncoder,
)
except Exception as e:
logger.exception("Error in Multi-Search data_retrieval_tool: %s", 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]:
"""Thực thi một search query đơn lẻ (Async)."""
try:
short_query = (item.query[:60] + "...") if item.query and len(item.query) > 60 else item.query
logger.debug(
"_execute_single_search started, query=%r, code=%r",
short_query,
item.magento_ref_code,
)
# Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector)
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)
if not sql:
return []
# Timer: execute DB query
db_start = time.time()
products = await db.execute_query_async(sql, params=params)
db_time = (time.time() - db_start) * 1000 # Convert to ms
logger.info(
"_execute_single_search done, products=%s, build_ms=%.2f, db_ms=%.2f, total_ms=%.2f",
len(products),
query_build_time,
db_time,
query_build_time + db_time,
)
# Debug: Log first product to see fields
if products:
first_p = products[0]
logger.info("🔍 [DEBUG] First product keys: %s", list(first_p.keys()))
logger.info("🔍 [DEBUG] First product price: %s, sale_price: %s",
first_p.get("original_price"), first_p.get("sale_price"))
return _format_product_results(products)
except Exception as e:
logger.exception("Single search error for item %r: %s", item, e)
return []
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."""
max_items = 15
formatted: list[dict] = []
for p in products[:max_items]:
desc_full = p.get("description_text_full", "")
# Parse các field từ description_text_full
parsed = _parse_description_text(desc_full)
formatted.append(
{
"sku": p.get("internal_ref_code"),
"name": parsed.get("product_name", ""),
"price": p.get("original_price") or 0,
"sale_price": p.get("sale_price") or 0,
"description": p.get("description_text_full", ""),
"url": parsed.get("product_web_url", ""),
"thumbnail_image_url": parsed.get("product_image_url_thumbnail", ""),
"discount_amount": p.get("discount_amount") or 0,
"max_score": p.get("max_score") or 0,
}
)
return formatted
def _parse_description_text(desc: str) -> dict:
"""
Parse description_text_full thành dict các field.
Format: "product_name: X. master_color: Y. product_web_url: https://canifa.com/... ..."
"""
import re
result = {}
if not desc:
return result
# 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)
if name_match:
result["product_name"] = name_match.group(1).strip()
# 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)
if thumb_match:
result["product_image_url_thumbnail"] = thumb_match.group(1).strip()
# 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)
if url_match:
result["product_web_url"] = url_match.group(1).strip()
# Extract master_color: từ field name đến ". product_image_url:"
color_match = re.search(r"master_color:\s*(.+?)\.(?:\s+product_image_url:|$)", desc)
if color_match:
result["master_color"] = color_match.group(1).strip()
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
from .brand_knowledge_tool import canifa_knowledge_search
from .customer_info_tool import collect_customer_info
from .data_retrieval_tool import data_retrieval_tool
from .promotion_canifa_tool import canifa_get_promotions
def get_retrieval_tools() -> list[Tool]:
"""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]:
......
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
FastAPI endpoints cho Fashion Q&A Agent service.
Router chỉ chứa định nghĩa API, logic nằm ở controller.
Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware)
"""
import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import JSONResponse
from agent.controller import chat_controller
from agent.models import QueryRequest
from common.message_limit import message_limit_service
from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
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")
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.
Note: Rate limit đã được check trong middleware.
"""
# 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: {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=str(identity_id), # Langfuse User ID
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
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}")
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(
identity_key=identity_id,
is_authenticated=is_authenticated,
)
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"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: {e}", exc_info=True)
# Trả về lỗi dạng JSON chuẩn với error_code="SYSTEM_ERROR"
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."
}
)
"""
Fashion Q&A Agent Router
FastAPI endpoints cho Fashion Q&A Agent service.
Router chỉ chứa định nghĩa API, logic nằm ở controller.
Note: Rate limit check đã được xử lý trong middleware (CanifaAuthMiddleware)
"""
import logging
from fastapi import APIRouter, BackgroundTasks, HTTPException, Request
from fastapi.responses import JSONResponse
from agent.controller import chat_controller
from agent.models import QueryRequest
from common.message_limit import message_limit_service
from common.cache import redis_cache
from common.rate_limit import rate_limit_service
from config import DEFAULT_MODEL
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")
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.
Note: Rate limit đã được check trong middleware.
"""
# 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: {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=str(identity_id), # Langfuse User ID
background_tasks=background_tasks,
model_name=DEFAULT_MODEL,
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}")
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(
identity_key=identity_id,
is_authenticated=is_authenticated,
)
return {
"status": "success",
"ai_response": result["ai_response"],
"product_ids": result.get("product_ids", []),
"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: {e}", exc_info=True)
# Trả về lỗi dạng JSON chuẩn với error_code="SYSTEM_ERROR"
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."
}
)
@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 json
import logging
import hashlib
import json
import logging
from openai import AsyncOpenAI, OpenAI
......@@ -75,8 +75,11 @@ async def create_embedding_async(text: str) -> list[float]:
try:
# 1. Try Layer 2 Cache
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
elif cached:
logger.warning(f"⚠️ Cached embedding has wrong dimension ({len(cached)}). Regenerating...")
# 2. Call OpenAI
client = get_async_embedding_client()
......@@ -93,7 +96,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.
"""
......@@ -101,28 +104,36 @@ async def create_embeddings_async(texts: list[str]) -> list[list[float]]:
if not texts:
return []
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
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):
use_cache = False
if cached:
loaded_emb = json.loads(cached)
# Validate dimension
if len(loaded_emb) == 1536:
results[i] = loaded_emb
use_cache = True
else:
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
if missed_texts:
......
import asyncio
import logging
import os
import platform
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
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",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Contract AI Service",
description="API for Contract AI Service",
version="1.0.0",
)
@app.on_event("startup")
async def startup_event():
"""Initialize Redis cache on startup."""
await redis_cache.initialize()
logger.info("✅ Redis cache initialized")
middleware_manager.setup(
app,
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)
app.include_router(mock_router)
try:
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
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}")
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...")
print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
print("=" * 60)
ENABLE_RELOAD = False
print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"]
if ENABLE_RELOAD:
os.environ["PYTHONUNBUFFERED"] = "1"
uvicorn.run(
"server:app",
host="0.0.0.0",
port=PORT,
reload=ENABLE_RELOAD,
reload_dirs=reload_dirs,
log_level="info",
)
import asyncio
import logging
import os
import platform
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.tool_prompt_route import router as tool_prompt_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
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",
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
app = FastAPI(
title="Contract AI Service",
description="API for Contract AI Service",
version="1.0.0",
)
@app.on_event("startup")
async def startup_event():
"""Initialize Redis cache on startup."""
await redis_cache.initialize()
logger.info("✅ Redis cache initialized")
middleware_manager.setup(
app,
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)
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):
os.makedirs(static_dir)
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}")
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...")
print("=" * 60)
print(f"📡 REST API: http://localhost:{PORT}")
print(f"📡 Test Chatbot: http://localhost:{PORT}/static/index.html")
print(f"📚 API Docs: http://localhost:{PORT}/docs")
print("=" * 60)
ENABLE_RELOAD = False
print(f"⚠️ Hot reload: {ENABLE_RELOAD}")
reload_dirs = ["common", "api", "agent"]
if ENABLE_RELOAD:
os.environ["PYTHONUNBUFFERED"] = "1"
uvicorn.run(
"server:app",
host="0.0.0.0",
port=PORT,
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