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

feat: move master_color to SQL filter + strengthen relevance rules

- Add master_color as explicit SQL FILTER field in SearchItem (was in description)
- Add LIKE filter in _get_metadata_clauses for master_color + product_color_name
- Add MAX_BY(master_color) to final SELECT aggregation
- Fix color fallback in format_product_results (parse from description_text_full)
- Strengthen Rule 3a: specific design/pattern matching (con lợn, Disney, etc.)
- Add thảo mai exception: truthfulness > flattery when no products match
- Update tool prompt examples to use master_color as separate field
parent dd1a212f
......@@ -6,6 +6,7 @@ Sử dụng ConversationManager (Postgres) để lưu history thay vì checkpoin
"""
import logging
from datetime import datetime
from typing import Any
from langchain_core.language_models import BaseChatModel
......@@ -19,7 +20,7 @@ from langgraph.types import CachePolicy
from common.llm_factory import create_llm
from .models import AgentConfig, AgentState, get_config
from .prompt import get_last_modified, get_system_prompt
from .prompt import get_last_modified, get_system_prompt, get_system_prompt_template
from .tools.get_tools import get_all_tools, get_collection_tools
logger = logging.getLogger(__name__)
......@@ -46,10 +47,11 @@ class CANIFAGraph:
self.retrieval_tools = self.all_tools
self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True)
self.system_prompt = get_system_prompt()
# Lưu template với {date_str} placeholder → inject dynamic mỗi request
self.system_prompt_template = get_system_prompt_template()
self.prompt_template = ChatPromptTemplate.from_messages(
[
("system", self.system_prompt),
("system", self.system_prompt_template),
(
"system",
"===== USER INSIGHT (TỪ TURN TRƯỚC) =====\n⚡ BẮT BUỘC: Đọc [NEXT] bên dưới và THỰC HIỆN chiến lược đã lên kế hoạch!\n\n{user_insight}\n=====================================",
......@@ -81,8 +83,12 @@ class CANIFAGraph:
or "⚠️ TRẠNG THÁI KHỞI TẠO: Chưa có User Insight từ lịch sử. Hãy bắt đầu thu thập thông tin mới (Nếu thiếu thông tin thì ghi 'Chưa rõ')."
)
# Inject date_str ĐỘNG mỗi request (không cache từ __init__)
current_date_str = datetime.now().strftime("%d/%m/%Y")
response = await self.chain.ainvoke(
{
"date_str": current_date_str,
"history": history,
"user_query": [user_query] if user_query else [],
"messages": messages,
......
......@@ -70,16 +70,22 @@ def format_product_results(products: list[dict]) -> list[dict]:
"description": "..."
}
"""
max_products = 10
max_products = 15
grouped: dict[str, dict] = {} # {magento_ref_code: {product_info}}
for p in products:
# Extract product info
if p.get("product_name"):
name = p["product_name"]
color_name = p.get("master_color", "")
thumb_url = p.get("product_image_url_thumbnail", "")
web_url = p.get("product_web_url", "")
color_name = p.get("master_color") or ""
thumb_url = p.get("product_image_url_thumbnail") or ""
web_url = p.get("product_web_url") or ""
# Fallback: parse from description_text_full if fields are empty
if not color_name or not thumb_url or not web_url:
parsed = _parse_description_text(p.get("description_text_full", ""))
color_name = color_name or parsed.get("master_color", "")
thumb_url = thumb_url or parsed.get("product_image_url_thumbnail", "")
web_url = web_url or parsed.get("product_web_url", "")
else:
desc_full = p.get("description_text_full", "")
parsed = _parse_description_text(desc_full)
......@@ -122,7 +128,7 @@ def format_product_results(products: list[dict]) -> list[dict]:
grouped[base_sku]["sale_price"] = int(sale_price)
else:
# New product - use first color's URL/thumbnail as default
grouped[base_sku] = {
product_entry = {
"sku": base_sku,
"name": name,
"color": color_name, # First color as default
......@@ -133,6 +139,11 @@ def format_product_results(products: list[dict]) -> list[dict]:
"thumbnail_image_url": thumb_url, # First color's thumbnail
"description": p.get("description_text_full", ""),
}
# Include quantity_sold if available (for best seller)
qty_sold = p.get("quantity_sold")
if qty_sold is not None:
product_entry["quantity_sold"] = int(qty_sold)
grouped[base_sku] = product_entry
formatted = list(grouped.values())[:max_products]
logger.info(f"📦 Formatted {len(formatted)} products (grouped by SKU)")
......
......@@ -39,6 +39,30 @@ def get_system_prompt() -> str:
"""
def get_system_prompt_template() -> str:
"""
Trả về system prompt template CHƯA replace {date_str}.
Dùng cho ChatPromptTemplate để inject ngày động mỗi request.
Returns:
str: System prompt template với placeholder {date_str}
"""
try:
if os.path.exists(PROMPT_FILE_PATH):
with open(PROMPT_FILE_PATH, "r", encoding="utf-8") as f:
return f.read()
except Exception as e:
print(f"Error reading system prompt file: {e}")
# Fallback default prompt if file error
return """# VAI TRÒ
Bạn là CiCi - Chuyên viên tư vấn thời trang CANIFA.
Hôm nay: {date_str}
KHÔNG BAO GIỜ BỊA ĐẶT. TRẢ LỜI NGẮN GỌN.
"""
def get_last_modified() -> float:
"""Trả về timestamp lần sửa cuối cùng của file system_prompt.txt."""
try:
......
This diff is collapsed.
......@@ -73,3 +73,33 @@ age_by_product: adult.
"TARGET": "...
2026-02-05 17:09:13,818 [WARNING] agent.controller_helpers: ✅ [user_insight] Extracted + saved in 0.00s | Key: cdcdc3dfef
←[32mINFO←[0m: 127.0.0.1:64945 - "←[1mGET /api/agent/user-insight HTTP/1.1←[0m" ←[32m200 OK←[0m
┌─────────────────────────────────────────────────────────┐
│ User: "phụ kiện nào hợp với áo này?" │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ LLM (thông minh): Hiểu "phụ kiện" → │
│ Tự điền product_name = "Khăn/ Mũ/ Túi xách/ Tất" │
│ (tách ra từng loại cụ thể thay vì gửi chung "phụ kiện")│
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Code split "/": ["khăn", "mũ", "túi xách", "tất"] │
│ ↓ │
│ PRODUCT_TYPE_MAPPING: │
│ "khăn" → "Khăn" (product_line_vn) │
│ "mũ" → "Mũ" (product_line_vn) │
│ "túi xách" → "Túi xách" (product_line_vn) │
│ "tất" → "Tất" (product_line_vn) │
└───────────────────────┬─────────────────────────────────┘
┌─────────────────────────────────────────────────────────┐
│ Filter: Chỉ giữ products có product_line_vn │
│ == "Khăn" OR "Mũ" OR "Túi xách" OR "Tất" │
│ │
│ ❌ "Áo Cổ Cao Tay Dài" → loại bỏ │
│ ✅ "Khăn" → giữ │
│ ✅ "Mũ" → giữ │
└─────────────────────────────────────────────────────────┘
\ No newline at end of file
......@@ -11,10 +11,7 @@ import time
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from agent.tools.data_retrieval_filter import (
apply_post_filters,
infer_product_name_from_description,
)
from agent.helper import format_product_results
from agent.tools.product_search_helpers import build_starrocks_query
from common.starrocks_connection import get_db_connection
......@@ -28,50 +25,71 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE
# ====== SEMANTIC SEARCH ======
description: str = Field(
description="Mô tả sản phẩm cho semantic search. QUY TẮC: Nếu khách hỏi THẲNG TÊN SP (quần jeans, áo polo) → description = product_name (VD: 'Quần jean', 'Áo Polo'). Nếu khách hỏi MÔ TẢ/NHU CẦU (đi dự tiệc, đồ đi biển) → description = HYDE mô tả (VD: 'Váy dự tiệc sang trọng nữ tính')"
)
product_name: str | None = Field(
description="CHỈ tên loại sản phẩm cơ bản: Áo, Váy, Quần, Chân váy, Áo khoác... KHÔNG bao gồm style/mô tả."
description=(
"Mô tả sản phẩm cho semantic search. "
"FORMAT: 'product_name: [tên]. description_text: [mô tả]. "
"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]'. "
"🚨 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! "
"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'."
)
magento_ref_code: str | None = Field(description="Mã sản phẩm chính xác (SKU)")
price_min: int | None = Field(description="Giá thấp nhất (VND)")
price_max: int | None = Field(description="Giá cao nhất (VND)")
discount_min: int | None = Field(
description="% giảm giá tối thiểu. VD: 50 nghĩa là chỉ lấy sản phẩm giảm >= 50%. Nếu khách hỏi sản phẩm giảm giá nhưng không chỉ rõ %, đặt = 1 để lấy tất cả sản phẩm đang sale."
)
discount_max: int | None = Field(
description="% giảm giá tối đa. VD: 70 nghĩa là chỉ lấy sản phẩm giảm <= 70%. Dùng kết hợp với discount_min để lọc khoảng % giảm giá."
# ====== SKU LOOKUP ======
magento_ref_code: str | None = Field(
description="Mã sản phẩm chính xác (SKU). Chỉ điền khi khách cung cấp mã cụ thể.",
)
# Metadata filters
# ====== SQL HARD FILTERS (lọc trực tiếp trong DB query) ======
gender_by_product: str | None = Field(
description="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(description="Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others")
master_color: str | None = Field(
description="Màu sắc chính. 16 MÀU DB: 'Đen/ Black', 'Trắng/ White', 'Xanh da trời/ Blue', 'Xám/ Gray', 'Hồng/ Pink- Magenta', 'Be/ Beige', 'Đỏ/ Red', 'Tím/ Purple', 'Xanh lá cây/ Green', 'Màu xanh Jeans', 'Xanh than/ Aqua', 'Nâu/ Brown', 'Vàng/ Yellow + Gold', 'Cam/ Orange'. CHỈ DÙNG 1 MÀU, KHÔNG ghép (VD: 'Trắng kem' sai → dùng 'Trắng/ White' hoặc 'Be/ Beige')"
age_by_product: str | None = Field(
description="[SQL FILTER] Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
)
form_sleeve: str | None = Field(
description="Dáng tay áo. GIÁ TRỊ DB: 'Full length Sleeve', 'Short Sleeve', 'Sleeveless'. Mapping: 'dài tay'→'Full length Sleeve', 'ngắn tay'→'Short Sleeve', 'sát nách'→'Sleeveless'"
master_color: str | None = Field(
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', 'đỏ'). "
"Tool sẽ tự match trong DB bằng LIKE. KHÔNG đưa màu vào description!"
),
)
style: str | None = Field(
description="Phong cách. 9 GIÁ TRỊ DB: 'Basic', 'Dynamic', 'Feminine', 'Utility', 'Smart Casual', 'Basic Update', 'Trend', 'Athleisure', 'Essential'. CHỈ điền nếu user nói CHÍNH XÁC (VD: 'basic' → 'Basic'). Nếu user nói 'sang trọng', 'tiểu thư' (KHÔNG match) → BỎ QUA field này, chỉ điền vào description."
price_min: int | None = Field(
description="[SQL FILTER] Giá thấp nhất (VND)",
)
fitting: str | None = Field(
description="Dáng đồ. GIÁ TRỊ DB: 'Regular', 'Slimfit', 'Relax', 'Oversize', 'Skinny', 'Slim', 'Boxy'. Mapping: 'vừa'→'Regular', 'ôm'→'Slimfit', 'rộng'→'Relax'"
price_max: int | None = Field(
description="[SQL FILTER] Giá cao nhất (VND)",
)
form_neckline: str | None = Field(
description="Dáng cổ áo. GIÁ TRỊ DB: 'Crew Neck', 'Classic Collar', 'V-neck', 'Hooded collar', 'Mock Neck/ High neck'"
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.",
)
material_group: str | None = Field(
description="Nhóm chất liệu. GIÁ TRỊ DB: 'Knit - Dệt Kim', 'Woven - Dệt Thoi', 'Yarn - Sợi'"
discount_max: int | None = Field(
description="[SQL FILTER] % giảm giá tối đa. VD: 70 → chỉ lấy SP giảm <= 70%.",
)
season: str | None = Field(description="Mùa. GIÁ TRỊ DB: 'Fall Winter', 'Spring Summer', 'Year'")
# Extra fields for SQL match if needed
product_line_vn: str | None = Field(
description="Dòng sản phẩm. TOP DB: 'Áo phông', 'Áo Polo', 'Áo Sơ mi', 'Áo len', 'Váy liền', 'Chân váy', 'Quần jean', 'Quần soóc', 'Quần dài'. LƯU Ý: 'Váy liền' (KHÔNG phải 'Váy liền thân')"
description=(
"[SQL FILTER] Đại loại sản phẩm — lọc RỘNG bằng prefix. "
"Hỗ trợ nhiều giá trị cách bằng '/'. "
"GIÁ TRỊ: 'Áo' (mọi loại áo), 'Quần' (mọi loại quần), "
"'Váy' (váy liền + chân váy), 'Bộ' (bộ quần áo, bộ mặc nhà), "
"'Khăn', 'Mũ', 'Túi', 'Tất', 'Chăn'. "
"VD: 'áo khaki' → product_line_vn: 'Áo'. 'quần hoặc váy' → 'Quần/ Váy'"
),
)
discovery_mode: str | None = Field(
description=(
"[SQL FILTER] Chế độ khám phá. 'new' = hàng mới (is_new_product=1), "
"'best_seller' = bán chạy nhất (ORDER BY quantity_sold). "
"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."
),
)
......@@ -99,16 +117,6 @@ async def _execute_single_search(
item.magento_ref_code,
)
# Infer product_name if missing (avoid wrong category results)
if not item.product_name:
inferred_name = infer_product_name_from_description(item.description)
if inferred_name:
try:
item = item.model_copy(update={"product_name": inferred_name})
except AttributeError:
item = item.copy(update={"product_name": inferred_name})
logger.warning("🧭 Inferred product_name='%s' from description", inferred_name)
# Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector)
......@@ -140,11 +148,7 @@ async def _execute_single_search(
first_p.get("sale_price"),
)
# ====== POST-FILTERS: Apply all filter layers via centralized function ======
is_sku_search = bool(item.magento_ref_code)
products, filter_info = apply_post_filters(products, item, is_sku_search=is_sku_search)
return format_product_results(products), filter_info
return format_product_results(products), {"fallback_used": False}
except Exception as e:
logger.exception("Single search error for item %r: %s", item, e)
return [], {"fallback_used": False, "error": str(e)}
......@@ -182,8 +186,21 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
# Aggregate filter info from first result for simplicity in response
final_info = all_filter_infos[0] if all_filter_infos else {}
# Include search input so LLM knows its own reasoning
search_inputs = [
{
"description": item.description,
"product_line_vn": item.product_line_vn,
"gender_by_product": item.gender_by_product,
"age_by_product": item.age_by_product,
"discovery_mode": item.discovery_mode,
}
for item in searches
]
output = {
"status": "success",
"search_input": search_inputs,
"results": combined_results,
"filter_info": final_info,
}
......
......@@ -95,3 +95,17 @@ Step 6: Agent nhận message → báo khách
│ │
│ → Color fallback có thể vì COLOR KHÔNG trong SQL query! │
└─────────────────────────────────────────────────────────────────────┘
Bro ơi, tao đã bỏ sạch product_line_vn khỏi SQL rồi!
Kiến trúc bây giờ:
Tầng Filter Mục đích
SQL (product_search_helpers.py) gender + age ONLY Giữ HNSW index nguyên vẹn
Python (data_retrieval_filter.py) PRODUCT_TYPE_MAPPING → exact match product_line_vn Lọc đúng loại sản phẩm
Flow cho "quần đùi bé trai":
SQL: Vector search "Quần soóc bé trai" → top 100 (HNSW intact ✅)
SQL: filtered_matches chỉ filter gender=boy, age=kid → ~80 results
Python: PRODUCT_TYPE_MAPPING["quần soóc"] = "Quần soóc" → exact match product_line_vn ✅
Nếu 0 match → reject all + message "Shop chưa có..."
Server đang reload. Test lại đi bro! 🚀
......@@ -3,6 +3,8 @@ Canifa API Service
Xử lý các logic liên quan đến API của Canifa (Magento)
"""
import hashlib
import json
import logging
from typing import Any
......@@ -10,11 +12,11 @@ import httpx
logger = logging.getLogger(__name__)
# CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
CANIFA_CUSTOMER_API = "https://canifa.com/v1/magento/customer"
# ⚡ Auth Token Cache: TTL 1 tiếng — tránh gọi canifa.com mỗi request
AUTH_CACHE_TTL = 3600 # 1 hour
AUTH_CACHE_PREFIX = "auth_token:"
_http_client: httpx.AsyncClient | None = None
......@@ -42,10 +44,48 @@ CANIFA_QUERY_BODY = [
{},
]
async def _get_cached_auth(token: str) -> dict | None:
"""Check Redis cache for verified token."""
try:
from common.cache import redis_cache
client = redis_cache.get_client()
if not client:
return None
token_hash = hashlib.md5(token.encode()).hexdigest()
key = f"{AUTH_CACHE_PREFIX}{token_hash}"
cached = await client.get(key)
if cached:
logger.info("⚡ Auth token cache HIT — skipping Canifa API call")
return json.loads(cached)
except Exception as e:
logger.warning(f"Auth cache read error: {e}")
return None
async def _set_cached_auth(token: str, data: dict) -> None:
"""Store verified token result in Redis."""
try:
from common.cache import redis_cache
client = redis_cache.get_client()
if not client:
return
token_hash = hashlib.md5(token.encode()).hexdigest()
key = f"{AUTH_CACHE_PREFIX}{token_hash}"
await client.setex(key, AUTH_CACHE_TTL, json.dumps(data, ensure_ascii=False))
logger.info(f"💾 Auth token cached (TTL: {AUTH_CACHE_TTL}s)")
except Exception as e:
logger.warning(f"Auth cache write error: {e}")
async def verify_canifa_token(token: str) -> dict[str, Any] | None:
"""
Verify token với API Canifa (Magento).
Dùng token làm cookie `vsf-customer` để gọi API lấy thông tin customer.
Verify token với API Canifa (Magento) — có Redis cache.
Lần đầu gọi API (~200-500ms), các lần sau hit cache (~2ms).
Args:
token: Giá trị của cookie vsf-customer (lấy từ Header Authorization)
......@@ -56,6 +96,12 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None:
if not token:
return None
# ⚡ Check cache first
cached_data = await _get_cached_auth(token)
if cached_data is not None:
return cached_data
# Cache miss → call Canifa API
headers = {
"accept": "application/json, text/plain, */*",
"content-type": "application/json",
......@@ -70,12 +116,11 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None:
data = response.json()
logger.debug(f"Canifa API Raw Response: {data}")
# Response format: {"data": {"customer": {...}}, "loading": false, ...}
if isinstance(data, dict):
# Trả về toàn bộ data để extract_user_id xử lý
# ⚡ Cache successful auth result
await _set_cached_auth(token, data)
return data
# Nếu Canifa trả list (batch request)
return data
logger.warning(f"Canifa API Failed: {response.status_code} - {response.text}")
......
......@@ -5,7 +5,7 @@ import platform
import uvicorn
from fastapi import FastAPI
from fastapi.responses import RedirectResponse
from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router
......@@ -32,6 +32,12 @@ logging.basicConfig(
logger = logging.getLogger(__name__)
# Static files directory
STATIC_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "static")
if not os.path.exists(STATIC_DIR):
os.makedirs(STATIC_DIR)
print(f"✅ Static dir resolved: {STATIC_DIR}")
app = FastAPI(
title="Contract AI Service",
......@@ -47,6 +53,30 @@ async def startup_event():
logger.info("✅ Redis cache initialized")
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
@app.get("/health")
async def health():
return JSONResponse({"status": "ok"})
@app.get("/static/{file_path:path}")
async def serve_static(file_path: str):
"""Serve static files explicitly to avoid middleware conflict."""
if not file_path:
file_path = "index.html"
full_path = os.path.join(STATIC_DIR, file_path)
if os.path.isfile(full_path):
return FileResponse(full_path)
return JSONResponse({"detail": "Not Found"}, status_code=404)
# =====================================================================
# MIDDLEWARE (after static mount)
# =====================================================================
middleware_manager.setup(
app,
enable_auth=True,
......@@ -66,21 +96,6 @@ app.include_router(mock_router)
app.include_router(stock_router)
try:
static_dir = os.path.join(os.path.dirname(__file__), "static")
if not os.path.exists(static_dir):
os.makedirs(static_dir)
app.mount("/static", StaticFiles(directory=static_dir, html=True), name="static")
print(f"✅ Static files mounted at /static (Dir: {static_dir})")
except Exception as e:
print(f"⚠️ Failed to mount static files: {e}")
@app.get("/")
async def root():
return RedirectResponse(url="/static/index.html")
if __name__ == "__main__":
print("=" * 60)
print("🚀 Contract AI Service Starting...")
......
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