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

refactor : refactor all codebase

parent 5deb9f3e
......@@ -36,6 +36,9 @@ backend/.venv/
backend/__pycache__/
backend/*.pyc
# Preference folder (development/temporary)
preference/
# OS
.DS_Store
Thumbs.db
......
......@@ -3,7 +3,7 @@
.PHONY: up down restart logs build ps clean setup-nginx monitor-up monitor-down
up:
suod docker compose up -d --build
sudo docker compose up -d --build
down:
docker-compose down
......
......@@ -46,47 +46,56 @@ async def chat_controller(
# Init ConversationManager (Singleton)
memory = await get_conversation_manager()
# LOAD HISTORY & Prepare State
# LOAD HISTORY & Prepare State (Optimize: history logic remains solid)
history_dicts = await memory.get_chat_history(user_id, limit=20)
# Convert to BaseMessage objects
history = []
for h in reversed(history_dicts):
if h["is_human"]:
history.append(HumanMessage(content=h["message"]))
else:
history.append(AIMessage(content=h["message"]))
msg_cls = HumanMessage if h["is_human"] else AIMessage
history.append(msg_cls(content=h["message"]))
initial_state, exec_config = _prepare_execution_context(
query=query, user_id=user_id, history=history, images=images
)
try:
# TỐI ƯU: Chạy Graph
result = await graph.ainvoke(initial_state, config=exec_config)
# logger.info(f"Answer result from ai: {result}")
# take ai message from result
final_ai_message = result.get("ai_response")
# TỐI ƯU: Extract IDs từ Tool Messages một lần duy nhất
all_product_ids = _extract_product_ids(result.get("messages", []))
# Extract product IDs from tool messages
product_ids = _extract_product_ids(result.get("messages", []))
# TỐI ƯU: Xử lý AI Response
ai_raw_content = result.get("ai_response").content if result.get("ai_response") else ""
logger.info(f"💾 [RAW AI OUTPUT]:\n{ai_raw_content}")
# Save to DB in background after response is sent
# Chỉ parse JSON một lần để lấy Explicit IDs từ AI (Nếu có)
try:
# Vì json_mode=True, OpenAI sẽ nhả raw JSON, không cần regex rườm rà
ai_json = json.loads(ai_raw_content)
explicit_ids = ai_json.get("product_ids", [])
if explicit_ids:
all_product_ids = list(set(all_product_ids + explicit_ids))
except (json.JSONDecodeError, Exception):
# Nếu AI trả về text thường (hiếm khi xảy ra trong JSON mode) thì ignore
pass
# BACKGROUND TASK: Lưu history nhanh gọn
background_tasks.add_task(
_handle_post_chat_async,
memory=memory,
user_id=user_id,
human_query=query,
ai_msg=final_ai_message,
ai_msg=AIMessage(content=ai_raw_content),
)
logger.info(f"✅ Request completed for user {user_id} with {len(product_ids)} products")
return {
"ai_response": final_ai_message.content if final_ai_message else "",
"product_ids": product_ids,
"ai_response": ai_raw_content,
"product_ids": all_product_ids,
}
except Exception as e:
logger.error(f"💥 Chat error: {e}", exc_info=True)
logger.error(f"💥 Chat error for user {user_id}: {e}", exc_info=True)
raise
......
......@@ -45,7 +45,7 @@ class CANIFAGraph:
self.collection_tools = get_collection_tools() # Vẫn lấy list name để routing
self.retrieval_tools = self.all_tools
self.llm_with_tools = self.llm.bind_tools(self.all_tools)
self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True)
self.system_prompt = get_system_prompt()
self.prompt_template = ChatPromptTemplate.from_messages(
[
......
......@@ -40,7 +40,7 @@ def get_system_prompt() -> str:
✅ **GỌI NGAY KHI:**
- Khách tìm sản phẩm: "Tìm áo...", "Có màu gì...", "Show me..."
- Khách nói rõ yêu cầu: màu sắc, giá, loại, phong cách, giới tính, độ tuổi
- Khách hỏi về sản phẩm cụ thể: "Sản phẩm 8TS24W001 có không?"
- Khách hỏi về sản phẩm cụ thể: "Sản phẩm 8TS24W001 có không?"
❌ **KHÔNG GỌI KHI:**
- Câu hỏi ngoài phạm vi: "Tra đơn hàng", "Đổi trả"
......@@ -50,331 +50,82 @@ def get_system_prompt() -> str:
## 3. CÁCH DÙNG TOOL - QUAN TRỌNG
### A. Phân biệt `query` vs `keywords` + metadata
**Dùng `query` (semantic search - BẮT BUỘC PHẢI CÓ):**
- LUÔN LUÔN PHẢI CÓ trong mọi tool call để cung cấp bối cảnh.
- Mô tả bối cảnh, dịp, hoàn cảnh (Ví dụ: "trang phục đi chơi năng động").
**Dùng `query` (semantic search) KHI:**
- Khách hỏi về **MỤC ĐÍCH, DỊP, HOÀN CẢNH** (không nói tên sản phẩm cụ thể)
- Ví dụ:
- "Đồ đi biển" → query="trang phục đi biển mát mẻ"
- "Áo hẹn hò" → query="trang phục hẹn hò lịch sự"
- "Outfit for interview" → query="professional interview attire"
**Dùng `keywords` + metadata (exact match) KHI:**
**Dùng metadata (keywords, magento_ref_code,...) KHI:**
- Khách nói rõ: **TÊN SẢN PHẨM, MÀU SẮC, GIÁ, SIZE, GIỚI TÍNH**
- Ví dụ:
```python
# ❌ SAI
"Áo polo nam dưới 500k" → query="áo polo nam giá rẻ" # SAI!
# ✅ ĐÚNG
"Áo polo nam dưới 500k" → keywords="áo polo", gender_by_product="male", price_max=500000
# ❌ SAI
"Tìm sp giá dưới 300k" → query="sản phẩm giá rẻ" # SAI!
# ✅ ĐÚNG
"Tìm sp giá dưới 300k" → price_max=300000 # Không cần query!
# ❌ SAI
"Áo màu đen" → query="áo màu đen" # SAI!
# ✅ ĐÚNG
"Áo màu đen" → keywords="áo", master_color="Đen"
```
- **QUY TẮC MÃ SẢN PHẨM:** Mọi loại mã (VD: `8TS...` hoặc `8TS...-SK...`) → Điền vào `magento_ref_code`.
- **QUY TẮC CHẤT LIÊU (material_group):** Chỉ được dùng: `Yarn - Sợi`, `Knit - Dệt Kim`, `Woven - Dệt Thoi`, `Knit/Woven - Dệt Kim/Dệt Thoi`.
### B. Quy tắc vàng
🚫 **KHÔNG BAO GIỜ dùng `query` cho:**
- Giá: "dưới 500k", "giá rẻ", "under 300k"
- Màu: "đen", "xanh", "red", "blue"
- Tên SP: "áo polo", "quần jean", "t-shirt"
🚫 **KHÔNG BAO GIỜ dùng `query` để chứa:**
- Giá: "dưới 500k", "giá rẻ"
- Màu: "đen", "xanh"
- Mã SP: "8TS24W001"
- Giới tính: "nam", "nữ", "male", "female"
✅ **Chỉ dùng `query` cho:**
- Dịp/mục đích: "đi biển", "hẹn hò", "dự tiệc"
- Phong cách: "năng động", "thanh lịch", "casual"
- Hãy đưa các thuộc tính này vào đúng field tương ứng (price_max, master_color, magento_ref_code).
## 4. XỬ LÝ KẾT QUẢ TOOL
🚨 **QUY TẮC QUAN TRỌNG:**
**Sau khi gọi tool:**
1. **Có sản phẩm (count > 0)?**
- ✅ DỪNG NGAY, show sản phẩm cho khách
- ❌ KHÔNG GỌI TOOL LẦN 2!
- Chờ khách phản hồi
2. **Không có sản phẩm (count = 0)?**
- Lần 1 thất bại → Thử lại 1 LẦN NỮA với filter rộng hơn
- Lần 2 vẫn thất bại → DỪNG, gợi ý thay thế cho khách
3. **Lỗi (status = "error")?**
- DỪNG NGAY, xin lỗi khách, không retry
**Ví dụ:**
```
❌ SAI - KHÔNG LÀM THẾ NÀY:
1. Gọi tool(keywords="áo polo", master_color="Xanh", price_max=300000)
2. Có 3 sản phẩm
3. Gọi tool lại(keywords="áo", price_max=500000) ← SAI! Đã có kết quả rồi
4. Có thêm sản phẩm
5. Gọi tiếp... ← VÔ HẠN!
✅ ĐÚNG - LÀM THẾ NÀY:
1. Gọi tool(keywords="áo polo", master_color="Xanh", price_max=300000)
2. Có 3 sản phẩm
3. DỪNG → Show 3 sản phẩm cho khách ✅
4. Đợi khách feedback
```
**Giới hạn:**
- **Tối đa 2 lần gọi tool** cho 1 yêu cầu (1 lần chính + 1 lần retry nếu không có kết quả)
- ✅ DỪNG NGAY, show sản phẩm cho khách. KHÔNG GỌI TOOL LẦN 2!
2. **Không có sản phẩm?**
- Có thể thử lại 1 LẦN NỮA với filter rộng hơn.
**Giới hạn:** Tối đa 2 lần gọi tool cho 1 yêu cầu.
---
# ĐỊNH DẠNG ĐẦU RA (OUTPUT FORMAT)
## Format JSON Response
Bạn PHẢI trả về JSON với cấu trúc:
```json
Bạn PHẢI trả về JSON nguyên bản (không bọc trong markdown backticks):
{{
"ai_response": "Câu trả lời của bạn ở đây (bằng ngôn ngữ của khách)",
"ai_response": "Câu trả lời của bạn ở đây (bằng ngôn ngữ của khách, format Markdown cực kỳ chi tiết)",
"product_ids": ["mã_sp_1", "mã_sp_2", "mã_sp_3"]
}}
```
**Lưu ý:**
- `ai_response`: Câu trả lời đầy đủ, format markdown
- `product_ids`: List các `internal_ref_code` của sản phẩm được nhắc đến
- Nếu không có sản phẩm → `[]`
- Nếu có sản phẩm → list mã SP ["8TS24W001", "1DS24C015"]
## Format Hiển Thị Sản Phẩm
**Khi show sản phẩm trong `ai_response`, PHẢI bao gồm:**
- Tên sản phẩm
- **Mã SP** (internal_ref_code): (Mã: XXX)
- **Giá** (sale_price): định dạng 299,000 VNĐ
- **Màu sắc**: Liệt kê tất cả màu available
- **Chất liệu**: material_group hoặc material
- **Link sản phẩm**: product_web_url (clickable)
- **Hình ảnh**: product_image_url hoặc thumbnail
**Template:**
```
🔹 **Mã sản phẩm [thứ nhất/thứ hai/thứ ba]: [internal_ref_code] - [Tên sản phẩm]**
• Kiểu dáng: [Mô tả thiết kế, form áo/quần]
• Chất liệu: [material_group hoặc material - mô tả đặc tính]
• Màu sắc có sẵn: [color1], [color2], [color3]
• Giá bán: [price] VNĐ
• Đặc điểm nổi bật: [Điểm mạnh của sản phẩm, phù hợp cho ai/dịp gì]
🔗 Xem chi tiết: [product_web_url]
📸 Hình ảnh: [product_image_url]
```
**Quy tắc:**
- Group sản phẩm cùng mã (cùng internal_ref_code) khác màu vào 1 block
- Luôn format giá có dấu phấy: 299,000 VNĐ
- Tối đa 5 sản phẩm/lần
- Highlight điểm đặc biệt (season, style, fitting)
---
# BỐI CẢNH (CONTEXT)
## Xử Lý Trường Hợp Đặc Biệt
**1. Không có kết quả:**
- Thừa nhận yêu cầu của khách
- Gợi ý thay thế gần nhất (màu khác, style khác, giá khác)
- Hỏi có muốn tìm kiếm khác không
**2. Yêu cầu không rõ ràng:**
- Hỏi làm rõ để extract parameters chính xác
- Gợi ý những gì bạn nghĩ họ muốn
- Ví dụ: "Bạn nói 'đồ đi chơi' là muốn áo, quần hay váy bạn nhỉ?"
**3. Câu hỏi ngoài phạm vi:**
- Đơn hàng, ship, đổi trả → "Vui lòng liên hệ CSKH hoặc web"
- Thương hiệu khác → "Mình chỉ tư vấn sản phẩm CANIFA thôi nhé"
## Quy Tắc Vàng
1. ✅ Gọi tool NGAY (không nói "để em kiểm tra")
2. ✅ Trả lời như thể bạn đã biết sản phẩm
3. ✅ **DỪNG sau khi có kết quả** - show ngay, không gọi lại
4. ✅ **Tối đa 2 lần gọi tool** (1 lần chính + 1 retry)
5. ✅ Group sản phẩm trùng theo màu/variant
6. ✅ Ngắn gọn, súc tích
7. ✅ Tư vấn chân thành > bán ép
8. ❌ Không gọi tool nhiều lần khi đã có SP
9. ❌ Không list cùng SP khác màu thành nhiều item
10. ❌ Không dùng "để em check", "chờ em xem"
---
# VÍ DỤ (EXAMPLES)
## Example 1: Chào hỏi
**Input:**
```
Khách: "Chào em"
```
## Example 4: Chào hỏi
**Input:** "Chào em"
**Output:**
```json
{{
"ai_response": "Chào anh/chị ạ! Em là CiCi - chuyên viên tư vấn thời trang CANIFA ✨\\n\\nHôm nay anh/chị cần em tìm gì ạ? Em có thể giúp:\\n- Tìm sản phẩm theo yêu cầu (áo, quần, váy...)\\n- Tư vấn phối đồ theo dịp\\n- Lọc theo màu sắc, giá, size\\n\\nCứ thoải mái nói với em nhé! 💖",
"ai_response": "Chào anh/chị ạ! Em là CiCi...",
"product_ids": []
}}
```
## Example 2: Tìm sản phẩm có kết quả
# VÍ DỤ (EXAMPLES)
**Input:**
```
Khách: "Tìm áo polo nam dưới 400k"
```
## Example 1: Tìm sản phẩm đơn giản
**Input:** "Áo polo nam dưới 400k"
**Tool Call:**
`data_retrieval_tool(searches=[{{"query": "áo polo nam giá dưới 400k", "keywords": "áo polo", "gender_by_product": "male", "price_max": 400000}}])`
## Example 2: So sánh & Phối đồ
**Input:** "So sánh áo thun đen và sơ mi trắng dưới 500k"
**Tool Call:**
```python
data_retrieval_tool(
keywords="áo polo",
gender_by_product="male",
price_max=400000
)
```
**Tool Result:**
```json
{{
"status": "success",
"count": 2,
"products": [
{{
"internal_ref_code": "8TS24W001",
"name": "Áo Polo Nam Cotton Premium",
"sale_price": 349000,
"available_colors": ["Đen", "Trắng", "Xanh navy"],
"material_group": "Knit - Dệt Kim",
"product_web_url": "https://canifa.com/ao-polo-8ts24w001",
"product_image_url": "https://cdn.canifa.com/8ts24w001.jpg"
}},
{{
"internal_ref_code": "8PL24S015",
"name": "Áo Polo Pique Classic",
"sale_price": 299000,
"available_colors": ["Xám", "Xanh lá"],
"material_group": "Cotton Pique",
"product_web_url": "https://canifa.com/ao-polo-8pl24s015",
"product_image_url": "https://cdn.canifa.com/8pl24s015.jpg"
}}
]
}}
```
**Output:**
```json
{{
"ai_response": "Dạ em tìm được 2 mẫu áo polo nam phù hợp với anh ạ:\\n\\n🔹 **Mã sản phẩm thứ nhất: 8TS24W001 - Áo Polo Nam Cotton Premium**\\n • Kiểu dáng: Polo cổ bẻ classic, form regular fit thoải mái\\n • Chất liệu: Knit - Dệt Kim cao cấp, mềm mại, thấm hút tốt\\n • Màu sắc có sẵn: Đen, Trắng, Xanh navy\\n • Giá bán: 349,000 VNĐ\\n • Đặc điểm nổi bật: Thiết kế basic dễ phối đồ, chất cotton cao cấp mặc thoải mái cả ngày\\n 🔗 Xem chi tiết: https://canifa.com/ao-polo-8ts24w001\\n 📸 Hình ảnh: https://cdn.canifa.com/8ts24w001.jpg\\n\\n🔹 **Mã sản phẩm thứ hai: 8PL24S015 - Áo Polo Pique Classic**\\n • Kiểu dáng: Polo classic, dáng suông thanh lịch\\n • Chất liệu: Cotton Pique có độ thoáng khí cao, bền đẹp\\n • Màu sắc có sẵn: Xám, Xanh lá\\n • Giá bán: 299,000 VNĐ\\n • Đặc điểm nổi bật: Phong cách lịch sự, phù hợp cả văn phòng và đi chơi\\n 🔗 Xem chi tiết: https://canifa.com/ao-polo-8pl24s015\\n 📸 Hình ảnh: https://cdn.canifa.com/8pl24s015.jpg\\n\\nCả 2 mẫu đều nằm trong tầm giá của anh. Anh thích kiểu nào hơn ạ? Em có thể tư vấn thêm! 😊",
"product_ids": ["8TS24W001", "8PL24S015"]
}}
```
## Example 3: Không có kết quả - Retry 1 lần
**Input:**
```
Khách: "Có áo màu hồng fuxia không?"
```
**Tool Call 1:**
```python
data_retrieval_tool(keywords="áo", master_color="Hồng fuxia")
```
**Tool Result 1:**
```json
{{
"status": "no_results",
"count": 0,
"products": []
}}
```
**Tool Call 2 (Retry - broader):**
```python
data_retrieval_tool(keywords="áo", master_color="Hồng")
```
**Tool Result 2:**
```json
{{
"status": "success",
"count": 1,
"products": [
{{
"internal_ref_code": "1TS24S089",
"name": "Áo Thun Hồng Pastel",
"sale_price": 199000,
"available_colors": ["Hồng pastel", "Hồng đào"],
"material": "Cotton 100%",
"product_web_url": "https://canifa.com/ao-thun-1ts24s089",
"product_image_url": "https://cdn.canifa.com/1ts24s089.jpg"
}}
]
}}
```
**Output:**
```json
{{
"ai_response": "Ối, màu hồng fuxia đang hết hàng rồi bạn ơi 😭 Nhưng không sao, em có mẫu màu hồng siêu xinh này:\\n\\n🔹 **Mã sản phẩm: 1TS24S089 - Áo Thun Hồng Pastel**\\n • Kiểu dáng: Áo thun cổ tròn basic, form fitted vừa vặn\\n • Chất liệu: Cotton 100% mềm mại, thoáng mát\\n • Màu sắc có sẵn: Hồng pastel, Hồng đào\\n • Giá bán: 199,000 VNĐ\\n • Đặc điểm nổi bật: Màu hồng nhẹ nhàng tôn da, dễ phối đồ hàng ngày\\n 🔗 Xem chi tiết: https://canifa.com/ao-thun-1ts24s089\\n 📸 Hình ảnh: https://cdn.canifa.com/1ts24s089.jpg\\n\\nBạn thích không? Hoặc em có thể tìm màu khác cho bạn! 💖",
"product_ids": ["1TS24S089"]
}}
```
## Example 4: Câu hỏi ngoài phạm vi
**Input:**
```
Khách: "Đơn hàng của tôi đến bao giờ?"
```
**Output:**
```json
{{
"ai_response": "Em chỉ chuyên tư vấn về sản phẩm thôi bạn ạ. Để kiểm tra đơn hàng, bạn vui lòng:\\n- Truy cập website CANIFA\\n- Hoặc liên hệ bộ phận CSKH: 1800 1009\\n\\nHọ sẽ hỗ trợ bạn ngay! Em có thể giúp bạn tìm sản phẩm mới nếu bạn cần nhé! 😊",
"product_ids": []
}}
```
## Example 5: Semantic search (dịp/mục đích)
**Input:**
```
Khách: "Tìm đồ đi biển mùa hè"
data_retrieval_tool(searches=[
{{"query": "áo thun nam màu đen dưới 500k", "keywords": "áo thun", "master_color": "Đen", "gender_by_product": "male", "price_max": 500000}},
{{"query": "áo sơ mi nam trắng dưới 500k", "keywords": "áo sơ mi", "master_color": "Trắng", "gender_by_product": "male", "price_max": 500000}}
])
```
## Example 3: Tìm theo chất liệu (Mapping)
**Input:** "Tìm đồ vải len cho nữ"
**Tool Call:**
```python
data_retrieval_tool(query="trang phục đi biển mùa hè mát mẻ")
```
**Output:** (Tương tự Example 2, show products với context đi biển)
`data_retrieval_tool(searches=[{{"query": "trang phục vải len nữ", "gender_by_product": "female", "material_group": "Yarn - Sợi"}}])`
---
# ĐẦU VÀO (INPUT)
Tin nhắn từ khách hàng (tiếng Việt hoặc tiếng Anh)
# BỐI CẢNH (CONTEXT)
- Luôn trả lời đầy đủ, trau chuốt, không bỏ sót thông tin.
- Sử dụng Markdown để trình bày bảng hoặc list sản phẩm đẹp mắt.
- Nhắc đúng mã sản phẩm trong list `product_ids`.
---"""
......
......@@ -12,7 +12,7 @@ logger = logging.getLogger(__name__)
@tool
async def collect_customer_info(name: str, phone: str, email: str | None = None) -> str:
async def collect_customer_info(name: str, phone: str, email: str | None) -> str:
"""
Sử dụng tool này để ghi lại thông tin khách hàng khi họ muốn tư vấn sâu hơn,
nhận khuyến mãi hoặc đăng ký mua hàng.
......
......@@ -3,6 +3,7 @@ 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
from decimal import Decimal
......@@ -13,7 +14,7 @@ from pydantic import BaseModel, Field
from agent.tools.product_search_helpers import build_starrocks_query
from common.starrocks_connection import StarRocksConnection
from langsmith import traceable
# from langsmith import traceable
logger = logging.getLogger(__name__)
......@@ -27,184 +28,136 @@ class DecimalEncoder(json.JSONEncoder):
return super().default(obj)
class SearchParams(BaseModel):
"""Cấu trúc tham số tìm kiếm mà Agent phải cung cấp, map trực tiếp với Database."""
class SearchItem(BaseModel):
"""Cấu trúc một mục tìm kiếm đơn lẻ trong Multi-Search."""
query: str | None = Field(
None,
query: str = Field(
...,
description="Câu hỏi/mục đích tự do của user (đi chơi, dự tiệc, phỏng vấn,...) - dùng cho Semantic Search",
)
keywords: str | None = Field(
None, description="Từ khóa kỹ thuật cụ thể (áo polo, quần jean,...) - dùng cho LIKE search"
..., description="Từ khóa sản phẩm cụ thể (áo polo, quần jean,...) - dùng cho LIKE search"
)
internal_ref_code: str | None = Field(None, description="Mã sản phẩm (ví dụ: 1TS23S012)")
product_color_code: str | None = Field(None, description="Mã màu sản phẩm (ví dụ: 1TS23S012-SK010)")
product_line_vn: str | None = Field(None, description="Dòng sản phẩm (Áo phông, Quần short,...)")
gender_by_product: str | None = Field(None, description="Giới tính: male, female")
age_by_product: str | None = Field(None, description="Độ tuổi: adult, kids, baby, others")
master_color: str | None = Field(None, description="Màu sắc chính (Đen/ Black, Trắng/ White,...)")
material_group: str | None = Field(None, description="Nhóm chất liệu (Knit - Dệt Kim, Woven - Dệt Thoi,...)")
season: str | None = Field(None, description="Mùa (Spring Summer, Autumn Winter)")
style: str | None = Field(None, description="Phong cách (Basic Update, Fashion,...)")
fitting: str | None = Field(None, description="Form dáng (Regular, Slim, Loose,...)")
form_neckline: str | None = Field(None, description="Kiểu cổ (Crew Neck, V-neck,...)")
form_sleeve: str | None = Field(None, description="Kiểu tay (Short Sleeve, Long Sleeve,...)")
price_min: float | None = Field(None, description="Giá thấp nhất")
price_max: float | None = Field(None, description="Giá cao nhất")
action: str = Field("search", description="Hành động: 'search' (tìm kiếm) hoặc 'visual_search' (phân tích ảnh)")
@tool(args_schema=SearchParams)
@traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(
action: str = "search",
query: str | None = None,
keywords: str | None = None,
internal_ref_code: str | None = None,
product_color_code: str | None = None,
product_line_vn: str | None = None,
gender_by_product: str | None = None,
age_by_product: str | None = None,
master_color: str | None = None,
material_group: str | None = None,
season: str | None = None,
style: str | None = None,
fitting: str | None = None,
form_neckline: str | None = None,
form_sleeve: str | None = None,
price_min: float | None = None,
price_max: float | None = None,
) -> str:
"""
Tìm kiếm sản phẩm CANIFA - Phân biệt rõ giữa Semantic Search và Metadata Filter.
magento_ref_code: str | None = Field(
..., description="Mã sản phẩm hoặc mã màu/SKU (Ví dụ: 8TS24W001 hoặc 8TS24W001-SK010)."
)
product_line_vn: str | None = Field(..., description="Dòng sản phẩm (Áo phông, Quần short,...)")
gender_by_product: str | None = Field(..., description="Giới tính: male, female")
age_by_product: str | None = Field(..., description="Độ tuổi: adult, kids, baby, others")
master_color: str | None = Field(..., description="Màu sắc chính (Đen/ Black, Trắng/ White,...)")
material_group: str | None = Field(
...,
description="Nhóm chất liệu. BẮT BUỘC dùng đúng: 'Yarn - Sợi', 'Knit - Dệt Kim', 'Woven - Dệt Thoi', 'Knit/Woven - Dệt Kim/Dệt Thoi'.",
)
season: str | None = Field(..., description="Mùa (Spring Summer, Autumn Winter)")
style: str | None = Field(..., description="Phong cách (Basic Update, Fashion,...)")
fitting: str | None = Field(..., description="Form dáng (Regular, Slim, Loose,...)")
form_neckline: str | None = Field(..., description="Kiểu cổ (Crew Neck, V-neck,...)")
form_sleeve: str | None = Field(..., description="Kiểu tay (Short Sleeve, Long Sleeve,...)")
price_min: float | None = Field(..., description="Giá thấp nhất")
price_max: float | None = Field(..., description="Giá cao nhất")
action: str = Field(..., description="Hành động: 'search' (tìm kiếm) hoặc 'visual_search' (phân tích ảnh)")
⚠️ QUAN TRỌNG - KHI NÀO DÙNG GÌ:
1️⃣ DÙNG 'query' (Semantic Search - Vector Embedding):
✅ Khi user hỏi về MỤC ĐÍCH, BỐI CẢNH, PHONG CÁCH SỐNG
✅ Câu hỏi trừu tượng, không có từ khóa sản phẩm rõ ràng
✅ Ví dụ:
- "Tìm đồ đi biển mát mẻ" → query="đồ đi biển mát mẻ"
- "Quần áo cho buổi hẹn hò" → query="trang phục hẹn hò lịch sự"
- "Đồ mặc dự tiệc sang trọng" → query="trang phục dự tiệc sang trọng"
- "Outfit cho mùa đông ấm áp" → query="trang phục mùa đông ấm áp"
2️⃣ DÙNG 'keywords' + METADATA FILTERS (Exact Match):
✅ Khi user hỏi về THUỘC TÍNH CỤ THỂ của sản phẩm
✅ Có TÊN SẢN PHẨM rõ ràng (áo polo, quần jean, váy liền,...)
✅ Có GIÁ, MÀU SẮC, SIZE, MÃ SẢN PHẨM
✅ Ví dụ:
- "Áo polo nam" → keywords="áo polo", gender_by_product="male"
- "Quần jean nữ dưới 500k" → keywords="quần jean", gender_by_product="female", price_max=500000
- "Áo thun đen giá rẻ" → keywords="áo thun", master_color="Đen", price_max=200000
- "Sản phẩm 8TS24W001" → internal_ref_code="8TS24W001"
- "Váy liền cho bé gái màu hồng" → keywords="váy liền", gender_by_product="female", age_by_product="others", master_color="Hồng"
🚫 KHÔNG BAO GIỜ DÙNG 'query' CHO:
- Câu hỏi về GIÁ (dưới 400k, từ 200k-500k, giá rẻ,...)
- Câu hỏi về MÀU SẮC cụ thể (đen, trắng, đỏ,...)
- Câu hỏi về TÊN SẢN PHẨM (áo polo, quần jean, váy liền,...)
- Câu hỏi về MÃ SẢN PHẨM (8TS24W001, 1DS24C015,...)
💡 KẾT HỢP CẢ HAI (Hybrid):
Chỉ dùng khi câu hỏi vừa có BỐI CẢNH trừu tượng, vừa có THUỘC TÍNH cụ thể:
- "Tìm áo khoác ấm áp cho mùa đông, giá dưới 1 triệu"
→ query="áo khoác ấm áp mùa đông", price_max=1000000
📝 VÍ DỤ CHI TIẾT:
Example 1 - Semantic Search (MỤC ĐÍCH):
User: "Tìm đồ đi làm chuyên nghiệp"
Tool: data_retrieval_tool(query="trang phục công sở chuyên nghiệp")
Example 2 - Metadata Filter (THUỘC TÍNH):
User: "Cho tôi xem áo polo nam dưới 400k"
Tool: data_retrieval_tool(keywords="áo polo", gender_by_product="male", price_max=400000)
Example 3 - Metadata Only (GIÁ + MÀU):
User: "Quần short đen giá rẻ"
Tool: data_retrieval_tool(keywords="quần short", master_color="Đen", price_max=300000)
Example 4 - Exact Match (MÃ SẢN PHẨM):
User: "Cho tôi thông tin sản phẩm 8TS24W001"
Tool: data_retrieval_tool(internal_ref_code="8TS24W001")
Example 5 - Hybrid (BỐI CẢNH + FILTER):
User: "Tìm áo khoác ấm cho mùa đông, cho bé trai, từ 200k-500k"
Tool: data_retrieval_tool(query="áo khoác ấm áp mùa đông", age_by_product="others", gender_by_product="male", price_min=200000, price_max=500000)
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")
@tool(args_schema=MultiSearchParams)
# @traceable(run_type="tool", name="data_retrieval_tool")
async def data_retrieval_tool(searches: list[SearchItem]) -> str:
"""
try:
# 1. Log & Prepare Params
# 1. Log & Prepare Params
params = SearchParams(
action=action,
query=query,
keywords=keywords,
internal_ref_code=internal_ref_code,
product_color_code=product_color_code,
product_line_vn=product_line_vn,
gender_by_product=gender_by_product,
age_by_product=age_by_product,
master_color=master_color,
material_group=material_group,
season=season,
style=style,
fitting=fitting,
form_neckline=form_neckline,
form_sleeve=form_sleeve,
price_min=price_min,
price_max=price_max,
)
_log_agent_call(params)
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 query).
# 2. Prepare Vector (Async) if needed
query_vector = None
if query:
from common.embedding_service import create_embedding_async
💡 ĐIỂM ĐẶC BIỆT:
Công cụ này cho phép thực hiện NHIỀU truy vấn tìm kiếm CÙNG LÚC.
Hãy dùng nó khi cần SO SÁNH sản phẩm hoặc tìm trọn bộ OUTFIT (mix & match).
query_vector = await create_embedding_async(query)
⚠️ QUAN TRỌNG - KHI NÀO DÙNG GÌ:
# 3. Execute Search (Async)
sql = build_starrocks_query(params, query_vector=query_vector)
1️⃣ DÙNG 'query' (Semantic Search - BUỘC PHẢI CÓ):
- Áp dụng cho mọi lượt search để cung cấp bối cảnh (context).
- Ví dụ: "áo thun nam đi biển", "quần tây công sở", "đồ cho bé màu xanh"...
2️⃣ DÙNG METADATA FILTERS (Exact/Partial Match):
- Khi khách nói rõ THUỘC TÍNH: Màu sắc, giá, giới tính, độ tuổi, mã sản phẩm.
- **QUY TẮC MÃ SẢN PHẨM:** Mọi loại mã (VD: `8TS...` hoặc `8TS...-SK...`) → Điền vào `magento_ref_code`.
- **QUY TẮC CHẤT LIÊU (material_group):** Chỉ dùng: `Yarn - Sợi`, `Knit - Dệt Kim`, `Woven - Dệt Thoi`, `Knit/Woven - Dệt Kim/Dệt Thoi`.
📝 VÍ DỤ CHI TIẾT (Single Search):
- Example 1: searches=[{"query": "áo polo nam giá dưới 400k", "keywords": "áo polo", "gender_by_product": "male", "price_max": 400000}]
- Example 2: searches=[{"query": "sản phẩm mã 8TS24W001", "magento_ref_code": "8TS24W001"}]
🚀 VÍ DỤ CẤP CAO (Multi-Search Parallel):
- Example 3 - So sánh: "So sánh áo thun nam đen và áo sơ mi trắng dưới 500k"
Tool Call: searches=[
{"query": "áo thun nam màu đen dưới 500k", "keywords": "áo thun", "master_color": "Đen", "gender_by_product": "male", "price_max": 500000},
{"query": "áo sơ mi nam trắng dưới 500k", "keywords": "áo sơ mi", "master_color": "Trắng", "gender_by_product": "male", "price_max": 500000}
]
- Example 4 - Phối đồ: "Tìm cho mình một cái quần jean và một cái áo khoác để đi chơi"
Tool Call: searches=[
{"query": "quần jean đi chơi năng động", "keywords": "quần jean"},
{"query": "áo khoác đi chơi năng động", "keywords": "áo khoác"}
]
- Example 5 - Cả gia đình: "Tìm áo phông màu xanh cho bố, mẹ và bé trai"
Tool Call: searches=[
{"query": "áo phông nam người lớn màu xanh", "keywords": "áo phông", "master_color": "Xanh", "gender_by_product": "male", "age_by_product": "adult"},
{"query": "áo phông nữ người lớn màu xanh", "keywords": "áo phông", "master_color": "Xanh", "gender_by_product": "female", "age_by_product": "adult"},
{"query": "áo phông bé trai màu xanh", "keywords": "áo phông", "master_color": "Xanh", "gender_by_product": "male", "age_by_product": "others"}
]
"""
try:
db = StarRocksConnection()
products = await db.execute_query_async(sql)
if not products:
return _handle_no_results(query, keywords)
# 0. Log input parameters (Đúng ý bro)
logger.info(f"📥 [Tool Input] data_retrieval_tool received {len(searches)} items:")
for idx, item in enumerate(searches):
logger.info(f" 🔹 Item [{idx}]: {item.dict(exclude_none=True)}")
# 1. Tạo tasks chạy song song (Parallel)
tasks = []
for item in searches:
tasks.append(_execute_single_search(db, item))
logger.info(f"🚀 [Parallel Search] Executing {len(searches)} queries simultaneously...")
results = await asyncio.gather(*tasks)
# 2. 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
})
# 4. Format Results
clean_products = _format_product_results(products)
return json.dumps(
{"status": "success", "count": len(clean_products), "products": clean_products},
{"status": "success", "results": combined_results},
ensure_ascii=False,
cls=DecimalEncoder,
cls=DecimalEncoder
)
except Exception as e:
logger.error(f"Error in data_retrieval_tool: {e}")
logger.error(f"Error in Multi-Search data_retrieval_tool: {e}")
return json.dumps({"status": "error", "message": str(e)})
def _log_agent_call(params: SearchParams):
"""Log parameters for debugging."""
filtered_params = {k: v for k, v in params.dict().items() if v is not None}
logger.info(f"📋 Agent Tool Call - data_retrieval_tool: {json.dumps(filtered_params, ensure_ascii=False)}")
def _handle_no_results(query: str | None, keywords: str | None) -> str:
"""Return standardized no-results message."""
logger.warning(f"No products found for search: query={query}, keywords={keywords}")
return json.dumps(
{
"status": "no_results",
"message": "Không tìm thấy sản phẩm nào phù hợp với yêu cầu. Vui lòng thử lại với từ khóa hoặc bộ lọc khác.",
},
ensure_ascii=False,
)
async def _execute_single_search(db: StarRocksConnection, item: SearchItem) -> list[dict]:
"""Thực thi một search query đơn lẻ (Async)."""
try:
# build_starrocks_query handles embedding internally (async)
sql = await build_starrocks_query(item)
products = await db.execute_query_async(sql)
return _format_product_results(products)
except Exception as e:
logger.error(f"Single search error for item {item}: {e}")
return []
def _format_product_results(products: list[dict]) -> list[dict]:
"""Filter and format product fields for the agent."""
"""Lọc và format kết quả trả về cho Agent."""
allowed_fields = {
"internal_ref_code",
"magento_ref_code",
......@@ -244,3 +197,4 @@ def _format_product_results(products: list[dict]) -> list[dict]:
"product_web_material",
}
return [{k: v for k, v in p.items() if k in allowed_fields} for p in products[:5]]
import logging
from common.embedding_service import create_embedding
from common.embedding_service import create_embedding_async
logger = logging.getLogger(__name__)
......@@ -14,7 +14,7 @@ def _get_where_clauses(params) -> list[str]:
"""Xây dựng danh sách các điều kiện lọc từ params."""
clauses = []
clauses.extend(_get_price_clauses(params))
clauses.extend(_get_exact_match_clauses(params))
clauses.extend(_get_metadata_clauses(params))
clauses.extend(_get_special_clauses(params))
return clauses
......@@ -31,12 +31,23 @@ def _get_price_clauses(params) -> list[str]:
return clauses
def _get_exact_match_clauses(params) -> list[str]:
"""Các trường lọc chính xác (Exact match)."""
def _get_metadata_clauses(params) -> list[str]:
"""Xây dựng điều kiện lọc từ metadata (Phối hợp Exact và Partial)."""
clauses = []
exact_filters = [
# 1. Exact Match (Giới tính, Độ tuổi) - Các trường này cần độ chính xác tuyệt đối
exact_fields = [
("gender_by_product", "gender_by_product"),
("age_by_product", "age_by_product"),
]
for param_name, col_name in exact_fields:
val = getattr(params, param_name, None)
if val:
clauses.append(f"{col_name} = '{_escape(val)}'")
# 2. Partial Match (LIKE) - Giúp map text linh hoạt hơn (Chất liệu, Dòng SP, Phong cách...)
# Cái này giúp map: "Yarn" -> "Yarn - Sợi", "Knit" -> "Knit - Dệt Kim"
partial_fields = [
("season", "season"),
("material_group", "material_group"),
("product_line_vn", "product_line_vn"),
......@@ -45,21 +56,24 @@ def _get_exact_match_clauses(params) -> list[str]:
("form_neckline", "form_neckline"),
("form_sleeve", "form_sleeve"),
]
for param_name, col_name in exact_filters:
for param_name, col_name in partial_fields:
val = getattr(params, param_name, None)
if val:
clauses.append(f"{col_name} = '{_escape(val)}'")
v = _escape(val).lower()
# Dùng LOWER + LIKE để cân mọi loại ký tự thừa hoặc hoa/thường
clauses.append(f"LOWER({col_name}) LIKE '%{v}%'")
return clauses
def _get_special_clauses(params) -> list[str]:
"""Các trường hợp đặc biệt: Mã sản phẩm, Màu sắc."""
clauses = []
# Mã sản phẩm
ref_code = getattr(params, "internal_ref_code", None)
if ref_code:
r = _escape(ref_code)
clauses.append(f"(internal_ref_code = '{r}' OR magento_ref_code = '{r}')")
# Mã sản phẩm / SKU
m_code = getattr(params, "magento_ref_code", None)
if m_code:
m = _escape(m_code)
clauses.append(f"(magento_ref_code = '{m}' OR internal_ref_code = '{m}')")
# Màu sắc
color = getattr(params, "master_color", None)
......@@ -69,7 +83,7 @@ def _get_special_clauses(params) -> list[str]:
return clauses
def build_starrocks_query(params, query_vector: list[float] | None = None) -> str:
async def build_starrocks_query(params, query_vector: list[float] | None = None) -> str:
"""
Build SQL Hybrid tối ưu:
1. Pre-filtering (Metadata)
......@@ -80,7 +94,7 @@ def build_starrocks_query(params, query_vector: list[float] | None = None) -> st
# --- Process vector in query field ---
query_text = getattr(params, "query", None)
if query_text and query_vector is None:
query_vector = create_embedding(query_text)
query_vector = await create_embedding_async(query_text)
# --- Build filter clauses ---
where_clauses = _get_where_clauses(params)
......@@ -144,7 +158,7 @@ def build_starrocks_query(params, query_vector: list[float] | None = None) -> st
FROM top_sku_candidates
GROUP BY internal_ref_code
ORDER BY max_score DESC
LIMIT 5
LIMIT 10
""" # noqa: S608
else:
# FALLBACK: Keyword search - MAXIMALLY OPTIMIZED (No CTE overhead)
......@@ -181,7 +195,7 @@ def build_starrocks_query(params, query_vector: list[float] | None = None) -> st
GROUP BY internal_ref_code
HAVING COUNT(*) > 0
ORDER BY sale_price ASC
LIMIT 5
LIMIT 10
""" # noqa: S608
logger.info(f"📊 Query Mode: {'Vector' if query_vector else 'Keyword'}")
......
"""
Test API Routes - Tất cả endpoints cho testing (isolated)
KHÔNG ĐỘNG VÀO chatbot_route.py chính!
"""
import asyncio
import logging
import random
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from agent.models import QueryRequest
from common.load_test_manager import get_load_test_manager
router = APIRouter(prefix="/test", tags=["Testing & Load Test"])
logger = logging.getLogger(__name__)
# ==================== MOCK CHAT ENDPOINT ====================
@router.post("/chat-mock", summary="Mock Chat API (for Load Testing)")
async def mock_chat(req: QueryRequest):
"""
Endpoint MOCK để test performance KHÔNG tốn tiền OpenAI.
Trả về response giả lập với latency ngẫu nhiên.
⚠️ CHỈ DÙNG CHO LOAD TESTING!
"""
# Giả lập latency của real API (100-500ms)
await asyncio.sleep(random.uniform(0.1, 0.5))
# Mock responses
mock_responses = [
"Dạ em đã tìm được một số mẫu áo sơ mi nam đẹp cho anh/chị ạ. Anh/chị có thể xem các sản phẩm sau đây.",
"Em xin gợi ý một số mẫu áo thun nam phù hợp với yêu cầu của anh/chị.",
"Dạ, em có tìm thấy một số mẫu quần jean nam trong khoảng giá anh/chị yêu cầu ạ.",
"Em xin giới thiệu các mẫu áo khoác nam đang có khuyến mãi tốt ạ.",
"Anh/chị có thể tham khảo các mẫu giày thể thao nam đang được ưa chuộng nhất.",
]
# Mock product IDs
mock_product_ids = [
f"MOCK_PROD_{random.randint(1000, 9999)}"
for _ in range(random.randint(2, 5))
]
return {
"status": "success",
"ai_response": random.choice(mock_responses),
"product_ids": mock_product_ids,
"_mock": True, # Flag để biết đây là mock response
"_latency_ms": random.randint(100, 500)
}
@router.post("/db-search", summary="DB Search Mock (Test StarRocks Performance)")
async def mock_db_search(req: QueryRequest):
"""
Endpoint để test PERFORMANCE của StarRocks DB query.
Hỗ trợ Multi-Search (Parallel).
"""
from agent.tools.data_retrieval_tool import data_retrieval_tool
try:
# Mock Multi-Search call (Parallel)
tool_result = await data_retrieval_tool.ainvoke({
"searches": [
{
"keywords": "áo sơ mi",
"gender_by_product": "male",
"price_max": 500000
},
{
"keywords": "quần jean",
"gender_by_product": "male",
"price_max": 800000
}
]
})
# Parse result
import json
result_data = json.loads(tool_result)
# Collect all product IDs from all search results
all_product_ids = []
if result_data.get("status") == "success":
for res in result_data.get("results", []):
ids = [p.get("internal_ref_code", "") for p in res.get("products", [])]
all_product_ids.extend(ids)
return {
"status": "success",
"ai_response": "Kết quả Multi-Search Parallel từ DB",
"product_ids": list(set(all_product_ids)),
"_db_test": True,
"_queries_count": len(result_data.get("results", [])),
"_total_products": len(all_product_ids)
}
except Exception as e:
logger.error(f"DB multi-search error: {e}")
return {
"status": "error",
"ai_response": f"Lỗi: {str(e)}",
"product_ids": [],
"_error": str(e)
}
# ==================== LOAD TEST CONTROL ====================
class StartTestRequest(BaseModel):
"""Request body để start test"""
target_url: str = Field(default="http://localhost:5000", description="Base URL của target")
num_users: int = Field(default=10, ge=1, le=1000, description="Số lượng concurrent users")
spawn_rate: int = Field(default=2, ge=1, le=100, description="Tốc độ spawn users (users/second)")
duration_seconds: int = Field(default=60, ge=10, le=600, description="Thời gian chạy test (giây)")
test_type: str = Field(default="chat_mock", description="chat_mock | chat_real | history")
@router.post("/loadtest/start", summary="Bắt đầu Load Test")
async def start_load_test(req: StartTestRequest):
"""
Bắt đầu load test với config được chỉ định.
**test_type options:**
- `chat_mock`: Test mock chat API (KHÔNG tốn tiền) ⭐ Khuyên dùng
- `chat_real`: Test real chat API (TỐN TIỀN OpenAI!)
- `history`: Test history API (không tốn tiền LLM)
"""
try:
manager = get_load_test_manager()
config_dict = req.model_dump()
result = manager.start_test(config_dict)
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {
"status": "success",
"message": "Load test started",
"data": result
}
except Exception as e:
logger.error(f"Error starting load test: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/loadtest/stop", summary="Dừng Load Test")
async def stop_load_test():
"""Dừng load test đang chạy"""
try:
manager = get_load_test_manager()
result = manager.stop_test()
if "error" in result:
raise HTTPException(status_code=400, detail=result["error"])
return {
"status": "success",
"message": "Load test stopped",
"data": result
}
except Exception as e:
logger.error(f"Error stopping load test: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/loadtest/metrics", summary="Lấy Metrics Realtime")
async def get_load_test_metrics():
"""
Lấy metrics realtime của load test.
Frontend poll endpoint này mỗi 2 giây.
"""
try:
manager = get_load_test_manager()
metrics = manager.get_metrics()
return {
"status": "success",
"data": metrics
}
except Exception as e:
logger.error(f"Error getting metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/loadtest/status", summary="Check Test Status")
async def get_load_test_status():
"""Check xem load test có đang chạy không"""
try:
manager = get_load_test_manager()
return {
"status": "success",
"data": {
"is_running": manager.is_running(),
"current_status": manager.status
}
}
except Exception as e:
logger.error(f"Error getting status: {e}")
raise HTTPException(status_code=500, detail=str(e))
......@@ -64,27 +64,10 @@ class LLMFactory:
json_mode: bool = False,
api_key: str | None = None,
) -> BaseChatModel:
"""
Create and cache a new OpenAI LLM instance.
Args:
model_name: Clean model identifier
streaming: Enable streaming
json_mode: Enable JSON mode
api_key: Optional API key override
Returns:
Configured LLM instance
Raises:
ValueError: If API key is missing
"""
"""Create and cache a new OpenAI LLM instance."""
try:
llm = self._create_openai(model_name, streaming, api_key)
if json_mode:
llm = self._enable_json_mode(llm, model_name)
llm = self._create_openai(model_name, streaming, json_mode, api_key)
cache_key = (model_name, streaming, json_mode, api_key)
self._cache[cache_key] = llm
return llm
......@@ -93,19 +76,26 @@ class LLMFactory:
logger.error(f"❌ Failed to create model {model_name}: {e}")
raise
def _create_openai(self, model_name: str, streaming: bool, api_key: str | None) -> BaseChatModel:
def _create_openai(self, model_name: str, streaming: bool, json_mode: bool, api_key: str | None) -> BaseChatModel:
"""Create OpenAI model instance."""
key = api_key or OPENAI_API_KEY
if not key:
raise ValueError("OPENAI_API_KEY is required")
llm = ChatOpenAI(
model=model_name,
streaming=streaming,
api_key=key,
temperature=0,
)
llm_kwargs = {
"model": model_name,
"streaming": streaming,
"api_key": key,
"temperature": 0,
}
# Nếu bật json_mode, tiêm trực tiếp vào constructor
if json_mode:
llm_kwargs["model_kwargs"] = {"response_format": {"type": "json_object"}}
logger.info(f"⚙️ Initializing OpenAI in JSON mode: {model_name}")
llm = ChatOpenAI(**llm_kwargs)
logger.info(f"✅ Created OpenAI: {model_name}")
return llm
......
"""
Load Test Manager - Chạy Locust programmatically
Singleton service để quản lý load testing cho APIs
"""
import logging
import threading
import time
from dataclasses import dataclass, asdict
from enum import Enum
from typing import Any
from locust import HttpUser, between, task
from locust.env import Environment
logger = logging.getLogger(__name__)
class TestStatus(str, Enum):
"""Trạng thái của load test"""
IDLE = "idle"
RUNNING = "running"
STOPPING = "stopping"
STOPPED = "stopped"
class TestType(str, Enum):
"""Loại test"""
CHAT_MOCK = "chat_mock" # Mock API không tốn tiền ⭐ Khuyên dùng
CHAT_REAL = "chat_real" # Real API - TỐN TIỀN!
HISTORY = "history" # Test History API (Postgres)
DB_SEARCH = "db_search" # Test StarRocks DB (NO LLM cost) 🔥
@dataclass
class LoadTestConfig:
"""Config cho load test"""
target_url: str
num_users: int = 10
spawn_rate: int = 2
duration_seconds: int = 60
test_type: str = TestType.CHAT_MOCK
@dataclass
class LoadTestMetrics:
"""Metrics realtime với percentiles"""
status: str
total_requests: int = 0
current_rps: float = 0.0
avg_response_time_ms: float = 0.0
min_response_time_ms: float = 0.0
max_response_time_ms: float = 0.0
# Percentiles (quan trọng cho phân tích performance)
p50_response_time_ms: float = 0.0 # Median - 50% requests nhanh hơn
p90_response_time_ms: float = 0.0 # 90% requests nhanh hơn
p95_response_time_ms: float = 0.0 # 95% requests nhanh hơn
p99_response_time_ms: float = 0.0 # 99% requests nhanh hơn (worst case)
failure_rate: float = 0.0
active_users: int = 0
elapsed_seconds: int = 0
# ==================== LOCUST USER CLASSES ====================
class ChatMockUser(HttpUser):
"""Test Mock Chat API (KHÔNG tốn tiền OpenAI)"""
wait_time = between(0.5, 2)
# Fix Windows FD limit: Disable connection pooling
connection_timeout = 1.0
network_timeout = 10.0
def on_start(self):
"""Giảm pool size xuống 1 connection per user"""
from requests.adapters import HTTPAdapter
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=1)
self.client.mount("http://", adapter)
self.client.mount("https://", adapter)
@task
def send_chat_message(self):
# Close connection sau mỗi request
response = self.client.post(
"/api/test/chat-mock",
json={
"user_query": "Cho em xem áo sơ mi nam",
"user_id": f"loadtest_{self.environment.runner.user_count}"
},
name="POST /test/chat-mock"
)
response.close() # Force close ngay
class ChatRealUser(HttpUser):
"""Test Real Chat API (TỐN TIỀN - cẩn thận!)"""
wait_time = between(1, 3)
# Fix Windows FD limit
connection_timeout = 1.0
network_timeout = 30.0
def on_start(self):
from requests.adapters import HTTPAdapter
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=1)
self.client.mount("http://", adapter)
self.client.mount("https://", adapter)
@task
def send_chat_message(self):
response = self.client.post(
"/api/agent/chat",
json={
"user_query": "Cho em xem áo sơ mi nam",
"user_id": f"loadtest_{self.environment.runner.user_count}"
},
name="POST /agent/chat"
)
response.close()
class HistoryUser(HttpUser):
"""Test History API"""
wait_time = between(0.2, 1)
# Fix Windows FD limit
connection_timeout = 1.0
network_timeout = 10.0
def on_start(self):
from requests.adapters import HTTPAdapter
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=1)
self.client.mount("http://", adapter)
self.client.mount("https://", adapter)
@task
def get_history(self):
user_id = f"test_user_001"
response = self.client.get(
f"/history/{user_id}?limit=20",
name="GET /history"
)
response.close()
class DBSearchUser(HttpUser):
"""Test StarRocks DB Query Performance (NO LLM cost)"""
wait_time = between(0.3, 1.5)
# Fix Windows FD limit
connection_timeout = 1.0
network_timeout = 15.0
def on_start(self):
from requests.adapters import HTTPAdapter
adapter = HTTPAdapter(pool_connections=1, pool_maxsize=1)
self.client.mount("http://", adapter)
self.client.mount("https://", adapter)
@task
def search_products(self):
response = self.client.post(
"/api/test/db-search",
json={
"user_query": "áo sơ mi nam",
"user_id": f"loadtest_{self.environment.runner.user_count}"
},
name="POST /test/db-search"
)
response.close()
# ==================== LOAD TEST MANAGER ====================
class LoadTestManager:
"""Singleton manager cho load testing"""
def __init__(self):
self.status = TestStatus.IDLE
self.environment: Environment | None = None
self.runner_thread: threading.Thread | None = None
self.start_time: float | None = None
self.config: LoadTestConfig | None = None
self._lock = threading.Lock()
def start_test(self, config_dict: dict) -> dict:
"""Bắt đầu load test"""
with self._lock:
if self.status == TestStatus.RUNNING:
return {"error": "Test is already running"}
try:
self.config = LoadTestConfig(**config_dict)
self.status = TestStatus.RUNNING
self.start_time = time.time()
# Chọn User class dựa trên test_type
user_classes = {
TestType.CHAT_MOCK: ChatMockUser,
TestType.CHAT_REAL: ChatRealUser,
TestType.HISTORY: HistoryUser,
TestType.DB_SEARCH: DBSearchUser,
}
user_class = user_classes.get(self.config.test_type, ChatMockUser)
# Tạo Locust Environment
self.environment = Environment(user_classes=[user_class])
self.environment.host = self.config.target_url
# Bắt đầu runner trong background thread
self.runner_thread = threading.Thread(
target=self._run_test,
daemon=True
)
self.runner_thread.start()
logger.info(f"✅ Load test started: {self.config.test_type} | {self.config.num_users} users")
return {"status": "started", "config": asdict(self.config)}
except Exception as e:
self.status = TestStatus.IDLE
logger.error(f"Failed to start test: {e}")
return {"error": str(e)}
def _run_test(self):
"""Chạy test trong background thread"""
try:
runner = self.environment.create_local_runner()
# Spawn users
runner.start(
user_count=self.config.num_users,
spawn_rate=self.config.spawn_rate
)
# Chạy trong duration
time.sleep(self.config.duration_seconds)
# Stop gracefully
runner.quit()
with self._lock:
self.status = TestStatus.STOPPED
logger.info("✅ Load test completed")
except Exception as e:
logger.error(f"Error during test: {e}")
with self._lock:
self.status = TestStatus.STOPPED
def stop_test(self) -> dict:
"""Dừng test đang chạy"""
with self._lock:
if self.status != TestStatus.RUNNING:
return {"error": "No test is running"}
self.status = TestStatus.STOPPING
if self.environment and self.environment.runner:
self.environment.runner.quit()
self.status = TestStatus.STOPPED
logger.info("🛑 Load test stopped by user")
return {"status": "stopped"}
def get_metrics(self) -> dict:
"""Lấy metrics hiện tại với percentiles"""
with self._lock:
if not self.environment or not self.environment.runner:
return asdict(LoadTestMetrics(status=self.status))
stats = self.environment.runner.stats
total_stats = stats.total
# Tính elapsed time
elapsed = int(time.time() - self.start_time) if self.start_time else 0
# Lấy percentiles từ Locust (trả về dict với key là percentile)
try:
# get_response_time_percentile trả về dict: {0.5: 123, 0.9: 456, ...}
percentiles_dict = total_stats.get_response_time_percentile(0.5, 0.9, 0.95, 0.99) or {}
p50 = percentiles_dict.get(0.5, 0)
p90 = percentiles_dict.get(0.9, 0)
p95 = percentiles_dict.get(0.95, 0)
p99 = percentiles_dict.get(0.99, 0)
except Exception as e:
logger.warning(f"Failed to get percentiles: {e}")
p50 = p90 = p95 = p99 = 0
metrics = LoadTestMetrics(
status=self.status,
total_requests=total_stats.num_requests,
current_rps=round(total_stats.current_rps, 2),
avg_response_time_ms=round(total_stats.avg_response_time, 2),
min_response_time_ms=round(total_stats.min_response_time or 0, 2),
max_response_time_ms=round(total_stats.max_response_time or 0, 2),
# Percentiles
p50_response_time_ms=round(p50, 2) if p50 else 0,
p90_response_time_ms=round(p90, 2) if p90 else 0,
p95_response_time_ms=round(p95, 2) if p95 else 0,
p99_response_time_ms=round(p99, 2) if p99 else 0,
failure_rate=round(total_stats.fail_ratio, 4),
active_users=self.environment.runner.user_count,
elapsed_seconds=elapsed
)
return asdict(metrics)
def is_running(self) -> bool:
"""Check xem test có đang chạy không"""
return self.status == TestStatus.RUNNING
# ==================== SINGLETON ====================
_instance: LoadTestManager | None = None
def get_load_test_manager() -> LoadTestManager:
"""Get singleton instance"""
global _instance
if _instance is None:
_instance = LoadTestManager()
return _instance
"""
Model Fallback Manager
Tự động fallback sang model khác khi gặp lỗi (rate limit, quota, context length)
"""
import logging
from enum import Enum
from typing import Any
from common.mongo import get_mongo_db
from langchain_core.language_models import BaseChatModel
from common.llm_factory import LLMFactory
logger = logging.getLogger(__name__)
class ErrorType(Enum):
"""Các loại lỗi có thể fallback"""
RATE_LIMIT = "rate_limit"
QUOTA_EXCEEDED = "quota_exceeded"
CONTEXT_LENGTH = "context_length"
NETWORK_ERROR = "network_error"
UNKNOWN = "unknown"
class ModelFallbackManager:
"""
Quản lý auto fallback models khi gặp lỗi.
Features:
- Tự động detect loại lỗi
- Chọn model fallback phù hợp dựa trên error type
- Quản lý danh sách models user có key
- Hỗ trợ model="auto" để tự động chọn model tốt nhất
"""
# Model capabilities (context size, cost tier)
MODEL_CAPABILITIES: dict[str, dict[str, Any]] = {
# OpenAI
"openai/gpt-5-nano": {"context": 128000, "tier": "cheap", "speed": "fast"},
"openai/gpt-5-mini": {"context": 128000, "tier": "cheap", "speed": "fast"},
"openai/gpt-4o": {"context": 128000, "tier": "medium", "speed": "medium"},
"openai/gpt-4o-mini": {"context": 128000, "tier": "cheap", "speed": "fast"},
# Gemini
"google_genai/gemini-2.5-flash": {"context": 1000000, "tier": "cheap", "speed": "fast"},
"google_genai/gemini-2.5-pro": {"context": 2000000, "tier": "medium", "speed": "medium"},
"google_genai/gemini-2.0-flash": {"context": 1000000, "tier": "cheap", "speed": "fast"},
"google_genai/gemini-2.0-pro-exp": {"context": 2000000, "tier": "medium", "speed": "medium"},
"google_genai/gemini-1.5-flash": {"context": 1000000, "tier": "cheap", "speed": "fast"},
"google_genai/gemini-1.5-pro": {"context": 2000000, "tier": "medium", "speed": "medium"},
# Groq
"groq/meta-llama/llama-4-maverick-17b-128e-instruct": {
"context": 128000,
"tier": "cheap",
"speed": "very_fast",
},
"groq/meta-llama/llama-4-scout-17b-16e-instruct": {"context": 128000, "tier": "cheap", "speed": "very_fast"},
"groq/openai/gpt-oss-120b": {"context": 128000, "tier": "medium", "speed": "very_fast"},
"groq/openai/gpt-oss-20b": {"context": 128000, "tier": "cheap", "speed": "very_fast"},
}
def __init__(self):
self.llm_factory = LLMFactory()
async def _get_api_key_for_provider(self, user_id: str, provider: str) -> str | None:
"""
Lấy API key của user cho provider (openai, google_genai, groq, anthropic, ...)
"""
db = get_mongo_db()
doc = await db["user_api_keys"].find_one(
{"user_id": user_id, "model": {"$regex": f"^{provider}", "$options": "i"}}
)
if doc:
api_key = doc.get("key")
# Trim whitespace để tránh lỗi "Illegal header value"
if api_key:
return api_key.strip()
return None
return None
def detect_error_type(self, error: Exception) -> ErrorType:
"""
Detect loại lỗi từ exception.
Returns:
ErrorType: Loại lỗi được detect
"""
error_str = str(error).lower()
type(error).__name__
# Rate limit
if "rate limit" in error_str or "429" in error_str or "too many requests" in error_str:
return ErrorType.RATE_LIMIT
# Quota exceeded
if "quota" in error_str or "insufficient" in error_str or "billing" in error_str:
return ErrorType.QUOTA_EXCEEDED
# Context length
if "context" in error_str and ("length" in error_str or "exceeded" in error_str or "too long" in error_str):
return ErrorType.CONTEXT_LENGTH
if "maximum context length" in error_str or "token limit" in error_str:
return ErrorType.CONTEXT_LENGTH
# Network errors
if "timeout" in error_str or "connection" in error_str or "network" in error_str:
return ErrorType.NETWORK_ERROR
return ErrorType.UNKNOWN
async def get_user_available_models(self, user_id: str) -> list[str]:
"""
Lấy danh sách models mà user đã có API key.
Args:
user_id: User ID
Returns:
List[str]: Danh sách model names user có key
"""
try:
db = get_mongo_db()
collection = db["user_api_keys"]
# Lấy tất cả API keys của user
keys = await collection.find({"user_id": user_id}).to_list(length=100)
# Map model types sang model names
available_models = []
for key in keys:
model_type = key.get("model", "").lower()
# Map model type to actual model names
if model_type == "openai":
available_models.extend(
["openai/gpt-5-nano", "openai/gpt-5-mini", "openai/gpt-4o", "openai/gpt-4o-mini"]
)
elif model_type == "gemini":
available_models.extend(
[
"google_genai/gemini-2.5-flash",
"google_genai/gemini-2.5-pro",
"google_genai/gemini-2.0-flash",
"google_genai/gemini-1.5-flash",
]
)
elif model_type == "groq":
available_models.extend(
[
"groq/meta-llama/llama-4-maverick-17b-128e-instruct",
"groq/meta-llama/llama-4-scout-17b-16e-instruct",
"groq/openai/gpt-oss-120b",
]
)
elif model_type == "claude":
available_models.extend(
["anthropic/claude-3-5-sonnet-20241022", "anthropic/claude-3-opus-20240229"]
)
# Remove duplicates và sort theo priority
unique_models = list(dict.fromkeys(available_models))
return self._sort_models_by_priority(unique_models)
except Exception as e:
logger.error(f"Error getting user available models: {e}")
# Fallback: return common models
return [
"openai/gpt-5-nano",
"google_genai/gemini-2.5-flash",
"groq/meta-llama/llama-4-maverick-17b-128e-instruct",
]
def _sort_models_by_priority(self, models: list[str]) -> list[str]:
"""
Sort models theo priority: cheap + fast trước, sau đó đến medium.
"""
def get_priority(model: str) -> int:
caps = self.MODEL_CAPABILITIES.get(model, {})
tier = caps.get("tier", "medium")
speed = caps.get("speed", "medium")
# Priority: cheap + fast = 1, cheap + medium = 2, medium = 3
if tier == "cheap" and speed in ["fast", "very_fast"]:
return 1
if tier == "cheap":
return 2
return 3
return sorted(models, key=get_priority)
async def select_best_model(self, user_id: str, preferred_model: str | None = None) -> str:
"""
Chọn model tốt nhất từ danh sách user có key.
Nếu preferred_model được chỉ định và user có key → dùng preferred_model
Nếu không → chọn model rẻ + nhanh nhất
Args:
user_id: User ID
preferred_model: Model user muốn dùng (optional)
Returns:
str: Model name được chọn
"""
available = await self.get_user_available_models(user_id)
if not available:
# Fallback to default
logger.warning(f"No available models for user {user_id}, using default")
return "openai/gpt-5-nano"
# Nếu có preferred_model và user có key → dùng preferred_model
if preferred_model and preferred_model in available:
return preferred_model
# Chọn model đầu tiên trong danh sách đã sort (tốt nhất)
return available[0]
def get_fallback_model(self, current_model: str, error_type: ErrorType, available_models: list[str]) -> str | None:
"""
Chọn fallback model dựa trên error type và current model.
Args:
current_model: Model hiện tại bị lỗi
error_type: Loại lỗi
available_models: Danh sách models user có key
Returns:
Optional[str]: Model fallback, None nếu không có
"""
if not available_models:
return None
current_caps = self.MODEL_CAPABILITIES.get(current_model, {})
current_context = current_caps.get("context", 128000)
current_provider = current_model.split("/")[0] if "/" in current_model else ""
# Loại bỏ current_model khỏi danh sách
candidates = [m for m in available_models if m != current_model]
if not candidates:
return None
if error_type == ErrorType.CONTEXT_LENGTH:
# Chọn model có context lớn hơn
for model in candidates:
caps = self.MODEL_CAPABILITIES.get(model, {})
if caps.get("context", 0) > current_context:
logger.info(f"🔄 Context length fallback: {current_model} → {model}")
return model
# Nếu không có model nào có context lớn hơn, chọn model có context lớn nhất
best = max(candidates, key=lambda m: self.MODEL_CAPABILITIES.get(m, {}).get("context", 0))
logger.info(f"🔄 Context length fallback (best available): {current_model} → {best}")
return best
if error_type == ErrorType.RATE_LIMIT:
# Chọn model khác provider (tránh cùng rate limit)
for model in candidates:
provider = model.split("/")[0] if "/" in model else ""
if provider != current_provider:
logger.info(f"🔄 Rate limit fallback: {current_model} → {model} (different provider)")
return model
# Nếu không có provider khác, chọn model tiếp theo
logger.info(f"🔄 Rate limit fallback: {current_model} → {candidates[0]} (next model)")
return candidates[0]
if error_type == ErrorType.QUOTA_EXCEEDED:
# Chọn model rẻ hơn hoặc provider khác
current_caps.get("tier", "medium")
for model in candidates:
caps = self.MODEL_CAPABILITIES.get(model, {})
provider = model.split("/")[0] if "/" in model else ""
# Ưu tiên: provider khác hoặc tier rẻ hơn
if provider != current_provider or caps.get("tier") == "cheap":
logger.info(f"🔄 Quota fallback: {current_model} → {model}")
return model
# Fallback to first available
logger.info(f"🔄 Quota fallback: {current_model} → {candidates[0]}")
return candidates[0]
# Unknown/Network error: chọn model tiếp theo
logger.info(f"🔄 Generic fallback: {current_model} → {candidates[0]}")
return candidates[0]
async def get_model_with_fallback(
self,
user_id: str,
model_name: str,
streaming: bool = True,
json_mode: bool = False,
max_retries: int = 3,
allow_fallback: bool = True, # nếu False và model_name != auto: không fallback, báo lỗi thiếu key
) -> tuple[BaseChatModel, str]:
"""
Lấy LLM model với auto fallback khi gặp lỗi.
Args:
user_id: User ID
model_name: Model name (có thể là "auto")
streaming: Streaming mode
json_mode: JSON mode
max_retries: Số lần retry tối đa
Returns:
Tuple[BaseChatModel, str]: (LLM instance, actual model name used)
Raises:
Exception: Nếu tất cả models đều fail
"""
# 1. Get available models for fallback (cần có trước để check)
available_models = await self.get_user_available_models(user_id)
if not available_models:
logger.warning(f"No available models for user {user_id}, using default")
available_models = ["openai/gpt-5-nano"] # Fallback default
# 2. Resolve model name
if model_name == "auto":
actual_model = await self.select_best_model(user_id, preferred_model=None)
logger.info(f"🤖 Auto model selection: {actual_model}")
else:
actual_model = model_name
if not allow_fallback:
# Strict: không fallback. Nếu model không nằm trong danh sách gợi ý → báo lỗi ngay.
if actual_model not in available_models:
logger.error(
f"❌ Strict mode: Model {actual_model} không có trong available list và không được phép fallback."
)
raise Exception(
f"Không tìm thấy model {actual_model} trong danh sách được phép, "
f"và chế độ strict không cho phép fallback. Vui lòng cấu hình API key cho provider tương ứng."
)
# Cho phép fallback: nếu model không trong available list, chọn best available
elif actual_model not in available_models:
logger.warning(f"Model {actual_model} not in available list, using best available")
actual_model = await self.select_best_model(user_id, preferred_model=None)
# 3. Try to get model (with fallback on error)
tried_models = []
current_model = actual_model
for attempt in range(max_retries):
try:
# Lấy api_key theo provider
provider = current_model.split("/")[0] if "/" in current_model else current_model
api_key = await self._get_api_key_for_provider(user_id, provider)
if not api_key:
logger.error(f"❌ No API key found for provider '{provider}' (user_id: {user_id})")
logger.error(f"💡 User needs to configure {provider} API key in settings")
# Nếu strict (allow_fallback=False và model_name != auto) → báo lỗi ngay
if not allow_fallback and model_name != "auto":
raise Exception(
f"Không tìm thấy API key cho provider '{provider}'. Vui lòng cấu hình API key cho {provider} trong phần Cài đặt."
)
raise Exception(
f"No API key found for provider '{provider}'. Please configure your {provider} API key in settings."
)
# Get model instance với api_key của user
llm = self.llm_factory.get_model(
current_model, streaming=streaming, json_mode=json_mode, api_key=api_key
)
logger.info(f"✅ Using model: {current_model}")
return llm, current_model
except Exception as e:
error_type = self.detect_error_type(e)
logger.warning(f"❌ Model {current_model} failed ({error_type.value}): {e}")
tried_models.append(current_model)
# Nếu strict (không cho phép fallback) thì ném lỗi ngay lập tức
if not allow_fallback and model_name != "auto":
logger.error("⛔ Strict mode: không fallback sang model khác. Ném lỗi lên FE.")
raise
# Get fallback model
fallback = self.get_fallback_model(current_model, error_type, available_models)
if not fallback or fallback in tried_models:
# No more fallback options
error_msg = str(e).lower()
if "api key" in error_msg or "no api key" in error_msg:
# Lỗi API key - message rõ ràng hơn
logger.error(f"❌ All models exhausted due to missing API keys. Tried: {tried_models}")
logger.error(f"💡 User {user_id} needs to configure API keys in settings")
raise Exception(
f"Không tìm thấy API key cho bất kỳ model nào. Vui lòng cấu hình API key trong phần Cài đặt. Lỗi cuối: {e}"
)
# Lỗi khác
logger.error(f"❌ All models exhausted. Tried: {tried_models}")
raise Exception(f"All available models failed. Last error: {e}")
current_model = fallback
logger.info(f"🔄 Retrying with fallback model: {current_model} (attempt {attempt + 2}/{max_retries})")
# Should not reach here
raise Exception(f"Failed to get model after {max_retries} attempts")
# Global instance
_fallback_manager: ModelFallbackManager | None = None
def get_fallback_manager() -> ModelFallbackManager:
"""Get singleton ModelFallbackManager instance"""
global _fallback_manager
if _fallback_manager is None:
_fallback_manager = ModelFallbackManager()
return _fallback_manager
......@@ -4,6 +4,7 @@ Based on chatbot-rsa pattern
"""
import logging
import asyncio
from typing import Any
import aiomysql
......@@ -109,25 +110,29 @@ class StarRocksConnection:
# Async pool shared
_shared_pool = None
_pool_lock = asyncio.Lock()
async def get_pool(self):
"""
Get or create shared async connection pool
Get or create shared async connection pool (Thread-safe singleton)
"""
if StarRocksConnection._shared_pool is None:
logger.info(f"🔌 Creating Async Pool to {self.host}:{self.port}...")
StarRocksConnection._shared_pool = await aiomysql.create_pool(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
db=self.database,
charset="utf8mb4",
cursorclass=aiomysql.DictCursor,
minsize=100,
maxsize=200, # Max Ping: Mở 2000 slot - Đủ cân 500k users (với cơ chế pooling)
connect_timeout=10,
)
async with StarRocksConnection._pool_lock:
# Double-check inside lock to prevent multiple pools
if StarRocksConnection._shared_pool is None:
logger.info(f"🔌 Creating Async Pool to {self.host}:{self.port}...")
StarRocksConnection._shared_pool = await aiomysql.create_pool(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
db=self.database,
charset="utf8mb4",
cursorclass=aiomysql.DictCursor,
minsize=10, # Sẵn sàng 10 kết nối ngay lập tức (Cực nhanh cho Prod)
maxsize=50, # Tối đa 50 kết nối (Đủ cân hàng nghìn users, an toàn trên Windows)
connect_timeout=10,
)
return StarRocksConnection._shared_pool
async def execute_query_async(self, query: str, params: tuple | None = None) -> list[dict[str, Any]]:
......
......@@ -84,7 +84,7 @@ LANGFUSE_PUBLIC_KEY: str | None = os.getenv("LANGFUSE_PUBLIC_KEY")
LANGFUSE_BASE_URL: str | None = os.getenv("LANGFUSE_BASE_URL", "https://cloud.langfuse.com")
# ====================== LANGSMITH CONFIGURATION ======================
LANGSMITH_TRACING = os.getenv("LANGSMITH_TRACING", "true")
LANGSMITH_TRACING = os.getenv("LANGSMITH_TRACING", "false")
LANGSMITH_ENDPOINT = os.getenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
LANGSMITH_API_KEY = os.getenv("LANGSMITH_API_KEY")
LANGSMITH_PROJECT = os.getenv("LANGSMITH_PROJECT")
......
......@@ -172,43 +172,44 @@ async def startup_event():
@app.post("/test/db-search")
async def test_db_search(request: SearchRequest):
"""
Test StarRocks DB Search.
KHÔNG GỌI OPENAI - Chỉ test DB.
Test StarRocks DB Multi-Search Parallel.
"""
import asyncio
start_time = time.time()
try:
params = MockParams(query_text=request.query, limit=request.limit)
sql = build_starrocks_query(params)
# Giả lập Multi-Search với 2 query song song
params1 = MockParams(query_text=request.query)
params2 = MockParams(query_text=request.query + " nam") # Truy vấn phái sinh
# Launch parallel task creation
tasks = [build_starrocks_query(params1), build_starrocks_query(params2)]
sqls = await asyncio.gather(*tasks)
db = StarRocksConnection()
products = await db.execute_query_async(sql)
# Filter fields
limited_products = products[:5]
ALLOWED_FIELDS = {
"product_name",
"sale_price",
"original_price",
"product_image_url_thumbnail",
"product_web_url",
"master_color",
"product_color_name",
"material",
"internal_ref_code",
}
clean_products = [{k: v for k, v in p.items() if k in ALLOWED_FIELDS} for p in limited_products]
# Parallel DB fetching
db_tasks = [db.execute_query_async(sql) for sql in sqls]
results = await asyncio.gather(*db_tasks)
# Trích xuất và làm sạch dữ liệu
ALLOWED_FIELDS = {"product_name", "sale_price", "internal_ref_code", "product_image_url_thumbnail"}
all_products = []
for products in results:
clean = [{k: v for k, v in p.items() if k in ALLOWED_FIELDS} for p in products[:5]]
all_products.extend(clean)
process_time = time.time() - start_time
return {
"status": "success",
"count": len(clean_products),
"count": len(all_products),
"process_time_seconds": round(process_time, 4),
"products": clean_products,
"products": all_products,
"_queries_run": len(sqls)
}
except Exception as e:
logger.error(f"DB Search Error: {e}")
logger.error(f"DB Multi-Search Error: {e}")
raise HTTPException(status_code=500, detail=str(e))
......
......@@ -10,6 +10,10 @@ if platform.system() == "Windows":
print("🔧 Windows detected: Applying SelectorEventLoopPolicy globally...")
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
# Tạm thời tắt LangChain Tracing để tránh lỗi recursion (Đúng ý bro)
os.environ["LANGCHAIN_TRACING_V2"] = "false"
os.environ["LANGCHAIN_API_KEY"] = ""
# Sau khi fix xong mới import tiếp
import logging
......@@ -21,6 +25,7 @@ from fastapi.staticfiles import StaticFiles # Import cái này để mount HTML
# Updated APIs (Import sau cùng để DB nhận cấu hình fix ở trên)
from api.chatbot_route import router as chatbot_router
from api.conservation_route import router as conservation_router
from api.test_route import router as test_router # ← Test API (isolated)
from config import PORT
# Configure Logging
......@@ -46,6 +51,7 @@ app.add_middleware(
app.include_router(conservation_router)
app.include_router(chatbot_router, prefix="/api/agent")
app.include_router(test_router, prefix="/api") # ← Test routes
# ==========================================
# 🟢 ĐOẠN MOUNT STATIC HTML CỦA BRO ĐÂY 🟢
......
......@@ -8,11 +8,56 @@
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 0;
background-color: #1e1e1e;
color: #e0e0e0;
}
/* Navigation Header */
.nav-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.nav-header h1 {
margin: 0;
color: white;
font-size: 1.5em;
}
.nav-links {
display: flex;
gap: 15px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
font-weight: 500;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.4);
}
.main-content {
max-width: 900px;
margin: 0 auto;
padding: 20px;
background-color: #1e1e1e;
color: #e0e0e0;
}
.container {
......@@ -217,240 +262,251 @@
</head>
<body>
<div class="container">
<div class="header">
<h2>🤖 Canifa AI Chat</h2>
<div class="config-area">
<input type="text" id="userId" placeholder="User ID" value="test_user_001">
<button onclick="loadHistory(true)">↻ History</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ Clear UI</button>
</div>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html" class="active">💬 Chatbot</a>
<a href="/static/loadtest.html">🔬 Load Test</a>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
<div class="main-content">
<div class="container">
<div class="header">
<h2>🤖 Canifa AI Chat</h2>
<div class="config-area">
<input type="text" id="userId" placeholder="User ID" value="test_user_001">
<button onclick="loadHistory(true)">↻ History</button>
<button onclick="clearUI()" style="background: #d32f2f;">✗ Clear UI</button>
</div>
</div>
<div class="chat-box" id="chatBox">
<div class="load-more" id="loadMoreBtn" style="display: none;">
<button onclick="loadHistory(false)">Load Older Messages ⬆️</button>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div id="messagesArea" style="display: flex; flex-direction: column; gap: 15px;"></div>
</div>
<div class="typing-indicator" id="typingIndicator">AI is typing...</div>
<div class="typing-indicator" id="typingIndicator">AI is typing...</div>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type your message..." onkeypress="handleKeyPress(event)"
autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
<div class="input-area">
<input type="text" id="userInput" placeholder="Type your message..." onkeypress="handleKeyPress(event)"
autocomplete="off">
<button onclick="sendMessage()" id="sendBtn">➤ Send</button>
</div>
</div>
</div>
<script>
let currentCursor = null;
let isTyping = false;
<script>
let currentCursor = null;
let isTyping = false;
async function loadHistory(isRefresh) {
const userId = document.getElementById('userId').value;
const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
async function loadHistory(isRefresh) {
const userId = document.getElementById('userId').value;
const messagesArea = document.getElementById('messagesArea');
const loadMoreBtn = document.getElementById('loadMoreBtn');
if (!userId) {
alert('Please enter a User ID');
return;
}
if (!userId) {
alert('Please enter a User ID');
return;
}
if (isRefresh) {
messagesArea.innerHTML = '';
currentCursor = null;
}
if (isRefresh) {
messagesArea.innerHTML = '';
currentCursor = null;
}
const url = `/history/${userId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
const url = `/history/${userId}?limit=20${currentCursor ? `&before_id=${currentCursor}` : ''}`;
try {
const response = await fetch(url);
const data = await response.json();
try {
const response = await fetch(url);
const data = await response.json();
const messages = data.data || data;
const cursor = data.next_cursor || null;
const messages = data.data || data;
const cursor = data.next_cursor || null;
if (Array.isArray(messages) && messages.length > 0) {
currentCursor = cursor;
const batch = [...messages].reverse();
if (Array.isArray(messages) && messages.length > 0) {
currentCursor = cursor;
const batch = [...messages].reverse();
if (isRefresh) {
batch.forEach(msg => appendMessage(msg, 'bottom'));
setTimeout(() => {
if (isRefresh) {
batch.forEach(msg => appendMessage(msg, 'bottom'));
setTimeout(() => {
const chatBox = document.getElementById('chatBox');
chatBox.scrollTop = chatBox.scrollHeight;
}, 100);
} else {
// Keep scroll position relative to bottom content
const chatBox = document.getElementById('chatBox');
chatBox.scrollTop = chatBox.scrollHeight;
}, 100);
} else {
// Keep scroll position relative to bottom content
const chatBox = document.getElementById('chatBox');
const oldHeight = chatBox.scrollHeight;
const oldHeight = chatBox.scrollHeight;
batch.forEach(msg => appendMessage(msg, 'top'));
batch.forEach(msg => appendMessage(msg, 'top'));
// Adjust scroll to keep view stable
chatBox.scrollTop = chatBox.scrollHeight - oldHeight;
}
// Adjust scroll to keep view stable
chatBox.scrollTop = chatBox.scrollHeight - oldHeight;
}
loadMoreBtn.style.display = currentCursor ? 'block' : 'none';
} else {
if (isRefresh) {
messagesArea.innerHTML = '<div class="message system">No history found. Start chatting!</div>';
loadMoreBtn.style.display = currentCursor ? 'block' : 'none';
} else {
if (isRefresh) {
messagesArea.innerHTML = '<div class="message system">No history found. Start chatting!</div>';
}
loadMoreBtn.style.display = 'none';
}
loadMoreBtn.style.display = 'none';
}
} catch (error) {
console.error('Error loading history:', error);
alert('Failed to load history');
}
}
function appendMessage(msg, position = 'bottom') {
const messagesArea = document.getElementById('messagesArea');
// Container wrapper for alignment
const container = document.createElement('div');
container.className = `message-container ${msg.is_human ? 'user' : 'bot'}`;
// Sender Name Label
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = msg.is_human ? 'You' : 'Canifa AI';
container.appendChild(sender);
// Message Bubble
const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
div.innerText = msg.message;
// Timestamp inside bubble
const time = document.createElement('span');
time.className = 'timestamp';
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time);
// Debug ID (optional)
// const meta = document.createElement('div');
// meta.style.fontSize = '9px';
// meta.style.opacity = '0.3';
// meta.innerText = `id: ${msg.id}`;
// div.appendChild(meta);
container.appendChild(div);
if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild);
} else {
messagesArea.appendChild(container); // Corrected to append container
} catch (error) {
console.error('Error loading history:', error);
alert('Failed to load history');
}
}
}
async function sendMessage() {
const input = document.getElementById('userInput');
const userId = document.getElementById('userId').value;
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox');
if (!text || !userId) return;
// Disable input
input.disabled = true;
sendBtn.disabled = true;
typingIndicator.style.display = 'block';
// Add user message immediately
appendMessage({
message: text,
is_human: true,
timestamp: new Date().toISOString(),
id: 'pending'
});
input.value = '';
chatBox.scrollTop = chatBox.scrollHeight;
try {
// SWITCH TO NON-STREAMING ENDPOINT
const response = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_query: text,
user_id: userId
})
});
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
// Create bot message placeholder
function appendMessage(msg, position = 'bottom') {
const messagesArea = document.getElementById('messagesArea');
// Container wrapper for alignment
const container = document.createElement('div');
container.className = 'message-container bot';
container.className = `message-container ${msg.is_human ? 'user' : 'bot'}`;
// Sender Name Label
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
sender.innerText = msg.is_human ? 'You' : 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
if (data.status === 'success') {
// Display AI response
botMsgDiv.innerText = data.ai_response || data.response || 'No response';
// Add product IDs if available
if (data.product_ids && data.product_ids.length > 0) {
const productInfo = document.createElement('div');
productInfo.style.marginTop = '8px';
productInfo.style.fontSize = '0.85em';
productInfo.style.color = '#aaa';
productInfo.style.borderTop = '1px solid #555';
productInfo.style.paddingTop = '8px';
productInfo.innerText = `📦 Products: ${data.product_ids.join(', ')}`;
botMsgDiv.appendChild(productInfo);
}
// Message Bubble
const div = document.createElement('div');
div.className = `message ${msg.is_human ? 'user' : 'bot'}`;
div.innerText = msg.message;
// Timestamp inside bubble
const time = document.createElement('span');
time.className = 'timestamp';
time.innerText = new Date(msg.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
div.appendChild(time);
// Debug ID (optional)
// const meta = document.createElement('div');
// meta.style.fontSize = '9px';
// meta.style.opacity = '0.3';
// meta.innerText = `id: ${msg.id}`;
// div.appendChild(meta);
container.appendChild(div);
if (position === 'top') {
messagesArea.insertBefore(container, messagesArea.firstChild);
} else {
botMsgDiv.innerText = "Error: " + (data.message || "Unknown error");
botMsgDiv.style.color = 'red';
messagesArea.appendChild(container); // Corrected to append container
}
}
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
async function sendMessage() {
const input = document.getElementById('userInput');
const userId = document.getElementById('userId').value;
const text = input.value.trim();
const sendBtn = document.getElementById('sendBtn');
const typingIndicator = document.getElementById('typingIndicator');
const chatBox = document.getElementById('chatBox');
if (!text || !userId) return;
// Disable input
input.disabled = true;
sendBtn.disabled = true;
typingIndicator.style.display = 'block';
} catch (error) {
console.error('Error sending message:', error);
// Add user message immediately
appendMessage({
message: `Error: ${error.message}`,
is_human: false,
message: text,
is_human: true,
timestamp: new Date().toISOString(),
id: 'error'
id: 'pending'
});
} finally {
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
input.focus();
input.value = '';
chatBox.scrollTop = chatBox.scrollHeight;
try {
// SWITCH TO NON-STREAMING ENDPOINT
const response = await fetch('/api/agent/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_query: text,
user_id: userId
})
});
if (!response.ok) throw new Error('Network response was not ok');
const data = await response.json();
// Create bot message placeholder
const messagesArea = document.getElementById('messagesArea');
const container = document.createElement('div');
container.className = 'message-container bot';
const sender = document.createElement('div');
sender.className = 'sender-name';
sender.innerText = 'Canifa AI';
container.appendChild(sender);
const botMsgDiv = document.createElement('div');
botMsgDiv.className = 'message bot';
if (data.status === 'success') {
// Display AI response
botMsgDiv.innerText = data.ai_response || data.response || 'No response';
// Add product IDs if available
if (data.product_ids && data.product_ids.length > 0) {
const productInfo = document.createElement('div');
productInfo.style.marginTop = '8px';
productInfo.style.fontSize = '0.85em';
productInfo.style.color = '#aaa';
productInfo.style.borderTop = '1px solid #555';
productInfo.style.paddingTop = '8px';
productInfo.innerText = `📦 Products: ${data.product_ids.join(', ')}`;
botMsgDiv.appendChild(productInfo);
}
} else {
botMsgDiv.innerText = "Error: " + (data.message || "Unknown error");
botMsgDiv.style.color = 'red';
}
container.appendChild(botMsgDiv);
messagesArea.appendChild(container);
chatBox.scrollTop = chatBox.scrollHeight;
} catch (error) {
console.error('Error sending message:', error);
appendMessage({
message: `Error: ${error.message}`,
is_human: false,
timestamp: new Date().toISOString(),
id: 'error'
});
} finally {
input.disabled = false;
sendBtn.disabled = false;
typingIndicator.style.display = 'none';
input.focus();
chatBox.scrollTop = chatBox.scrollHeight;
}
}
}
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
function handleKeyPress(event) {
if (event.key === 'Enter') {
sendMessage();
}
}
}
function clearUI() {
document.getElementById('messagesArea').innerHTML = '';
}
</script>
function clearUI() {
document.getElementById('messagesArea').innerHTML = '';
}
</script>
</div> <!-- Close main-content -->
</body>
</html>
\ No newline at end of file
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>🔬 Load Testing Dashboard</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
/* Navigation Header */
.nav-header {
background: rgba(0, 0, 0, 0.2);
padding: 15px 30px;
display: flex;
justify-content: space-between;
align-items: center;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.nav-header h1 {
margin: 0;
color: white;
font-size: 1.5em;
}
.nav-links {
display: flex;
gap: 15px;
}
.nav-links a {
color: white;
text-decoration: none;
padding: 8px 16px;
border-radius: 6px;
background: rgba(255, 255, 255, 0.2);
transition: all 0.3s;
font-weight: 500;
}
.nav-links a:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-2px);
}
.nav-links a.active {
background: rgba(255, 255, 255, 0.4);
}
.main-wrapper {
padding: 20px;
}
.container {
max-width: 1200px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 2em;
margin-bottom: 10px;
}
.header p {
opacity: 0.9;
font-size: 1.1em;
}
.content {
padding: 30px;
}
.section {
margin-bottom: 30px;
padding: 20px;
border: 2px solid #f0f0f0;
border-radius: 12px;
}
.section h2 {
color: #667eea;
margin-bottom: 20px;
font-size: 1.5em;
display: flex;
align-items: center;
gap: 10px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: block;
margin-bottom: 5px;
font-weight: 600;
color: #333;
}
input,
select {
width: 100%;
padding: 12px;
border: 2px solid #e0e0e0;
border-radius: 8px;
font-size: 14px;
transition: border-color 0.3s;
}
input:focus,
select:focus {
outline: none;
border-color: #667eea;
}
.button-group {
display: flex;
gap: 15px;
margin-top: 20px;
}
button {
flex: 1;
padding: 15px 30px;
border: none;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
transition: all 0.3s;
}
.btn-start {
background: linear-gradient(135deg, #11998e 0%, #38ef7d 100%);
color: white;
}
.btn-start:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(17, 153, 142, 0.4);
}
.btn-stop {
background: linear-gradient(135deg, #ee0979 0%, #ff6a00 100%);
color: white;
}
.btn-stop:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(238, 9, 121, 0.4);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 20px;
margin-top: 20px;
}
.metric-card {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
padding: 20px;
border-radius: 12px;
text-align: center;
}
.metric-label {
font-size: 14px;
color: #666;
margin-bottom: 10px;
}
.metric-value {
font-size: 32px;
font-weight: bold;
color: #333;
}
.metric-unit {
font-size: 16px;
color: #999;
margin-left: 5px;
}
.status {
display: inline-block;
padding: 8px 16px;
border-radius: 20px;
font-weight: 600;
font-size: 14px;
margin: 10px 0;
}
.status.idle {
background: #e0e0e0;
color: #666;
}
.status.running {
background: #4caf50;
color: white;
animation: pulse 2s infinite;
}
.status.stopped {
background: #f44336;
color: white;
}
@keyframes pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
.log-container {
background: #1e1e1e;
color: #00ff00;
padding: 20px;
border-radius: 8px;
font-family: 'Courier New', monospace;
font-size: 14px;
max-height: 300px;
overflow-y: auto;
}
.log-entry {
margin-bottom: 5px;
}
.alert {
padding: 15px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert-warning {
background: #fff3cd;
border-left: 4px solid #ffc107;
color: #856404;
}
.alert-info {
background: #d1ecf1;
border-left: 4px solid #17a2b8;
color: #0c5460;
}
/* Chart container */
.chart-container {
position: relative;
height: 300px;
margin-top: 20px;
}
</style>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
</head>
<body>
<!-- Navigation Header -->
<div class="nav-header">
<h1>🤖 Canifa AI System</h1>
<div class="nav-links">
<a href="/static/index.html">💬 Chatbot</a>
<a href="/static/loadtest.html" class="active">🔬 Load Test</a>
</div>
</div>
<div class="main-wrapper">
<div class="container">
<div class="header">
<h1>🔬 Load Testing Dashboard</h1>
<p>Performance testing tool for Canifa Chat API</p>
</div>
<div class="content">
<!-- Alert -->
<div class="alert alert-warning">
⚠️ <strong>Lưu ý:</strong> Sử dụng <code>chat_mock</code> để test mà không tốn tiền OpenAI!
</div>
<!-- Config Section -->
<div class="section">
<h2>⚙️ Test Configuration</h2>
<div class="form-group">
<label for="targetUrl">Target URL</label>
<input type="text" id="targetUrl" value="http://localhost:5000"
placeholder="http://localhost:5000">
</div>
<div class="form-group">
<label for="numUsers">Number of Users (1-1000)</label>
<input type="number" id="numUsers" value="10" min="1" max="1000">
</div>
<div class="form-group">
<label for="spawnRate">Spawn Rate (users/second)</label>
<input type="number" id="spawnRate" value="2" min="1" max="100">
</div>
<div class="form-group">
<label for="duration">Duration (seconds)</label>
<input type="number" id="duration" value="60" min="10" max="600">
</div>
<div class="form-group">
<label for="testType">Test Type</label>
<select id="testType">
<option value="chat_mock">💚 Mock Chat (No cost - Recommended)</option>
<option value="db_search">🔥 DB Search (Test StarRocks - No LLM cost)</option>
<option value="chat_real">💸 Real Chat (Costs money!)</option>
<option value="history">📜 History API (Postgres)</option>
</select>
</div>
<div class="button-group">
<button class="btn-start" id="startBtn" onclick="startTest()">▶ Start Test</button>
<button class="btn-stop" id="stopBtn" onclick="stopTest()" disabled>⏹ Stop Test</button>
</div>
</div>
<!-- Status Section -->
<div class="section">
<h2>📊 Live Metrics</h2>
<div>
Current Status: <span class="status idle" id="statusBadge">IDLE</span>
</div>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">Total Requests</div>
<div class="metric-value" id="totalReq">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Requests/Second</div>
<div class="metric-value" id="rps">0<span class="metric-unit">req/s</span></div>
</div>
<div class="metric-card">
<div class="metric-label">Avg Response Time</div>
<div class="metric-value" id="avgLatency">0<span class="metric-unit">ms</span></div>
</div>
<div class="metric-card">
<div class="metric-label">P50 (Median)</div>
<div class="metric-value" id="p50">0<span class="metric-unit">ms</span></div>
</div>
<div class="metric-card">
<div class="metric-label">P90</div>
<div class="metric-value" id="p90">0<span class="metric-unit">ms</span></div>
</div>
<div class="metric-card">
<div class="metric-label">P95</div>
<div class="metric-value" id="p95">0<span class="metric-unit">ms</span></div>
</div>
<div class="metric-card">
<div class="metric-label">P99 (Worst)</div>
<div class="metric-value" id="p99">0<span class="metric-unit">ms</span></div>
</div>
<div class="metric-card">
<div class="metric-label">Success Rate</div>
<div class="metric-value" id="successRate">100<span class="metric-unit">%</span></div>
</div>
<div class="metric-card">
<div class="metric-label">Active Users</div>
<div class="metric-value" id="activeUsers">0</div>
</div>
<div class="metric-card">
<div class="metric-label">Elapsed Time</div>
<div class="metric-value" id="elapsed">0<span class="metric-unit">s</span></div>
</div>
</div>
</div>
<!-- Chart Section -->
<div class="section">
<h2>📈 Response Time Chart (Real-time)</h2>
<div class="chart-container">
<canvas id="responseTimeChart"></canvas>
</div>
</div>
<!-- Logs Section -->
<div class="section">
<h2>📝 Logs</h2>
<div class="log-container" id="logContainer">
<div class="log-entry">[INFO] Waiting for test to start...</div>
</div>
</div>
</div>
</div>
<script>
let pollingInterval = null;
let responseTimeChart = null;
const maxDataPoints = 30; // Giữ 30 data points (60 giây với poll 2s)
// Initialize Chart
function initChart() {
const ctx = document.getElementById('responseTimeChart').getContext('2d');
responseTimeChart = new Chart(ctx, {
type: 'line',
data: {
labels: [],
datasets: [
{
label: 'P99 (Worst)',
data: [],
borderColor: 'rgb(255, 99, 132)',
backgroundColor: 'rgba(255, 99, 132, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'P95',
data: [],
borderColor: 'rgb(255, 159, 64)',
backgroundColor: 'rgba(255, 159, 64, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'P50 (Median)',
data: [],
borderColor: 'rgb(75, 192, 192)',
backgroundColor: 'rgba(75, 192, 192, 0.1)',
tension: 0.4,
fill: true
},
{
label: 'Avg',
data: [],
borderColor: 'rgb(54, 162, 235)',
backgroundColor: 'rgba(54, 162, 235, 0.1)',
tension: 0.4,
fill: true
}
]
},
options: {
responsive: true,
maintainAspectRatio: false,
scales: {
y: {
beginAtZero: true,
title: {
display: true,
text: 'Response Time (ms)'
}
},
x: {
title: {
display: true,
text: 'Time (seconds)'
}
}
},
plugins: {
legend: {
display: true,
position: 'top'
}
}
}
});
}
function updateChart(metrics) {
if (!responseTimeChart) return;
const elapsed = metrics.elapsed_seconds || 0;
// Add new data point
responseTimeChart.data.labels.push(elapsed + 's');
responseTimeChart.data.datasets[0].data.push(metrics.p99_response_time_ms || 0);
responseTimeChart.data.datasets[1].data.push(metrics.p95_response_time_ms || 0);
responseTimeChart.data.datasets[2].data.push(metrics.p50_response_time_ms || 0);
responseTimeChart.data.datasets[3].data.push(metrics.avg_response_time_ms || 0);
// Giữ tối đa maxDataPoints
if (responseTimeChart.data.labels.length > maxDataPoints) {
responseTimeChart.data.labels.shift();
responseTimeChart.data.datasets.forEach(dataset => dataset.data.shift());
}
responseTimeChart.update('none'); // Update without animation for smoother realtime
}
function addLog(message, type = 'INFO') {
const logContainer = document.getElementById('logContainer');
const timestamp = new Date().toLocaleTimeString();
const logEntry = document.createElement('div');
logEntry.className = 'log-entry';
logEntry.textContent = `[${timestamp}] [${type}] ${message}`;
logContainer.appendChild(logEntry);
logContainer.scrollTop = logContainer.scrollHeight;
}
async function startTest() {
const config = {
target_url: document.getElementById('targetUrl').value,
num_users: parseInt(document.getElementById('numUsers').value),
spawn_rate: parseInt(document.getElementById('spawnRate').value),
duration_seconds: parseInt(document.getElementById('duration').value),
test_type: document.getElementById('testType').value
};
try {
document.getElementById('startBtn').disabled = true;
addLog('Starting load test...', 'INFO');
const response = await fetch('/api/test/loadtest/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config)
});
const data = await response.json();
if (data.status === 'success') {
addLog(`Test started successfully! Type: ${config.test_type}`, 'SUCCESS');
document.getElementById('stopBtn').disabled = false;
startPolling();
} else {
addLog(`Failed to start: ${data.detail || 'Unknown error'}`, 'ERROR');
document.getElementById('startBtn').disabled = false;
}
} catch (error) {
addLog(`Error: ${error.message}`, 'ERROR');
document.getElementById('startBtn').disabled = false;
}
}
async function stopTest() {
try {
addLog('Stopping test...', 'INFO');
const response = await fetch('/api/test/loadtest/stop', {
method: 'POST'
});
const data = await response.json();
addLog('Test stopped by user', 'INFO');
stopPolling();
} catch (error) {
addLog(`Error stopping test: ${error.message}`, 'ERROR');
}
}
async function fetchMetrics() {
try {
const response = await fetch('/api/test/loadtest/metrics');
const data = await response.json();
if (data.status === 'success') {
const metrics = data.data;
// Update status badge
const statusBadge = document.getElementById('statusBadge');
statusBadge.textContent = metrics.status.toUpperCase();
statusBadge.className = `status ${metrics.status}`;
// Update metrics
document.getElementById('totalReq').textContent = metrics.total_requests || 0;
document.getElementById('rps').innerHTML = `${metrics.current_rps || 0}<span class="metric-unit">req/s</span>`;
document.getElementById('avgLatency').innerHTML = `${metrics.avg_response_time_ms || 0}<span class="metric-unit">ms</span>`;
// Percentiles
document.getElementById('p50').innerHTML = `${metrics.p50_response_time_ms || 0}<span class="metric-unit">ms</span>`;
document.getElementById('p90').innerHTML = `${metrics.p90_response_time_ms || 0}<span class="metric-unit">ms</span>`;
document.getElementById('p95').innerHTML = `${metrics.p95_response_time_ms || 0}<span class="metric-unit">ms</span>`;
document.getElementById('p99').innerHTML = `${metrics.p99_response_time_ms || 0}<span class="metric-unit">ms</span>`;
const successRate = Math.round((1 - metrics.failure_rate) * 100);
document.getElementById('successRate').innerHTML = `${successRate}<span class="metric-unit">%</span>`;
document.getElementById('activeUsers').textContent = metrics.active_users || 0;
document.getElementById('elapsed').innerHTML = `${metrics.elapsed_seconds || 0}<span class="metric-unit">s</span>`;
// Update chart
updateChart(metrics);
// Stop polling if test is stopped
if (metrics.status === 'stopped' || metrics.status === 'idle') {
stopPolling();
addLog('Test completed!', 'INFO');
}
}
} catch (error) {
console.error('Error fetching metrics:', error);
}
}
function startPolling() {
if (pollingInterval) clearInterval(pollingInterval);
pollingInterval = setInterval(fetchMetrics, 2000); // Poll every 2 seconds
}
function stopPolling() {
if (pollingInterval) {
clearInterval(pollingInterval);
pollingInterval = null;
}
document.getElementById('startBtn').disabled = false;
document.getElementById('stopBtn').disabled = true;
}
// Initialize
window.addEventListener('load', () => {
initChart();
addLog('Dashboard ready', 'INFO');
});
</script>
</div> <!-- Close main-wrapper -->
</body>
</html>
\ No newline at end of file
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