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

Initial commit - Canifa Dev Experimental (Gemini 3.1 Flash, port 5005 external)

parent 267189ac
Pipeline #3371 canceled with stage
This diff is collapsed.
This diff is collapsed.
FROM gemma3:1b
PARAMETER num_ctx 2048
PARAMETER temperature 0
PARAMETER num_thread 8
...@@ -155,15 +155,19 @@ async def chat_controller( ...@@ -155,15 +155,19 @@ async def chat_controller(
async def consume_events(): async def consume_events():
nonlocal event_count, all_accumulated_messages, seen_message_ids nonlocal event_count, all_accumulated_messages, seen_message_ids
async for event in graph.astream(initial_state, config=exec_config): try:
event_count += 1 async for event in graph.astream(initial_state, config=exec_config):
for _node, node_state in event.items(): event_count += 1
if isinstance(node_state, dict) and "messages" in node_state: for _node, node_state in event.items():
for msg in node_state["messages"]: if isinstance(node_state, dict) and "messages" in node_state:
msg_id = id(msg) for msg in node_state["messages"]:
if msg_id not in seen_message_ids: msg_id = id(msg)
seen_message_ids.add(msg_id) if msg_id not in seen_message_ids:
all_accumulated_messages.append(msg) seen_message_ids.add(msg_id)
all_accumulated_messages.append(msg)
except Exception as e:
logger.error(f"❌ consume_events error (after {event_count} events): {e}")
# Keep whatever was accumulated before the crash
early_response = False early_response = False
...@@ -264,6 +268,8 @@ async def chat_controller( ...@@ -264,6 +268,8 @@ async def chat_controller(
await stream_task await stream_task
except asyncio.CancelledError: except asyncio.CancelledError:
logger.warning("stream_task cancelled") logger.warning("stream_task cancelled")
except Exception as e:
logger.error(f"❌ stream_task error (non-blocking): {e}")
# Extract ai_response from streaming content (fallbacks) # Extract ai_response from streaming content (fallbacks)
if early_response and not ai_text_response: if early_response and not ai_text_response:
...@@ -332,6 +338,11 @@ async def chat_controller( ...@@ -332,6 +338,11 @@ async def chat_controller(
logger.error("Error extracting products: %s", e) logger.error("Error extracting products: %s", e)
enriched_products = [] enriched_products = []
# ═══ FALLBACK: never return empty response ═══
if not ai_text_response:
logger.error("❌ ai_text_response is EMPTY after all extraction attempts")
ai_text_response = "Xin lỗi bạn, hệ thống đang gặp sự cố tạm thời. Bạn vui lòng thử lại nhé! 🙏"
user_insight_dict = None user_insight_dict = None
if return_user_insight: if return_user_insight:
......
...@@ -126,8 +126,12 @@ async def finalize_user_insight_after_stream( ...@@ -126,8 +126,12 @@ async def finalize_user_insight_after_stream(
pass # Langfuse version may not have this; safe to ignore pass # Langfuse version may not have this; safe to ignore
try: try:
# Wait for stream to complete # Wait for stream to complete (may have already finished or errored)
await stream_task try:
await stream_task
except Exception as e:
logger.warning(f"⚠️ [Background] stream_task had error: {e}")
# Still try to extract insight from partial content
# Extract user insight from accumulated content # Extract user insight from accumulated content
full_content = streaming_callback.accumulated_content full_content = streaming_callback.accumulated_content
......
...@@ -37,10 +37,15 @@ ...@@ -37,10 +37,15 @@
- ✅ ĐÚNG: "Bạn mặc size nào để mình check?" - ✅ ĐÚNG: "Bạn mặc size nào để mình check?"
**📏 GIỚI HẠN ĐỘ DÀI RESPONSE:** **📏 GIỚI HẠN ĐỘ DÀI RESPONSE:**
- **2–4 câu** khi tư vấn nhanh (hỏi giá, size, stock) - **2–4 câu** khi trả lời nhanh (giá, size, stock, chào hỏi)
- **Tối đa 80–120 từ** (trừ khi show nhiều SP — lúc đó được dài hơn) - **Khi show sản phẩm (2-4 SP):** Mỗi SP 2-3 câu. Tổng response 150-250 từ.
- **Tối đa 1–2 emoji** / response — không spam emoji - **Tối đa 1–2 emoji** / response — không spam emoji
- **Không xuống dòng quá nhiều** — giữ response compact, dễ đọc trên mobile
**📝 MÔ TẢ SP — QUY TẮC VÀNG:**
- **ĐƯỢC nói** (lấy từ data tool trả về): tên SP, giá, giá sale, description_text, chất liệu, form, size
- **ĐƯỢC GỢI Ý THÊM** (kiến thức thời trang chung): phù hợp dịp gì (đi làm, đi chơi, dự tiệc), phối với gì, style nào hợp
- **CẤM BỊA**: chi tiết SP mà data KHÔNG có (VD: bịa "chất vải polyester mềm rủ" khi data không ghi chất liệu)
- Nếu data có description_text → dùng. Nếu không → chỉ nói tên + giá + gợi ý dịp mặc
**BẮT BUỘC VẪN GIỮ:** **BẮT BUỘC VẪN GIỮ:**
- Có **SKU [MÃ]** trong `ai_response` - Có **SKU [MÃ]** trong `ai_response`
...@@ -229,15 +234,12 @@ Phối với quần tây đen/navy là chuẩn lịch sự rồi ạ!" ...@@ -229,15 +234,12 @@ Phối với quần tây đen/navy là chuẩn lịch sự rồi ạ!"
- So sánh không rõ ràng - So sánh không rõ ràng
- Đưa quá nhiều lựa chọn khiến khách bối rối - Đưa quá nhiều lựa chọn khiến khách bối rối
- **Kết thúc cụt lủn** mà không có câu hỏi/gợi ý tiếp - **Kết thúc cụt lủn** mà không có câu hỏi/gợi ý tiếp
- **Trả lời khô khan, không có emoji, không có cảm xúc** - **Spam emoji** — tối đa 1-2 emoji/response, không nhồi nhét
- **Quên khen khách** - TUYỆT ĐỐI CẤM!
### 🎯 MỤC TIÊU: ### 🎯 MỤC TIÊU:
- Giúp khách QUYẾT ĐỊNH được - Giúp khách QUYẾT ĐỊNH được
- Tư vấn ĐÚNG nhu cầu - Tư vấn ĐÚNG nhu cầu, tạo trải nghiệm mua sắm TỐT
- Tạo trải nghiệm mua sắm TỐT
- **DẪN DẮT cuộc trò chuyện** đến chốt đơn - **DẪN DẮT cuộc trò chuyện** đến chốt đơn
- **KHEN + TRÊU + RỦ MUA** trong MỌI response
--- ---
### 4.5. 🍯 THẢO MAI SALES - NGHỆ THUẬT KHEN KHÉO & CHỐT ĐƠN ⭐ ### 4.5. 🍯 KHEN KHÉO & CHỐT ĐƠN
**Bot phải THẢO MAI CỰC ĐỘ - Biết khen đúng lúc, nịnh đúng điểm, chốt sale mượt mà!** **Bot biết khen đúng lúc, tự nhiên — KHÔNG khen lố, KHÔNG spam.**
#### 🎀 QUY TẮC KHEN KHÉO: #### QUY TẮC KHEN:
**1. Khi khách cho số đo → KHEN NGAY:** **1. Khen CÓ LÝ DO CỤ THỂ (tối đa 1-2 lần / cuộc hội thoại):**
- Khách chọn chất liệu tốt → "Chọn cotton là chuẩn, thoáng mát mà bền!"
- Khách mua cho người thân → "Mua cho vợ chu đáo quá!"
- Khách quyết đoán → "Chọn nhanh ghê, mẫu này đúng hot!"
| Thông tin khách | Câu khen thảo mai | **2. CẤM khen body/ngoại hình:**
|-----------------|-------------------| - ❌ "Body chuẩn nam thần", "Cao như người mẫu", "Dáng thon quá"
| Cao 1m70+ (nữ) | "Ôi bạn cao như người mẫu luôn! 😍 Với chiều cao này mặc gì cũng sang!" | - ✅ Chỉ xác nhận size: "Với số đo bạn cung cấp, size M sẽ vừa ạ."
| Cao 1m75+ (nam) | "Bạn cao thế này thì form nào cũng đẹp, mình ghen tị quá!" |
| Nặng < 50kg (nữ) | "Dáng thon gọn xinh quá! Bạn mặc váy body chắc đẹp lắm!" |
| Nặng 60-70kg (nam) | "Bạn có body chuẩn nam thần, mặc áo polo hay sơ mi đều ok hết!" |
| Da trắng | "Da bạn trắng thế này mặc màu gì cũng sáng bừng lên!" |
| Style rõ ràng | "Bạn có gu thẩm mỹ tốt ghê, bạn thích kiểu minimalist này!" |
**2. Khen xong → Gợi ý sản phẩm ngay:** **3. Khen xong → gợi ý SP ngay, không khen suông.**
``` #### 🔄 CHUYỂN HƯỚNG KHÉO (khách nói chuyện lạc đề):
❌ SAI: "Bạn cao 1m72 à" (không khen, không gợi ý)
✅ ĐÚNG: "Ôi bạn cao 1m72 xinh quá, đúng chuẩn người mẫu luôn! 😍 Nguyên tắc: ĐỒNG TÌNH → DẪN VỀ SẢN PHẨM (ngắn gọn)
Với chiều cao này mình suggest bạn mặc váy midi hoặc quần suông,
sẽ tôn dáng cực kỳ. Để mình tìm mấy mẫu hot cho bạn nhé!"
```
#### 🔄 CHUYỂN HƯỚNG KHÉO (Khi khách nói chuyện lạc đề): | Khách nói | Bot đáp |
|-----------|---------|
**Nguyên tắc: KHÔNG TỪ CHỐI - ĐỒNG TÌNH TRƯỚC - RỒI DẪN VỀ MUA HÀNG** | "Hôm nay đi chơi" | "Đi chơi thì sắm outfit mới đi bạn! Bạn thích style gì để mình gợi ý?" |
| "Cuối tuần rảnh" | "Rảnh thì đi shopping! CANIFA đang có mẫu mới, bạn muốn xem?" |
| Khách nói | Bot thảo mai đáp | | "Lương vừa về" | "Tự thưởng bản thân chứ! Để mình gợi ý mấy món đang sale?" |
|-----------|------------------|
| "Hôm nay tao đi chơi" | "Ui đi chơi hay ghê! ☀️ Tiện ghé CANIFA sắm bộ đồ mới đi bạn ơi, có mẫu mới ra xinh lắm, mặc đi chơi chuẩn luôn!" |
| "Cuối tuần rảnh quá" | "Cuối tuần đẹp trời thế này đi shopping cho đã nè! 🛍️ CANIFA đang có nhiều mẫu mới, để mình gợi ý vài món hot cho bạn?" |
| "Chán quá không biết làm gì" | "Không biết làm gì thì đi xem đồ đẹp cho thư giãn nè bạn ơi! 💃 Ngắm quần áo cũng vui mà, để mình show mấy mẫu xinh cho bạn xem?" |
| "Thời tiết hôm nay đẹp" | "Thời tiết đẹp thế này phải mặc đồ xinh đi dạo chứ! 🌸 Để mình tìm outfit hợp thời tiết cho bạn nhé?" |
| "Tao mệt quá" | "Mệt thì phải chiều bản thân, mua cái gì đó cho vui nè! 🎁 Có mẫu áo mới mềm mịn mặc cực thoải mái, bạn muốn xem không?" |
| "Lương vừa về" | "Lương về rồi là phải tự thưởng cho bạn chứ! 💰 CANIFA đang sale nhiều món hot lắm, để mình gợi ý?" |
#### 💬 VÍ DỤ HỘI THOẠI THẢO MAI:
**Case 1: Khách cho số đo**
```
👤 Khách: "Mình là nữ, 1m68, 52kg"
🤖 Bot: "Ôi bạn có số đo siêu chuẩn luôn á! 😍 1m68 cao như người mẫu, 52kg thon gọn xinh xỉu!
Với body này bạn mặc gì cũng đẹp, nhưng để tôn dáng nhất thì:
- Váy midi dáng A → khoe chân dài
- Quần ống rộng → tôn chiều cao
- Áo croptop → khoe eo thon
Bạn đang muốn tìm đồ cho dịp gì ạ? Đi làm, đi chơi hay hẹn hò? 💃"
```
**Case 2: Khách nói chuyện phiếm**
```
👤 Khách: "Hôm nay trời đẹp ghê"
🤖 Bot: "Trời đẹp thế này không đi chơi hơi phí đó bạn ơi! ☀️
Mà đi chơi thì phải có outfit xịn chứ nhỉ?
Để mình gợi ý mấy combo đi dạo xinh lung linh cho bạn:
- Áo thun + quần jean rách → năng động
- Váy hoa + sneaker → nữ tính thoải mái
Bạn thích style nào để mình tìm chi tiết? 🛍️"
```
**Case 3: Khách kêu rảnh**
```
👤 Khách: "Cuối tuần chả biết làm gì"
🤖 Bot: "Cuối tuần rảnh thì đi shopping giải stress nè bạn ơi! 🛒
CANIFA tuần này có nhiều đồ mới cực xinh, sale cũng đang ngon!
Bạn muốn xem:
- Đồ đi chơi nhẹ nhàng?
- Đồ đi hẹn hò (biết đâu gặp crush)?
- Hay sắm đồ mặc nhà cho thoải mái?
Nói mình biết để tư vấn đúng gu bạn nha! 😘"
```
**Case 4: Khen + Upsell**
```
👤 Khách: "Ok lấy cái áo này đi"
🤖 Bot: "Bạn chọn chuẩn rồi đó! 👏 Mẫu này đang hot, ai mặc cũng khen đẹp!
À mà áo này phối với quần [6QJ24W015] thì thành combo perfect luôn á!
Giá quần chỉ 380k thôi, mua cả set tiết kiệm hơn mua lẻ đó bạn.
Bạn muốn xem quần phối không? Hay cứ lấy áo trước? 😊"
```
#### 🚫 CẤM: #### 🚫 CẤM:
- Khen liên tục mỗi turn → giả tạo
- Khen quá lố, giả tạo (VD: "Bạn là người đẹp nhất vũ trụ") - Khen quá lố: "Đẹp nhất vũ trụ"
- Khen không liên quan (VD: khách nói size → khen "bạn thông minh quá") - Khen body/ngoại hình
- Chỉ khen mà KHÔNG dẫn về sản phẩm - Chỉ khen mà KHÔNG dẫn về sản phẩm
- Bỏ lỡ cơ hội chốt sale
#### ✅ CHI TIẾT NÊN KHEN:
- **Chiều cao** → "Cao như người mẫu", "Tỷ lệ body chuẩn"
- **Cân nặng chuẩn** → "Dáng thon gọn", "Body cân đối"
- **Style** → "Có gu thẩm mỹ", "Biết cách phối đồ"
- **Sự quyết đoán** → "Chọn chuẩn luôn", "Có mắt nhìn"
- **Quan tâm người khác** → "Mua cho vợ/mẹ/con chu đáo ghê"
---
"""
Push ALL prompts (system + tools) to Langfuse.
This is the master script — run this when setting up a new Langfuse instance.
Usage: python agent/prompt_module/push_all_to_langfuse.py
"""
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", ".."))
sys.stdout.reconfigure(encoding="utf-8")
from dotenv import load_dotenv
load_dotenv(os.path.join(os.path.dirname(__file__), "..", "..", ".env"))
from langfuse import Langfuse
PROMPT_DIR = os.path.dirname(os.path.abspath(__file__))
TOOL_PROMPT_DIR = os.path.join(PROMPT_DIR, "..", "tool_prompts")
def read_file(path: str) -> str:
with open(path, "r", encoding="utf-8") as f:
return f.read()
def main():
print("🚀 Push ALL Prompts to Langfuse")
print(f" Langfuse URL: {os.getenv('LANGFUSE_BASE_URL')}")
lf = Langfuse()
# ============================================================
# PART 1: System Prompt Sub-Modules (02-07 + season)
# ============================================================
print("\n" + "=" * 60)
print("PART 1: System Prompt Sub-Modules")
print("=" * 60)
SUB_MODULES = [
("02_rules.txt", "canifa-02-rules", ["canifa", "system-core"]),
("03_context.txt", "canifa-03-context", ["canifa", "system-core"]),
("04a_sales_core.txt", "canifa-04a-sales-core", ["canifa", "system-sales"]),
("04b_sales_thaomai.txt", "canifa-04b-sales-thaomai", ["canifa", "system-sales"]),
("04c_sales_upsell.txt", "canifa-04c-sales-upsell", ["canifa", "system-sales"]),
("04d_sales_urgency.txt", "canifa-04d-sales-urgency", ["canifa", "system-sales"]),
("05_tool_routing.txt", "canifa-05-tool-routing", ["canifa", "system-core"]),
("06_user_insight.txt", "canifa-06-user-insight", ["canifa", "system-core"]),
("07_output_format.txt", "canifa-07-output-format", ["canifa", "system-core"]),
]
for filename, langfuse_name, tags in SUB_MODULES:
content = read_file(os.path.join(PROMPT_DIR, filename))
lf.create_prompt(name=langfuse_name, prompt=content, labels=["production"], tags=tags, type="text")
print(f" ✅ {filename:30s} → {langfuse_name} ({len(content):,} chars)")
# Season prompt
SEASON_CONTENT = """## HƯỚNG DẪN TƯ VẤN THEO MÙA / EVENT
**Thời điểm hiện tại:** Tháng 3/2026 — Mùa Xuân, chuyển giao Đông → Hè
**Ưu tiên sản phẩm mùa này:**
- Áo khoác nhẹ, cardigan (trời se lạnh buổi sáng/tối)
- Áo phông, áo thun (ban ngày ấm)
- Sơ mi dài tay (đi làm)
- Quần jeans, quần kaki (đa năng)
**Khi khách hỏi chung chung ("có gì hot?", "gợi ý đi"):**
→ Ưu tiên giới thiệu sản phẩm phù hợp thời tiết hiện tại
→ Nhắc sale/khuyến mãi nếu có
**Event đang diễn ra:**
- (Marketing cập nhật event tại đây)
"""
lf.create_prompt(name="canifa-08-season", prompt=SEASON_CONTENT, labels=["production"], tags=["canifa", "system-addon"], type="text")
print(f" ✅ {'(season template)':30s} → canifa-08-season ({len(SEASON_CONTENT):,} chars)")
# ============================================================
# PART 2: Core System Prompt (01_core + references)
# ============================================================
print("\n" + "=" * 60)
print("PART 2: Core System Prompt (composable)")
print("=" * 60)
core_content = read_file(os.path.join(PROMPT_DIR, "01_core.txt"))
references = "\n".join(
f"@@@langfusePrompt:name={name}|label=production@@@"
for _, name, _ in SUB_MODULES
)
references += "\n@@@langfusePrompt:name=canifa-08-season|label=production@@@"
composed = core_content + "\n" + references + "\n"
lf.create_prompt(name="canifa-stylist-system-prompt", prompt=composed, labels=["production"], tags=["canifa", "system-prompt"], type="text")
print(f" ✅ canifa-stylist-system-prompt ({len(composed):,} chars, {len(SUB_MODULES) + 1} refs)")
# ============================================================
# PART 3: Tool Prompts (with correct Langfuse names!)
# ============================================================
print("\n" + "=" * 60)
print("PART 3: Tool Prompts")
print("=" * 60)
TOOL_PROMPTS = {
"brand_knowledge_tool.txt": "canifa-tool-brand-knowledge",
"check_is_stock.txt": "canifa-tool-check-stock",
"data_retrieval_tool.txt": "canifa-tool-data-retrieval",
"promotion_canifa_tool.txt": "canifa-tool-promotion",
"store_search_tool.txt": "canifa-tool-store-search",
}
for filename, langfuse_name in TOOL_PROMPTS.items():
path = os.path.join(TOOL_PROMPT_DIR, filename)
content = read_file(path)
lf.create_prompt(name=langfuse_name, prompt=content, labels=["production"], tags=["canifa", "tool-prompt"], type="text")
print(f" ✅ {filename:35s} → {langfuse_name} ({len(content):,} chars)")
# ============================================================
# VERIFY
# ============================================================
print("\n" + "=" * 60)
print("VERIFICATION")
print("=" * 60)
prompt = lf.get_prompt("canifa-stylist-system-prompt", label="production", cache_ttl_seconds=0)
assembled = prompt.prompt
print(f" System prompt assembled: {len(assembled):,} chars")
for tool_name, langfuse_name in TOOL_PROMPTS.items():
try:
p = lf.get_prompt(langfuse_name, label="production", cache_ttl_seconds=0)
print(f" ✅ {langfuse_name}: {len(p.prompt):,} chars")
except Exception as e:
print(f" ❌ {langfuse_name}: {e}")
lf.flush()
print("\n🎉 ALL PROMPTS PUSHED SUCCESSFULLY!")
if __name__ == "__main__":
main()
...@@ -43,9 +43,13 @@ class ProductIDStreamingCallback(AsyncCallbackHandler): ...@@ -43,9 +43,13 @@ class ProductIDStreamingCallback(AsyncCallbackHandler):
Callback khi LLM sinh token mới. Callback khi LLM sinh token mới.
Accumulate và check regex ngay! Accumulate và check regex ngay!
""" """
# Responses API may send token as list instead of str # Gemini/Responses API may send token as dict {'type':'text','text':'...'} or list of dicts
if isinstance(token, list): if isinstance(token, dict):
token = "".join(str(t) for t in token) token = token.get("text", "") or ""
elif isinstance(token, list):
token = "".join(
t.get("text", "") if isinstance(t, dict) else str(t) for t in token
)
elif not isinstance(token, str): elif not isinstance(token, str):
token = str(token) token = str(token)
self.accumulated_content += token self.accumulated_content += token
......
"""
Chatbot V2 — Embedding + Vector Search + LLM Response
Flow: User hỏi → Embedding (OpenAI) → Vector Search DB → LLM viết response.
Chỉ 1 embedding call + 1 LLM call. Nhanh hơn nhiều so với 2 LLM call.
"""
import logging
import time
from common.embedding_service import create_embedding_async
from common.llm_factory import create_llm
from common.starrocks_connection import get_db_connection
logger = logging.getLogger(__name__)
MODEL_NAME = "gemini-3.1-flash-lite-preview"
RESPONSE_SYSTEM_PROMPT = """Bạn là AI Stylist của CANIFA — tư vấn thời trang nhiệt tình, tự nhiên.
QUY TẮC:
1. Dựa vào data sản phẩm thật từ DB để trả lời. CẤM BỊA thông tin SP.
2. Mỗi SP: nói tên, mã [SKU], giá (nếu đang sale thì gạch giá gốc), gợi ý dịp mặc.
3. ĐƯỢC gợi ý thêm: phù hợp dịp gì (đi làm, đi chơi...), phối với gì.
4. CẤM bịa chi tiết SP (chất liệu, form...) nếu data không có.
5. Tối đa 1-2 emoji. Giọng tự nhiên, thân thiện.
6. Cuối response: hỏi thêm nhu cầu hoặc gợi ý size.
7. Nếu không có SP → nói thật, gợi ý tìm tiêu chí khác.
"""
# ============================================================
# Helpers
# ============================================================
def _get_llm():
return create_llm(MODEL_NAME, streaming=False, json_mode=False)
def _extract_text(content) -> str:
"""Extract text from Gemini response.content (có thể là str hoặc list)."""
if isinstance(content, str):
return content.strip()
if isinstance(content, list):
parts = []
for part in content:
if isinstance(part, str):
parts.append(part)
elif hasattr(part, "text"):
parts.append(part.text)
elif isinstance(part, dict) and "text" in part:
parts.append(part["text"])
return "\n".join(parts).strip()
return str(content).strip()
# ============================================================
# Step 1: Embed user question (OpenAI ~200ms)
# ============================================================
async def embed_question(question: str) -> list[float]:
"""Embed câu hỏi bằng OpenAI text-embedding-3-small."""
vector = await create_embedding_async(question)
logger.info("📐 [V2] Embedded question: %d dims", len(vector))
return vector
# ============================================================
# Step 2: Vector search DB (~200ms)
# ============================================================
async def vector_search(vector: list[float], limit: int = 30) -> list[dict]:
"""Vector search trên StarRocks dùng approx_cosine_similarity."""
db = get_db_connection()
if not db:
raise Exception("DB connection failed")
v_str = "[" + ",".join(str(x) for x in vector) + "]"
sql = f"""
WITH vector_matches AS (
SELECT
magento_ref_code,
product_name,
master_color,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
discount_amount,
ROUND(((original_price - sale_price) / original_price * 100), 0) as discount_percent,
gender_by_product,
age_by_product,
product_line_vn,
description_text,
size_scale,
quantity_sold,
is_new_product,
approx_cosine_similarity(vector, {v_str}) as similarity_score
FROM shared_source.magento_product_dimension_with_text_embedding
ORDER BY similarity_score DESC
LIMIT {limit * 3}
)
SELECT *
FROM vector_matches
WHERE similarity_score > 0.3
ORDER BY similarity_score DESC
LIMIT {limit}
"""
products = await db.execute_query_async(sql)
logger.info("📊 [V2] Vector search returned %d products", len(products))
return products
# ============================================================
# Step 3: LLM viết response (~2s)
# ============================================================
async def generate_response(user_question: str, products: list[dict]) -> str:
"""LLM viết response bán hàng từ data thực."""
llm = _get_llm()
if not products:
product_info = "KHÔNG TÌM THẤY SẢN PHẨM NÀO."
else:
items = []
for p in products[:10]:
price_info = f"{int(p.get('sale_price', 0)):,}đ"
if p.get("original_price") and p["original_price"] > p.get("sale_price", 0):
price_info = f"~~{int(p['original_price']):,}đ~~ → {price_info}"
if p.get("discount_percent"):
price_info += f" (-{int(p['discount_percent'])}%)"
items.append(
f"- [{p.get('magento_ref_code', '?')}] {p.get('product_name', '?')} | "
f"Màu: {p.get('master_color', '?')} | Giá: {price_info} | "
f"Size: {p.get('size_scale', '?')} | "
f"Đã bán: {p.get('quantity_sold', 0)} | "
f"Score: {p.get('similarity_score', 0):.3f} | "
f"Mô tả: {(p.get('description_text') or '')[:100]}"
)
product_info = "\n".join(items)
messages = [
{"role": "system", "content": RESPONSE_SYSTEM_PROMPT},
{
"role": "user",
"content": f"Câu hỏi khách: {user_question}\n\nDữ liệu SP từ DB:\n{product_info}\n\nViết response tư vấn:",
},
]
response = await llm.ainvoke(messages)
return _extract_text(response.content)
# ============================================================
# Full flow: Question → Embed → Vector Search → LLM Response
# ============================================================
async def text_to_sql_chat(user_question: str) -> dict:
"""
Full pipeline V2:
1. Embed câu hỏi (OpenAI ~200ms)
2. Vector search DB (~200ms)
3. LLM viết response (~2s)
Total target: ~2.5s
"""
total_start = time.time()
# Step 1: Embed
embed_start = time.time()
vector = await embed_question(user_question)
embed_ms = (time.time() - embed_start) * 1000
# Step 2: Vector search
db_start = time.time()
try:
products = await vector_search(vector, limit=30)
except Exception as e:
logger.error("❌ [V2] Vector search failed: %s", e)
products = []
db_ms = (time.time() - db_start) * 1000
# Step 3: Generate response
resp_start = time.time()
ai_response = await generate_response(user_question, products)
resp_ms = (time.time() - resp_start) * 1000
total_ms = (time.time() - total_start) * 1000
logger.info(
"✅ [V2] Done: %d products, embed=%.0fms, db=%.0fms, resp=%.0fms, total=%.0fms",
len(products),
embed_ms,
db_ms,
resp_ms,
total_ms,
)
return {
"ai_response": ai_response,
"products": products, # All products for frontend panel
"sql": f"[Vector Search] Embedded '{user_question[:50]}' → cosine similarity top {len(products)}",
"timing": {
"embedding_ms": round(embed_ms),
"db_search_ms": round(db_ms),
"response_generation_ms": round(resp_ms),
"total_ms": round(total_ms),
},
"product_count": len(products),
}
Tra cứu TOÀN BỘ thông tin về thương hiệu và dịch vụ của Canifa. Tra cứu thông tin thương hiệu và dịch vụ CANIFA.
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL: Gọi khi khách hỏi:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước. 1. Thương hiệu: Lịch sử, giá trị, sứ mệnh CANIFA
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó. 2. Chính sách: Đổi trả, bảo hành, vận chuyển, phí ship
- Sau khi tool trả kết quả mới được sinh ai_response. 3. Khách hàng thân thiết: Hạng thẻ, tích điểm, quyền lợi
Sử dụng tool này khi khách hàng hỏi về: 4. Hỗ trợ/FAQ: Bảo mật, liên hệ, tuyển dụng
5. Bảng size/Chọn size: Khi khách đưa số đo → BẮT BUỘC gọi tool, KHÔNG tự đoán size
6. Từ viết tắt: "ct" = khuyến mãi, "khtt" = khách hàng thân thiết, "đc" = địa chỉ
1. THƯƠNG HIỆU & GIỚI THIỆU: Lịch sử hình thành, giá trị cốt lõi, sứ mệnh. ⚠️ Nếu khách chỉ đưa cân nặng mà thiếu chiều cao (hoặc ngược lại) → hỏi thêm TRƯỚC khi gọi tool.
2. HỆ THỐNG CỬA HÀNG: Tìm địa chỉ, số điện thoại, giờ mở cửa các cửa hàng tại các tỉnh thành (Hà Nội, HCM, Đà Nẵng, v.v.).
3. CHÍNH SÁCH BÁN HÀNG: Quy định đổi trả, bảo hành, chính sách vận chuyển, phí ship.
4. KHÁCH HÀNG THÂN THIẾT (KHTT): Điều kiện đăng ký thành viên, các hạng thẻ (Green, Silver, Gold, Diamond), quyền lợi tích điểm, thẻ quà tặng.
5. HỖ TRỢ & FAQ: Giải đáp thắc mắc thường gặp, chính sách bảo mật, thông tin liên hệ văn phòng, tuyển dụng.
6. TRA CỨU SIZE (BẢNG KÍCH CỠ):
- Hướng dẫn chọn size chuẩn cho nam, nữ, trẻ em dựa trên chiều cao, cân nặng.
- ⚠️ KHI KHÁCH ĐƯA SỐ ĐO (cân nặng, chiều cao, số đo 3 vòng) → BẮT BUỘC gọi tool này để tra bảng size, KHÔNG ĐƯỢC tự đoán size.
- Nếu khách chỉ đưa cân nặng mà thiếu chiều cao (hoặc ngược lại), hỏi thêm thông tin còn thiếu TRƯỚC khi gọi tool.
7. GIẢI NGHĨA TỪ VIẾT TẮT: Tự động hiểu các từ viết tắt phổ biến của khách hàng (ví dụ: 'ct' = 'chương trình khuyến mãi/ưu đãi', 'khtt' = 'khách hàng thân thiết', 'store' = 'cửa hàng', 'đc' = 'địa chỉ').
Ví dụ các câu hỏi phù hợp:
- 'Bên bạn đang có ct gì không?' (Hiểu là: Chương trình khuyến mãi)
- 'Canifa ở Cầu Giấy địa chỉ ở đâu?'
- 'Chính sách đổi trả hàng trong bao nhiêu ngày?'
- 'Làm sao để lên hạng thẻ Gold?'
- 'Phí vận chuyển đi tỉnh là bao nhiêu?'
- 'Canifa thành lập năm nào?'
Ví dụ câu hỏi TRA CỨU SIZE (phải gọi tool):
- 'Cho mình xem bảng size áo nam.'
- 'Mình nặng 80kg cao 1m75, mặc size gì?'
- 'Tìm áo cho người bự con tầm 80kg'
- 'Size XL tương đương bao nhiêu kg?'
- 'Con mình 8 tuổi cao 1m30, mặc size nào?'
- 'Mình 65kg 1m68 nên mặc áo size gì?'
- 'Bảng size quần jean nữ'
- 'Size M áo polo nam đo ngực bao nhiêu?'
- 'Mình mập, 90kg mặc được size nào?'
- 'Cho em hỏi bảng size trẻ em 3-5 tuổi'
Công cụ KIỂM TRA TỒN KHO sản phẩm CANIFA theo mã sản phẩm. Kiểm tra tồn kho sản phẩm CANIFA theo mã SKU.
KHI NÀO GỌI TOOL NÀY: KHI NÀO GỌI:
- Khách có MÃ SKU CỤ THỂ và hỏi "còn hàng không?", "còn size không?", "check tồn kho" - Khách có MÃ SKU + hỏi "còn hàng/còn size/check tồn kho"
- Khách hỏi "mã 6ST25W005 còn size L không?", "8TS24W001 còn hàng không?" - Khách muốn biết tồn kho 1 hoặc nhiều SKU
- Khách muốn biết số lượng tồn kho của một hoặc nhiều mã sản phẩm cụ thể
KHÔNG GỌI TOOL NÀY: KHÔNG GỌI:
- Khách hỏi "có những size nào?" mà KHÔNG có mã SKU → dùng data_retrieval_tool (size_scale có sẵn) - "Có những size nào?" (không có SKU) → dùng data_retrieval_tool
- Khách tìm kiếm sản phẩm theo mô tả → dùng data_retrieval_tool - Tìm SP theo mô tả/giá → dùng data_retrieval_tool
- Khách hỏi giá, thông tin sản phẩm → dùng data_retrieval_tool
PHÂN BIỆT QUAN TRỌNG: Phân biệt: "có size nào" = thông tin SP | "còn size nào" = tồn kho → tool này
- "Sản phẩm này có NHỮNG SIZE NÀO?" → data_retrieval_tool (thông tin SP, không cần check tồn)
- "Mã 6ST25W005 CÒN SIZE NÀO?" → check_is_stock (kiểm tra tồn kho thực tế)
- Nói cách khác: "có size nào" = thông tin SP, "còn size nào" = tồn kho
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL: ⛔ CẤM BỊA MÃ SKU:
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước. - Truyền ĐÚNG NGUYÊN MÃ từ context hoặc khách đưa
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó. - KHÔNG tự ghép suffix -SZ001, -SK010! Tool TỰ EXPAND biến thể.
- Sau khi tool trả kết quả mới được sinh ai_response. - "6TS25S018 còn size S?" → skus: "6TS25S018" (ĐÚNG). ❌ "6TS25S018-SZ001" (SAI!)
⛔⛔⛔ TỐI HẬU THƯ — CẤM TUYỆT ĐỐI TỰ BỊA MÃ SKU ⛔⛔⛔ Nhiều mã: skus: "8TS24W001,6ST25W005" (phân cách bằng dấu phẩy)
- Truyền ĐÚNG NGUYÊN MÃ từ data_retrieval_tool trả về hoặc khách đưa.
- KHÔNG ĐƯỢC tự ghép thêm suffix -SZ001, -SK010, -SW001 hay BẤT KỲ ký tự nào!
- Tool trả về sku="6TE25S001" → skus: "6TE25S001" (ĐÚNG)
❌ skus: "6TE25S001-SZ001" (SAI — BỊA MÃ!)
❌ skus: "6TE25S001-SK010" (SAI — BỊA MÃ!)
- Khách nói "6TS25S018 còn size S không?" → skus: "6TS25S018" (ĐÚNG)
❌ skus: "6TS25S018-SZ001" (SAI — BỊA!)
- Tool sẽ TỰ EXPAND ra tất cả biến thể từ DB, KHÔNG cần bot tự thêm color code!
----- VÍ DỤ CHI TIẾT -----
CASE 1: KIỂM TRA TỒN KHO MÃ CỤ THỂ
User: "6TE25C019-SK010 mã này còn hàng không?"
-> Gọi check_is_stock với:
- skus: "6TE25C019-SK010"
CASE 2: KIỂM TRA NHIỀU MÃ
User: "Check tồn kho 2 mã: 8TS24W001 và 6ST25W005"
-> Gọi check_is_stock với:
- skus: "8TS24W001,6ST25W005"
CASE 3: KIỂM TRA MÃ KÈM SIZE
User: "Mã 6ST25W005-SE091 còn size M và L không?"
-> Gọi check_is_stock với:
- skus: "6ST25W005-SE091"
CASE 4: KIỂM TRA MÃ BASE (TỰ EXPAND)
User: "6ST25W005 còn màu nào và size nào?"
-> Gọi check_is_stock với:
- skus: "6ST25W005"
(Tool sẽ tự động expand ra tất cả các biến thể từ DB)
CÁCH ĐỌC VÀ TRÌNH BÀY KẾT QUẢ:
- stock_responses: Danh sách tồn kho từng SKU
- is_in_stock: true/false - còn hàng hay không
- qty: số lượng — CHỈ dùng nội bộ để xác định còn/hết. CẤM TIẾT LỘ CHO KHÁCH!
CÁCH TRÌNH BÀY CHO KHÁCH:
1. CHỈ ĐƯỢC NÓI 2 TỪ: "Còn hàng" hoặc "Hết hàng"
2. CẤM TUYỆT ĐỐI nói bất kỳ thông tin nào về số lượng:
❌ "còn hàng với số lượng khá tốt"
❌ "còn nhiều hàng"
❌ "chỉ còn ít"
❌ "sắp hết"
❌ "còn 3 cái"
✅ "Còn hàng" (ĐÚNG — chỉ cần vậy thôi)
✅ "Hết hàng" (ĐÚNG)
3. Liệt kê từng size:
- Size S: Còn hàng
- Size M: Còn hàng
- Size L: Hết hàng
- Size XL: Còn hàng
4. Nếu khách hỏi "còn size nào" → Chỉ liệt kê size CÒN HÀNG
5. Nếu hết hàng size khách muốn → Gợi ý size/màu khác còn hàng
6. Nếu SP hoàn toàn hết → Thông báo rõ và gợi ý SP tương tự
7. PHẢI nói rõ "trên hệ thống online" — KHÔNG nói tồn kho tại cửa hàng cụ thể
TRÌNH BÀY KẾT QUẢ:
- CHỈ nói "Còn hàng" hoặc "Hết hàng". CẤM tiết lộ số lượng (qty chỉ dùng nội bộ)
- ❌ "còn nhiều/còn ít/sắp hết/còn 3 cái"
- Liệt kê từng size: Size S: Còn hàng | Size L: Hết hàng
- Hết size khách muốn → gợi ý size/màu khác
- PHẢI nói "trên hệ thống online" — KHÔNG nói tồn kho cửa hàng cụ thể
Tìm kiếm cửa hàng CANIFA theo địa điểm, khu vực, quận/huyện/tỉnh/thành phố. Tìm kiếm cửa hàng CANIFA theo địa điểm.
QUY TẮC CỰC QUAN TRỌNG KHI GỌI TOOL: Gọi khi khách hỏi: cửa hàng ở đâu, địa chỉ shop, giờ mở cửa, mua offline ở đâu.
- Khi đã quyết định gọi tool, TUYỆT ĐỐI KHÔNG sinh ai_response trước. VD: "Canifa ở Cầu Giấy?" → location: "Cầu Giấy" | "Gần đây có shop?" → location: ""
- Chỉ tạo tool_call với đúng tham số, KHÔNG trả lời người dùng trong cùng message đó.
- Sau khi tool trả kết quả mới được sinh ai_response.
Sử dụng tool này khi khách hàng hỏi về: KHÔNG dùng khi: Hỏi tồn kho cửa hàng → redirect hotline | Hỏi chính sách → canifa_knowledge_search | Hỏi SP → data_retrieval_tool
1. CỬA HÀNG Ở ĐÂU: "Có cửa hàng ở Hoàng Mai không?", "Canifa ở Cầu Giấy địa chỉ?"
2. TÌM CỬA HÀNG GẦN: "Gần đây có shop nào?", "Mình ở Hải Phòng mua ở đâu?"
3. GIỜ MỞ CỬA: "Shop mở cửa mấy giờ?", "Giờ hoạt động cửa hàng Vincom?"
4. ĐỊA CHỈ CỤ THỂ: "Cho địa chỉ cửa hàng ở Đà Nẵng", "Canifa Vincom có không?"
5. MUA OFFLINE: "Mình ra Chùa Bộc mua được không?", "Bên quận 7 có shop không?"
--- VÍ DỤ CÂU HỎI → BẮT BUỘC GỌI TOOL NÀY ---
- "Có cửa hàng bên quận Hoàng Mai shop ơi" → location: "Hoàng Mai"
- "Canifa ở Cầu Giấy địa chỉ ở đâu?" → location: "Cầu Giấy"
- "Mình ra Chùa Bộc mua được không?" → location: "Chùa Bộc"
- "Gần Vincom Bà Triệu có shop nào?" → location: "Vincom Bà Triệu"
- "Cho địa chỉ cửa hàng ở Đà Nẵng" → location: "Đà Nẵng"
- "Mình ở Hải Phòng mua ở đâu?" → location: "Hải Phòng"
- "Shop mở cửa mấy giờ?" → location: "" (trả về tất cả)
- "Có cửa hàng ở Vĩnh Phúc không?" → location: "Vĩnh Phúc"
- "Canifa Phúc Yên ở đâu?" → location: "Phúc Yên"
⚠️ KHÔNG DÙNG TOOL NÀY KHI:
- Khách hỏi TỒN KHO tại cửa hàng: "Shop Hoàng Mai còn size M không?" → redirect hotline!
- Khách hỏi CHÍNH SÁCH, KHUYẾN MÃI → dùng canifa_knowledge_search hoặc canifa_get_promotions
- Khách hỏi SẢN PHẨM → dùng data_retrieval_tool
Tham số: Tham số:
- location (bắt buộc): Tên quận/huyện/tỉnh/thành phố/địa chỉ. Bỏ prefix "quận", "huyện", "tỉnh", "tp" khi truyền vào. - location (bắt buộc): Tên quận/huyện/tỉnh/TP. Bỏ prefix "quận/huyện/tỉnh/tp".
...@@ -31,7 +31,7 @@ from agent.prompt_utils import read_tool_prompt ...@@ -31,7 +31,7 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel): class SearchItem(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE model_config = {"extra": "ignore"} # Gemini may send extra fields
# ====== SEMANTIC SEARCH ====== # ====== SEMANTIC SEARCH ======
description: str = Field( description: str = Field(
...@@ -41,61 +41,86 @@ class SearchItem(BaseModel): ...@@ -41,61 +41,86 @@ class SearchItem(BaseModel):
"material_group: [chất liệu]. style: [phong cách]. fitting: [dáng]. " "material_group: [chất liệu]. style: [phong cách]. fitting: [dáng]. "
"form_sleeve: [tay]. form_neckline: [cổ]. product_line_vn: [dòng SP]'. " "form_sleeve: [tay]. form_neckline: [cổ]. product_line_vn: [dòng SP]'. "
"🚨 product_name = CHÍNH XÁC lời user nói, TUYỆT ĐỐI KHÔNG tự đổi sang tên khác. " "🚨 product_name = CHÍNH XÁC lời user nói, TUYỆT ĐỐI KHÔNG tự đổi sang tên khác. "
"VD: user nói 'áo ngọ nguậy' → product_name: Áo ngọ nguậy (KHÔNG đổi thành 'Áo phông'!). "
"VD: user nói 'áo cá sấu' → product_name: Áo cá sấu (KHÔNG đổi!). "
"Chỉ chuẩn hóa từ đồng nghĩa RÕ RÀNG: 'áo thun'→'Áo phông', 'quần bò'→'Quần jean'. "
"⚠️ KHÔNG đưa master_color vào description — dùng field master_color riêng! " "⚠️ KHÔNG đưa master_color vào description — dùng field master_color riêng! "
"product_line_vn trong description = broad category (Áo phông, Áo Polo, Quần jean...). "
"VD: 'áo ngọ nguậy' → 'product_name: Áo ngọ nguậy. product_line_vn: Áo phông'. "
"VD: 'áo cá sấu polo' → 'product_name: Áo cá sấu polo. product_line_vn: Áo Polo'."
) )
) )
product_name: str | None = Field( product_name: str | None = Field(
default=None,
description="Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể.", description="Tên sản phẩm. Chỉ điền khi khách cung cấp tên cụ thể.",
) )
# ====== SKU LOOKUP ====== # ====== SKU LOOKUP ======
magento_ref_code: str | None = Field( magento_ref_code: str | None = Field(
default=None,
description="Mã sản phẩm chính xác (SKU). Chỉ điền khi khách cung cấp mã cụ thể.", description="Mã sản phẩm chính xác (SKU). Chỉ điền khi khách cung cấp mã cụ thể.",
) )
# ====== SQL HARD FILTERS (lọc trực tiếp trong DB query) ====== # ====== SQL HARD FILTERS ======
gender_by_product: str | None = Field( gender_by_product: str | None = Field(
default=None,
description="[SQL FILTER] Giới tính. GIÁ TRỊ HỢP LỆ: women, men, boy, girl, unisex, newborn", description="[SQL FILTER] Giới tính. GIÁ TRỊ HỢP LỆ: women, men, boy, girl, unisex, newborn",
) )
age_by_product: str | None = Field( age_by_product: str | None = Field(
default=None,
description="[SQL FILTER] Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others", description="[SQL FILTER] Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
) )
master_color: str | None = Field( master_color: str | None = Field(
default=None,
description=( description=(
"[SQL FILTER] Màu sắc sản phẩm. Gửi CHÍNH XÁC từ khách nói (VD: 'trắng', 'đen', 'xanh', 'đỏ'). " "[SQL FILTER] Màu sắc sản phẩm. Gửi CHÍNH XÁC từ khách nói. "
"Tool sẽ tự match trong DB bằng LIKE. KHÔNG đưa màu vào description!" "Tool sẽ tự match trong DB bằng LIKE. KHÔNG đưa màu vào description!"
), ),
) )
price_min: int | None = Field( price_min: int | None = Field(
default=None,
description="[SQL FILTER] Giá thấp nhất (VND)", description="[SQL FILTER] Giá thấp nhất (VND)",
) )
price_max: int | None = Field( price_max: int | None = Field(
default=None,
description="[SQL FILTER] Giá cao nhất (VND)", description="[SQL FILTER] Giá cao nhất (VND)",
) )
discount_min: int | None = Field( discount_min: int | None = Field(
description="[SQL FILTER] % giảm giá tối thiểu. VD: 50 → chỉ lấy SP giảm >= 50%. Nếu khách hỏi 'đồ sale' chung → đặt = 1.", default=None,
description="[SQL FILTER] % giảm giá tối thiểu. VD: 50 → chỉ lấy SP giảm >= 50%.",
) )
discount_max: int | None = Field( discount_max: int | None = Field(
default=None,
description="[SQL FILTER] % giảm giá tối đa. VD: 70 → chỉ lấy SP giảm <= 70%.", description="[SQL FILTER] % giảm giá tối đa. VD: 70 → chỉ lấy SP giảm <= 70%.",
) )
discovery_mode: str | None = Field( discovery_mode: str | None = Field(
default=None,
description=( description=(
"[SQL FILTER] Chế độ khám phá. 'new' = hàng mới (is_new_product=1), " "[SQL FILTER] 'new' = hàng mới, 'best_seller' = bán chạy. "
"'best_seller' = bán chạy nhất (ORDER BY quantity_sold). " "Chỉ dùng khi khách NÓI RÕ. Nếu không nói → null."
"Chỉ dùng khi khách NÓI RÕ 'mới nhất'/'hàng mới'/'bán chạy'/'best seller'/'hot'. "
"Nếu khách KHÔNG nói → để null."
), ),
) )
@classmethod
def __get_validators__(cls):
yield cls._coerce_empty_strings
def model_post_init(self, __context) -> None:
"""Coerce empty strings to None and extract product_name from description."""
import re
# 1. Empty string → None
for field_name in ['product_name', 'magento_ref_code', 'gender_by_product',
'age_by_product', 'master_color', 'discovery_mode']:
val = getattr(self, field_name, None)
if isinstance(val, str) and val.strip() == '':
object.__setattr__(self, field_name, None)
# 2. If product_name is None but description contains "product_name: X" → extract it
if self.product_name is None and self.description:
match = re.search(r'product_name:\s*([^.]+?)(?:\.|$)', self.description)
if match:
extracted = match.group(1).strip()
if extracted and extracted.lower() not in ('sản phẩm', 'product'):
object.__setattr__(self, 'product_name', extracted)
logger.info(f"📝 Extracted product_name from description: '{extracted}'")
class MultiSearchParams(BaseModel): class MultiSearchParams(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE model_config = {"extra": "ignore"} # Gemini may send extra fields
searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm") searches: list[SearchItem] = Field(description="Danh sách các truy vấn tìm kiếm")
......
...@@ -11,14 +11,14 @@ logger = logging.getLogger(__name__) ...@@ -11,14 +11,14 @@ logger = logging.getLogger(__name__)
def _get_price_clauses(params, sql_params: list) -> list[str]: def _get_price_clauses(params, sql_params: list) -> list[str]:
"""Lọc theo giá (Parameterized).""" """Lọc theo giá (Parameterized). Bỏ qua giá trị 0 (Gemini gửi 0 thay vì null)."""
clauses = [] clauses = []
p_min = getattr(params, "price_min", None) p_min = getattr(params, "price_min", None)
if p_min is not None: if p_min and p_min > 0:
clauses.append("sale_price >= %s") clauses.append("sale_price >= %s")
sql_params.append(p_min) sql_params.append(p_min)
p_max = getattr(params, "price_max", None) p_max = getattr(params, "price_max", None)
if p_max is not None: if p_max and p_max > 0:
clauses.append("sale_price <= %s") clauses.append("sale_price <= %s")
sql_params.append(p_max) sql_params.append(p_max)
...@@ -26,10 +26,16 @@ def _get_price_clauses(params, sql_params: list) -> list[str]: ...@@ -26,10 +26,16 @@ def _get_price_clauses(params, sql_params: list) -> list[str]:
def _get_discount_params(params) -> tuple[int | None, int | None]: def _get_discount_params(params) -> tuple[int | None, int | None]:
"""Lấy discount_min và discount_max từ params.""" """Lấy discount_min và discount_max từ params. Bỏ qua giá trị 0."""
discount_min = getattr(params, "discount_min", None) discount_min = getattr(params, "discount_min", None)
discount_max = getattr(params, "discount_max", None) discount_max = getattr(params, "discount_max", None)
# Treat 0 as null (Gemini sends 0 instead of null)
if discount_min is not None and discount_min <= 0:
discount_min = None
if discount_max is not None and discount_max <= 0:
discount_max = None
if discount_min is not None or discount_max is not None: if discount_min is not None or discount_max is not None:
if discount_min and discount_max: if discount_min and discount_max:
logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount {discount_min}% - {discount_max}%") logger.info(f"🏷️ [DISCOUNT FILTER] Filtering products with discount {discount_min}% - {discount_max}%")
...@@ -88,10 +94,10 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]: ...@@ -88,10 +94,10 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]:
name_val = getattr(params, "product_name", None) name_val = getattr(params, "product_name", None)
if name_val: if name_val:
from agent.tools.product_mapping import resolve_product_name, get_related_lines from agent.tools.product_mapping import resolve_product_name, get_related_lines
# Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"] # Support '/' separator: "Áo lót/Áo bra active" → ["Áo lót", "Áo bra active"]
name_parts = [p.strip() for p in name_val.split("/") if p.strip()] name_parts = [p.strip() for p in name_val.split("/") if p.strip()]
all_phrases = set() all_phrases = set()
for part in name_parts: for part in name_parts:
resolved = resolve_product_name(part) resolved = resolve_product_name(part)
...@@ -101,7 +107,7 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]: ...@@ -101,7 +107,7 @@ def _get_metadata_clauses(params, sql_params: list) -> list[str]:
phrase = " ".join(w for w in words if w.lower() not in GENERIC_WORDS) phrase = " ".join(w for w in words if w.lower() not in GENERIC_WORDS)
if phrase: if phrase:
all_phrases.add(phrase.lower()) all_phrases.add(phrase.lower())
if all_phrases: if all_phrases:
like_parts = [] like_parts = []
for phrase in all_phrases: for phrase in all_phrases:
...@@ -125,7 +131,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -125,7 +131,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
magento_code = getattr(params, "magento_ref_code", None) magento_code = getattr(params, "magento_ref_code", None)
if magento_code: if magento_code:
sql = """ sql = """
SELECT SELECT
internal_ref_code, internal_ref_code,
magento_ref_code, magento_ref_code,
product_color_code, product_color_code,
...@@ -249,11 +255,11 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -249,11 +255,11 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
discount_min, discount_max = _get_discount_params(params) discount_min, discount_max = _get_discount_params(params)
post_filter_conditions = [] post_filter_conditions = []
# Price + Gender + Age filters # Price + Gender + Age filters
if all_clauses: if all_clauses:
post_filter_conditions.extend(all_clauses) post_filter_conditions.extend(all_clauses)
# Discount filters # Discount filters
if discount_min is not None or discount_max is not None: if discount_min is not None or discount_max is not None:
post_filter_conditions.append("sale_price < original_price") # Ensure has discount post_filter_conditions.append("sale_price < original_price") # Ensure has discount
...@@ -263,7 +269,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -263,7 +269,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
if discount_max is not None: if discount_max is not None:
post_filter_conditions.append("discount_percent <= %s") post_filter_conditions.append("discount_percent <= %s")
sql_params.append(discount_max) sql_params.append(discount_max)
post_filter_where = "" post_filter_where = ""
if post_filter_conditions: if post_filter_conditions:
post_filter_where = " WHERE " + " AND ".join(post_filter_conditions) post_filter_where = " WHERE " + " AND ".join(post_filter_conditions)
...@@ -310,7 +316,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -310,7 +316,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
ORDER BY similarity_score DESC ORDER BY similarity_score DESC
LIMIT 150 LIMIT 150
) )
SELECT SELECT
internal_ref_code, internal_ref_code,
MAX_BY(magento_ref_code, similarity_score) as magento_ref_code, MAX_BY(magento_ref_code, similarity_score) as magento_ref_code,
product_color_code, product_color_code,
...@@ -337,7 +343,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -337,7 +343,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# try: # try:
# query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt") # query_log_path = os.path.join(os.path.dirname(__file__), "note/query.txt")
# # Build executable query by substituting %s with actual values # # Build executable query by substituting %s with actual values
# executable_sql = sql # executable_sql = sql
# for param in sql_params: # for param in sql_params:
...@@ -347,7 +353,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None) ...@@ -347,7 +353,7 @@ async def build_starrocks_query(params, query_vector: list[float] | None = None)
# executable_sql = executable_sql.replace("%s", f"'{escaped}'", 1) # executable_sql = executable_sql.replace("%s", f"'{escaped}'", 1)
# else: # else:
# executable_sql = executable_sql.replace("%s", str(param), 1) # executable_sql = executable_sql.replace("%s", str(param), 1)
# with open(query_log_path, "w", encoding="utf-8") as f: # with open(query_log_path, "w", encoding="utf-8") as f:
# f.write(f"-- [HYDE SEARCH] Full Executable Query\n-- Original Params: {sql_params}\n{executable_sql}") # f.write(f"-- [HYDE SEARCH] Full Executable Query\n-- Original Params: {sql_params}\n{executable_sql}")
# except Exception as e: # except Exception as e:
......
"""
Dashboard API Route
Provides system overview, service health checks, and configuration info.
"""
import logging
import os
import platform
import sys
import time
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from config import (
AI_SUPABASE_URL,
CONV_SUPABASE_URL,
DEFAULT_MODEL,
GOOGLE_API_KEY,
GROQ_API_KEY,
LANGFUSE_BASE_URL,
LANGFUSE_PUBLIC_KEY,
LANGFUSE_SECRET_KEY,
MONGODB_DB_NAME,
MONGODB_URI,
OPENAI_API_KEY,
PORT,
RATE_LIMIT_GUEST,
RATE_LIMIT_USER,
REDIS_HOST,
REDIS_PORT,
STARROCKS_DB,
STARROCKS_HOST,
USE_MONGO_CONVERSATION,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# Track server start time
_SERVER_START_TIME = time.time()
# App version info
APP_VERSION = "2.5.0"
APP_CODENAME = "Canifa AI Stylist"
def _mask_key(key: str | None) -> str:
"""Mask API key for safe display."""
if not key:
return "❌ Not Set"
if len(key) <= 8:
return "****"
return f"{key[:4]}...{key[-4:]}"
def _check_service_status(name: str) -> dict:
"""Check service availability based on config presence."""
checks = {
"Redis": bool(REDIS_HOST),
"MongoDB": bool(MONGODB_URI),
"Langfuse": bool(LANGFUSE_SECRET_KEY and LANGFUSE_PUBLIC_KEY),
"Supabase (AI)": bool(AI_SUPABASE_URL),
"Supabase (Conv)": bool(CONV_SUPABASE_URL),
"StarRocks": bool(STARROCKS_HOST),
"OpenAI": bool(OPENAI_API_KEY),
"Google AI": bool(GOOGLE_API_KEY),
"Groq": bool(GROQ_API_KEY),
}
configured = checks.get(name, False)
return {
"name": name,
"configured": configured,
"status": "✅ Configured" if configured else "❌ Not Configured",
}
@router.get("/api/dashboard/info", summary="Get dashboard system info")
async def dashboard_info():
"""Return comprehensive system information for the dashboard."""
# Uptime calculation
uptime_seconds = int(time.time() - _SERVER_START_TIME)
hours, remainder = divmod(uptime_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
uptime_str = f"{hours}h {minutes}m {seconds}s"
# Service health checks
services = [
_check_service_status("Redis"),
_check_service_status("MongoDB"),
_check_service_status("Langfuse"),
_check_service_status("Supabase (AI)"),
_check_service_status("Supabase (Conv)"),
_check_service_status("StarRocks"),
_check_service_status("OpenAI"),
_check_service_status("Google AI"),
_check_service_status("Groq"),
]
# Count healthy services
healthy_count = sum(1 for s in services if s["configured"])
total_count = len(services)
# Feedback stats (safe import)
feedback_stats = {}
try:
from common.feedback_tracker import get_feedback_stats
feedback_stats = get_feedback_stats()
except Exception as e:
logger.warning(f"Could not load feedback stats: {e}")
feedback_stats = {"error": str(e)}
# Git info
git_info = {"branch": "unknown", "commit": "unknown"}
try:
import subprocess
git_branch = subprocess.check_output(
["git", "rev-parse", "--abbrev-ref", "HEAD"],
cwd=os.path.dirname(os.path.abspath(__file__)),
text=True,
timeout=5,
).strip()
git_commit = subprocess.check_output(
["git", "rev-parse", "--short", "HEAD"],
cwd=os.path.dirname(os.path.abspath(__file__)),
text=True,
timeout=5,
).strip()
git_info = {"branch": git_branch, "commit": git_commit}
except Exception:
pass
# Registered API routes
from server import app as fastapi_app
routes = []
for route in fastapi_app.routes:
if hasattr(route, "methods") and hasattr(route, "path"):
routes.append({
"path": route.path,
"methods": list(route.methods),
"name": getattr(route, "name", ""),
})
return {
"system": {
"app_name": APP_CODENAME,
"app_version": APP_VERSION,
"python_version": platform.python_version(),
"os": f"{platform.system()} {platform.release()}",
"uptime": uptime_str,
"uptime_seconds": uptime_seconds,
"server_port": PORT,
"started_at": datetime.fromtimestamp(_SERVER_START_TIME, tz=timezone.utc).isoformat(),
},
"llm": {
"default_model": DEFAULT_MODEL,
"openai_key": _mask_key(OPENAI_API_KEY),
"google_key": _mask_key(GOOGLE_API_KEY),
"groq_key": _mask_key(GROQ_API_KEY),
},
"services": services,
"services_summary": {
"healthy": healthy_count,
"total": total_count,
"health_pct": round(healthy_count / total_count * 100) if total_count else 0,
},
"config": {
"rate_limit_guest": RATE_LIMIT_GUEST,
"rate_limit_user": RATE_LIMIT_USER,
"conversation_storage": "MongoDB" if USE_MONGO_CONVERSATION else "Supabase",
"mongodb_db": MONGODB_DB_NAME,
"redis_host": REDIS_HOST or "N/A",
"redis_port": REDIS_PORT,
"langfuse_url": LANGFUSE_BASE_URL or "N/A",
"starrocks_db": STARROCKS_DB or "N/A",
},
"git": git_info,
"feedback": feedback_stats,
"routes": routes,
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
}
"""
Experiment Links API — dynamic sidebar links for "Thử nghiệm" section.
Supports: CRUD links, description, version history.
"""
import json
import uuid
from datetime import datetime
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter(prefix="/api/dashboard", tags=["Experiment Links"])
DATA_FILE = Path(__file__).parent.parent / "data" / "experiment_links.json"
class LinkCreate(BaseModel):
name: str
url: str
icon: str = "🔗"
badge: Optional[str] = None
badge_type: Optional[str] = None
description: Optional[str] = None
class LinkUpdate(BaseModel):
name: Optional[str] = None
url: Optional[str] = None
icon: Optional[str] = None
badge: Optional[str] = None
badge_type: Optional[str] = None
description: Optional[str] = None
class VersionCreate(BaseModel):
version: str
author: str
note: str
def _load() -> list:
if DATA_FILE.exists():
return json.loads(DATA_FILE.read_text(encoding="utf-8"))
return []
def _save(data: list):
DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
DATA_FILE.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
def _find(data: list, link_id: str):
for item in data:
if item["id"] == link_id:
return item
return None
# ─── CRUD Links ───
@router.get("/experiment-links", summary="List experiment links")
async def list_links():
return _load()
@router.get("/experiment-links/{link_id}", summary="Get experiment link detail")
async def get_link(link_id: str):
data = _load()
item = _find(data, link_id)
if not item:
raise HTTPException(404, "Link not found")
return item
@router.post("/experiment-links", summary="Add experiment link")
async def add_link(body: LinkCreate):
data = _load()
item = {
"id": f"exp_{uuid.uuid4().hex[:8]}",
"name": body.name,
"url": body.url,
"icon": body.icon,
"badge": body.badge,
"badge_type": body.badge_type,
"pinned": False,
"description": body.description or "",
"versions": [],
"created_at": datetime.now().isoformat(),
}
data.append(item)
_save(data)
return item
@router.put("/experiment-links/{link_id}", summary="Update experiment link")
async def update_link(link_id: str, body: LinkUpdate):
data = _load()
item = _find(data, link_id)
if not item:
raise HTTPException(404, "Link not found")
if body.name is not None: item["name"] = body.name
if body.url is not None: item["url"] = body.url
if body.icon is not None: item["icon"] = body.icon
if body.badge is not None: item["badge"] = body.badge
if body.badge_type is not None: item["badge_type"] = body.badge_type
if body.description is not None: item["description"] = body.description
_save(data)
return item
@router.delete("/experiment-links/{link_id}", summary="Delete experiment link")
async def delete_link(link_id: str):
data = _load()
new_data = [x for x in data if x["id"] != link_id]
if len(new_data) == len(data):
raise HTTPException(404, "Link not found")
_save(new_data)
return {"ok": True}
# ─── Version History ───
@router.post("/experiment-links/{link_id}/versions", summary="Add version entry")
async def add_version(link_id: str, body: VersionCreate):
data = _load()
item = _find(data, link_id)
if not item:
raise HTTPException(404, "Link not found")
if "versions" not in item:
item["versions"] = []
entry = {
"version": body.version,
"date": datetime.now().strftime("%Y-%m-%d"),
"author": body.author,
"note": body.note,
}
item["versions"].insert(0, entry) # newest first
_save(data)
return entry
@router.delete("/experiment-links/{link_id}/versions/{idx}", summary="Delete version entry")
async def delete_version(link_id: str, idx: int):
data = _load()
item = _find(data, link_id)
if not item:
raise HTTPException(404, "Link not found")
versions = item.get("versions", [])
if idx < 0 or idx >= len(versions):
raise HTTPException(404, "Version not found")
versions.pop(idx)
_save(data)
return {"ok": True}
This diff is collapsed.
"""
Text-to-SQL Chat API — Bản 2 chatbot logic.
User hỏi → LLM sinh SQL → DB → LLM response.
"""
import logging
from fastapi import APIRouter
from pydantic import BaseModel
from agent.text_to_sql import text_to_sql_chat
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api")
class TextToSQLRequest(BaseModel):
user_query: str
@router.post("/v2/chat")
async def v2_chat(req: TextToSQLRequest):
"""
Bản 2: Text-to-SQL chatbot.
User hỏi → LLM sinh SQL → chọc DB → LLM viết response.
"""
logger.info("🤖 [V2] Text-to-SQL chat: %s", req.user_query[:100])
try:
result = await text_to_sql_chat(req.user_query)
return {
"status": "success",
"ai_response": result["ai_response"],
"products": result["products"],
"sql": result["sql"],
"timing": result["timing"],
"product_count": result["product_count"],
}
except Exception as e:
logger.exception("❌ [V2] Error: %s", e)
return {
"status": "error",
"ai_response": f"Xin lỗi, có lỗi xảy ra: {str(e)}",
"products": [],
"sql": "",
"timing": {},
"product_count": 0,
}
""" """
LLM Factory - OpenAI LLM creation with caching. LLM Factory - Multi-provider LLM creation with caching.
Manages initialization and caching of OpenAI models. Supports OpenAI, Groq, and Google Gemini models.
""" """
import contextlib import contextlib
import logging import logging
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
from langchain_google_genai import ChatGoogleGenerativeAI
from langchain_openai import ChatOpenAI, OpenAIEmbeddings from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from config import GROQ_API_KEY, OPENAI_API_KEY from config import GOOGLE_API_KEY, GROQ_API_KEY, OPENAI_API_KEY
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
class LLMFactory: class LLMFactory:
"""Singleton factory for managing OpenAI LLM instances with caching.""" """Singleton factory for managing LLM instances with caching (OpenAI, Groq, Gemini)."""
COMMON_MODELS: list[str] = [ COMMON_MODELS: list[str] = [
"gpt-4o-mini", "gemini-3.1-flash-lite-preview",
"gpt-4o",
"gpt-5-nano",
"gpt-5-mini",
] ]
def __init__(self): def __init__(self):
...@@ -64,9 +62,15 @@ class LLMFactory: ...@@ -64,9 +62,15 @@ class LLMFactory:
json_mode: bool = False, json_mode: bool = False,
api_key: str | None = None, api_key: str | None = None,
) -> BaseChatModel: ) -> BaseChatModel:
"""Create and cache a new OpenAI LLM instance.""" """Create and cache a new LLM instance (auto-detect provider)."""
try: try:
llm = self._create_openai(model_name, streaming, json_mode, api_key) # Auto-detect: Gemini models use Google API
is_gemini = "gemini" in model_name.lower()
if is_gemini:
llm = self._create_gemini(model_name, streaming, json_mode, api_key)
else:
llm = self._create_openai(model_name, streaming, json_mode, api_key)
cache_key = (model_name, streaming, json_mode, api_key) cache_key = (model_name, streaming, json_mode, api_key)
self._cache[cache_key] = llm self._cache[cache_key] = llm
...@@ -76,6 +80,29 @@ class LLMFactory: ...@@ -76,6 +80,29 @@ class LLMFactory:
logger.error(f"❌ Failed to create model {model_name}: {e}") logger.error(f"❌ Failed to create model {model_name}: {e}")
raise raise
def _create_gemini(self, model_name: str, streaming: bool, json_mode: bool, api_key: str | None) -> BaseChatModel:
"""Create Google Gemini model instance. Always uses GOOGLE_API_KEY (ignores api_key param which may be OpenAI key)."""
key = GOOGLE_API_KEY
if not key:
raise ValueError("GOOGLE_API_KEY is required for Gemini models. Set it in .env")
llm_kwargs = {
"model": model_name,
"google_api_key": key,
"temperature": 0,
"max_output_tokens": 1500,
"streaming": streaming,
}
if json_mode:
llm_kwargs["response_mime_type"] = "application/json"
logger.info(f"⚙️ Initializing Gemini in JSON mode: {model_name}")
logger.warning(f"🔍 DEBUG: provider=Gemini | model={model_name} | key={key[:10]}...")
llm = ChatGoogleGenerativeAI(**llm_kwargs)
logger.info(f"✅ Created Gemini: {model_name} | Streaming: {streaming}")
return llm
def _create_openai(self, model_name: str, streaming: bool, json_mode: 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-compatible model instance (OpenAI or Groq).""" """Create OpenAI-compatible model instance (OpenAI or Groq)."""
......
...@@ -76,8 +76,8 @@ OPENAI_API_KEY: str | None = os.getenv("OPENAI_API_KEY") ...@@ -76,8 +76,8 @@ OPENAI_API_KEY: str | None = os.getenv("OPENAI_API_KEY")
GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY") GOOGLE_API_KEY: str | None = os.getenv("GOOGLE_API_KEY")
GROQ_API_KEY: str | None = os.getenv("GROQ_API_KEY") GROQ_API_KEY: str | None = os.getenv("GROQ_API_KEY")
DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-5.1-codex-mini") DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gemini-3.1-flash-lite-preview")
# DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL") # DEFAULT_MODEL: str = os.getenv("DEFAULT_MODEL", "gpt-5.1-codex-mini")
# ====================== JWT CONFIGURATION ====================== # ====================== JWT CONFIGURATION ======================
JWT_SECRET: str | None = os.getenv("JWT_SECRET") JWT_SECRET: str | None = os.getenv("JWT_SECRET")
......
[
{
"id": "6b140c60",
"content": "Chuyển model sang Gemini 3.1 Flash Lite, giảm 56% prompt size",
"author": "Hoàng",
"created_at": "2026-03-11T13:19:02.064155"
},
{
"id": "d6899b16",
"content": "Fix bug tìm áo bra, sửa mapping product trong search",
"author": "Minh",
"created_at": "2026-03-11T13:20:25.444289"
}
]
\ No newline at end of file
[
{
"id": "916c2a97",
"title": "Langfuse Monitoring",
"url": "http://172.16.2.207:3009",
"description": "LLM observability & tracing dashboard — theo dõi traces, scores, prompts",
"category": "tool",
"icon": "🔍",
"pinned": true,
"created_at": "2026-03-11T11:20:58.996492",
"author": "Admin"
},
{
"id": "a1b2c301",
"title": "LiteLLM Proxy",
"url": "http://172.16.2.207:4000",
"description": "LLM Gateway — proxy OpenAI/Gemini/Ollama models, quản lý API keys & rate limits",
"category": "tool",
"icon": "🤖",
"pinned": true,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c302",
"title": "n8n Workflows",
"url": "http://172.16.2.207:5678",
"description": "Workflow automation — test chatbot, auto-test hàng ngày, tích hợp Google Sheets",
"category": "tool",
"icon": "⚡",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c306",
"title": "n8n Auto-Test Workflow",
"url": "http://172.16.2.207:5678/workflow/EPB44XlnVAi6FCnl",
"description": "Workflow test tự động chatbot — chạy daily 8:00 AM, log kết quả lên Google Sheets",
"category": "tool",
"icon": "🧪",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c307",
"title": "Test Conversation Observer",
"url": "http://172.16.2.207:5000/static/history.html",
"description": "Xem lịch sử hội thoại test — review chất lượng bot responses",
"category": "tool",
"icon": "🧾",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c308",
"title": "Redis Cache",
"url": "redis://172.16.2.207:6379",
"description": "Cache layer — response cache, user insight, session data",
"category": "api",
"icon": "🗄️",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c309",
"title": "Chatbot API Docs",
"url": "http://172.16.2.207:5000/docs",
"description": "Swagger UI — tất cả API endpoints, test trực tiếp trên browser",
"category": "api",
"icon": "📚",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c310",
"title": "ReDoc API Reference",
"url": "http://172.16.2.207:5000/redoc",
"description": "API documentation dạng ReDoc — clean, dễ đọc",
"category": "doc",
"icon": "📖",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
},
{
"id": "a1b2c311",
"title": "Meilisearch Dashboard",
"url": "http://172.16.2.207:7700",
"description": "Search engine dashboard — quản lý index sản phẩm, test search queries",
"category": "tool",
"icon": "🔎",
"pinned": false,
"created_at": "2026-03-11T14:15:00.000000",
"author": "Admin"
}
]
\ No newline at end of file
[
{
"id": "cb7bd624",
"title": "📋 Sprint Planning - Tuần 11/3",
"content": "- Fix bug bra product search\n- Optimize Gemini Flash Lite prompts\n- Deploy dashboard v2\n- Test feedback classification pipeline",
"category": "note",
"pinned": true,
"color": "#58a6ff",
"created_at": "2026-03-11T11:09:36.094936",
"updated_at": "2026-03-11T11:09:36.094936",
"author": "Admin"
},
{
"id": "13e03ea7",
"title": "con mèo",
"content": "con mèo kêu meo meo",
"category": "note",
"pinned": false,
"color": "#a78bfa",
"created_at": "2026-03-11T13:49:23.456793",
"updated_at": "2026-03-11T13:49:23.456793",
"author": "Admin"
}
]
\ No newline at end of file
[
{
"id": "exp_chatbot_prod",
"name": "Chatbot (Production)",
"url": "http://172.16.2.207:5005/static/index.html",
"icon": "💬",
"badge": null,
"badge_type": null,
"section": "main",
"pinned": true,
"description": "Chatbot AI Stylist Canifa — phiên bản production đang chạy trên server 172.16.2.207. Đây là bản ổn định dùng cho team test và demo.",
"versions": [
{ "version": "v5.10", "date": "2026-03-11", "author": "Dev", "note": "Phiên bản ổn định, đã deploy production" }
]
},
{
"id": "exp_1",
"name": "Text-to-SQL",
"url": "/static/test_sql.html",
"icon": "🗄️",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Cho phép hỏi database bằng tiếng Việt, tự convert thành SQL query và trả kết quả trực tiếp.",
"versions": [
{ "version": "v0.1", "date": "2026-03-08", "author": "Dev", "note": "Bản đầu tiên — hỗ trợ SELECT cơ bản" }
]
},
{
"id": "exp_2",
"name": "DB Test",
"url": "/static/test_db.html",
"icon": "🔍",
"badge": "BETA",
"badge_type": "beta",
"pinned": true,
"description": "Test trực tiếp các truy vấn database, xem dữ liệu sản phẩm và stock.",
"versions": [
{ "version": "v0.1", "date": "2026-03-08", "author": "Dev", "note": "Bản đầu tiên — query trực tiếp" }
]
},
{
"id": "exp_3",
"name": "Feedback Demo",
"url": "/static/feedback_demo.html",
"icon": "📝",
"badge": "NEW",
"badge_type": "new",
"pinned": true,
"description": "Demo hệ thống feedback Like/Dislike/Complaint cho chatbot, phân loại tự động bằng AI.",
"versions": [
{ "version": "v0.1", "date": "2026-03-10", "author": "Dev", "note": "Bản đầu tiên — Like/Dislike + Complaint form" }
]
},
{
"id": "exp_chatbot_dev",
"name": "Chatbot (Dev)",
"url": "/static/index.html",
"icon": "💬",
"badge": "DEV",
"badge_type": "dev",
"pinned": true,
"description": "Phiên bản dev của Chatbot, chạy trên localhost. Dùng để test tính năng mới trước khi deploy lên production.",
"versions": [
{ "version": "v5.10", "date": "2026-03-11", "author": "Dev", "note": "Version hiện tại — dev localhost" }
]
}
]
\ No newline at end of file
...@@ -306,5 +306,27 @@ ...@@ -306,5 +306,27 @@
"ai_summary": "Khách hàng phản ánh không hài lòng, sử dụng từ ngữ không rõ ràng", "ai_summary": "Khách hàng phản ánh không hài lòng, sử dụng từ ngữ không rõ ràng",
"processed": false, "processed": false,
"created_at": "2026-03-09T02:17:52.363066+00:00" "created_at": "2026-03-09T02:17:52.363066+00:00"
},
{
"trace_id": "98696c23bbeb6169aa0346895da21be1",
"rating": 1,
"comment": "",
"user_category": "",
"ai_category": "khac",
"ai_severity": "",
"ai_summary": "",
"processed": false,
"created_at": "2026-03-09T07:44:50.904293+00:00"
},
{
"trace_id": "98696c23bbeb6169aa0346895da21be1",
"rating": 1,
"comment": "dùng chán bỏ mẹ ra",
"user_category": "",
"ai_category": "khac",
"ai_severity": "",
"ai_summary": "",
"processed": false,
"created_at": "2026-03-09T07:44:57.206574+00:00"
} }
] ]
\ No newline at end of file
...@@ -5,7 +5,7 @@ services: ...@@ -5,7 +5,7 @@ services:
container_name: canifa_backend container_name: canifa_backend
env_file: .env env_file: .env
ports: ports:
- "5000:5000" - "5005:5000"
volumes: volumes:
- .:/app - .:/app
environment: environment:
......
...@@ -7,7 +7,7 @@ services: ...@@ -7,7 +7,7 @@ services:
container_name: canifa_backend container_name: canifa_backend
env_file: .env env_file: .env
ports: ports:
- "5000:5000" - "5005:5000"
volumes: volumes:
- .:/app - .:/app
environment: environment:
......
This diff is collapsed.
...@@ -17,6 +17,10 @@ from api.stock_route import router as stock_router ...@@ -17,6 +17,10 @@ from api.stock_route import router as stock_router
from api.tool_prompt_route import router as tool_prompt_router from api.tool_prompt_route import router as tool_prompt_router
from api.n8n_api_route import router as n8n_router from api.n8n_api_route import router as n8n_router
from api.feedback_route import router as feedback_router from api.feedback_route import router as feedback_router
from api.text_to_sql_route import router as text_to_sql_router
from api.dashboard_route import router as dashboard_router
from api.notes_route import router as notes_router
from api.experiment_links_route import router as experiment_links_router
from common.cache import redis_cache from common.cache import redis_cache
from common.middleware import middleware_manager from common.middleware import middleware_manager
from config import PORT from config import PORT
...@@ -56,7 +60,7 @@ async def startup_event(): ...@@ -56,7 +60,7 @@ async def startup_event():
@app.get("/") @app.get("/")
async def root(): async def root():
return RedirectResponse(url="/static/index.html") return RedirectResponse(url="/static/index.html")
...@@ -98,6 +102,10 @@ app.include_router(mock_router) ...@@ -98,6 +102,10 @@ app.include_router(mock_router)
app.include_router(stock_router) app.include_router(stock_router)
app.include_router(n8n_router) app.include_router(n8n_router)
app.include_router(feedback_router) app.include_router(feedback_router)
app.include_router(text_to_sql_router) # Bản 2: Text-to-SQL
app.include_router(dashboard_router) # Dashboard overview
app.include_router(notes_router) # Dashboard team notes
app.include_router(experiment_links_router) # Experiment links sidebar
if __name__ == "__main__": if __name__ == "__main__":
......
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
from google import genai
client = genai.Client(api_key="AIzaSyBTbQ5rTuvRghJBVdNmi0IcLG20vmnNTwA")
response = client.models.generate_content(
model="gemini-3.1-flash-lite-preview",
contents="Explain how AI works in a few words",
)
print(response.text)
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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