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 ...@@ -6,6 +6,7 @@ Sử dụng ConversationManager (Postgres) để lưu history thay vì checkpoin
""" """
import logging import logging
from datetime import datetime
from typing import Any from typing import Any
from langchain_core.language_models import BaseChatModel from langchain_core.language_models import BaseChatModel
...@@ -19,7 +20,7 @@ from langgraph.types import CachePolicy ...@@ -19,7 +20,7 @@ from langgraph.types import CachePolicy
from common.llm_factory import create_llm from common.llm_factory import create_llm
from .models import AgentConfig, AgentState, get_config 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 from .tools.get_tools import get_all_tools, get_collection_tools
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
...@@ -46,10 +47,11 @@ class CANIFAGraph: ...@@ -46,10 +47,11 @@ class CANIFAGraph:
self.retrieval_tools = self.all_tools self.retrieval_tools = self.all_tools
self.llm_with_tools = self.llm.bind_tools(self.all_tools, strict=True) 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( self.prompt_template = ChatPromptTemplate.from_messages(
[ [
("system", self.system_prompt), ("system", self.system_prompt_template),
( (
"system", "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=====================================", "===== 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: ...@@ -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õ')." 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( response = await self.chain.ainvoke(
{ {
"date_str": current_date_str,
"history": history, "history": history,
"user_query": [user_query] if user_query else [], "user_query": [user_query] if user_query else [],
"messages": messages, "messages": messages,
......
...@@ -70,16 +70,22 @@ def format_product_results(products: list[dict]) -> list[dict]: ...@@ -70,16 +70,22 @@ def format_product_results(products: list[dict]) -> list[dict]:
"description": "..." "description": "..."
} }
""" """
max_products = 10 max_products = 15
grouped: dict[str, dict] = {} # {magento_ref_code: {product_info}} grouped: dict[str, dict] = {} # {magento_ref_code: {product_info}}
for p in products: for p in products:
# Extract product info # Extract product info
if p.get("product_name"): if p.get("product_name"):
name = p["product_name"] name = p["product_name"]
color_name = p.get("master_color", "") color_name = p.get("master_color") or ""
thumb_url = p.get("product_image_url_thumbnail", "") thumb_url = p.get("product_image_url_thumbnail") or ""
web_url = p.get("product_web_url", "") 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: else:
desc_full = p.get("description_text_full", "") desc_full = p.get("description_text_full", "")
parsed = _parse_description_text(desc_full) parsed = _parse_description_text(desc_full)
...@@ -122,7 +128,7 @@ def format_product_results(products: list[dict]) -> list[dict]: ...@@ -122,7 +128,7 @@ def format_product_results(products: list[dict]) -> list[dict]:
grouped[base_sku]["sale_price"] = int(sale_price) grouped[base_sku]["sale_price"] = int(sale_price)
else: else:
# New product - use first color's URL/thumbnail as default # New product - use first color's URL/thumbnail as default
grouped[base_sku] = { product_entry = {
"sku": base_sku, "sku": base_sku,
"name": name, "name": name,
"color": color_name, # First color as default "color": color_name, # First color as default
...@@ -133,6 +139,11 @@ def format_product_results(products: list[dict]) -> list[dict]: ...@@ -133,6 +139,11 @@ def format_product_results(products: list[dict]) -> list[dict]:
"thumbnail_image_url": thumb_url, # First color's thumbnail "thumbnail_image_url": thumb_url, # First color's thumbnail
"description": p.get("description_text_full", ""), "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] formatted = list(grouped.values())[:max_products]
logger.info(f"📦 Formatted {len(formatted)} products (grouped by SKU)") logger.info(f"📦 Formatted {len(formatted)} products (grouped by SKU)")
......
...@@ -39,6 +39,30 @@ def get_system_prompt() -> str: ...@@ -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: def get_last_modified() -> float:
"""Trả về timestamp lần sửa cuối cùng của file system_prompt.txt.""" """Trả về timestamp lần sửa cuối cùng của file system_prompt.txt."""
try: try:
......
This diff is collapsed.
...@@ -72,4 +72,34 @@ age_by_product: adult. ...@@ -72,4 +72,34 @@ age_by_product: adult.
"USER": "Nam, Adult, xưng anh.", "USER": "Nam, Adult, xưng anh.",
"TARGET": "... "TARGET": "...
2026-02-05 17:09:13,818 [WARNING] agent.controller_helpers: ✅ [user_insight] Extracted + saved in 0.00s | Key: cdcdc3dfef 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 ←[32mINFO←[0m: 127.0.0.1:64945 - "←[1mGET /api/agent/user-insight HTTP/1.1←[0m" ←[32m200 OK←[0m
\ No newline at end of file
┌─────────────────────────────────────────────────────────┐
│ 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 ...@@ -11,10 +11,7 @@ import time
from langchain_core.tools import tool from langchain_core.tools import tool
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from agent.tools.data_retrieval_filter import (
apply_post_filters,
infer_product_name_from_description,
)
from agent.helper import format_product_results from agent.helper import format_product_results
from agent.tools.product_search_helpers import build_starrocks_query from agent.tools.product_search_helpers import build_starrocks_query
from common.starrocks_connection import get_db_connection from common.starrocks_connection import get_db_connection
...@@ -28,50 +25,71 @@ from agent.prompt_utils import read_tool_prompt ...@@ -28,50 +25,71 @@ from agent.prompt_utils import read_tool_prompt
class SearchItem(BaseModel): class SearchItem(BaseModel):
model_config = {"extra": "forbid"} # STRICT MODE model_config = {"extra": "forbid"} # STRICT MODE
# ====== SEMANTIC SEARCH ======
description: str = Field( 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')" description=(
) "Mô tả sản phẩm cho semantic search. "
product_name: str | None = Field( "FORMAT: 'product_name: [tên]. description_text: [mô tả]. "
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ả." "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]'. "
magento_ref_code: str | None = Field(description="Mã sản phẩm chính xác (SKU)") "🚨 product_name = CHÍNH XÁC lời user nói, TUYỆT ĐỐI KHÔNG tự đổi sang tên khác. "
price_min: int | None = Field(description="Giá thấp nhất (VND)") "VD: user nói 'áo ngọ nguậy' → product_name: Áo ngọ nguậy (KHÔNG đổi thành 'Áo phông'!). "
price_max: int | None = Field(description="Giá cao nhất (VND)") "VD: user nói 'áo cá sấu' → product_name: Áo cá sấu (KHÔNG đổi!). "
discount_min: int | None = Field( "Chỉ chuẩn hóa từ đồng nghĩa RÕ RÀNG: 'áo thun'→'Áo phông', 'quần bò'→'Quần jean'. "
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." "⚠️ 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'."
)
) )
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( 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") age_by_product: str | None = Field(
master_color: str | None = Field( description="[SQL FILTER] Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
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')"
) )
form_sleeve: str | None = Field( master_color: 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'" 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( price_min: int | 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." description="[SQL FILTER] Giá thấp nhất (VND)",
) )
fitting: str | None = Field( price_max: int | None = Field(
description="Dáng đồ. GIÁ TRỊ DB: 'Regular', 'Slimfit', 'Relax', 'Oversize', 'Skinny', 'Slim', 'Boxy'. Mapping: 'vừa'→'Regular', 'ôm'→'Slimfit', 'rộng'→'Relax'" description="[SQL FILTER] Giá cao nhất (VND)",
) )
form_neckline: str | None = Field( discount_min: int | None = Field(
description="Dáng cổ áo. GIÁ TRỊ DB: 'Crew Neck', 'Classic Collar', 'V-neck', 'Hooded collar', 'Mock Neck/ High neck'" 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( discount_max: int | None = Field(
description="Nhóm chất liệu. GIÁ TRỊ DB: 'Knit - Dệt Kim', 'Woven - Dệt Thoi', 'Yarn - Sợi'" 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( 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( ...@@ -99,16 +117,6 @@ async def _execute_single_search(
item.magento_ref_code, 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) # Timer: build query (sử dụng vector đã có hoặc build mới)
query_build_start = time.time() query_build_start = time.time()
sql, params = await build_starrocks_query(item, query_vector=query_vector) sql, params = await build_starrocks_query(item, query_vector=query_vector)
...@@ -140,11 +148,7 @@ async def _execute_single_search( ...@@ -140,11 +148,7 @@ async def _execute_single_search(
first_p.get("sale_price"), first_p.get("sale_price"),
) )
# ====== POST-FILTERS: Apply all filter layers via centralized function ====== return format_product_results(products), {"fallback_used": False}
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
except Exception as e: except Exception as e:
logger.exception("Single search error for item %r: %s", item, e) logger.exception("Single search error for item %r: %s", item, e)
return [], {"fallback_used": False, "error": str(e)} return [], {"fallback_used": False, "error": str(e)}
...@@ -182,8 +186,21 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str: ...@@ -182,8 +186,21 @@ async def data_retrieval_tool(searches: list[SearchItem]) -> str:
# Aggregate filter info from first result for simplicity in response # Aggregate filter info from first result for simplicity in response
final_info = all_filter_infos[0] if all_filter_infos else {} 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 = { output = {
"status": "success", "status": "success",
"search_input": search_inputs,
"results": combined_results, "results": combined_results,
"filter_info": final_info, "filter_info": final_info,
} }
......
...@@ -94,4 +94,18 @@ Step 6: Agent nhận message → báo khách ...@@ -94,4 +94,18 @@ Step 6: Agent nhận message → báo khách
│ → Lọc màu + fallback nếu không có │ │ → Lọc màu + fallback nếu không có │
│ │ │ │
│ → Color fallback có thể vì COLOR KHÔNG trong SQL query! │ │ → Color fallback có thể vì COLOR KHÔNG trong SQL query! │
└─────────────────────────────────────────────────────────────────────┘ └─────────────────────────────────────────────────────────────────────┘
\ No newline at end of file
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 ...@@ -3,6 +3,8 @@ Canifa API Service
Xử lý các logic liên quan đến API của Canifa (Magento) Xử lý các logic liên quan đến API của Canifa (Magento)
""" """
import hashlib
import json
import logging import logging
from typing import Any from typing import Any
...@@ -10,11 +12,11 @@ import httpx ...@@ -10,11 +12,11 @@ import httpx
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# CANIFA_CUSTOMER_API = "https://vsf2.canifa.com/v1/magento/customer"
CANIFA_CUSTOMER_API = "https://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 _http_client: httpx.AsyncClient | None = None
...@@ -42,10 +44,48 @@ CANIFA_QUERY_BODY = [ ...@@ -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: async def verify_canifa_token(token: str) -> dict[str, Any] | None:
""" """
Verify token với API Canifa (Magento). Verify token với API Canifa (Magento) — có Redis cache.
Dùng token làm cookie `vsf-customer` để gọi API lấy thông tin customer. Lần đầu gọi API (~200-500ms), các lần sau hit cache (~2ms).
Args: Args:
token: Giá trị của cookie vsf-customer (lấy từ Header Authorization) token: Giá trị của cookie vsf-customer (lấy từ Header Authorization)
...@@ -56,10 +96,16 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None: ...@@ -56,10 +96,16 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None:
if not token: if not token:
return None 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 = { headers = {
"accept": "application/json, text/plain, */*", "accept": "application/json, text/plain, */*",
"content-type": "application/json", "content-type": "application/json",
"Cookie": f"vsf-customer={token}", "Cookie": f"vsf-customer={token}",
} }
try: try:
...@@ -70,12 +116,11 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None: ...@@ -70,12 +116,11 @@ async def verify_canifa_token(token: str) -> dict[str, Any] | None:
data = response.json() data = response.json()
logger.debug(f"Canifa API Raw Response: {data}") logger.debug(f"Canifa API Raw Response: {data}")
# Response format: {"data": {"customer": {...}}, "loading": false, ...}
if isinstance(data, dict): 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 return data
# Nếu Canifa trả list (batch request)
return data return data
logger.warning(f"Canifa API Failed: {response.status_code} - {response.text}") logger.warning(f"Canifa API Failed: {response.status_code} - {response.text}")
......
...@@ -5,7 +5,7 @@ import platform ...@@ -5,7 +5,7 @@ import platform
import uvicorn import uvicorn
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import RedirectResponse from fastapi.responses import FileResponse, JSONResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from api.chatbot_route import router as chatbot_router from api.chatbot_route import router as chatbot_router
...@@ -32,6 +32,12 @@ logging.basicConfig( ...@@ -32,6 +32,12 @@ logging.basicConfig(
logger = logging.getLogger(__name__) 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( app = FastAPI(
title="Contract AI Service", title="Contract AI Service",
...@@ -47,6 +53,30 @@ async def startup_event(): ...@@ -47,6 +53,30 @@ async def startup_event():
logger.info("✅ Redis cache initialized") 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( middleware_manager.setup(
app, app,
enable_auth=True, enable_auth=True,
...@@ -66,21 +96,6 @@ app.include_router(mock_router) ...@@ -66,21 +96,6 @@ app.include_router(mock_router)
app.include_router(stock_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__": if __name__ == "__main__":
print("=" * 60) print("=" * 60)
print("🚀 Contract AI Service Starting...") 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