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

feat: faq simulator, db sync, lead logic update

parent a7bbe2c0
Subproject commit 44af4fbec40276b94e54a275162e2ae803e5e13c
......@@ -15,7 +15,7 @@ from langgraph.prebuilt import ToolNode
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
from agent.tag_search_agent.tag_search_tool import tag_search_tool as canifa_tag_search
from agent.lead_stage_agent.lead_search_tool import lead_search_tool as canifa_tag_search
from .prompts import VISION_PLANNER_PROMPT, VISION_RESPONDER_PROMPT
logger = logging.getLogger(__name__)
......
......@@ -96,7 +96,7 @@ class ClassifierOutput(BaseModel):
# ── TH 2: EARLY EXIT ──
ai_response: str | None = Field(default=None, description="Cau tra loi cho khach. CHI nha ra khi tool_name=null. Neu goi tool, bat buoc de null.")
product_ids: list[str] = Field(default_factory=list, description="Ma SKU (neu khach hoi trung ma).")
product_ids: list[str] | None = Field(default_factory=list, description="Ma SKU (neu khach hoi trung ma).")
class InsightJSON(BaseModel):
......
"""
Lead Search Tool - LangChain @tool cho AI goi.
AI sinh keywords -> Tool search -> cascade -> Tra ket qua.
AI sinh keywords + tags -> Tool search -> cascade -> Tra ket qua.
+ Tu dong goi Canifa Stock API -> chi tra SP CON HANG.
+ Doc outfit tu SQLite 123.db
Architecture:
CO DINH (luon giu): product_type + color + gender + price
CO DINH (luon giu): product_type + color + gender + price + size
BIEN DOI (fallback):
Tang 1: CO DINH + keywords LIKE description_text
Tang 2: CO DINH + embedding cosine (vector column)
Tang 3: CHI CO DINH (product_type + color + gender)
Tang 1: CO DINH + keywords (NGRAMBF tren product_name hoac LIKE tren description)
Tang 2: CO DINH + tags (BITMAP hoac LIKE full phrase)
Tang 3: CHI CO DINH (product_type + color + gender + size + price)
Tang 4: Drop gender
Tang 5-6: Price relaxation (1.5x, 2x)
Tang 7: Bo price hoan toan
"""
import json
import logging
import time
from datetime import datetime, timezone
import os as _os
import sqlite3
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from common.embedding_service import create_embedding_async
from common.pool_wrapper import get_pooled_connection_compat
from .product_mapping import SYNONYM_TO_DB, get_related_lines
from .product_mapping import SYNONYM_TO_DB, get_related_lines, resolve_product_line
logger = logging.getLogger(__name__)
try:
from langfuse import get_client as get_langfuse
from langfuse import get_client as _get_langfuse
_LANGFUSE_AVAILABLE = True
except ImportError:
_get_langfuse = None
_LANGFUSE_AVAILABLE = False
def _lf_span(name: str, input_data: dict | None = None):
"""Create a Langfuse span if available, else return a no-op context manager."""
if not _LANGFUSE_AVAILABLE:
import contextlib
return contextlib.nullcontext()
try:
lf = get_langfuse()
if lf:
return lf.start_as_current_observation(
as_type="span",
name=name,
input=input_data or {},
)
except Exception:
pass
import contextlib
return contextlib.nullcontext()
# ═══════════════════════════════════════════════
# Stock API — Kiểm tra tồn kho Canifa
# ═══════════════════════════════════════════════
CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent"
_STOCK_TIMEOUT = 1.5 # seconds
async def _fetch_stock_batch(base_codes: list[str]) -> dict[str, list[dict]]:
if not base_codes:
return {}, 0.0, False
logger = logging.getLogger(__name__)
sku_string = ",".join(base_codes)
url = f"{CANIFA_STOCK_API}?skus={sku_string}"
t0 = time.time()
try:
async with httpx.AsyncClient(timeout=_STOCK_TIMEOUT) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
elapsed = round((time.time() - t0) * 1000, 1)
stock_map: dict[str, list[dict]] = {}
results = data.get("result", [])
if isinstance(results, list):
for item in results:
sku_full = item.get("sku", "")
qty = item.get("qty", 0) or 0
if not sku_full:
continue
parts = sku_full.rsplit("-", 1)
if len(parts) == 2:
color_code = parts[0]
size = parts[1]
status = "còn hàng" if qty > 0 else "hết hàng"
stock_map.setdefault(color_code, []).append({
"size": size, "qty": qty, "status": status
})
logger.info("📦 Stock API: %d base_codes -> %d color_codes | %.0fms", len(base_codes), len(stock_map), elapsed)
return stock_map, elapsed, False
except httpx.TimeoutException:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⏰ Stock API timeout (%.1fs=%.0fms)", _STOCK_TIMEOUT, elapsed)
return {}, elapsed, True
except Exception as e:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⚠️ Stock API error (%.0fms): %s", elapsed, e)
return {}, elapsed, False
async def _enrich_with_stock(products: list[dict]) -> tuple[list[dict], bool, float, bool]:
if not products:
return [], False, 0.0, False
base_codes = list({p.get("internal_ref_code", "") for p in products if p.get("internal_ref_code")})
if not base_codes:
return products, False, 0.0, False
stock_map, stock_api_ms, timed_out = await _fetch_stock_batch(base_codes)
if not stock_map:
return products, False, stock_api_ms, timed_out
in_stock = []
dropped = 0
for p in products:
color_code = p.get("product_color_code", "")
if color_code in stock_map:
sizes_detail = stock_map[color_code]
total_qty = sum(s["qty"] for s in sizes_detail)
if total_qty > 0:
p["_stock_detail"] = sizes_detail
p["_total_qty"] = total_qty
in_stock.append(p)
else:
dropped += 1
logger.info("📦 Stock filter: %d -> %d in-stock, %d dropped | api=%.0fms", len(products), len(in_stock), dropped, stock_api_ms)
return in_stock if in_stock else products[:5], True, stock_api_ms, timed_out
# ═══════════════════════════════════════════════
# SQLite local DB path
# ═══════════════════════════════════════════════
_DB_123_PATH = _os.path.normpath(
_os.path.join(_os.path.dirname(__file__), "..", "..", "123.db")
)
TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding"
PG_TABLE = "dashboard_canifa.ultra_descriptions" # Postgres — can write/join
SELECT_COLUMNS = """
internal_ref_code,
......@@ -78,69 +156,84 @@ SELECT_COLUMNS = """
similar_items
"""
TAGS_TO_OCCASION: dict[str, str] = {
"occ:di_lam": "di_lam",
"occ:di_choi": "di_choi",
"occ:di_tiec": "di_tiec",
"occ:di_hoc": "di_choi",
"occ:mac_nha": "mac_nha",
"occ:the_thao": "the_thao",
"occ:di_bien": "di_choi",
"occ:du_lich": "di_choi",
"occ:da_ngoai": "di_choi",
"occ:di_ngu": "mac_nha",
"occ:hang_ngay": "hang_ngay",
}
def _resolve_occasion(tags: list[str]) -> str:
for tag in (tags or []):
occ = TAGS_TO_OCCASION.get(tag.lower())
if occ:
return occ
return "hang_ngay"
# ======================================================
# Pydantic Schema - AI se fill vao day
# ======================================================
class LeadSearchInput(BaseModel):
"""Schema cho hybrid search: keywords (LIKE) + embedding (cosine) fallback."""
class LeadSearchInput(BaseModel):
model_config = {"extra": "forbid"}
# KEYWORDS - Rut gon intent tu cau hoi user
keywords: list[str] = Field(
default=[],
description=(
"Rut gon y dinh khach thanh cac CUM TU NGAN de search MO TA SAN PHAM. "
"TUYET DOI CAM dua MAU SAC vao keywords — mau da co truong master_color rieng. "
"Chi dua: chat lieu (cotton, interlock, denim), phong cach (oversize, ong rong), "
"dip (dam cuoi, di bien), ky nang che dau nhuoc diem (che bung, che dui)... "
"VD: khach noi 'ao do chat lieu interlock' -> keywords=['interlock'], master_color='do'. "
"VD: khach noi 'di dam cuoi troi 40 do beo bung' -> keywords=['dam cuoi', 'thoang mat', 'che bung']. "
"VD: khach noi 'cotton ong rong' -> keywords=['cotton', 'ong rong']. "
"VD: khach noi 'goi y do dep' -> keywords=[]. "
"Toi da 4. De [] neu khach hoi chung chung."
),
"Từ khoá NGUYÊN VĂN từ câu hỏi khách. "
"VD: khách nói '30/4' -> keywords=['30/4']. "
"Khách nói 'cờ đỏ sao vàng' -> keywords=['cờ đỏ sao vàng']. "
"Khách nói 'cotton ống rộng' -> keywords=['cotton', 'ống rộng']. "
"Tối đa 2. Để [] nếu khách không nhắc gì đặc biệt."
)
)
tags: list[str] = Field(
default=[],
description=(
"AI suy luận ý định khách -> chọn từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!): "
"Trục 1 (occ:): occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu. "
"Trục 2a (wthr:): wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang. "
"Trục 2b (func:): func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio. "
"Trục 3 (style:): style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual. "
"Trục 4 (fit:): fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed. "
"VD: 'đi tiệc' -> tags=['occ:di_tiec']. 'mùa đông' -> tags=['wthr:mua_dong', 'func:giu_am']. "
"KHÔNG tự nghĩ tag mới! PHẢI giữ prefix! Tối đa 3."
)
)
# CO DINH - Luon giu trong moi tang
product_line_vn: list[str] = Field(
default=[],
description=(
"Danh muc san pham chuẩn (VD: 'Áo phông', 'Áo Polo', 'Váy liền'...). "
"LUON UU TIEN dien neu xac dinh duoc loai san pham khach muon. "
"Dung de filter chinh xac theo cot product_line_vn trong DB."
"Dòng sản phẩm. CHÍNH XÁC lời user nói: 'áo phông', 'váy liền', 'đồ lót', 'quần jean'... "
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' -> 'Áo phông'). "
"Nếu khách nói chung chung ('đồ mùa đông', 'đồ tập') hoặc không nhắc, để []. "
),
)
gender_by_product: str | None = Field(
default=None,
description="Gioi tinh. GIA TRI HOP LE: women, men, boy, girl, unisex, newborn",
description="Giới tính. GIÁ TRỊ HỢP LỆ: women, men, boy, girl, unisex, newborn",
)
age_by_product: str | None = Field(
default=None,
description="Do tuoi. GIA TRI HOP LE: adult, kid, others",
description="Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
)
master_color: str | None = Field(
default=None,
description=(
"Mau sac. Ghi CHINH XAC tu khach noi: 'trang', 'den', 'do'... "
"VD: 'ao co do sao vang' -> color='do'."
"Màu sắc. Ghi CHÍNH XÁC từ khách nói: 'trắng', 'đen', 'đỏ'... "
"VD: 'áo cờ đỏ sao vàng' -> color='đỏ'."
),
)
price_min: int | None = Field(default=None, description="Gia thap nhat (VND)")
price_max: int | None = Field(default=None, description="Gia cao nhat (VND)")
discount_min: int | None = Field(
default=None,
description="% giam gia TOI THIEU. VD: 'giam 50%' -> 50. 'hang sale' -> 1.",
)
discount_max: int | None = Field(
default=None,
description="% giam gia TOI DA. VD: 'giam duoi 30%' -> 30.",
)
discovery_mode: str | None = Field(
default=None,
description="'new' = hang moi, 'best_seller' = ban chay. Chi dung khi khach noi ro.",
)
price_min: int | None = Field(default=None, description="Giá thấp nhất (VND)")
price_max: int | None = Field(default=None, description="Giá cao nhất (VND)")
discount_min: int | None = Field(default=None, description="% giảm giá TỐI THIỂU.")
discount_max: int | None = Field(default=None, description="% giảm giá TỐI ĐA.")
discovery_mode: str | None = Field(default=None, description="'new' = hàng mới, 'best_seller' = bán chạy.")
size: str | None = Field(
default=None,
description=(
......@@ -148,41 +241,25 @@ class LeadSearchInput(BaseModel):
"Nguoi lon: XS, S, M, L, XL, XXL, 3XL, 4XL. "
"Tre em (chieu cao cm): 80, 86, 92, 98, 104, 110, 116, 122, 128, 134, 140, 152, 164. "
"Tre so sinh (thang): 1/3, 4/6, 7/9, 10/12, 13/14. "
"VD: 'ao size L' -> size='L'. 'quan be 110' -> size='110'. "
"Chi dien khi khach NOI RO size. Neu khong nhac -> None."
),
)
magento_ref_code: str | None = Field(
default=None,
description="Ma SKU cu the. Chi dien khi khach cung cap.",
)
reasoning: str | None = Field(
default=None,
description=(
"SUY LUAN: Giai thich ngan gon TAI SAO ban chon params nay. "
"VD: '2/9 = Quoc Khanh -> ao do di choi', 'khach muon di dam cuoi troi nong -> thoang mat, lich su'. "
"Viet 1-2 cau bang tieng Viet."
"VD: 'ao size L' -> size='L'."
),
)
magento_ref_code: str | None = Field(default=None, description="Ma SKU cu the.")
reasoning: str | None = Field(default=None, description="SUY LUẬN TẠI SAO bạn chọn params này.")
# ======================================================
# SQL Builder - Hard Filters
# SQL Builder
# ======================================================
def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
"""Build các filter CỐ ĐỊNH (luôn giữ trong mọi tầng)."""
clauses = []
# Product Line VN
if req.product_line_vn:
lines = []
for line in req.product_line_vn:
if not line: continue
# Resolve synonyms (e.g. "áo polo" -> "Áo Polo")
from .product_mapping import resolve_product_line
resolved = resolve_product_line(line)
for r in resolved:
# Expand related lines (e.g. "Áo lót" -> ["Áo lót", "Áo bra active"])
expanded = get_related_lines(r)
lines.extend(expanded)
......@@ -191,11 +268,9 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
params.extend(lines)
clauses.append(f"product_line_vn IN ({placeholders})")
# Gender
if req.gender_by_product:
gender_lower = req.gender_by_product.lower().strip()
genders_to_search = []
if gender_lower in ("women", "nu", "female", "nữ"):
genders_to_search = ["female", "women", "nu", "nữ", "unisex"]
elif gender_lower in ("men", "nam", "male"):
......@@ -211,7 +286,6 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
params.extend(genders_to_search)
clauses.append(f"gender_by_product IN ({placeholders})")
# Age
if req.age_by_product:
age_lower = req.age_by_product.lower().strip()
if age_lower in ("baby", "newborn"):
......@@ -220,63 +294,42 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
params.append(age_lower)
clauses.append("age_by_product = %s")
# Color — StarRocks LOWER() khong handle Vietnamese diacritics
# → match nhieu variant: tieng Viet (goc + khong dau) + tieng Anh
if req.master_color:
c_raw = req.master_color.strip()
# Map mau Viet -> EN. Bao gom ca dang co dau va khong dau
# Vi "d-stroke" (U+0111) khong decompose qua NFD nen phai map thu cong
COLOR_EN_MAP = {
# Khong dau
"do": "red", "trang": "white", "den": "black", "xanh": "blue",
"hong": "pink", "tim": "purple", "cam": "orange", "vang": "yellow",
"nau": "brown", "xam": "gray", "be": "beige", "kem": "cream",
"xanh la": "green", "bac": "silver", "vang dong": "gold",
# Co dau (d-stroke variants)
"đo": "red", # đo
"đỏ": "red", # đỏ (capital Đ lower = đ, o + hook above = ỏ)
"trắng": "white", # trắng
"đen": "black", # đen
"xanh lá": "green", # xanh lá
"hồng": "pink", # hồng
"tím": "purple", # tím
"nâu": "brown", # nâu
"xám": "gray", # xám
"vàng": "yellow", # vàng
"bạc": "silver", # bạc
"đo": "red", "đỏ": "red", "trắng": "white", "đen": "black",
"xanh lá": "green", "hồng": "pink", "tím": "purple", "nâu": "brown",
"xám": "gray", "vàng": "yellow", "bạc": "silver"
}
c_key = c_raw.lower().strip()
en_color = COLOR_EN_MAP.get(c_key)
if not en_color:
# fallback: strip combining marks only (works for o/a/e etc, not d-stroke)
import unicodedata
c_no_acc = "".join(
ch for ch in unicodedata.normalize("NFD", c_key)
if unicodedata.category(ch) != "Mn"
).strip()
en_color = COLOR_EN_MAP.get(c_no_acc)
c_no_accent = c_key # used below for non-LOWER match
c_no_accent = c_key
color_parts = []
# 1. Match chinh xac khong qua LOWER (tranh loi diacritics)
color_parts.append("(master_color LIKE %s OR product_color_name LIKE %s)")
params.append(f"%{c_raw}%")
params.append(f"%{c_raw}%")
# 2. Match khong dau (VD: "do" -> match "Do", "do", "DO")
if c_no_accent != c_raw.lower():
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{c_no_accent}%")
params.append(f"%{c_no_accent}%")
# 3. Match tieng Anh (VD: "do" -> "red")
if en_color:
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{en_color}%")
params.append(f"%{en_color}%")
clauses.append("(" + " OR ".join(color_parts) + ")")
# Price
if req.price_min is not None:
params.append(req.price_min)
clauses.append("sale_price >= %s")
......@@ -284,7 +337,6 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
params.append(req.price_max)
clauses.append("sale_price <= %s")
# Discount
if req.discount_min is not None:
params.append(req.discount_min)
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= %s)")
......@@ -292,13 +344,11 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
params.append(req.discount_max)
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
# Size
if req.size:
s = req.size.strip().upper()
params.append(s)
clauses.append("FIND_IN_SET(%s, REPLACE(size_scale, '|', ',')) > 0")
# Discovery mode
if req.discovery_mode:
mode = req.discovery_mode.lower().strip()
if mode == "new":
......@@ -308,44 +358,118 @@ def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
return clauses
TAG_TO_BITMAP_COL: dict[str, tuple[str, str]] = {
"style:thanh_lich": ("style", "Feminine"),
"style:nang_dong": ("style", "Dynamic"),
"style:basic": ("style", "Basic"),
"style:ca_tinh": ("style", "Street"),
"style:de_thuong": ("style", "Cute"),
"style:tre_trung": ("style", "Trend"),
"style:toi_gian": ("style", "Essential"),
"style:smart_casual":("style", "Smart Casual"),
"fit:oversize": ("fitting", "Oversize"),
"fit:slim": ("fitting", "Slim"),
"fit:regular": ("fitting", "Regular"),
"fit:wide_leg": ("fitting", "Relax"),
"fit:cropped": ("fitting", "Boxy"),
"fit:relaxed": ("fitting", "Relax"),
"wthr:mua_he": ("season_sale", "Summer"),
"wthr:mua_dong": ("season_sale", "Winter"),
"occ:di_bien": ("season_sale", "Summer"),
"occ:du_lich": ("season_sale", "Summer"),
"occ:the_thao": ("style", "Athleisure"),
}
TAG_TO_SEARCH_TEXT: dict[str, str] = {
"occ:di_lam": "đi làm",
"occ:di_choi": "đi chơi",
"occ:di_tiec": "đi tiệc",
"occ:di_hoc": "đi học",
"occ:mac_nha": "mặc nhà",
"occ:da_ngoai": "dã ngoại",
"occ:di_ngu": "ngủ",
"wthr:giao_mua": "giao mùa",
"wthr:troi_mua": "mưa",
"wthr:troi_nang": "nắng",
"func:thoang_mat": "thoáng mát",
"func:giu_am": "giữ ấm",
"func:tham_hut": "thấm hút",
"func:nhanh_kho": "nhanh khô",
"func:chong_uv": "chống UV",
"func:can_gio": "cản gió",
}
def _build_tag_clauses(tags: list[str], params: list) -> str:
if not tags: return ""
bitmap_by_col: dict[str, list[str]] = {}
text_terms: list[str] = []
for tag in tags:
tag_lower = tag.strip().lower()
if tag_lower in TAG_TO_BITMAP_COL:
col, val = TAG_TO_BITMAP_COL[tag_lower]
bitmap_by_col.setdefault(col, []).append(val)
elif tag_lower in TAG_TO_SEARCH_TEXT:
text_terms.append(TAG_TO_SEARCH_TEXT[tag_lower])
else:
text_terms.append(tag_lower.replace(":", " ").replace("_", " "))
# ======================================================
# Tier 1: Keywords LIKE search on description_text
# ======================================================
def _build_keyword_clause(keywords: list[str], params: list) -> str | None:
"""Build menh de LIKE cho tung keyword, AND voi nhau de tang do chinh xac."""
if not keywords:
return None
parts = []
for kw in keywords[:4]:
kw = kw.strip()
if not kw:
continue
params.append(f"%{kw}%")
params.append(f"%{kw}%")
# Search ca description_text (mien ngan) va description_text_full (day du)
parts.append("(LOWER(description_text) LIKE LOWER(%s) OR LOWER(description_text_full) LIKE LOWER(%s))")
if not parts:
return None
# Dùng OR thay vì AND để tăng độ phủ (Recall) cho tìm kiếm từ khóa
return "(" + " OR ".join(parts) + ")"
all_clauses = []
for col, values in bitmap_by_col.items():
if len(values) == 1:
params.append(values[0])
all_clauses.append(f"{col} = %s")
else:
placeholders = ", ".join(["%s"] * len(values))
params.extend(values)
all_clauses.append(f"{col} IN ({placeholders})")
if text_terms:
term_clauses = []
for term in text_terms:
term = term.strip()
if not term: continue
params.append(f"%{term}%")
params.append(f"%{term}%")
term_clauses.append(f"(LOWER(description_text) LIKE %s OR product_name LIKE %s)")
if term_clauses:
all_clauses.append(f"({' OR '.join(term_clauses)})")
if all_clauses:
return f"({' OR '.join(all_clauses)})"
return ""
def _build_search_clause(search_terms: list[str], params: list) -> str:
term_clauses = []
for term in search_terms:
t = term.strip()
if not t: continue
words = t.split()
if not words: continue
word_clauses = []
for word in words:
if len(word) >= 4:
params.append(f"%{word}%")
word_clauses.append("product_name LIKE %s")
else:
params.append(f"%{word}%")
params.append(f"%{word}%")
word_clauses.append("(product_name LIKE %s OR LOWER(description_text) LIKE %s)")
if word_clauses:
term_clauses.append(f"({' AND '.join(word_clauses)})")
if term_clauses:
return f"({' OR '.join(term_clauses)})"
return ""
def _build_exclusion_clauses(keywords: list[str], params: list) -> list[str]:
"""Build các filter LOẠI TRỪ (Negative filters) dựa trên từ khóa."""
clauses = []
kws_str = " ".join(keywords).lower()
# 1. Mùa đông/Lạnh -> Loại bỏ đồ hè hở hang
if any(k in kws_str for k in ["đông", "lạnh", "winter", "tuyết", "giữ ấm"]):
# Loại trừ Quần soóc, Áo ba lỗ
clauses.append("product_line_vn NOT IN (%s, %s)")
params.extend(["Quần soóc", "Áo ba lỗ"])
# 2. Đi làm/Công sở -> Loại bỏ đồ hoạt hình/manga nếu có thể
if any(k in kws_str for k in ["đi làm", "công sở", "văn phòng", "office"]):
# Loại trừ các mô tả chứa cartoon/manga/anime
# Dùng LIKE NOT để lọc bớt
forbidden = ["cartoon", "hoạt hình", "manga", "anime", "demon slayer", "naruto", "disney", "marvel"]
for f in forbidden:
clauses.append("LOWER(description_text) NOT LIKE %s")
......@@ -353,156 +477,23 @@ def _build_exclusion_clauses(keywords: list[str], params: list) -> list[str]:
return clauses
def _build_full_query(fixed_clauses: list[str], keyword_clause: str | None, exclusion_clauses: list[str] = None) -> str:
"""Lắp ráp câu SELECT hoàn chỉnh từ fixed + keyword + exclusion clauses."""
all_clauses = fixed_clauses[:]
if keyword_clause:
all_clauses.append(keyword_clause)
def _build_full_query(fixed_clauses: list[str], search_clause: str | None, exclusion_clauses: list[str] = None) -> str:
all_clauses = list(fixed_clauses)
if search_clause:
all_clauses.append(search_clause)
if exclusion_clauses:
all_clauses.extend(exclusion_clauses)
where_sql = ""
if all_clauses:
where_sql = "WHERE " + " AND ".join(all_clauses)
where = " AND ".join(all_clauses) if all_clauses else "1=1"
return f"""
SELECT
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
COALESCE(discount_amount, 0) AS discount_amount,
ROUND(CASE WHEN original_price > 0
THEN ((original_price - sale_price) / original_price * 100)
ELSE 0 END, 0) AS discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product,
size_scale,
description_text,
suggest_items,
similar_items
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
{where_sql}
ORDER BY quantity_sold DESC NULLS LAST
WHERE {where}
ORDER BY quantity_sold DESC NULLS LAST, sale_price ASC
LIMIT 20
"""
# ======================================================
# Tier 2: Embedding semantic search (HNSW ANN)
# ======================================================
async def _embedding_search_with_vec(
query_vec: list,
query_str: str,
fixed_clauses: list[str],
fixed_params: list,
db,
exclusion_clauses: list[str] = None,
) -> list:
"""
Nhận vector đã compute -> approx_cosine_similarity trên StarRocks.
CTE: vector_matches(top200) -> filtered(hard) -> GROUP BY dedup -> top 20.
"""
t0 = time.time()
vec_literal = "[" + ",".join(str(v) for v in query_vec) + "]"
post_filter_clauses = fixed_clauses[:]
if exclusion_clauses:
post_filter_clauses.extend(exclusion_clauses)
post_filter_where = ""
if post_filter_clauses:
post_filter_where = " WHERE " + " AND ".join(post_filter_clauses)
sql = f"""
WITH vector_matches AS (
SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
COALESCE(discount_amount, 0) AS discount_amount,
ROUND(CASE WHEN original_price > 0
THEN ((original_price - sale_price) / original_price * 100)
ELSE 0 END, 0) AS discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product,
size_scale,
description_text,
suggest_items,
similar_items,
approx_cosine_similarity(vector, {vec_literal}) AS similarity_score
FROM {TABLE_NAME}
ORDER BY similarity_score DESC
LIMIT 200
),
filtered_matches AS (
SELECT * FROM vector_matches
{post_filter_where}
ORDER BY similarity_score DESC
LIMIT 100
)
SELECT
internal_ref_code,
MAX_BY(magento_ref_code, similarity_score) AS magento_ref_code,
product_color_code,
MAX_BY(product_name, similarity_score) AS product_name,
MAX_BY(master_color, similarity_score) AS master_color,
MAX_BY(product_color_name, similarity_score) AS product_color_name,
MAX_BY(product_image_url_thumbnail, similarity_score) AS product_image_url_thumbnail,
MAX_BY(product_web_url, similarity_score) AS product_web_url,
MAX_BY(sale_price, similarity_score) AS sale_price,
MAX_BY(original_price, similarity_score) AS original_price,
MAX_BY(discount_amount, similarity_score) AS discount_amount,
MAX_BY(ROUND(CASE WHEN original_price > 0
THEN ((original_price - sale_price) / original_price * 100)
ELSE 0 END, 0), similarity_score) AS discount_percent,
MAX_BY(age_by_product, similarity_score) AS age_by_product,
MAX_BY(gender_by_product, similarity_score) AS gender_by_product,
MAX_BY(product_line_vn, similarity_score) AS product_line_vn,
MAX_BY(quantity_sold, similarity_score) AS quantity_sold,
MAX_BY(is_new_product, similarity_score) AS is_new_product,
MAX_BY(size_scale, similarity_score) AS size_scale,
MAX_BY(description_text, similarity_score) AS description_text,
ANY_VALUE(suggest_items) AS suggest_items,
ANY_VALUE(similar_items) AS similar_items,
MAX(similarity_score) AS _score
FROM filtered_matches
GROUP BY product_color_code, internal_ref_code
ORDER BY _score DESC
LIMIT 20
"""
products = await db.execute_query_async(sql, params=tuple(fixed_params) if fixed_params else None)
search_ms = round((time.time() - t0) * 1000, 1)
logger.info("[EMBEDDING] query='%s' -> %d results | search=%.0fms", query_str[:60], len(products), search_ms)
return products
# ======================================================
# Cascading Search
# ======================================================
async def _price_relaxed_search(req: LeadSearchInput, db, multiplier: float) -> list:
"""Tim voi gia noi (nhan multiplier), giu nguyen product_line + gender."""
if req.price_max is None:
return []
saved_max = req.price_max
......@@ -517,230 +508,59 @@ async def _price_relaxed_search(req: LeadSearchInput, db, multiplier: float) ->
req.price_min = saved_min
return products
async def _cascading_search(
req: LeadSearchInput,
db,
raw_message: str = "",
) -> tuple[list, int, str | None, dict]:
"""
Cascading search:
Tang 1: CO DINH + keywords LIKE description_text
Tang 2: CO DINH + embedding cosine (keywords joined -> embed)
Tang 3: CHI CO DINH (product_line + gender + price)
Tang 4: Drop gender
Tang 5-6: Price relaxation (1.5x, 2x)
Tang 7: Bo price hoan toan
Returns: (products, tier_matched, fallback_message | None, search_details)
"""
search_details: dict = {
"keyword_query": None,
"keyword_time_ms": None,
"keyword_results": None,
"embed_query": None,
"openai_embed_ms": None,
"starrocks_search_ms": None,
"embed_total_ms": None,
"embed_results": None,
}
_cascade_meta = {
"product_line": req.product_line_vn,
"gender": req.gender_by_product,
"age": req.age_by_product,
"color": req.master_color,
"price_min": req.price_min,
"price_max": req.price_max,
"keywords": req.keywords,
"raw_message": raw_message,
}
try:
_lf = get_langfuse() if _LANGFUSE_AVAILABLE else None
except Exception:
_lf = None
# ---- Prep Negative Filters (Exclusions) ----
async def _cascading_search(req: LeadSearchInput, db) -> tuple[list, int, str | None]:
ex_params = []
exclusions = _build_exclusion_clauses(req.keywords, ex_params)
# ---- Tang 1: CO DINH + KEYWORDS LIKE ----
# Tang 1: Fixed + Keywords (NGRAMBF)
if req.keywords:
t1 = time.time()
params: list = []
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_keyword_clause(req.keywords, params)
search = _build_search_clause(req.keywords, params)
if search:
# Combine params: fixed + search + exclusions
all_params = params + ex_params
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(all_params))
kw_ms = round((time.time() - t1) * 1000, 1)
search_details["keyword_query"] = req.keywords
search_details["keyword_time_ms"] = kw_ms
search_details["keyword_results"] = len(products)
logger.info(
"[SEARCH][TIER-1 KEYWORD] query=%s | filters={line=%s gender=%s color=%s price=%s~%s} -> %d results | %.0fms",
req.keywords, req.product_line_vn, req.gender_by_product,
req.master_color, req.price_min, req.price_max,
len(products), kw_ms,
)
if _lf:
try:
with _lf.start_as_current_observation(
as_type="span", name="search:tier1_keyword",
input={"keywords": req.keywords, "filters": _cascade_meta},
metadata={"sql_clauses_count": len(fixed)},
) as _sp1:
_sp1.update(output={"results": len(products), "latency_ms": kw_ms, "hit": bool(products)})
except Exception:
pass
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("[SEARCH] => HIT Tier 1 (keyword LIKE) | %d results", len(products))
return products, 1, None, search_details
logger.info("[SEARCH] => MISS Tier 1 -> fallback embedding")
# ---- Tang 2: CO DINH + EMBEDDING cosine ----
embed_query = " ".join(req.keywords) if req.keywords else raw_message
if embed_query.strip():
t2 = time.time()
# Step 2a: OpenAI Embedding
query_vec = await create_embedding_async(embed_query.strip())
openai_ms = round((time.time() - t2) * 1000, 1)
if query_vec and len(query_vec) == 1536:
# Step 2b: StarRocks cosine search
t2b = time.time()
params = []
fixed = _build_fixed_clauses(req, params)
# Combine params for embedding search
all_params = params + ex_params
products = await _embedding_search_with_vec(query_vec, embed_query, fixed, all_params, db, exclusions)
sr_ms = round((time.time() - t2b) * 1000, 1)
embed_total = round((time.time() - t2) * 1000, 1)
search_details["embed_query"] = embed_query.strip()
search_details["openai_embed_ms"] = openai_ms
search_details["starrocks_search_ms"] = sr_ms
search_details["embed_total_ms"] = embed_total
search_details["embed_results"] = len(products)
logger.info(
"[SEARCH][TIER-2 SEMANTIC] query='%s' | filters={line=%s gender=%s} -> %d results | openai=%.0fms sr=%.0fms total=%.0fms",
embed_query[:60], req.product_line_vn, req.gender_by_product,
len(products), openai_ms, sr_ms, embed_total,
)
if _lf:
try:
with _lf.start_as_current_observation(
as_type="span", name="search:tier2_semantic",
input={"embed_query": embed_query, "filters": _cascade_meta},
metadata={"openai_embed_ms": openai_ms, "starrocks_search_ms": sr_ms},
) as _sp2:
_sp2.update(output={"results": len(products), "total_ms": embed_total, "hit": bool(products)})
except Exception:
pass
return products, 1, None
# Tang 2: Fixed + Tags (BITMAP)
if req.tags:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_tag_clauses(req.tags, params)
if search:
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("[SEARCH] => HIT Tier 2 (semantic embedding) | %d results", len(products))
return products, 2, None, search_details
else:
search_details["openai_embed_ms"] = openai_ms
logger.warning("[SEARCH] => Embedding FAILED for: %s", embed_query[:60])
return products, 2, None
# ---- Tang 3: CHI CO DINH ----
# Tang 3: Chỉ cố định
params = []
fixed = _build_fixed_clauses(req, params)
# Neu khong co filter cung (line, gender, color, price) thi Tier 3 KHONG tra gi ca
# de tranh tra ve best-seller toan cong ty khong lien quan.
has_hard_filter = bool(req.product_line_vn or req.gender_by_product or req.master_color or req.price_max)
if not has_hard_filter:
logger.info("[SEARCH][TIER-3] SKIPPED - no hard filters")
products = []
else:
all_params = params + ex_params
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(all_params))
logger.info(
"[SEARCH][TIER-3 HARD-FILTERS] filters={line=%s gender=%s age=%s color=%s price=%s~%s disc=%s~%s size=%s mode=%s} -> %d results",
req.product_line_vn, req.gender_by_product, req.age_by_product,
req.master_color, req.price_min, req.price_max,
req.discount_min, req.discount_max, req.size, req.discovery_mode,
len(products),
)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("[SEARCH] => HIT Tier 3 (hard filters only) | %d results", len(products))
return products, 3, None, search_details
logger.info("[SEARCH] => MISS Tier 3 -> try drop gender")
return products, 3, None
# ---- Tang 4a: DROP GENDER ----
# Tang 4: Drop gender
if req.product_line_vn and req.gender_by_product:
logger.info("[SEARCH][TIER-4a DROP-GENDER] dropping gender='%s' ...", req.gender_by_product)
saved_gender = req.gender_by_product
req.gender_by_product = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.gender_by_product = saved_gender
if products:
logger.info("[SEARCH] => HIT Tier 4a (drop gender) | %d results", len(products))
return products, 4, None, search_details
logger.info("[SEARCH] => MISS Tier 4a -> try drop color")
# ---- Tang 4b: DROP COLOR ----
if req.product_line_vn and req.master_color:
logger.info("[SEARCH][TIER-4b DROP-COLOR] dropping color='%s' ...", req.master_color)
saved_color = req.master_color
req.master_color = None
params = []
fixed = _build_fixed_clauses(req, params)
return products, 4, None
# Khi drop color: loai tat ca keywords lien quan mau sac ra khoi embed query
# chi giu lai keywords ve chuc nang / chat lieu / phong cach
_color_terms = {saved_color.lower()} if saved_color else set()
non_color_kws = [
kw for kw in (req.keywords or [])
if saved_color and saved_color.lower() not in kw.lower()
and not any(c in kw.lower() for c in ("mau", "màu", "color"))
]
embed_query_color = " ".join(non_color_kws) if non_color_kws else ""
logger.info(
"[SEARCH][TIER-4b] embed_query (no color): '%s' (original kws=%s)",
embed_query_color, req.keywords,
)
if embed_query_color.strip():
query_vec_color = await create_embedding_async(embed_query_color.strip())
if query_vec_color and len(query_vec_color) == 1536:
products = await _embedding_search_with_vec(query_vec_color, embed_query_color, fixed, params, db)
logger.info(
"[SEARCH][TIER-4b DROP-COLOR EMBED] query='%s' -> %d results",
embed_query_color[:50], len(products),
)
if products:
req.master_color = saved_color
logger.info("[SEARCH] => HIT Tier 4b (drop color + semantic) | %d results", len(products))
return products, 4, None, search_details
# fallback: chi hard filter, khong co color, khong co keywords
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
req.master_color = saved_color
if products:
logger.info("[SEARCH] => HIT Tier 4b (drop color + hard filter) | %d results", len(products))
return products, 4, None, search_details
logger.info("[SEARCH] => MISS Tier 4b -> price relaxation")
# ---- Tang 5-7: PRICE RELAXATION ----
# Tang 5-7: Price relaxation
if req.price_max is not None and req.product_line_vn:
original_max = req.price_max
product_label = "/".join(req.product_line_vn)
for multiplier, tier, label in [(1.5, 5, "1.5x"), (2.0, 6, "2x")]:
new_max = int(original_max * multiplier)
logger.info("[SEARCH][TIER-%d PRICE-RELAX %s] original_max=%d -> new_max=%d", tier, label, original_max, new_max)
products = await _price_relaxed_search(req, db, multiplier)
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
......@@ -754,18 +574,17 @@ async def _cascading_search(
f"Tong co {len(products)} mau trong tam {new_max:,.0f}d. "
f"-> HAY goi y SP nay cho khach va noi kheo: them chut xiu la co mau rat dep!"
)
return products, tier, msg, search_details
return products, tier, msg
# Tang 7: bo price hoan toan
logger.info("[SEARCH][TIER-7 PRICE-UNLIMITED] dropping price entirely (original_max=%d)", original_max)
saved_max = req.price_max
saved_min = req.price_min
req.price_max = None
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.price_max = saved_max
req.price_min = saved_min
if products:
......@@ -778,106 +597,98 @@ async def _cascading_search(
f"Tong co {len(products)} mau. "
f"-> HAY goi y SP nay va noi: budget them chut la co mau rat dang!"
)
return products, 7, msg, search_details
return products, 7, msg
return [], 3, None, search_details
return [], 3, None
async def _enrich_with_outfit(
products: list[dict],
db,
tags: list[str] | None = None,
) -> list[dict]:
if not products:
return products
# ======================================================
# Postgres Enrichment — ai_description + ai_matches
# ======================================================
def _enrich_from_postgres(products: list[dict]) -> dict[str, dict]:
"""
Batch-fetch extra columns from Postgres ultra_descriptions
for a list of formatted products.
Returns: {magento_ref_code: {ai_description, ai_matches, ...}}
"""
codes = [p.get("sku") for p in products if p.get("sku")]
if not codes:
return {}
if not _os.path.exists(_DB_123_PATH):
return products
top_products = products[:3]
anchor_base_codes = [
(p.get("internal_ref_code") or p.get("magento_ref_code", "").split("-")[0]).strip()
for p in top_products
]
anchor_base_codes = [c for c in anchor_base_codes if c]
if not anchor_base_codes:
return products
occasion = _resolve_occasion(tags or [])
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
ph = ", ".join(["%s"] * len(codes))
cur.execute(
conn = sqlite3.connect(_DB_123_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(anchor_base_codes))
ultra_rows = cursor.execute(
f"""
SELECT magento_ref_code,
description_data,
ai_matches
FROM {PG_TABLE}
WHERE magento_ref_code IN ({ph})
SELECT base_ref_code, magento_ref_code, ai_matches, clean_description
FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders})
""",
tuple(codes),
)
rows = cur.fetchall()
cur.close()
anchor_base_codes,
).fetchall()
conn.close()
except Exception as e:
logger.warning("[LEAD] Postgres enrich error: %s", e)
return {}
finally:
if conn:
conn.close()
enrich_map: dict[str, dict] = {}
for code, dd_raw, am_raw in rows:
# Parse description_data
if isinstance(dd_raw, str):
try:
dd_raw = json.loads(dd_raw)
except Exception:
dd_raw = {}
dd = dd_raw if isinstance(dd_raw, dict) else {}
# Parse ai_matches
if isinstance(am_raw, str):
try:
am_raw = json.loads(am_raw)
except Exception:
am_raw = {}
am = am_raw if isinstance(am_raw, dict) else {}
# Build a compact ai_description snippet for the LLM
parts = []
if dd.get("tagline"): parts.append(dd["tagline"])
if dd.get("phong_cach"): parts.append(f"Phong cách: {dd['phong_cach']}")
if dd.get("dip_mac"): parts.append(f"Dịp: {dd['dip_mac']}")
if dd.get("ly_do_mua"): parts.append(f"Điểm nổi bật: {dd['ly_do_mua']}")
if dd.get("chat_lieu"): parts.append(f"Chất liệu: {dd['chat_lieu']}")
enrich_map[code] = {
"ai_description": " | ".join(parts) if parts else None,
"ai_matches": am or None,
logger.error("❌ SQLite ultra_desc read error: %s", e)
return products
ultra_map: dict[str, dict] = {}
for row in ultra_rows:
ultra_map[row["base_ref_code"]] = {
"ai_matches": row["ai_matches"],
"clean_description": row["clean_description"],
}
return enrich_map
for p in top_products:
anchor_magento = p.get("magento_ref_code", "")
base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip()
ultra = ultra_map.get(base)
if not ultra or not ultra["ai_matches"]:
continue
if ultra["clean_description"]:
p["ai_description"] = ultra["clean_description"]
try:
ai_matches_json = json.loads(ultra["ai_matches"])
p["ai_matches"] = ai_matches_json
except (json.JSONDecodeError, TypeError):
continue
return products
async def _format_products(products: list, db) -> list[dict]:
"""Format output cho AI doc, dong thoi TU DONG TRA CUU thong tin suggest/similar items."""
formatted = []
# ── Fetch Suggest/Similar from DB ──
all_related_skus: set = set()
raw_parsed_map: dict = {}
def parse_item_list(raw_val) -> list[str]:
if not raw_val:
return []
if isinstance(raw_val, list):
return [str(x) for x in raw_val[:3]]
if not raw_val: return []
if isinstance(raw_val, list): return [str(x) for x in raw_val[:3]]
if isinstance(raw_val, str):
try:
parsed = json.loads(raw_val)
if isinstance(parsed, list):
return [str(x) for x in parsed[:3]]
except Exception:
pass
if isinstance(parsed, list): return [str(x) for x in parsed[:3]]
except Exception: pass
return []
for p in products[:20]:
for p in products[:3]:
sku = p.get("magento_ref_code", "")
sug_skus = parse_item_list(p.get("suggest_items"))[:2]
sim_skus = parse_item_list(p.get("similar_items"))[:2]
sim_skus = parse_item_list(p.get("similar_items"))[:3]
raw_parsed_map[sku] = {"suggest": sug_skus, "similar": sim_skus}
all_related_skus.update(sug_skus)
all_related_skus.update(sim_skus)
......@@ -896,19 +707,20 @@ async def _format_products(products: list, db) -> list[dict]:
"price": int(rp.get("sale_price") or 0),
}
for p in products[:20]:
for p in products[:3]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0)
desc = (p.get("description_text") or "").strip()
desc_short = desc[:300] + "..." if len(desc) > 300 else desc
main_sku = p.get("magento_ref_code", "")
parsed_rel = raw_parsed_map.get(main_sku, {"suggest": [], "similar": []})
rich_suggest = [sku_lookup[s] for s in parsed_rel["suggest"] if s in sku_lookup]
rich_similar = [sku_lookup[s] for s in parsed_rel["similar"] if s in sku_lookup]
desc = (p.get("description_text") or "").strip()
desc_short = desc[:200] + "..." if len(desc) > 200 else desc
item = {
"sku": main_sku,
"name": p.get("product_name", ""),
......@@ -925,31 +737,21 @@ async def _format_products(products: list, db) -> list[dict]:
"suggest_items": rich_suggest,
"similar_items": rich_similar,
}
# Embedding score neu co
if "_score" in p:
item["relevance_score"] = round(float(p["_score"]), 4)
if "in_stock" in p:
item["in_stock"] = p["in_stock"]
item["total_qty"] = p["total_qty"]
item["stock"] = p["stock"]
if "ai_description" in p:
item["ai_description"] = p["ai_description"]
if "ai_matches" in p:
item["ai_matches"] = p["ai_matches"]
formatted.append(item)
# ── Enrich from Postgres (ai_description + ai_matches) ──
try:
pg_data = _enrich_from_postgres(formatted)
for item in formatted:
extra = pg_data.get(item.get("sku"), {})
if extra.get("ai_description"):
item["ai_description"] = extra["ai_description"]
if extra.get("ai_matches"):
item["ai_matches"] = extra["ai_matches"]
except Exception as e:
logger.warning("[LEAD] Postgres enrich merge error: %s", e)
return formatted
# ======================================================
# SKU Lookup (dac biet, bypass cascade)
# ======================================================
def _build_sku_query(code: str) -> tuple[str, list]:
"""Build query tim theo ma SKU."""
code = code.strip().upper()
sql = f"""
SELECT {SELECT_COLUMNS}
......@@ -962,13 +764,10 @@ def _build_sku_query(code: str) -> tuple[str, list]:
"""
return sql, [code, code, f"{code}%"]
# ======================================================
# Tool Entry Point
# ======================================================
@tool(args_schema=LeadSearchInput)
async def lead_search_tool(
keywords: list[str] | None = None,
tags: list[str] | None = None,
product_line_vn: list[str] | None = None,
gender_by_product: str | None = None,
age_by_product: str | None = None,
......@@ -983,13 +782,13 @@ async def lead_search_tool(
reasoning: str | None = None,
) -> str:
"""
Tim kiem san pham CANIFA - Hybrid: Keywords LIKE + Embedding cosine fallback.
AI rut gon intent thanh keywords -> search description -> neu miss -> embed + cosine.
Tim kiem san pham CANIFA theo tags va tu khoa. Toi uu hoa de chay cuc nhanh bang BITMAP index.
"""
start = time.time()
req = LeadSearchInput(
keywords=keywords or [],
tags=tags or [],
product_line_vn=product_line_vn or [],
gender_by_product=gender_by_product,
age_by_product=age_by_product,
......@@ -1007,87 +806,49 @@ async def lead_search_tool(
try:
fallback_msg = None
s_details: dict = {}
if req.magento_ref_code:
sql, params = _build_sku_query(req.magento_ref_code)
products = await db.execute_query_async(sql, params=tuple(params))
tier = 0
else:
raw_query = " ".join(keywords) if keywords else (reasoning or "")
products, tier, fallback_msg, s_details = await _cascading_search(req, db, raw_message=raw_query)
products, tier, fallback_msg = await _cascading_search(req, db)
# Kiem tra ton kho (chi lay SP con hang)
products, stock_checked, stock_api_ms, timed_out = await _enrich_with_stock(products)
# Inject thong tin outfit neu co
products = await _enrich_with_outfit(products, db, tags=tags)
# Danh dau de formatter lay them data
for p in products:
if stock_checked and p.get("_stock_detail"):
p["in_stock"] = True
p["total_qty"] = p.get("_total_qty")
p["stock"] = p.get("_stock_detail")
formatted = await _format_products(products, db)
elapsed_ms = round((time.time() - start) * 1000, 2)
_search_modes = [
"sku_lookup",
"keywords_like",
"embedding_cosine",
"keywords_ngrambf",
"tags_bitmap",
"hard_filters_only",
"drop_gender",
"price_1.5x",
"price_2x",
"price_unlimited",
]
_mode_label = _search_modes[min(tier, 7)]
logger.info(
"[LEAD SEARCH] ==========================================================",
)
logger.info(
"[LEAD SEARCH] REQUEST | keywords=%s | line=%s | gender=%s | color=%s | price=%s~%s | size=%s | mode=%s",
keywords, product_line_vn, gender_by_product, master_color, price_min, price_max, size, discovery_mode,
"[LEAD SEARCH] REQUEST | tags=%s | kw=%s | line=%s | price=%s~%s | size=%s",
tags, keywords, product_line_vn, price_min, price_max, size
)
logger.info(
"[LEAD SEARCH] RESULT | tier=%d (%s) | results=%d | time=%.0fms",
tier, _mode_label, len(products), elapsed_ms,
"[LEAD SEARCH] RESULT | tier=%d (%s) | results=%d | time=%.0fms | stock=%.0fms",
tier, _search_modes[min(tier, 7)], len(products), elapsed_ms, stock_api_ms
)
if s_details.get("keyword_query"):
logger.info("[LEAD SEARCH] KW-SEARCH | query=%s | results=%s | %.0fms",
s_details["keyword_query"], s_details["keyword_results"], s_details.get("keyword_time_ms") or 0)
if s_details.get("embed_query"):
logger.info("[LEAD SEARCH] SEMANTIC | query='%s' | results=%s | openai=%.0fms sr=%.0fms",
s_details["embed_query"][:60], s_details["embed_results"],
s_details.get("openai_embed_ms") or 0, s_details.get("starrocks_search_ms") or 0)
logger.info("[LEAD SEARCH] ==========================================================")
# Langfuse: log search result as a span on the existing trace
if _LANGFUSE_AVAILABLE:
try:
_lf2 = get_langfuse()
if _lf2:
with _lf2.start_as_current_observation(
as_type="span", name="lead_search_tool",
input={
"keywords": keywords or [],
"product_line_vn": product_line_vn or [],
"gender": gender_by_product,
"color": master_color,
"price_min": price_min,
"price_max": price_max,
"size": size,
"discovery_mode": discovery_mode,
},
) as _root_sp:
_root_sp.update(
output={
"tier": tier,
"search_mode": _mode_label,
"results": len(products),
"elapsed_ms": elapsed_ms,
},
metadata={
"keyword_results": s_details.get("keyword_results"),
"keyword_time_ms": s_details.get("keyword_time_ms"),
"embed_query": s_details.get("embed_query"),
"embed_results": s_details.get("embed_results"),
"openai_embed_ms": s_details.get("openai_embed_ms"),
"starrocks_search_ms": s_details.get("starrocks_search_ms"),
"reasoning": reasoning,
},
)
except Exception:
pass
formatted = await _format_products(products, db)
result = {
"status": "success",
......@@ -1097,14 +858,10 @@ async def lead_search_tool(
"elapsed_ms": elapsed_ms,
"reasoning": reasoning or "",
"keywords_used": keywords or [],
"tags_used": tags or [],
"products": formatted,
}
if req.magento_ref_code:
result["search_details"] = {"mode": "sku_lookup"}
else:
result["search_details"] = {k: v for k, v in s_details.items() if v is not None}
if fallback_msg:
result["fallback_message"] = fallback_msg
......
════════════════════════════════════════════════════════════════
TAG SEARCH AGENT — Kiến trúc tìm kiếm sản phẩm CANIFA
════════════════════════════════════════════════════════════════
📅 Cập nhật: 2026-04-05
═══ 1. TỔNG QUAN ═══
Tag Search Agent là hệ thống tìm kiếm sản phẩm thông minh cho chatbot
CANIFA. AI nhận câu hỏi tự nhiên từ khách → phân tích → gọi tool SQL
→ trả kết quả sản phẩm phù hợp.
Flow: Khách hỏi → LLM phân tích → tag_search_tool → SQL → Postgres → Kết quả
═══ 2. CÁC FILE CHÍNH ═══
┌─────────────────────────┬────────────────────────────────────────┐
│ File │ Vai trò │
├─────────────────────────┼────────────────────────────────────────┤
│ tag_search_graph.py │ LangGraph graph + System Prompt cho AI │
│ tag_search_tool.py │ Pydantic schema + SQL builder + Tool │
│ __init__.py │ Export module │
└─────────────────────────┴────────────────────────────────────────┘
═══ 3. SEARCH ARCHITECTURE: 3-TẦNG FALLBACK ═══
Tầng 1: KEYWORDS + TAGS + FIXED FILTERS
↓ (0 kết quả?)
Tầng 2: TAGS + FIXED FILTERS (bỏ keywords)
↓ (0 kết quả?)
Tầng 3: CHỈ FIXED FILTERS (bỏ cả tags)
FIXED FILTERS = product_type + color + gender + price (luôn giữ, không bỏ)
═══ 4. INPUT SCHEMA (TagSearchInput) ═══
① KEYWORDS (list[str]) — từ khoá lấy từ mô tả SP hoặc câu hỏi khách
Ưu tiên: từ đặc biệt như "30/4", "cờ đỏ sao vàng", "cotton"...
Tối đa 2 keywords.
② TAGS (list[str]) — AI suy luận từ ngữ cảnh
DANH SÁCH CỐ ĐỊNH (CHỈ 12 GIÁ TRỊ, KHÔNG TỰ NGHĨ RA):
đi tiệc, đi học, đi chơi, dạo phố, mặc nhà, đi ngủ,
thể thao, đi dã ngoại, đi biển, đi làm, giữ ấm, thoáng mát
⚠️ "đi ăn nhà hàng" ❌ → "đi tiệc" ✅
⚠️ "đi picnic" ❌ → "đi dã ngoại" ✅
③ PRODUCT_TYPE (list[str]) — CLOSED LIST, CHỈ 14 GIÁ TRỊ:
┌──────────────────────────┐
│ Áo phông │
│ Áo len │
│ Áo nỉ │
│ Áo Polo │
│ Áo Sơ mi │
│ Áo kiểu │
│ Áo khoác chống nắng │
│ Cardigan │
│ Váy liền │
│ Chân váy │
│ Quần nỉ │
│ Quần dài │
│ Quần soóc │
│ Pyjama │
└──────────────────────────┘
⚠️ AI KHÔNG ĐƯỢC tự nghĩ ra loại mới!
"đồ đông" ❌ → ["Áo len", "Áo nỉ", "Quần nỉ"] ✅
"đồ hè" ❌ → ["Áo phông", "Quần soóc"] ✅
"bộ thể thao" ❌ → ["Áo phông", "Quần soóc"] ✅
⚠️ AUTO-EXPAND từ khái quát:
"áo" → ["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
"quần" → ["Quần dài", "Quần soóc", "Quần nỉ"]
"váy" → ["Váy liền", "Chân váy"]
❌ TUYỆT ĐỐI KHÔNG để product_type=[] khi khách đã nhắc loại đồ!
④ CÁC FILTER KHÁC:
- gender_by_product: women, men, boy, girl, unisex, newborn
- master_color: Màu sắc (AI suy luận: "30/4" → đỏ)
- price_min / price_max: đơn vị VND
- discovery_mode: "new" | "best_seller"
- magento_ref_code: mã SKU cụ thể
═══ 5. QUY TẮC QUAN TRỌNG ═══
✅ Tags + Product_type CHỈ chọn từ danh sách cố định
✅ Nếu khách nói "áo" → auto-expand thành nhiều loại áo
✅ Nếu khách hỏi mơ hồ ("cả gia đình", "cho mọi người")
→ HỎI LẠI: "Bạn muốn tìm cho ai ạ?" TRƯỚC khi gọi tool
❌ KHÔNG tự nghĩ ra tags mới (VD: "đi ăn nhà hàng")
❌ KHÔNG tự nghĩ ra product_type mới (VD: "đồ đông")
❌ KHÔNG để product_type=[] khi khách đã nhắc loại đồ
═══ 6. VÍ DỤ MAPPING ═══
Câu hỏi khách → AI gửi tool_call
─────────────────────────────────────────────────────────────
"Váy đi tiệc cho bé" → tags=["đi tiệc"]
product_type=["Váy liền", "Chân váy"]
gender="girl"
"Áo mặc 30/4" → keywords=["30/4"]
tags=["đi chơi"]
product_type=["Áo phông", "Áo kiểu"]
color="đỏ"
"Bộ thể thao nam" → tags=["thể thao"]
product_type=["Áo phông", "Quần soóc"]
gender="men"
"Đồ mùa đông cho bé gái" → tags=["giữ ấm"]
product_type=["Áo len", "Áo nỉ", "Quần nỉ"]
gender="girl"
"Hàng mới bán chạy" → product_type=[]
discovery_mode="best_seller"
"Áo đi ăn nhà hàng cho nữ" → tags=["đi tiệc"] (KHÔNG phải "đi ăn nhà hàng")
product_type=["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
gender="women"
"Có áo cho cả gia đình k?" → HỎI LẠI: "Bạn muốn tìm cho ai ạ?"
═══ 6. SQL GENERATION ═══
product_type là list → sinh OR clauses:
WHERE (
(LOWER(product_name) LIKE '%áo len%' OR LOWER(ultra_description_text) LIKE '%áo len%')
OR
(LOWER(product_name) LIKE '%áo nỉ%' OR LOWER(ultra_description_text) LIKE '%áo nỉ%')
OR
(LOWER(product_name) LIKE '%quần nỉ%' OR LOWER(ultra_description_text) LIKE '%quần nỉ%')
)
AND gender_by_product IN ('female', 'unisex')
AND ...
═══ 7. API ENDPOINTS ═══
POST /api/tag-search/chat → Chat với AI agent
GET /api/tag-search/inventory → Thống kê kho hàng (count, giá, màu, tags)
═══ 8. TECH STACK ═══
- Backend: FastAPI + LangGraph + LangChain
- LLM: GPT-4.1-nano (OpenAI)
- Database: PostgreSQL (law_bot)
- Schema: Pydantic v2
- Frontend: Vanilla HTML/JS/CSS
════════════════════════════════════════════════════════════════
END OF DOCUMENTATION
════════════════════════════════════════════════════════════════
"""
Tag Search Agent — AI-powered product search using tag selection.
No vector search. Pure SQL tag matching.
"""
"""
Product Line Mapping
Key = DB product_line_vn (chính xác)
Value = list các từ khách hàng hay dùng (synonym)
"""
# DB value → [các từ khách hàng hay gọi]
PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo Sơ mi": ["áo sơ mi"],
"Áo Polo": ["áo polo", "áo cổ bẻ"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym"],
"Áo nỉ có mũ": ["áo nỉ có mũ"],
"Áo nỉ": ["áo nỉ"],
"Áo mặc nhà": ["áo mặc nhà"],
"Áo lót": ["áo lót", "áo ngực", "áo quây", "áo lót nữ", "áo lót nam", "áo lót trẻ em"],
"Áo len gilet": ["áo len gilet"],
"Áo len": ["áo len"],
"Áo kiểu": ["áo kiểu"],
"Áo khoác sợi": ["áo khoác sợi"],
"Áo khoác nỉ không mũ": ["áo khoác nỉ không mũ"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ"],
"Áo khoác lông vũ": ["áo khoác lông vũ"],
"Áo khoác gió": ["áo khoác gió", "áo gió", "áo khoác mỏng"],
"Áo khoác gilet chần bông": ["áo khoác gilet chần bông", "áo khoác gilet trần bông", "áo gilet chần bông", "áo gilet trần bông"],
"Áo khoác gilet": ["áo khoác gilet"],
"Áo khoác dạ": ["áo khoác dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn"],
"Áo khoác chống nắng": ["áo khoác chống nắng"],
"Áo khoác chần bông": ["áo khoác chần bông", "áo khoác trần bông", "áo chần bông", "áo trần bông"],
"Áo khoác": ["áo khoác"],
"Áo giữ nhiệt": ["áo giữ nhiệt"],
"Áo bra active": ["áo bra active", "áo bra", "bra"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn"],
"Áo ba lỗ": ["áo ba lỗ", "áo sát nách", "tanktop", "tank top", "áo dây", "áo 2 dây", "áo hai dây"],
"Váy liền": ["váy liền", "đầm"],
"Tất": ["tất", "vớ"],
"Túi xách": ["túi xách"],
"Quần giả váy": ["quần giả váy", "quần váy"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger"],
"Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer", "boxer", "sịp đùi"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief", "brief", "sịp tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings", "leggings"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean"],
"Quần giữ nhiệt": ["quần giữ nhiệt"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng"],
"Quần culottes": ["quần culottes"],
"Quần Body": ["quần body"],
"Pyjama": ["pyjama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "phụ kiện Canifa", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "phụ kiện Canifa", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện Canifa", "phụ kiện"],
"Khăn": ["khăn", "phụ kiện Canifa", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "phụ kiện Canifa", "phụ kiện"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "phụ kiện Canifa", "phụ kiện"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài"],
"Cardigan": ["cardigan"],
"Bộ thể thao": ["bộ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà"],
"Blazer": ["blazer"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
}
# ==============================================================================
# AUTO-GENERATE reverse lookup: synonym → DB value
# "áo thun" → "Áo phông", "quần bò" → "Quần jean", ...
# ==============================================================================
SYNONYM_TO_DB: dict[str, str] = {}
for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_product_name(raw_name: str) -> str:
"""
Resolve synonym trong product_name → tên DB thật.
Dùng longest-match-first để tránh match sai.
VD:
"áo cổ bẻ khaki" → "Áo Polo khaki"
"áo thun disney" → "Áo phông disney"
"quần bò ống rộng" → "Quần jean ống rộng"
"áo polo khaki" → "Áo Polo khaki" (giữ nguyên nếu đã đúng)
"""
name_lower = raw_name.lower().strip()
for synonym in _SORTED_SYNONYMS:
if name_lower.startswith(synonym):
db_value = SYNONYM_TO_DB[synonym]
remainder = name_lower[len(synonym):].strip()
return f"{db_value} {remainder}".strip() if remainder else db_value
return raw_name
def resolve_product_line(raw_value: str) -> list[str]:
"""
Lookup keyword → DB product_line_vn.
Hỗ trợ '/' separator (VD: "Quần/ Váy").
Không tìm thấy → giữ nguyên (prefix match ở SQL).
"""
parts = [p.strip() for p in raw_value.split("/") if p.strip()]
resolved = []
for part in parts:
mapped = SYNONYM_TO_DB.get(part.lower())
if mapped:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
"""Tag Search Agent — Prompts package."""
from .planner_prompt import PLANNER_PROMPT
from .responder_prompt import RESPONDER_PROMPT
__all__ = ["PLANNER_PROMPT", "RESPONDER_PROMPT"]
"""
Planner Prompt — Tag Search Agent.
AI PLANNER phân tích câu hỏi khách → gọi tool tìm sản phẩm.
"""
PLANNER_PROMPT = """Bạn là AI PLANNER của CANIFA. Nhiệm vụ DUY NHẤT: Phân tích câu hỏi khách → quyết định HỎI LẠI hoặc gọi tag_search_tool.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## BƯỚC 1 — CÂU HỎI ĐỦ RÕ CHƯA?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### HỎI LẠI khi:
- Không rõ CHO AI (gender): "cả gia đình", "cho mọi người", "cho nhà mình"
- Không rõ LOẠI GÌ VÀ CHO AI (chung chung hoàn toàn): "có gì đẹp không?"
⚠️ HỎI LẠI bằng TEXT THUẦN, tuyệt đối KHÔNG gọi tool!
### KHÔNG CẦN hỏi lại khi:
- Đã rõ gender HOẶC đã rõ loại đồ: "áo mùa đông" → OK gọi tool (chưa rõ gender nhưng rõ loại)
- Chỉ thiếu 1 info phụ: "quần jean" → OK gọi tool (không cần hỏi size, màu,...)
Ví dụ HỎI LẠI:
│ Khách: "Có đồ đi biển cho cả gia đình không?"
│ → "Bạn muốn tìm đồ đi biển cho ai ạ? (nam/nữ/bé trai/bé gái?) Mình sẽ tìm phù hợp cho từng người nhé!"
│
│ Khách: "Cho mọi người cái gì đẹp đẹp"
│ → "Bạn muốn mình tìm cho ai trước ạ? Nam, nữ hay bé nhỉ?"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## BƯỚC 2 — SINH PARAMS & GỌI TOOL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### ① keywords — TỪ NGUYÊN VĂN KHÁCH NÓI (ƯU TIÊN CAO NHẤT)
keywords = từ/cụm từ ĐẶC BIỆT, RIÊNG mà khách nhắc đến nguyên văn.
Hệ thống sẽ tìm chính xác cụm từ này trong description sản phẩm.
→ Chỉ bỏ vào keywords khi từ đó KHÔNG PHỔ THÔNG, KHÔNG PHẢI tên loại đồ (VD: tên riêng của một sản phẩm như "áo bò thêu", "áo ngọ nghiêng", tên chất liệu, hoạ tiết, v.v.).
→ Tối đa 2 keywords. Để [] nếu không có gì đặc biệt.
✅ LÀ keyword: tên chất liệu (cotton, linen, polyester), tên bộ sưu tập (cờ đỏ sao vàng), tên motif (disney, hoạ tiết), thuật ngữ kỹ thuật (heattech, UV, SPF), tên riêng sản phẩm (ngọ nghiêng, cá sấu).
❌ KHÔNG phải keyword: "mát", "đẹp", "thoải mái", "đi chơi", "mùa hè" → Đây là NGỮ CẢNH → dùng tags
❌ NGÀY LỄ KHÔNG phải keyword: "2/9", "30/4", "1/6", "Noel", "Tết" → Đây là DỊP ĐI CHƠI → dùng tags occ:di_choi hoặc occ:di_tiec!
Ví dụ:
│ "áo cotton mát" → keywords=["cotton"], tags=["func:thoang_mat"]
│ "quần linen ống rộng" → keywords=["linen"], tags=["fit:wide_leg"]
│ "áo đi chơi 2/9" → keywords=[], tags=["occ:di_choi"] (2/9 là dịp lễ = đi chơi, KHÔNG search "2/9" trong SP!)
│ "đồ đi chơi 30/4" → keywords=[], tags=["occ:di_choi", "occ:du_lich"] (30/4 = dịp nghỉ lễ)
│ "áo Noel" → keywords=[], tags=["occ:di_tiec"] (Noel = dịp tiệc/đi chơi)
│ "áo Tết đẹp" → keywords=[], tags=["occ:di_tiec", "style:thanh_lich"]
│ "áo cờ đỏ sao vàng" → keywords=["cờ đỏ sao vàng"] (đây là motif in trên áo, search trong SP description)
│ "đồ mickey" → keywords=["mickey"]
│ "áo heattech giữ ấm" → keywords=["heattech"], tags=["func:giu_am"]
│ "váy đi tiệc" → keywords=[], tags=["occ:di_tiec"] (KHÔNG có keyword đặc biệt!)
│ "quần jean" → keywords=[] (tên loại đồ = product_line_vn, KHÔNG phải keyword)
### ② tags — AI SUY LUẬN TỪ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC CÓ PREFIX!)
⚠️ CHỈ CHỌN TỪ DANH SÁCH DƯỚI ĐÂY. KHÔNG TỰ NGHĨ TAG MỚI!
⚠️ PHẢI GIỮ NGUYÊN PREFIX (occ:, wthr:, func:, style:, fit:)
#### Trục 1 — Hoàn cảnh & Dịp (prefix: occ:)
Hỏi: "Mặc đi đâu? Dịp nào?"
occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu
#### Trục 2a — Thời tiết (prefix: wthr:)
Hỏi: "Thời tiết/mùa nào?"
wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang
#### Trục 2b — Tính năng vải (prefix: func:)
Hỏi: "Cần tính năng gì?"
func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio
#### Trục 3 — Phong cách & Vibe (prefix: style:)
Hỏi: "Gu/phong cách gì?"
style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual
#### Trục 4 — Dáng & Kiểu cắt (prefix: fit:)
Hỏi: "Dáng/form ra sao?"
fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed
Quy tắc suy luận tag:
│ "đi đánh golf" → occ:the_thao (golf = thể thao)
│ "đám cưới bạn" → occ:di_tiec (đám cưới = tiệc)
│ "đi nhà hàng" → occ:di_tiec (nhà hàng = tiệc/sang trọng)
│ "ra biển Đà Nẵng" → occ:di_bien, occ:du_lich
│ "leo núi cuối tuần" → occ:da_ngoai, occ:the_thao
│ "mặc ở nhà cho thoải mái" → occ:mac_nha
│ "ngủ" → occ:di_ngu
│ "nóng quá" → wthr:mua_he, func:thoang_mat
│ "rét" / "lạnh" → wthr:mua_dong, func:giu_am
│ "trời mưa" → wthr:troi_mua
│ "mát mẻ" / "thoáng" → func:thoang_mat
│ "chống nắng" → func:chong_uv, wthr:troi_nang
│ "nhanh khô" → func:nhanh_kho
│ "sang trọng" → style:thanh_lich
│ "năng động" → style:nang_dong
│ "dễ thương" → style:de_thuong
│ "tối giản" → style:toi_gian
│ "rộng rãi" / "thoải mái" (về dáng) → fit:relaxed
│ "ôm body" / "bó" → fit:slim
│ "rộng" / "oversize" → fit:oversize
│ "lửng" / "ngắn" (về quần) → fit:cropped
### ③ product_line_vn — LOẠI ĐỒ CỤ THỂ
#### DANH MỤC SẢN PHẨM CANIFA (dùng để HIỂU khách muốn gì):
🔵 ÁO: Áo Sơ mi | Áo Polo | Áo phông | Áo nỉ (có mũ/không) | Áo len (gilet/thường) | Áo kiểu | Áo khoác (gió/lông vũ/dạ/chần bông/gilet/chống nắng/dáng ngắn/sợi/nỉ) | Áo giữ nhiệt | Áo bra active | Áo Body/croptop | Áo ba lỗ/tanktop | Áo lót | Áo mặc nhà
🔵 QUẦN: Quần jean | Quần Khaki/âu | Quần soóc/short | Quần nỉ/jogger | Quần dài/suông | Quần leggings | Quần Body | Quần culottes | Quần lót (đùi/tam giác) | Quần giả váy | Quần giữ nhiệt | Quần mặc nhà
🔵 VÁY & BỘ: Váy liền/đầm | Chân váy | Blazer | Cardigan | Bộ thể thao | Bộ quần áo | Bộ mặc nhà/đồ ngủ | Pyjama
🔵 PHỤ KIỆN: Mũ/nón | Khăn (tắm/mặt/lau đầu) | Tất/vớ | Găng tay chống nắng | Chăn cá nhân | Túi xách
#### product_line_vn: Bê NGUYÊN VĂN từ khách nói!
- Khách nói CỤ THỂ ("áo polo", "quần jogger", "sịp đùi") → dùng đúng từ khách nói
- Khách nói CHUNG ("áo", "quần", "đồ") → giữ nguyên từ chung, KHÔNG đoán cụ thể! Tool tự tìm rộng.
- Tool sẽ tự chuẩn hoá đồng nghĩa (áo thun → Áo phông, sịp → Quần lót)
🚫 CẤM TUYỆT ĐỐI thay từ khách nói bằng từ "thông minh hơn":
│ "sịp đi biển" → product_line_vn=["sịp"] ✅ KHÔNG PHẢI ["quần bơi"] ❌
│ "áo ấm" → product_line_vn=["áo"] ✅ KHÔNG PHẢI ["áo khoác"] ❌
│ "quần đi biển" → product_line_vn=["quần"] ✅ KHÔNG PHẢI ["quần bơi"] ❌
│ "đồ ngủ sexy" → product_line_vn=["đồ ngủ"] ✅ KHÔNG PHẢI ["váy ngủ"] ❌
### ⑤ reasoning — SUY LUẬN (BẮT BUỘC!)
Giải thích ngắn gọn (1-2 câu tiếng Việt) TẠI SAO bạn chọn params này.
VD:
│ "áo 2/9" → reasoning="2/9 = Quốc Khánh, cờ đỏ sao vàng → suy luận áo màu đỏ, dịp đi chơi"
│ "sịp đi biển" → reasoning="Khách muốn sịp mặc đi biển → giữ nguyên từ sịp, thêm tag đi biển"
│ "quần jean nữ" → reasoning="Khách nói rõ quần jean + nữ → tìm cụ thể"
### ④ BỘ LỌC SQL (FILTER CỨNG)
- gender_by_product: women / men / girl / boy
"nữ" → women | "nam" → men | "bé gái"/"con gái" → girl | "bé trai"/"con trai" → boy
- age_by_product: adult / kid
"trẻ em"/"bé" → kid | "người lớn" → adult
- master_color: Ghi ĐÚNG màu khách nói (tiếng Việt): "trắng", "đen", "xanh dương"...
- price_min / price_max: đơn vị VND (200k = 200000)
- discount_min / discount_max: % giảm giá
"giảm 50%" → discount_min=50 | "hàng sale"/"giảm giá" → discount_min=1
- discovery_mode: "new" (hàng mới) | "best_seller" (bán chạy)
- magento_ref_code: mã SKU (khi khách đưa mã cụ thể)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## QUY TẮC CỨNG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. keywords ƯU TIÊN TRƯỚC tags! Hệ thống tìm keywords trước → không có kết quả mới fallback tags.
2. product_line_vn = LOẠI QUẦN ÁO. tags = LÝ DO MẶC / NGỮ CẢNH. KHÔNG trộn lẫn!
3. ⚠️ QUAN TRỌNG NHẤT: product_line_vn phải là NGUYÊN VĂN từ khách nói! CẤM thay thế, diễn giải, hoặc "thông minh hóa"! Tool có synonym mapping sẵn, bạn KHÔNG CẦN dịch!
4. TUYỆT ĐỐI KHÔNG trả lời về sản phẩm nếu chưa gọi tool.
5. KHÔNG tự nghĩ tag ngoài danh sách. PHẢI có prefix!
6. BỎ QUA history trước đó khi extract product_line_vn. Chỉ nhìn câu hỏi HIỆN TẠI của khách.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 30+ VÍ DỤ HUẤN LUYỆN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### 💬 Loại đồ cơ bản
"Sịp nam"
→ product_line_vn=["sịp"], gender="men", reasoning="Khách nói sịp nam → giữ nguyên 'sịp', tool tự map"
"Quần jean nữ"
→ product_line_vn=["quần jean"], gender="women", reasoning="Khách nói rõ quần jean + nữ → tìm cụ thể"
"Đồ lót trẻ em"
→ product_line_vn=["đồ lót"], age_by_product="kid", reasoning="Đồ lót cho trẻ em, chưa rõ gender"
"Áo polo nam"
→ product_line_vn=["áo polo"], gender="men", reasoning="Áo polo nam, tìm thẳng"
"Áo sơ mi nam đi làm"
→ product_line_vn=["áo sơ mi"], gender="men", tags=["occ:di_lam"], reasoning="Áo sơ mi + đi làm = occasion di_lam"
### 💬 Có keyword đặc biệt
"Áo cotton mát cho mùa hè"
→ product_line_vn=["áo"], keywords=["cotton"], tags=["wthr:mua_he", "func:thoang_mat"], reasoning="Cotton = chất liệu → keyword, mùa hè + mát = tags"
"Đồ disney bé gái"
→ keywords=["disney"], gender="girl", age_by_product="kid", reasoning="Disney = motif → keyword, bé gái = girl+kid"
"Áo cờ đỏ sao vàng"
→ product_line_vn=["áo"], keywords=["cờ đỏ sao vàng"], reasoning="Cờ đỏ sao vàng = motif in trên áo → keyword"
"Áo heattech giữ ấm mùa đông"
→ product_line_vn=["áo"], keywords=["heattech"], tags=["wthr:mua_dong", "func:giu_am"], reasoning="Heattech = kỹ thuật → keyword, mùa đông + giữ ấm = tags"
### 💬 Ngữ cảnh → suy luận tags
"Váy đi tiệc cuối năm"
→ product_line_vn=["váy"], tags=["occ:di_tiec"], gender="women", reasoning="Đi tiệc = occasion di_tiec"
"Áo đi chơi cuối tuần"
→ product_line_vn=["áo"], tags=["occ:di_choi"], reasoning="Đi chơi = occasion di_choi"
"Đồ mặc nhà thoải mái"
→ tags=["occ:mac_nha"], reasoning="Mặc nhà = occasion mac_nha"
"Áo đi đám cưới"
→ product_line_vn=["áo"], tags=["occ:di_tiec", "style:thanh_lich"], reasoning="Đám cưới = tiệc trang trọng → di_tiec + thanh_lich"
"Áo khoác đi du lịch Đà Lạt"
→ product_line_vn=["áo khoác"], tags=["occ:du_lich", "wthr:mua_dong", "func:giu_am"], reasoning="Đà Lạt = lạnh → du_lich + mua_dong + giu_am"
### 💬 Thời tiết / Mùa
"Áo mùa đông bé gái"
→ product_line_vn=["áo"], tags=["wthr:mua_dong", "func:giu_am"], gender="girl", age_by_product="kid", reasoning="Mùa đông = giữ ấm, bé gái = girl+kid"
"Trời nóng quá có gì mát cho nữ?"
→ tags=["wthr:mua_he", "func:thoang_mat"], gender="women", reasoning="Nóng = mùa hè + thoáng mát, nữ = women, không rõ loại đồ → để trống"
### 💬 Phong cách & Dáng
"Quần jean ống rộng nữ"
→ product_line_vn=["quần jean"], tags=["fit:wide_leg"], gender="women", reasoning="Quần jean + ống rộng = wide_leg"
"Áo oversize nam"
→ product_line_vn=["áo"], tags=["fit:oversize"], gender="men", reasoning="Oversize = dáng → fit:oversize"
"Áo smart casual đi ăn"
→ product_line_vn=["áo"], tags=["style:smart_casual", "occ:di_tiec"], reasoning="Smart casual + đi ăn = phong cách + tiệc"
### 💬 Giá / Giảm giá
"Áo phông dưới 200k"
→ product_line_vn=["áo phông"], price_max=200000, reasoning="Áo phông + giá ≤200k"
"Hàng mới về"
→ discovery_mode="new", reasoning="Hàng mới = discovery new"
### 💬 🔴 Ngày lễ / Dịp đặc biệt (KHÔNG dùng keyword! + SUY LUẬN MÀU!)
⚠️ Ngày lễ KHÔNG PHẢI keyword! AI phải SUY LUẬN dịp + màu sắc:
│ 2/9 (Quốc Khánh) → cờ đỏ sao vàng → master_color="đỏ"
│ Noel/Giáng sinh → master_color="đỏ"
│ Tết Nguyên Đán → master_color="đỏ"
│ 30/4, 1/6 → đi chơi, không ràng buộc màu
"Áo 2/9"
→ product_line_vn=["áo"], tags=["occ:di_choi"], master_color="đỏ", reasoning="2/9 = Quốc Khánh, cờ đỏ sao vàng → áo màu đỏ, dịp đi chơi"
"Áo đi chơi 2/9 cho bé trai"
→ product_line_vn=["áo"], tags=["occ:di_choi"], master_color="đỏ", gender="boy", age_by_product="kid", reasoning="2/9 = Quốc Khánh → áo đỏ cho bé trai đi chơi"
"Đồ đi chơi 30/4"
→ tags=["occ:di_choi", "occ:du_lich"], reasoning="30/4 = dịp nghỉ lễ dài → đi chơi + du lịch"
"Váy đi tiệc Noel"
→ product_line_vn=["váy"], tags=["occ:di_tiec"], master_color="đỏ", gender="women", reasoning="Noel = Giáng sinh → váy đỏ đi tiệc"
"Áo Tết cho bé trai"
→ product_line_vn=["áo"], tags=["occ:di_tiec", "style:thanh_lich"], master_color="đỏ", gender="boy", age_by_product="kid", reasoning="Tết = dịp lễ → áo đỏ may mắn"
### 💬 ⚠️ CẤM THAY ĐỔI TỪ KHÁCH NÓI
"Sịp đi biển" → product_line_vn=["sịp"], tags=["occ:di_bien"], reasoning="Giữ nguyên 'sịp', KHÔNG đổi thành 'quần bơi'" ✅
"Quần đi biển" → product_line_vn=["quần"], tags=["occ:di_bien"], reasoning="Giữ nguyên 'quần'" ✅
"Áo mùa đông" → product_line_vn=["áo"], tags=["wthr:mua_dong", "func:giu_am"], reasoning="Giữ nguyên 'áo', KHÔNG đổi thành 'áo khoác'" ✅
### 💬 Hỏi lại (KHÔNG gọi tool)
"Có đồ đi biển cho cả gia đình không?"
→ HỎI LẠI: "Bạn muốn tìm đồ đi biển cho ai ạ? (nam/nữ/bé trai/bé gái?)"
"Cho mình xem đồ đẹp đẹp"
→ HỎI LẠI: "Bạn muốn mình tìm cho ai trước ạ? Nam, nữ, hay bé nhỉ?"
### 💬 Nên đẩy tên sản phẩm riêng vào keyword
"Áo ngọ nghiêng"
→ product_line_vn=["áo"], keywords=["ngọ nghiêng"], reasoning="ngọ nghiêng là tên/đặc điểm riêng -> dùng làm keyword để tìm chính xác."
"""
"""
Responder Prompt — Tag Search Agent.
AI RESPONDER format kết quả tìm kiếm thành câu trả lời tự nhiên.
"""
RESPONDER_PROMPT = """Bạn là AI RESPONDER của CANIFA. Nhiệm vụ DUY NHẤT: Format kết quả tìm kiếm từ tool thành câu trả lời tự nhiên cho khách.
## QUY TẮC #1 — TRÌNH BÀY KẾT QUẢ, KHÔNG PHÁN XÉT
⚠️ BẮT BUỘC: Nếu tool trả về sản phẩm → BẠN PHẢI TRÌNH BÀY chúng cho khách.
KHÔNG ĐƯỢC tự ý:
- Phán "sản phẩm này không phù hợp với dịp X" rồi giấu kết quả
- Nói "tiếc, chưa có" khi tool ĐÃ TRẢ VỀ sản phẩm
- Tự đánh giá sản phẩm "không chuyên dụng" hay "không phù hợp"
- Từ chối hiển thị kết quả dựa trên suy đoán của bạn
Việc của bạn là GIỚI THIỆU, không phải KIỂM DUYỆT. Khách tự quyết định mua gì.
## KHI NÀO ĐƯỢC NÓI "KHÔNG CÓ":
CHỈ KHI:
1. Tool trả về 0 kết quả (count = 0)
2. Kết quả sai GIỚI TÍNH rõ ràng (user hỏi "nữ" nhưng toàn sản phẩm "nam")
3. Kết quả sai ĐỘ TUỔI rõ ràng (user hỏi "người lớn" nhưng toàn "bé gái")
Ngoài 3 trường hợp trên → PHẢI trình bày sản phẩm, ĐƯỢC PHÉP thêm gợi ý nhẹ.
## FORMAT MỖI SẢN PHẨM:
- **Tên sản phẩm** (Màu)
- Giá: xxx₫ ~~(giá gốc)~~ giảm xx%
- 🔗 [Xem và mua](link)
## NGUYÊN TẮC:
- Tiếng Việt, thân thiện, ngắn gọn.
- KHÔNG tự bịa thông tin. Chỉ dùng data từ tool.
- Cuối cùng: "Bạn muốn xem thêm mẫu khác không?"
- ĐƯỢC PHÉP thêm gợi ý bổ sung SAU KHI đã trình bày kết quả: "Ngoài ra, bạn có thể tham khảo thêm..."
"""
"""
Tag Search Graph — 2-Agent LangGraph Architecture cho tư vấn sản phẩm.
Flow: User query → Planner AI → (hỏi lại | gọi tool) → Responder AI → answer
2 con AI:
- Planner: Phân tích ý định, extract params, gọi tool. Nếu mơ hồ → hỏi lại.
- Responder: Nhận kết quả tool, format câu trả lời tự nhiên, thân thiện.
"""
import logging
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from common.llm_factory import create_llm
from .tag_search_tool import tag_search_tool
from .prompts import PLANNER_PROMPT, RESPONDER_PROMPT
logger = logging.getLogger(__name__)
def _extract_text(content) -> str:
"""Extract plain text from msg.content — handles both str and Gemini list format."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
# ═══════════════════════════════════════════════
# State
# ═══════════════════════════════════════════════
class TagSearchState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture
# ═══════════════════════════════════════════════
class TagSearchGraph:
"""2-Agent LangGraph: Planner → Tools → Responder."""
# Tag Search dùng GPT-5-nano — nhanh nhất, tool calling tốt
TAG_SEARCH_MODEL = "gpt-5.4-nano"
def __init__(self, model_name: str | None = None):
self.model_name = model_name or self.TAG_SEARCH_MODEL
self.tools = [tag_search_tool]
# 2 con LLM riêng biệt
self.planner_llm = create_llm(model_name=self.model_name, streaming=True)
self.planner_with_tools = self.planner_llm.bind_tools(self.tools)
self.responder_llm = create_llm(model_name=self.model_name, streaming=True)
self._compiled = None
logger.info(f"✅ TagSearchGraph 2-Agent initialized with model: {self.model_name}")
# ─────────────────────────────────────
# Node 1: PLANNER — Phân tích + gọi tool
# ─────────────────────────────────────
async def _planner_node(self, state: TagSearchState) -> dict:
"""Planner AI: phân tích câu hỏi, quyết định gọi tool hoặc hỏi lại."""
messages = state["messages"]
# Inject planner system prompt
planner_messages = [SystemMessage(content=PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
response = await self.planner_with_tools.ainvoke(planner_messages)
return {"messages": [response]}
# ─────────────────────────────────────
# Node 2: RESPONDER — Format kết quả
# ─────────────────────────────────────
async def _responder_node(self, state: TagSearchState) -> dict:
"""Responder AI: nhận kết quả tool, format câu trả lời đẹp."""
messages = state["messages"]
# Inject responder system prompt
responder_messages = [SystemMessage(content=RESPONDER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
responder_messages.append(msg)
response = await self.responder_llm.ainvoke(responder_messages)
return {"messages": [response]}
# ─────────────────────────────────────
# Router: Planner → tool hoặc end
# ─────────────────────────────────────
def _after_planner(self, state: TagSearchState) -> str:
"""Sau Planner: nếu có tool_calls → đi tools, nếu không → end (hỏi lại)."""
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
return "end" # Planner hỏi lại khách → end luôn
def build(self):
"""Build 2-agent graph."""
if self._compiled:
return self._compiled
workflow = StateGraph(TagSearchState)
# 3 Nodes
workflow.add_node("planner", self._planner_node)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("responder", self._responder_node)
# Flow: Entry → Planner
workflow.set_entry_point("planner")
# Planner → (tools | end)
workflow.add_conditional_edges(
"planner",
self._after_planner,
{"tools": "tools", "end": END},
)
# Tools → Responder → END
workflow.add_edge("tools", "responder")
workflow.add_edge("responder", END)
self._compiled = workflow.compile()
logger.info("✅ TagSearchGraph 2-Agent compiled")
return self._compiled
async def chat(self, user_message: str, history: list[BaseMessage] | None = None) -> dict:
"""
Main entry point — gửi message và nhận response.
Returns full pipeline trace for UI display.
"""
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
messages.append(HumanMessage(content=user_message))
result = await graph.ainvoke({"messages": messages})
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
ai_response = ""
tool_calls_log: list[dict[str, Any]] = []
has_tool_calls = False
pipeline: list[dict[str, Any]] = []
for msg in all_messages:
content_str = _extract_text(msg.content)
if isinstance(msg, HumanMessage):
pipeline.append({
"step": "user",
"label": "👤 User",
"content": content_str,
})
elif isinstance(msg, AIMessage):
if msg.tool_calls:
has_tool_calls = True
tool_calls_log.extend(msg.tool_calls)
pipeline.append({
"step": "planner",
"label": "🧠 Planner AI",
"content": content_str if content_str and content_str != "[]" else "(gọi tool...)",
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in msg.tool_calls
],
})
elif msg.content:
ai_response = content_str
pipeline.append({
"step": "responder",
"label": "💬 Responder AI",
"content": content_str[:500] + ("..." if len(content_str) > 500 else ""),
})
else:
tool_content = content_str
summary = tool_content[:600]
try:
import json as _json
parsed = _json.loads(tool_content)
if parsed.get("status") == "success":
result_count = parsed.get("count", len(parsed.get("products", [])))
tier = parsed.get("tier", 0)
tier_info = f"[Tier {tier}]" if tier > 0 else "[SKU Direct]"
summary = f"✅ Tìm thấy {result_count} sản phẩm ({parsed.get('elapsed_ms', '?')}ms) {tier_info}"
products = parsed.get("products", [])
if products:
names = [f"• {p.get('name','')}" for p in products[:5]]
summary += "\n" + "\n".join(names)
if len(products) > 5:
summary += f"\n... và {len(products) - 5} sản phẩm khác"
elif parsed.get("status") == "error":
summary = f"❌ {parsed.get('message', 'Error')}"
except Exception:
pass
raw_json = tool_content
try:
raw_json = _json.dumps(_json.loads(tool_content), ensure_ascii=False, indent=2, default=str)
except Exception:
pass
pipeline.append({
"step": "tool_result",
"label": f"📦 Tool Result ({getattr(msg, 'name', 'tool')})",
"content": summary,
"raw_json": raw_json,
})
# Collect products from tool results for frontend rendering
all_products = []
for step in pipeline:
if step.get("step") == "tool_result" and step.get("raw_json"):
try:
import json as _json2
parsed_result = _json2.loads(step["raw_json"])
if parsed_result.get("status") == "success":
all_products.extend(parsed_result.get("products", []))
except Exception:
pass
agent_path = "planner→tools→responder" if has_tool_calls else "planner_only"
logger.info(
"🏷️ [TAG SEARCH] query='%s' | path=%s | tools=%d | time=%.2fms",
user_message[:50], agent_path, len(tool_calls_log), elapsed_ms,
)
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for tc in tool_calls_log
],
"agent_path": agent_path,
"pipeline": pipeline,
"products": all_products,
}
# ═══════════════════════════════════════════════
# Singleton
# ═══════════════════════════════════════════════
_instance: TagSearchGraph | None = None
def get_tag_search_agent(model_name: str | None = None) -> TagSearchGraph:
"""Get or create TagSearchGraph singleton."""
global _instance
if _instance is None:
_instance = TagSearchGraph(model_name)
return _instance
"""
Tag Search Tool — LangChain @tool cho AI gọi.
AI sinh keywords + tags → Tool build SQL → 3 tầng cascade → Trả kết quả.
+ Tự động gọi Canifa Stock API → chỉ trả SP CÒN HÀNG.
Architecture:
CỐ ĐỊNH (luôn giữ): product_type + color + gender + price
BIẾN ĐỔI (fallback):
Tầng 1: CỐ ĐỊNH + keywords (từ nguyên văn user)
Tầng 2: CỐ ĐỊNH + tags (AI suy luận → tags chung)
Tầng 3: CHỈ CỐ ĐỊNH (product_type + color + gender)
"""
import json
import logging
import time
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from .product_mapping import SYNONYM_TO_DB, get_related_lines
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════
# Stock API — Kiểm tra tồn kho Canifa
# ═══════════════════════════════════════════════
CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent"
_STOCK_TIMEOUT = 1.5 # seconds — giữ tổng < 200ms
async def _fetch_stock_batch(base_codes: list[str]) -> dict[str, list[dict]]:
"""
Gọi Canifa Stock API batch → trả dict: product_color_code → [{size, qty, status}].
Giữ ALL sizes (kể cả hết hàng) để AI biết full thông tin.
Timeout 1.5s. Nếu lỗi → trả dict rỗng (graceful fallback).
"""
if not base_codes:
return {}
sku_string = ",".join(base_codes)
url = f"{CANIFA_STOCK_API}?skus={sku_string}"
try:
async with httpx.AsyncClient(timeout=_STOCK_TIMEOUT) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
# Parse: gom ALL sizes theo product_color_code (kể cả hết hàng)
# API trả: [{"sku": "6TS25S018-SB055-S", "is_in_stock": true, "qty": 2}, ...]
stock_map: dict[str, list[dict]] = {}
results = data.get("result", [])
if isinstance(results, list):
for item in results:
sku_full = item.get("sku", "") # "6TS25S018-SB055-S"
qty = item.get("qty", 0) or 0
if not sku_full:
continue
parts = sku_full.rsplit("-", 1)
if len(parts) == 2:
color_code = parts[0] # "6TS25S018-SB055"
size = parts[1] # "S"
status = "còn hàng" if qty > 0 else "hết hàng"
stock_map.setdefault(color_code, []).append({
"size": size, "qty": qty, "status": status
})
logger.info("📦 Stock batch: %d base_codes → %d color_codes in-stock", len(base_codes), len(stock_map))
return stock_map
except httpx.TimeoutException:
logger.warning("⏰ Stock API timeout (%.1fs) — skip stock filter", _STOCK_TIMEOUT)
return {}
except Exception as e:
logger.warning("⚠️ Stock API error: %s — skip stock filter", e)
return {}
async def _enrich_with_stock(products: list[dict]) -> tuple[list[dict], bool]:
"""
Gọi stock API cho batch products → lọc chỉ giữ SP còn hàng + kèm chi tiết tồn.
Returns: (filtered_products, stock_checked)
"""
if not products:
return [], False
# Gom base_codes (deduplicate) — API nhận base code
base_codes = list({p.get("internal_ref_code", "") for p in products if p.get("internal_ref_code")})
if not base_codes:
return products, False
stock_map = await _fetch_stock_batch(base_codes)
# Nếu stock API fail → trả nguyên, đánh dấu unchecked
if not stock_map:
return products, False
# Lọc: giữ SP có ÍT NHẤT 1 size còn hàng (qty > 0)
# SP nào ALL sizes = 0 → bỏ, không tư vấn
in_stock = []
dropped = 0
for p in products:
color_code = p.get("product_color_code", "")
if color_code in stock_map:
sizes_detail = stock_map[color_code]
total_qty = sum(s["qty"] for s in sizes_detail)
if total_qty > 0:
# Có ít nhất 1 size còn hàng → giữ, kèm FULL sizes (cả hết hàng)
p["_stock_detail"] = sizes_detail
p["_total_qty"] = total_qty
in_stock.append(p)
else:
dropped += 1 # ALL sizes hết → bỏ
# Không có trong stock_map = API không trả data → bỏ
logger.info("📦 Stock filter: %d → %d in-stock, %d dropped (all sizes sold out)", len(products), len(in_stock), dropped)
return in_stock if in_stock else products[:5], True # fallback top 5 nếu tất cả hết hàng
TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding"
SELECT_COLUMNS = """
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
COALESCE(discount_amount, 0) AS discount_amount,
ROUND(CASE WHEN original_price > 0
THEN ((original_price - sale_price) / original_price * 100)
ELSE 0 END, 0) AS discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product,
size_scale,
description_text
"""
# ═══════════════════════════════════════════════
# Pydantic Schema — AI sẽ fill vào đây
# ═══════════════════════════════════════════════
class TagSearchInput(BaseModel):
"""Schema cho hybrid search: keywords (user) + tags (AI suy luận)."""
model_config = {"extra": "forbid"}
# ① KEYWORDS — từ NGUYÊN VĂN của user (ưu tiên cao, search TRƯỚC)
keywords: list[str] = Field(
default=[],
description=(
"Từ khoá NGUYÊN VĂN từ câu hỏi khách. "
"VD: khách nói '30/4' → keywords=['30/4']. "
"Khách nói 'cờ đỏ sao vàng' → keywords=['cờ đỏ sao vàng']. "
"Khách nói 'cotton ống rộng' → keywords=['cotton', 'ống rộng']. "
"Tối đa 2. Để [] nếu khách không nhắc gì đặc biệt."
)
)
# ② TAGS — AI suy luận từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!)
tags: list[str] = Field(
default=[],
description=(
"AI suy luận ý định khách → chọn từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!): "
"Trục 1 (occ:): occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu. "
"Trục 2a (wthr:): wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang. "
"Trục 2b (func:): func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio. "
"Trục 3 (style:): style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual. "
"Trục 4 (fit:): fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed. "
"VD: 'đi tiệc' → tags=['occ:di_tiec']. 'mùa đông' → tags=['wthr:mua_dong', 'func:giu_am']. "
"KHÔNG tự nghĩ tag mới! PHẢI giữ prefix! Tối đa 3."
)
)
# ═══ CỐ ĐỊNH — Luôn giữ trong mọi tầng ═══
product_line_vn: list[str] = Field(
default=[],
description=(
"Dòng sản phẩm. CHÍNH XÁC lời user nói: 'áo phông', 'váy liền', 'đồ lót', 'quần jean'... "
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' → 'Áo phông'). "
"Nếu khách nói chung chung ('đồ mùa đông', 'đồ tập') hoặc không nhắc, để []. "
),
)
gender_by_product: str | None = Field(
default=None,
description="Giới tính. GIÁ TRỊ HỢP LỆ: women, men, boy, girl, unisex, newborn",
)
age_by_product: str | None = Field(
default=None,
description="Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
)
master_color: str | None = Field(
default=None,
description=(
"Màu sắc. Ghi CHÍNH XÁC từ khách nói: 'trắng', 'đen', 'đỏ'... "
"VD: 'áo cờ đỏ sao vàng' → color='đỏ'."
),
)
price_min: int | None = Field(default=None, description="Giá thấp nhất (VND)")
price_max: int | None = Field(default=None, description="Giá cao nhất (VND)")
discount_min: int | None = Field(
default=None,
description="% giảm giá TỐI THIỂU. VD: 'giảm 50%' → 50. 'hàng sale' → 1."
)
discount_max: int | None = Field(
default=None,
description="% giảm giá TỐI ĐA. VD: 'giảm dưới 30%' → 30."
)
discovery_mode: str | None = Field(
default=None,
description="'new' = hàng mới, 'best_seller' = bán chạy. Chỉ dùng khi khách nói rõ.",
)
magento_ref_code: str | None = Field(
default=None,
description="Mã SKU cụ thể. Chỉ điền khi khách cung cấp.",
)
reasoning: str | None = Field(
default=None,
description=(
"SUY LUẬN: Giải thích ngắn gọn TẠI SAO bạn chọn params này. "
"VD: '2/9 = Quốc Khánh → áo đỏ đi chơi', 'khách muốn sịp mặc đi biển → giữ nguyên sịp, tag đi biển'. "
"Viết 1-2 câu bằng tiếng Việt."
),
)
# ═══════════════════════════════════════════════
# SQL Builder
# ═══════════════════════════════════════════════
def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]:
"""Build các filter CỐ ĐỊNH (luôn giữ trong mọi tầng)."""
clauses = []
# Product line (list) — resolve synonym → search cột product_line_vn
if req.product_line_vn:
resolved_types = []
for pt_raw in req.product_line_vn:
pt = pt_raw.strip()
if not pt:
continue
# Synonym resolve: "sịp" → "Quần lót", "áo thun" → "Áo phông"
mapped = SYNONYM_TO_DB.get(pt.lower(), pt)
# Related lines: "Quần lót" → ["Quần lót", "Quần lót đùi", "Quần lót tam giác"]
related = get_related_lines(mapped)
resolved_types.extend(related)
resolved_types = list(dict.fromkeys(resolved_types)) # dedupe, keep order
if resolved_types:
in_placeholders = []
like_clauses = []
# Broad terms that need LIKE instead of exact IN match
BROAD_TERMS = {"áo", "quần", "váy", "đồ", "bộ", "phụ kiện", "giày", "dép", "túi", "balo", "mũ", "nón", "tất"}
for rt in resolved_types:
if rt.lower() in BROAD_TERMS:
like_clauses.append("LOWER(product_line_vn) LIKE LOWER(%s)")
params.append(f"%{rt}%")
else:
in_placeholders.append("%s")
params.append(rt)
sub_clauses = []
if in_placeholders:
sub_clauses.append(f"LOWER(product_line_vn) IN ({', '.join(f'LOWER({p})' for p in in_placeholders)})")
sub_clauses.extend(like_clauses)
if sub_clauses:
clauses.append("(" + " OR ".join(sub_clauses) + ")")
logger.info("🏷️ [MAPPING] product_line_vn %s → resolved %s", req.product_line_vn, resolved_types)
# Gender
if req.gender_by_product:
gender_lower = req.gender_by_product.lower().strip()
if gender_lower in ("women", "nữ", "female"):
gender_db = "female"
elif gender_lower in ("men", "nam", "male"):
gender_db = "male"
else:
gender_db = gender_lower
if gender_db in ("female", "male"):
params.append(gender_db)
params.append("unisex")
clauses.append(f"gender_by_product IN (%s, %s)")
else:
params.append(gender_db)
clauses.append(f"gender_by_product = %s")
# Age
if req.age_by_product:
age_lower = req.age_by_product.lower().strip()
if age_lower in ("baby", "newborn"):
params.append("kid")
clauses.append(f"age_by_product = %s")
else:
params.append(age_lower)
clauses.append(f"age_by_product = %s")
# Color
if req.master_color:
c = req.master_color.lower().strip()
params.append(f"%{c}%")
params.append(f"%{c}%")
clauses.append(f"(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
# Price
if req.price_min is not None:
params.append(req.price_min)
clauses.append(f"sale_price >= %s")
if req.price_max is not None:
params.append(req.price_max)
clauses.append(f"sale_price <= %s")
# Discount
if req.discount_min is not None:
params.append(req.discount_min)
# discount_percent is usually derived as ((original - sale) / original * 100)
# Let's assume there is a discount_percent column, but looking at SELECT_COLUMNS,
# discount_percent is a calculated field. We should calculate it in the WHERE clause or use original_price > sale_price.
# Actually in magento_product_dimension_with_text_embedding there probably IS some discount logic, but let's use:
# WHERE original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= X
clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= %s)")
if req.discount_max is not None:
params.append(req.discount_max)
clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
# Discovery mode
if req.discovery_mode:
mode = req.discovery_mode.lower().strip()
if mode == "new":
clauses.append("is_new_product = 1")
elif mode == "best_seller":
clauses.append("quantity_sold > 0")
return clauses
# ═══════════════════════════════════════════════
# Tag → Vietnamese search text (cho LIKE query)
# ═══════════════════════════════════════════════
TAG_TO_SEARCH_TEXT: dict[str, str] = {
# Trục 1 — Hoàn cảnh & Dịp
"occ:di_lam": "đi làm",
"occ:di_choi": "đi chơi",
"occ:di_tiec": "đi tiệc",
"occ:di_hoc": "đi học",
"occ:mac_nha": "mặc nhà",
"occ:the_thao": "thể thao",
"occ:di_bien": "đi biển",
"occ:du_lich": "du lịch",
"occ:da_ngoai": "dã ngoại",
"occ:di_ngu": "đi ngủ",
# Trục 2a — Thời tiết
"wthr:mua_he": "mùa hè",
"wthr:mua_dong": "mùa đông",
"wthr:giao_mua": "giao mùa",
"wthr:troi_mua": "trời mưa",
"wthr:troi_nang": "trời nắng",
# Trục 2b — Tính năng
"func:thoang_mat": "thoáng mát",
"func:giu_am": "giữ ấm",
"func:tham_hut": "thấm hút",
"func:nhanh_kho": "nhanh khô",
"func:chong_uv": "chống uv",
"func:can_gio": "cản gió",
# Trục 3 — Phong cách
"style:thanh_lich": "thanh lịch",
"style:nang_dong": "năng động",
"style:basic": "basic",
"style:ca_tinh": "cá tính",
"style:de_thuong": "dễ thương",
"style:tre_trung": "trẻ trung",
"style:toi_gian": "tối giản",
"style:smart_casual": "smart casual",
# Trục 4 — Dáng
"fit:oversize": "oversize",
"fit:slim": "slim",
"fit:regular": "regular",
"fit:wide_leg": "ống rộng",
"fit:cropped": "cropped",
"fit:relaxed": "relaxed",
}
def _resolve_tag(tag: str) -> str:
"""Chuyển prefixed tag → Vietnamese text cho SQL LIKE.
VD: 'occ:di_tiec' → 'đi tiệc', 'func:giu_am' → 'giữ ấm'.
Nếu không có trong mapping (tag cũ format) → giữ nguyên.
"""
return TAG_TO_SEARCH_TEXT.get(tag.strip().lower(), tag)
def _build_search_clause(search_terms: list[str], params: list, resolve_tags: bool = False) -> str:
"""Build OR clause cho keywords hoặc tags, search trong description_text và product_name.
Tách các từ trong mỗi term bằng khoảng trắng và áp dụng toán tử AND để tìm kiếm rộng hơn.
"""
term_clauses = []
for term in search_terms:
t = _resolve_tag(term) if resolve_tags else term.strip().lower()
if not t:
continue
words = t.split()
if not words:
continue
word_clauses = []
for word in words:
params.append(f"%{word}%")
params.append(f"%{word}%")
word_clauses.append(f"(LOWER(description_text) LIKE %s OR LOWER(product_name) LIKE %s)")
term_clauses.append(f"({' AND '.join(word_clauses)})")
if term_clauses:
return f"({' OR '.join(term_clauses)})"
return ""
def _build_full_query(fixed_clauses: list[str], search_clause: str | None) -> str:
"""Combine fixed + search clauses thành SQL hoàn chỉnh."""
all_clauses = list(fixed_clauses)
if search_clause:
all_clauses.append(search_clause)
where = " AND ".join(all_clauses) if all_clauses else "1=1"
return f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE {where}
ORDER BY quantity_sold DESC NULLS LAST, sale_price ASC
LIMIT 20
"""
# ═══════════════════════════════════════════════
# 3-Tier Cascading Search
# ═══════════════════════════════════════════════
async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
"""
3 tầng fallback:
Tầng 1: CỐ ĐỊNH + keywords (từ nguyên văn user)
Tầng 2: CỐ ĐỊNH + tags (AI suy luận → tags chung)
Tầng 3: CHỈ CỐ ĐỊNH (product_type + color + gender)
Returns: (products, tier_matched)
"""
# ---- Tầng 1: CỐ ĐỊNH + KEYWORDS ----
if req.keywords:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_search_clause(req.keywords, params)
if search:
sql = _build_full_query(fixed, search)
products = await db.execute_query_async(sql, params=tuple(params))
if products:
logger.info("🎯 Tầng 1 (keywords) match! %d kết quả", len(products))
return products, 1
# ---- Tầng 2: CỐ ĐỊNH + TAGS ----
if req.tags:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_search_clause(req.tags, params, resolve_tags=True)
if search:
sql = _build_full_query(fixed, search)
products = await db.execute_query_async(sql, params=tuple(params))
if products:
logger.info("🎯 Tầng 2 (tags chung) match! %d kết quả", len(products))
return products, 2
# ---- Tầng 3: CHỈ CỐ ĐỊNH ----
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
if products:
logger.info("🎯 Tầng 3 (chỉ cố định) match! %d kết quả", len(products))
return products, 3
logger.info("🎯 Tầng 3 (chỉ cố định) match! 0 kết quả")
# ---- Tầng 4: DROP GENDER, chỉ giữ product_line ----
# VD: "sịp nam" → gender=men returns 0 → try again without gender
if req.product_line_vn and req.gender_by_product:
logger.info("🔄 Tầng 4: thử drop gender '%s' để tìm rộng hơn...", req.gender_by_product)
saved_gender = req.gender_by_product
req.gender_by_product = None # drop gender
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
req.gender_by_product = saved_gender # restore
if products:
logger.info("🎯 Tầng 4 (drop gender) match! %d kết quả", len(products))
return products, 4
return [], 3
def _format_products(products: list, stock_checked: bool = False) -> list[dict]:
"""Format output cho AI đọc. Kèm stock info nếu có."""
formatted = []
for p in products[:15]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0)
item = {
"sku": p.get("magento_ref_code", ""),
"name": p.get("product_name", ""),
"price": int(sale),
"original_price": int(orig),
"discount": f"-{disc_pct}%" if has_discount else None,
"color": p.get("master_color", ""),
"gender": p.get("gender_by_product", ""),
"product_line": p.get("product_line_vn", ""),
"image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""),
}
# Stock info nếu đã check
if stock_checked:
stock_detail = p.get("_stock_detail", [])
total_qty = p.get("_total_qty", 0)
item["in_stock"] = total_qty > 0
item["total_qty"] = total_qty
item["stock"] = stock_detail # [{"size": "S", "qty": 2}, {"size": "M", "qty": 1}]
formatted.append(item)
return formatted
# ═══════════════════════════════════════════════
# SKU Lookup (đặc biệt, bypass cascade)
# ═══════════════════════════════════════════════
def _build_sku_query(code: str) -> tuple[str, list]:
"""Build query tìm theo mã SKU."""
code = code.strip().upper()
sql = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE (UPPER(magento_ref_code) = %s
OR UPPER(internal_ref_code) = %s
OR UPPER(magento_ref_code) LIKE %s)
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 20
"""
return sql, [code, code, f"{code}%"]
# ═══════════════════════════════════════════════
# Tool Entry Point
# ═══════════════════════════════════════════════
@tool(args_schema=TagSearchInput)
async def tag_search_tool(
keywords: list[str] | None = None,
tags: list[str] | None = None,
product_line_vn: list[str] | None = None,
gender_by_product: str | None = None,
age_by_product: str | None = None,
master_color: str | None = None,
price_min: int | None = None,
price_max: int | None = None,
discount_min: int | None = None,
discount_max: int | None = None,
discovery_mode: str | None = None,
magento_ref_code: str | None = None,
reasoning: str | None = None,
) -> str:
"""
Tìm kiếm sản phẩm CANIFA + tự động check tồn kho.
Chỉ trả về sản phẩm CÒN HÀNG (nếu stock API available).
"""
start = time.time()
req = TagSearchInput(
keywords=keywords or [],
tags=tags or [],
product_line_vn=product_line_vn or [],
gender_by_product=gender_by_product,
age_by_product=age_by_product,
master_color=master_color,
price_min=price_min,
price_max=price_max,
discount_min=discount_min,
discount_max=discount_max,
discovery_mode=discovery_mode,
magento_ref_code=magento_ref_code,
)
db = get_db_connection()
try:
# SKU lookup (bypass cascade)
if req.magento_ref_code:
sql, params = _build_sku_query(req.magento_ref_code)
products = await db.execute_query_async(sql, params=tuple(params))
tier = 0
else:
# 3-tier cascading search
products, tier = await _cascading_search(req, db)
search_ms = round((time.time() - start) * 1000, 2)
# ── Stock Check: gọi Canifa API → lọc chỉ giữ SP còn hàng ──
stock_start = time.time()
products, stock_checked = await _enrich_with_stock(products)
stock_ms = round((time.time() - stock_start) * 1000, 2)
total_ms = round((time.time() - start) * 1000, 2)
logger.info(
"🏷️ [SEARCH+STOCK] keywords=%s, tags=%s, type=%s, color=%s, gender=%s | "
"tier=%d, results=%d, search=%.0fms, stock=%.0fms, total=%.0fms, stock_checked=%s",
keywords, tags, product_line_vn, master_color, gender_by_product,
tier, len(products), search_ms, stock_ms, total_ms, stock_checked,
)
formatted = _format_products(products, stock_checked=stock_checked)
return json.dumps({
"status": "success",
"count": len(formatted),
"tier": tier,
"stock_checked": stock_checked,
"elapsed_ms": total_ms,
"reasoning": reasoning or "",
"keywords_used": keywords or [],
"tags_used": tags or [],
"products": formatted,
}, ensure_ascii=False, default=str)
except Exception as e:
logger.error("❌ Search tool error: %s", e)
return json.dumps({"status": "error", "message": str(e)})
════════════════════════════════════════════════════════════════
TAG SEARCH AGENT — Kiến trúc tìm kiếm sản phẩm CANIFA
════════════════════════════════════════════════════════════════
📅 Cập nhật: 2026-04-05
═══ 1. TỔNG QUAN ═══
Tag Search Agent là hệ thống tìm kiếm sản phẩm thông minh cho chatbot
CANIFA. AI nhận câu hỏi tự nhiên từ khách → phân tích → gọi tool SQL
→ trả kết quả sản phẩm phù hợp.
Flow: Khách hỏi → LLM phân tích → tag_search_tool → SQL → Postgres → Kết quả
═══ 2. CÁC FILE CHÍNH ═══
┌─────────────────────────┬────────────────────────────────────────┐
│ File │ Vai trò │
├─────────────────────────┼────────────────────────────────────────┤
│ tag_search_graph.py │ LangGraph graph + System Prompt cho AI │
│ tag_search_tool.py │ Pydantic schema + SQL builder + Tool │
│ __init__.py │ Export module │
└─────────────────────────┴────────────────────────────────────────┘
═══ 3. SEARCH ARCHITECTURE: 3-TẦNG FALLBACK ═══
Tầng 1: KEYWORDS + TAGS + FIXED FILTERS
↓ (0 kết quả?)
Tầng 2: TAGS + FIXED FILTERS (bỏ keywords)
↓ (0 kết quả?)
Tầng 3: CHỈ FIXED FILTERS (bỏ cả tags)
FIXED FILTERS = product_type + color + gender + price (luôn giữ, không bỏ)
═══ 4. INPUT SCHEMA (TagSearchInput) ═══
① KEYWORDS (list[str]) — từ khoá lấy từ mô tả SP hoặc câu hỏi khách
Ưu tiên: từ đặc biệt như "30/4", "cờ đỏ sao vàng", "cotton"...
Tối đa 2 keywords.
② TAGS (list[str]) — AI suy luận từ ngữ cảnh
DANH SÁCH CỐ ĐỊNH (CHỈ 12 GIÁ TRỊ, KHÔNG TỰ NGHĨ RA):
đi tiệc, đi học, đi chơi, dạo phố, mặc nhà, đi ngủ,
thể thao, đi dã ngoại, đi biển, đi làm, giữ ấm, thoáng mát
⚠️ "đi ăn nhà hàng" ❌ → "đi tiệc" ✅
⚠️ "đi picnic" ❌ → "đi dã ngoại" ✅
③ PRODUCT_TYPE (list[str]) — CLOSED LIST, CHỈ 14 GIÁ TRỊ:
┌──────────────────────────┐
│ Áo phông │
│ Áo len │
│ Áo nỉ │
│ Áo Polo │
│ Áo Sơ mi │
│ Áo kiểu │
│ Áo khoác chống nắng │
│ Cardigan │
│ Váy liền │
│ Chân váy │
│ Quần nỉ │
│ Quần dài │
│ Quần soóc │
│ Pyjama │
└──────────────────────────┘
⚠️ AI KHÔNG ĐƯỢC tự nghĩ ra loại mới!
"đồ đông" ❌ → ["Áo len", "Áo nỉ", "Quần nỉ"] ✅
"đồ hè" ❌ → ["Áo phông", "Quần soóc"] ✅
"bộ thể thao" ❌ → ["Áo phông", "Quần soóc"] ✅
⚠️ AUTO-EXPAND từ khái quát:
"áo" → ["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
"quần" → ["Quần dài", "Quần soóc", "Quần nỉ"]
"váy" → ["Váy liền", "Chân váy"]
❌ TUYỆT ĐỐI KHÔNG để product_type=[] khi khách đã nhắc loại đồ!
④ CÁC FILTER KHÁC:
- gender_by_product: women, men, boy, girl, unisex, newborn
- master_color: Màu sắc (AI suy luận: "30/4" → đỏ)
- price_min / price_max: đơn vị VND
- discovery_mode: "new" | "best_seller"
- magento_ref_code: mã SKU cụ thể
═══ 5. QUY TẮC QUAN TRỌNG ═══
✅ Tags + Product_type CHỈ chọn từ danh sách cố định
✅ Nếu khách nói "áo" → auto-expand thành nhiều loại áo
✅ Nếu khách hỏi mơ hồ ("cả gia đình", "cho mọi người")
→ HỎI LẠI: "Bạn muốn tìm cho ai ạ?" TRƯỚC khi gọi tool
❌ KHÔNG tự nghĩ ra tags mới (VD: "đi ăn nhà hàng")
❌ KHÔNG tự nghĩ ra product_type mới (VD: "đồ đông")
❌ KHÔNG để product_type=[] khi khách đã nhắc loại đồ
═══ 6. VÍ DỤ MAPPING ═══
Câu hỏi khách → AI gửi tool_call
─────────────────────────────────────────────────────────────
"Váy đi tiệc cho bé" → tags=["đi tiệc"]
product_type=["Váy liền", "Chân váy"]
gender="girl"
"Áo mặc 30/4" → keywords=["30/4"]
tags=["đi chơi"]
product_type=["Áo phông", "Áo kiểu"]
color="đỏ"
"Bộ thể thao nam" → tags=["thể thao"]
product_type=["Áo phông", "Quần soóc"]
gender="men"
"Đồ mùa đông cho bé gái" → tags=["giữ ấm"]
product_type=["Áo len", "Áo nỉ", "Quần nỉ"]
gender="girl"
"Hàng mới bán chạy" → product_type=[]
discovery_mode="best_seller"
"Áo đi ăn nhà hàng cho nữ" → tags=["đi tiệc"] (KHÔNG phải "đi ăn nhà hàng")
product_type=["Áo phông", "Áo Polo", "Áo Sơ mi", "Áo kiểu"]
gender="women"
"Có áo cho cả gia đình k?" → HỎI LẠI: "Bạn muốn tìm cho ai ạ?"
═══ 6. SQL GENERATION ═══
product_type là list → sinh OR clauses:
WHERE (
(LOWER(product_name) LIKE '%áo len%' OR LOWER(ultra_description_text) LIKE '%áo len%')
OR
(LOWER(product_name) LIKE '%áo nỉ%' OR LOWER(ultra_description_text) LIKE '%áo nỉ%')
OR
(LOWER(product_name) LIKE '%quần nỉ%' OR LOWER(ultra_description_text) LIKE '%quần nỉ%')
)
AND gender_by_product IN ('female', 'unisex')
AND ...
═══ 7. API ENDPOINTS ═══
POST /api/tag-search/chat → Chat với AI agent
GET /api/tag-search/inventory → Thống kê kho hàng (count, giá, màu, tags)
═══ 8. TECH STACK ═══
- Backend: FastAPI + LangGraph + LangChain
- LLM: GPT-4.1-nano (OpenAI)
- Database: PostgreSQL (law_bot)
- Schema: Pydantic v2
- Frontend: Vanilla HTML/JS/CSS
════════════════════════════════════════════════════════════════
END OF DOCUMENTATION
════════════════════════════════════════════════════════════════
"""
Tag Search Agent — AI-powered product search using tag selection.
No vector search. Pure SQL tag matching with 3-tier cascading fallback.
"""
from .controller import tag_search_chat_controller
from .tag_search_graph import get_tag_search_agent
__all__ = ["tag_search_chat_controller", "get_tag_search_agent"]
"""
Tag Search Agent Controller — Entry point with Langfuse tracing.
Wraps TagSearchGraph (Planner ⇄ Tools → Responder) with:
- Langfuse trace + span context (timing: planner_ms, responder_ms, tool_ms)
- Session-based Redis history (via ai_tag_search Redis helpers)
- Background flush for Langfuse
Every call creates a named Langfuse trace "tag-search-chat" so the full
Planner → Tool → Responder pipeline is visible in the Langfuse dashboard.
"""
import logging
import time
import uuid
from fastapi import BackgroundTasks
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langfuse import Langfuse, get_client as get_langfuse
from common.langfuse_client import async_flush_langfuse, get_callback_handler
from .tag_search_graph import get_tag_search_agent
logger = logging.getLogger(__name__)
async def tag_search_chat_controller(
*,
query: str,
session_id: str | None = None,
background_tasks: BackgroundTasks,
model_name: str | None = None,
history: list | None = None, # pre-loaded LangChain messages (optional)
user_id: str | None = None,
) -> dict:
"""
Controller cho Tag Search Agent — full Langfuse tracing.
Flow:
1. Build Langfuse trace context
2. Run TagSearchGraph (Planner ⇄ Tools → Responder)
3. Extract timing breakdown từ pipeline diagnostics
4. Update Langfuse trace output
5. Flush Langfuse in background
Returns:
dict with: status, response, products, product_ids, pipeline, timing, trace_id
"""
start_time = time.time()
run_id = str(uuid.uuid4())
trace_id = Langfuse.create_trace_id()
effective_session = session_id or f"ts-{run_id[:8]}"
effective_user = user_id or effective_session
logger.info(
"📥 [TagSearch Controller] session=%s | query=%.80s",
effective_session[:12], query,
)
# ═══ 1. BUILD LANGFUSE TRACE ═══
tags = ["tag_search", "experiment"]
langfuse = get_langfuse()
observation_ctx = None
if langfuse:
try:
observation_ctx = langfuse.start_as_current_observation(
as_type="span",
name="tag-search-chat",
trace_context={"trace_id": trace_id},
)
except Exception as e:
logger.warning("⚠️ Langfuse span init failed: %s", e)
span = None
if observation_ctx:
try:
span = observation_ctx.__enter__()
span.update_trace(
name="tag-search-chat",
user_id=effective_user,
session_id=effective_session,
tags=tags,
input={"query": query},
metadata={
"model": model_name,
"session_id": effective_session,
"history_turns": len(history) if history else 0,
},
)
except Exception as e:
logger.warning("⚠️ Langfuse trace update failed: %s", e)
# Langfuse callback handler — tracks each LLM call inside the graph
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
callbacks=[langfuse_handler] if langfuse_handler else [],
run_name="tag-search-graph",
run_id=run_id,
metadata={
"langfuse_session_id": effective_session,
"langfuse_user_id": effective_user,
"langfuse_tags": tags,
"trace_id": trace_id,
},
)
# ═══ 2. RUN TAG SEARCH GRAPH ═══
try:
agent = get_tag_search_agent(model_name)
chat_result = await agent.chat(
user_message=query,
history=history or [],
config=exec_config,
)
ai_response = chat_result.get("response", "")
products = chat_result.get("products", [])
pipeline = chat_result.get("pipeline", [])
agent_path = chat_result.get("agent_path", "unknown")
total_elapsed = chat_result.get("elapsed_ms", 0)
# ─── Extract product_ids (SKU array) ───
product_ids = [p["sku"] for p in products if p.get("sku")]
# ─── Timing breakdown from pipeline diagnostics ───
planner_ms = 0
responder_ms = 0
tool_ms = 0
for step in pipeline:
step_type = step.get("step", "")
ms = step.get("elapsed_ms", 0) or 0
if "planner" in step_type:
planner_ms += ms
elif step_type == "responder":
responder_ms += ms
# tool_ms không có elapsed_ms in pipeline (tool timing tracked by tool itself)
wall_ms = round((time.time() - start_time) * 1000)
# ─── Log timing breakdown ───
logger.info(
"✅ [TagSearch Controller] path=%s | products=%d | "
"planner=%dms | responder=%dms | total=%dms | wall=%dms",
agent_path, len(products),
planner_ms, responder_ms, round(total_elapsed), wall_ms,
)
# ═══ 3. UPDATE LANGFUSE TRACE OUTPUT ═══
if span:
try:
span.update_trace(
output={
"ai_response": (ai_response or "")[:500],
"agent_path": agent_path,
"product_count": len(products),
"product_ids": product_ids[:10], # top 10 for trace
"elapsed_ms": round(total_elapsed),
"planner_ms": planner_ms,
"responder_ms": responder_ms,
},
)
except Exception:
pass
# ═══ 4. RETURN ═══
return {
"status": "success",
"response": ai_response,
"products": products,
"product_ids": product_ids,
"product_count": len(products),
"pipeline": pipeline,
"agent_path": agent_path,
"tool_calls": chat_result.get("tool_calls", []),
"trace_id": trace_id,
"session_id": effective_session,
"timing": {
"planner_ms": planner_ms,
"responder_ms": responder_ms,
"tool_ms": tool_ms,
"total_ms": round(total_elapsed),
"wall_ms": wall_ms,
},
}
except Exception as e:
elapsed = round((time.time() - start_time) * 1000)
logger.error(
"❌ [TagSearch Controller] Error after %dms: %s", elapsed, e,
exc_info=True,
)
if span:
try:
span.update_trace(
output={"error": str(e)[:500], "elapsed_ms": elapsed},
level="ERROR",
)
except Exception:
pass
return {
"status": "error",
"error_code": "TAG_SEARCH_ERROR",
"message": f"Tag Search Error: {str(e)[:200]}",
"trace_id": trace_id,
}
finally:
if observation_ctx:
try:
observation_ctx.__exit__(None, None, None)
except Exception:
pass
# Async flush Langfuse in background — non-blocking
if background_tasks:
background_tasks.add_task(async_flush_langfuse)
"""
Product Line Mapping
Key = DB product_line_vn (chính xác)
Value = list các từ khách hàng hay dùng (synonym)
"""
# DB value → [các từ khách hàng hay gọi]
PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo Sơ mi": ["áo sơ mi"],
"Áo Polo": ["áo polo", "áo cổ bẻ"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym"],
"Áo nỉ có mũ": ["áo nỉ có mũ"],
"Áo nỉ": ["áo nỉ"],
"Áo mặc nhà": ["áo mặc nhà"],
"Áo lót": ["áo lót", "áo ngực", "áo quây", "áo lót nữ", "áo lót nam", "áo lót trẻ em"],
"Áo len gilet": ["áo len gilet"],
"Áo len": ["áo len"],
"Áo kiểu": ["áo kiểu"],
"Áo khoác sợi": ["áo khoác sợi"],
"Áo khoác nỉ không mũ": ["áo khoác nỉ không mũ"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ"],
"Áo khoác lông vũ": ["áo khoác lông vũ"],
"Áo khoác gió": ["áo khoác gió", "áo gió", "áo khoác mỏng"],
"Áo khoác gilet chần bông": ["áo khoác gilet chần bông", "áo khoác gilet trần bông", "áo gilet chần bông", "áo gilet trần bông"],
"Áo khoác gilet": ["áo khoác gilet"],
"Áo khoác dạ": ["áo khoác dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn"],
"Áo khoác chống nắng": ["áo khoác chống nắng"],
"Áo khoác chần bông": ["áo khoác chần bông", "áo khoác trần bông", "áo chần bông", "áo trần bông"],
"Áo khoác": ["áo khoác"],
"Áo giữ nhiệt": ["áo giữ nhiệt"],
"Áo bra active": ["áo bra active", "áo bra", "bra"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn"],
"Áo ba lỗ": ["áo ba lỗ", "áo sát nách", "tanktop", "tank top", "áo dây", "áo 2 dây", "áo hai dây"],
"Váy liền": ["váy liền", "đầm"],
"Tất": ["tất", "vớ"],
"Túi xách": ["túi xách"],
"Quần giả váy": ["quần giả váy", "quần váy"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger"],
"Quần mặc nhà": ["quần mặc nhà"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer", "boxer", "sịp đùi"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief", "brief", "sịp tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "quần lót nữ", "quần lót nam", "quần lót trẻ em", "quần sơ lít", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà"],
"Quần leggings": ["quần leggings", "leggings"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean"],
"Quần giữ nhiệt": ["quần giữ nhiệt"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng"],
"Quần culottes": ["quần culottes"],
"Quần Body": ["quần body"],
"Pyjama": ["pyjama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "phụ kiện Canifa", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "phụ kiện Canifa", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện Canifa", "phụ kiện"],
"Khăn": ["khăn", "phụ kiện Canifa", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "phụ kiện Canifa", "phụ kiện"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "phụ kiện Canifa", "phụ kiện"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài"],
"Cardigan": ["cardigan"],
"Bộ thể thao": ["bộ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà"],
"Blazer": ["blazer"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
}
# ==============================================================================
# AUTO-GENERATE reverse lookup: synonym → DB value
# "áo thun" → "Áo phông", "quần bò" → "Quần jean", ...
# ==============================================================================
SYNONYM_TO_DB: dict[str, str] = {}
for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_product_name(raw_name: str) -> str:
"""
Resolve synonym trong product_name → tên DB thật.
Dùng longest-match-first để tránh match sai.
VD:
"áo cổ bẻ khaki" → "Áo Polo khaki"
"áo thun disney" → "Áo phông disney"
"quần bò ống rộng" → "Quần jean ống rộng"
"áo polo khaki" → "Áo Polo khaki" (giữ nguyên nếu đã đúng)
"""
name_lower = raw_name.lower().strip()
for synonym in _SORTED_SYNONYMS:
if name_lower.startswith(synonym):
db_value = SYNONYM_TO_DB[synonym]
remainder = name_lower[len(synonym):].strip()
return f"{db_value} {remainder}".strip() if remainder else db_value
return raw_name
def resolve_product_line(raw_value: str) -> list[str]:
"""
Lookup keyword → DB product_line_vn.
Hỗ trợ '/' separator (VD: "Quần/ Váy").
Không tìm thấy → giữ nguyên (prefix match ở SQL).
"""
parts = [p.strip() for p in raw_value.split("/") if p.strip()]
resolved = []
for part in parts:
mapped = SYNONYM_TO_DB.get(part.lower())
if mapped:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
"""Tag Search Agent — Prompts package."""
from .planner_prompt import PLANNER_PROMPT
from .responder_prompt import RESPONDER_PROMPT
__all__ = ["PLANNER_PROMPT", "RESPONDER_PROMPT"]
"""
Planner Prompt — Tag Search Agent.
AI PLANNER phân tích câu hỏi khách → gọi tool tìm sản phẩm.
"""
PLANNER_PROMPT = """Bạn là AI PLANNER của CANIFA. Nhiệm vụ DUY NHẤT: Phân tích câu hỏi khách → quyết định HỎI LẠI hoặc gọi tag_search_tool.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## BƯỚC 1 — CÂU HỎI ĐỦ RÕ CHƯA?
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### HỎI LẠI khi:
- Không rõ CHO AI (gender): "cả gia đình", "cho mọi người", "cho nhà mình"
- Không rõ LOẠI GÌ VÀ CHO AI (chung chung hoàn toàn): "có gì đẹp không?"
⚠️ HỎI LẠI bằng TEXT THUẦN, tuyệt đối KHÔNG gọi tool!
### KHÔNG CẦN hỏi lại khi:
- Đã rõ gender HOẶC đã rõ loại đồ: "áo mùa đông" → OK gọi tool (chưa rõ gender nhưng rõ loại)
- Chỉ thiếu 1 info phụ: "quần jean" → OK gọi tool (không cần hỏi size, màu,...)
- Rõ gender thôi cũng đủ: "đồ nam", "đồ nữ", "đồ bé trai" → gọi tool tìm broad
- Câu follow-up sau kết quả: "có rẻ hơn không?", "thêm nữa đi", "có màu khác không?" → gọi lại tool với filter bổ sung hoặc giữ nguyên params cũ
Ví dụ HỎI LẠI:
│ Khách: "Có đồ đi biển cho cả gia đình không?"
│ → "Bạn muốn tìm đồ đi biển cho ai ạ? (nam/nữ/bé trai/bé gái?) Mình sẽ tìm phù hợp cho từng người nhé!"
│
│ Khách: "Cho mọi người cái gì đẹp đẹp"
│ → "Bạn muốn mình tìm cho ai trước ạ? Nam, nữ hay bé nhỉ?"
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## BƯỚC 2 — SINH PARAMS & GỌI TOOL
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### ① keywords — TỪ NGUYÊN VĂN KHÁCH NÓI (ƯU TIÊN CAO NHẤT)
keywords = từ/cụm từ ĐẶC BIỆT, RIÊNG mà khách nhắc đến nguyên văn.
Hệ thống sẽ tìm chính xác cụm từ này trong description sản phẩm.
→ Chỉ bỏ vào keywords khi từ đó KHÔNG PHỔ THÔNG, KHÔNG PHẢI tên loại đồ (VD: tên riêng của một sản phẩm như "áo bò thêu", "áo ngọ nghiêng", tên chất liệu, hoạ tiết, v.v.).
→ Tối đa 2 keywords. Để [] nếu không có gì đặc biệt.
✅ LÀ keyword: tên chất liệu (cotton, linen, polyester), tên bộ sưu tập (cờ đỏ sao vàng), tên motif (disney, hoạ tiết), thuật ngữ kỹ thuật (heattech, UV, SPF), tên riêng sản phẩm (ngọ nghiêng, cá sấu).
❌ KHÔNG phải keyword: "mát", "đẹp", "thoải mái", "đi chơi", "mùa hè" → Đây là NGỮ CẢNH → dùng tags
❌ NGÀY LỄ KHÔNG phải keyword: "2/9", "30/4", "1/6", "Noel", "Tết" → Đây là DỊP ĐI CHƠI → dùng tags occ:di_choi hoặc occ:di_tiec!
Ví dụ:
│ "áo cotton mát" → keywords=["cotton"], tags=["func:thoang_mat"]
│ "quần linen ống rộng" → keywords=["linen"], tags=["fit:wide_leg"]
│ "áo đi chơi 2/9" → keywords=[], tags=["occ:di_choi"] (2/9 là dịp lễ = đi chơi, KHÔNG search "2/9" trong SP!)
│ "đồ đi chơi 30/4" → keywords=[], tags=["occ:di_choi", "occ:du_lich"] (30/4 = dịp nghỉ lễ)
│ "áo Noel" → keywords=[], tags=["occ:di_tiec"] (Noel = dịp tiệc/đi chơi)
│ "áo Tết đẹp" → keywords=[], tags=["occ:di_tiec", "style:thanh_lich"]
│ "áo cờ đỏ sao vàng" → keywords=["cờ đỏ sao vàng"] (đây là motif in trên áo, search trong SP description)
│ "đồ mickey" → keywords=["mickey"]
│ "áo heattech giữ ấm" → keywords=["heattech"], tags=["func:giu_am"]
│ "váy đi tiệc" → keywords=[], tags=["occ:di_tiec"] (KHÔNG có keyword đặc biệt!)
│ "quần jean" → keywords=[] (tên loại đồ = product_line_vn, KHÔNG phải keyword)
### ② tags — AI SUY LUẬN TỪ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC CÓ PREFIX!)
⚠️ CHỈ CHỌN TỪ DANH SÁCH DƯỚI ĐÂY. KHÔNG TỰ NGHĨ TAG MỚI!
⚠️ PHẢI GIỮ NGUYÊN PREFIX (occ:, wthr:, func:, style:, fit:)
#### Trục 1 — Hoàn cảnh & Dịp (prefix: occ:)
Hỏi: "Mặc đi đâu? Dịp nào?"
occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu
#### Trục 2a — Thời tiết (prefix: wthr:)
Hỏi: "Thời tiết/mùa nào?"
wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang
#### Trục 2b — Tính năng vải (prefix: func:)
Hỏi: "Cần tính năng gì?"
func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio
#### Trục 3 — Phong cách & Vibe (prefix: style:)
Hỏi: "Gu/phong cách gì?"
style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual
#### Trục 4 — Dáng & Kiểu cắt (prefix: fit:)
Hỏi: "Dáng/form ra sao?"
fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed
Quy tắc suy luận tag:
│ "đi đánh golf" → occ:the_thao (golf = thể thao)
│ "đám cưới bạn" → occ:di_tiec (đám cưới = tiệc)
│ "đi nhà hàng" → occ:di_tiec (nhà hàng = tiệc/sang trọng)
│ "ra biển Đà Nẵng" → occ:di_bien, occ:du_lich
│ "leo núi cuối tuần" → occ:da_ngoai, occ:the_thao
│ "mặc ở nhà cho thoải mái" → occ:mac_nha
│ "ngủ" → occ:di_ngu
│ "nóng quá" → wthr:mua_he, func:thoang_mat
│ "rét" / "lạnh" → wthr:mua_dong, func:giu_am
│ "trời mưa" → wthr:troi_mua
│ "mát mẻ" / "thoáng" → func:thoang_mat
│ "chống nắng" → func:chong_uv, wthr:troi_nang
│ "nhanh khô" → func:nhanh_kho
│ "sang trọng" → style:thanh_lich
│ "năng động" → style:nang_dong
│ "dễ thương" → style:de_thuong
│ "tối giản" → style:toi_gian
│ "rộng rãi" / "thoải mái" (về dáng) → fit:relaxed
│ "ôm body" / "bó" → fit:slim
│ "rộng" / "oversize" → fit:oversize
│ "lửng" / "ngắn" (về quần) → fit:cropped
### ③ product_line_vn — LOẠI ĐỒ CỤ THỂ
#### DANH MỤC SẢN PHẨM CANIFA (dùng để HIỂU khách muốn gì):
🔵 ÁO: Áo Sơ mi | Áo Polo | Áo phông | Áo nỉ (có mũ/không) | Áo len (gilet/thường) | Áo kiểu | Áo khoác (gió/lông vũ/dạ/chần bông/gilet/chống nắng/dáng ngắn/sợi/nỉ) | Áo giữ nhiệt | Áo bra active | Áo Body/croptop | Áo ba lỗ/tanktop | Áo lót | Áo mặc nhà
🔵 QUẦN: Quần jean | Quần Khaki/âu | Quần soóc/short | Quần nỉ/jogger | Quần dài/suông | Quần leggings | Quần Body | Quần culottes | Quần lót (đùi/tam giác) | Quần giả váy | Quần giữ nhiệt | Quần mặc nhà
🔵 VÁY & BỘ: Váy liền/đầm | Chân váy | Blazer | Cardigan | Bộ thể thao | Bộ quần áo | Bộ mặc nhà/đồ ngủ | Pyjama
🔵 PHỤ KIỆN: Mũ/nón | Khăn (tắm/mặt/lau đầu) | Tất/vớ | Găng tay chống nắng | Chăn cá nhân | Túi xách
#### product_line_vn: Bê NGUYÊN VĂN từ khách nói!
- Khách nói CỤ THỂ ("áo polo", "quần jogger", "sịp đùi") → dùng đúng từ khách nói
- Khách nói CHUNG ("áo", "quần", "đồ") → giữ nguyên từ chung, KHÔNG đoán cụ thể! Tool tự tìm rộng.
- Tool sẽ tự chuẩn hoá đồng nghĩa (áo thun → Áo phông, sịp → Quần lót)
🚫 CẤM TUYỆT ĐỐI thay từ khách nói bằng từ "thông minh hơn":
│ "sịp đi biển" → product_line_vn=["sịp"] ✅ KHÔNG PHẢI ["quần bơi"] ❌
│ "áo ấm" → product_line_vn=["áo"] ✅ KHÔNG PHẢI ["áo khoác"] ❌
│ "quần đi biển" → product_line_vn=["quần"] ✅ KHÔNG PHẢI ["quần bơi"] ❌
│ "đồ ngủ sexy" → product_line_vn=["đồ ngủ"] ✅ KHÔNG PHẢI ["váy ngủ"] ❌
### ⑤ reasoning — SUY LUẬN (BẮT BUỘC!)
Giải thích ngắn gọn (1-2 câu tiếng Việt) TẠI SAO bạn chọn params này.
VD:
│ "áo 2/9" → reasoning="2/9 = Quốc Khánh, cờ đỏ sao vàng → suy luận áo màu đỏ, dịp đi chơi"
│ "sịp đi biển" → reasoning="Khách muốn sịp mặc đi biển → giữ nguyên từ sịp, thêm tag đi biển"
│ "quần jean nữ" → reasoning="Khách nói rõ quần jean + nữ → tìm cụ thể"
### ④ BỘ LỌC SQL (FILTER CỨNG)
- gender_by_product: women / men / girl / boy
"nữ" → women | "nam" → men | "bé gái"/"con gái" → girl | "bé trai"/"con trai" → boy
- age_by_product: adult / kid
"trẻ em"/"bé" → kid | "người lớn" → adult
- master_color: Ghi ĐÚNG màu khách nói (tiếng Việt): "trắng", "đen", "xanh dương"...
- price_min / price_max: đơn vị VND (200k = 200000)
- discount_min / discount_max: % giảm giá
"giảm 50%" → discount_min=50 | "hàng sale"/"giảm giá" → discount_min=1
- discovery_mode: "new" (hàng mới) | "best_seller" (bán chạy)
- magento_ref_code: mã SKU (khi khách đưa mã cụ thể)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## QUY TẮC CỨNG
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
1. keywords ƯU TIÊN TRƯỚC tags! Hệ thống tìm keywords trước → không có kết quả mới fallback tags.
2. product_line_vn = LOẠI QUẦN ÁO. tags = LÝ DO MẶC / NGỮ CẢNH. KHÔNG trộn lẫn!
3. ⚠️ QUAN TRỌNG NHẤT: product_line_vn phải là NGUYÊN VĂN từ khách nói! CẤM thay thế, diễn giải, hoặc "thông minh hóa"! Tool có synonym mapping sẵn, bạn KHÔNG CẦN dịch!
4. TUYỆT ĐỐI KHÔNG trả lời về sản phẩm nếu chưa gọi tool.
5. KHÔNG tự nghĩ tag ngoài danh sách. PHẢI có prefix!
6. BỎ QUA history trước đó khi extract product_line_vn. Chỉ nhìn câu hỏi HIỆN TẠI của khách.
7. Câu follow-up ngắn ("oke", "được", "tiếp đi") mà không có nhu cầu mới → HỎI LẠI xem khách muốn tìm gì tiếp theo.
8. Nếu khách hỏi "có rẻ hơn không?" → gọi lại tool với price_max bằng 80% giá đã thấy, giữ nguyên params còn lại.
9. Mục tiêu: CALL TOOL sớm nhất có thể. Chỉ HỎI LẠI khi thực sự không thể đoán được intent.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## 30+ VÍ DỤ HUẤN LUYỆN
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### 💬 Loại đồ cơ bản
"Sịp nam"
→ product_line_vn=["sịp"], gender="men", reasoning="Khách nói sịp nam → giữ nguyên 'sịp', tool tự map"
"Quần jean nữ"
→ product_line_vn=["quần jean"], gender="women", reasoning="Khách nói rõ quần jean + nữ → tìm cụ thể"
"Đồ lót trẻ em"
→ product_line_vn=["đồ lót"], age_by_product="kid", reasoning="Đồ lót cho trẻ em, chưa rõ gender"
"Áo polo nam"
→ product_line_vn=["áo polo"], gender="men", reasoning="Áo polo nam, tìm thẳng"
"Áo sơ mi nam đi làm"
→ product_line_vn=["áo sơ mi"], gender="men", tags=["occ:di_lam"], reasoning="Áo sơ mi + đi làm = occasion di_lam"
### 💬 Có keyword đặc biệt
"Áo cotton mát cho mùa hè"
→ product_line_vn=["áo"], keywords=["cotton"], tags=["wthr:mua_he", "func:thoang_mat"], reasoning="Cotton = chất liệu → keyword, mùa hè + mát = tags"
"Đồ disney bé gái"
→ keywords=["disney"], gender="girl", age_by_product="kid", reasoning="Disney = motif → keyword, bé gái = girl+kid"
"Áo cờ đỏ sao vàng"
→ product_line_vn=["áo"], keywords=["cờ đỏ sao vàng"], reasoning="Cờ đỏ sao vàng = motif in trên áo → keyword"
"Áo heattech giữ ấm mùa đông"
→ product_line_vn=["áo"], keywords=["heattech"], tags=["wthr:mua_dong", "func:giu_am"], reasoning="Heattech = kỹ thuật → keyword, mùa đông + giữ ấm = tags"
### 💬 Ngữ cảnh → suy luận tags
"Váy đi tiệc cuối năm"
→ product_line_vn=["váy"], tags=["occ:di_tiec"], gender="women", reasoning="Đi tiệc = occasion di_tiec"
"Áo đi chơi cuối tuần"
→ product_line_vn=["áo"], tags=["occ:di_choi"], reasoning="Đi chơi = occasion di_choi"
"Đồ mặc nhà thoải mái"
→ tags=["occ:mac_nha"], reasoning="Mặc nhà = occasion mac_nha"
"Áo đi đám cưới"
→ product_line_vn=["áo"], tags=["occ:di_tiec", "style:thanh_lich"], reasoning="Đám cưới = tiệc trang trọng → di_tiec + thanh_lich"
"Áo khoác đi du lịch Đà Lạt"
→ product_line_vn=["áo khoác"], tags=["occ:du_lich", "wthr:mua_dong", "func:giu_am"], reasoning="Đà Lạt = lạnh → du_lich + mua_dong + giu_am"
### 💬 Thời tiết / Mùa
"Áo mùa đông bé gái"
→ product_line_vn=["áo"], tags=["wthr:mua_dong", "func:giu_am"], gender="girl", age_by_product="kid", reasoning="Mùa đông = giữ ấm, bé gái = girl+kid"
"Trời nóng quá có gì mát cho nữ?"
→ tags=["wthr:mua_he", "func:thoang_mat"], gender="women", reasoning="Nóng = mùa hè + thoáng mát, nữ = women, không rõ loại đồ → để trống"
### 💬 Phong cách & Dáng
"Quần jean ống rộng nữ"
→ product_line_vn=["quần jean"], tags=["fit:wide_leg"], gender="women", reasoning="Quần jean + ống rộng = wide_leg"
"Áo oversize nam"
→ product_line_vn=["áo"], tags=["fit:oversize"], gender="men", reasoning="Oversize = dáng → fit:oversize"
"Áo smart casual đi ăn"
→ product_line_vn=["áo"], tags=["style:smart_casual", "occ:di_tiec"], reasoning="Smart casual + đi ăn = phong cách + tiệc"
### 💬 Giá / Giảm giá
"Áo phông dưới 200k"
→ product_line_vn=["áo phông"], price_max=200000, reasoning="Áo phông + giá ≤200k"
"Hàng mới về"
→ discovery_mode="new", reasoning="Hàng mới = discovery new"
### 💬 🔴 Ngày lễ / Dịp đặc biệt (KHÔNG dùng keyword! + SUY LUẬN MÀU!)
⚠️ Ngày lễ KHÔNG PHẢI keyword! AI phải SUY LUẬN dịp + màu sắc:
│ 2/9 (Quốc Khánh) → cờ đỏ sao vàng → master_color="đỏ"
│ Noel/Giáng sinh → master_color="đỏ"
│ Tết Nguyên Đán → master_color="đỏ"
│ 30/4, 1/6 → đi chơi, không ràng buộc màu
"Áo 2/9"
→ product_line_vn=["áo"], tags=["occ:di_choi"], master_color="đỏ", reasoning="2/9 = Quốc Khánh, cờ đỏ sao vàng → áo màu đỏ, dịp đi chơi"
"Áo đi chơi 2/9 cho bé trai"
→ product_line_vn=["áo"], tags=["occ:di_choi"], master_color="đỏ", gender="boy", age_by_product="kid", reasoning="2/9 = Quốc Khánh → áo đỏ cho bé trai đi chơi"
"Đồ đi chơi 30/4"
→ tags=["occ:di_choi", "occ:du_lich"], reasoning="30/4 = dịp nghỉ lễ dài → đi chơi + du lịch"
"Váy đi tiệc Noel"
→ product_line_vn=["váy"], tags=["occ:di_tiec"], master_color="đỏ", gender="women", reasoning="Noel = Giáng sinh → váy đỏ đi tiệc"
"Áo Tết cho bé trai"
→ product_line_vn=["áo"], tags=["occ:di_tiec", "style:thanh_lich"], master_color="đỏ", gender="boy", age_by_product="kid", reasoning="Tết = dịp lễ → áo đỏ may mắn"
### 💬 ⚠️ CẤM THAY ĐỔI TỪ KHÁCH NÓI
"Sịp đi biển" → product_line_vn=["sịp"], tags=["occ:di_bien"], reasoning="Giữ nguyên 'sịp', KHÔNG đổi thành 'quần bơi'" ✅
"Quần đi biển" → product_line_vn=["quần"], tags=["occ:di_bien"], reasoning="Giữ nguyên 'quần'" ✅
"Áo mùa đông" → product_line_vn=["áo"], tags=["wthr:mua_dong", "func:giu_am"], reasoning="Giữ nguyên 'áo', KHÔNG đổi thành 'áo khoác'" ✅
### 💬 Hỏi lại (KHÔNG gọi tool)
"Có đồ đi biển cho cả gia đình không?"
→ HỎI LẠI: "Bạn muốn tìm đồ đi biển cho ai ạ? (nam/nữ/bé trai/bé gái?)"
"Cho mình xem đồ đẹp đẹp"
→ HỎI LẠI: "Bạn muốn mình tìm cho ai trước ạ? Nam, nữ, hay bé nhỉ?"
### 💬 Nên đẩy tên sản phẩm riêng vào keyword
"Áo ngọ nghiêng"
→ product_line_vn=["áo"], keywords=["ngọ nghiêng"], reasoning="ngọ nghiêng là tên/đặc điểm riêng -> dùng làm keyword để tìm chính xác."
"""
"""
Responder Prompt — Tag Search Agent.
AI RESPONDER nhận kết quả tool → format câu trả lời tự nhiên, thân thiện như stylist.
"""
RESPONDER_PROMPT = """Bạn là AI STYLIST của CANIFA — người tư vấn thời trang thân thiện, am hiểu sản phẩm.
Nhiệm vụ: Nhận kết quả tìm kiếm từ tool → trình bày cho khách một cách tự nhiên, hấp dẫn.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## NGUYÊN TẮC CỐT LÕI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### ✅ PHẢI LÀM:
- **Mở đầu bằng 1 câu dẫn ngắn** phù hợp ngữ cảnh câu hỏi → tạo cảm giác được tư vấn thật sự
VD: "Để đi biển mùa hè, CANIFA có mấy mẫu này khá ổn nè bạn 🌊"
VD: "Mình tìm được vài mẫu áo polo nam phù hợp, bạn xem thử nhé!"
- **Trình bày sản phẩm đúng format** (xem bên dưới)
- **Kết thúc bằng follow-up tự nhiên** — gợi ý lọc thêm hoặc hỏi nhu cầu thêm
- Dùng **emoji** nhẹ nhàng, không lạm dụng (1-2 cái là đủ)
- Tiếng Việt, tự nhiên như nhắn tin
### ❌ KHÔNG ĐƯỢC:
- Tự đánh giá sản phẩm "không phù hợp", "không chuyên dụng", rồi giấu kết quả
- Nói "tiếc, chưa có" khi tool ĐÃ TRẢ VỀ sản phẩm
- Bịa thêm thông tin không có trong data (màu, giá, tính năng)
- Từ chối hiển thị kết quả dựa trên suy đoán cá nhân
- Liệt kê quá 6 sản phẩm trong 1 lần (chọn highlight những cái đặc sắc nhất)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## FORMAT SẢN PHẨM (BẮT BUỘC)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Mỗi sản phẩm trình bày như sau:
**[Tên sản phẩm]** _(màu nếu có)_
💰 [giá bán]₫ ~~[giá gốc]~~ (-[% giảm]%)
🔗 [Xem & mua ngay](link)
---
Nếu không có giảm giá → chỉ hiện giá bán, bỏ qua dòng giảm.
Nếu không có link → bỏ qua dòng link.
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## XỬ LÝ KẾT QUẢ
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
### Khi có sản phẩm (count > 0):
1. Câu dẫn ngắn liên quan đến nhu cầu khách
2. Highlight 3-6 sản phẩm đặc sắc nhất (nếu có nhiều → chọn cái đa dạng nhất)
3. Gợi ý follow-up: lọc thêm màu/size/giá, hoặc hỏi nhu cầu cụ thể hơn
Ví dụ follow-up tốt:
- "Bạn thích màu gì để mình lọc chính xác hơn? 😊"
- "Tầm giá bao nhiêu là phù hợp với bạn nhỉ?"
- "Muốn xem thêm mẫu khác không, mình tìm tiếp cho bạn nhé!"
- "Bạn cần size nào để mình check hàng còn không?"
### Khi KHÔNG có sản phẩm (count = 0):
- Thông cảm ngắn gọn, KHÔNG phân tích dài dòng
- Gợi ý tìm thay thế hoặc mở rộng tiêu chí
VD: "Hm, CANIFA chưa có đúng mẫu đó bạn ơi 😅 Mình thử tìm theo hướng khác nhé — bạn có thể bỏ bớt điều kiện nào không?"
### Khi kết quả sai gender/age rõ ràng:
- Thông báo nhẹ nhàng rằng filter chưa khớp
- Gợi ý tìm lại với gender đúng
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## VÍ DỤ TONE & STYLE
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
❌ Robot:
"Dưới đây là danh sách sản phẩm phù hợp với yêu cầu của bạn:"
✅ Thân thiện:
"Đây bạn! Mình tìm được mấy mẫu áo sơ mi đi làm khá oke 👔"
❌ Robot:
"Sản phẩm 1: Áo Sơ mi Nam. Giá: 399,000. Link: ..."
✅ Thân thiện:
**Áo Sơ mi Nam Kẻ Ngang** _(Màu xanh navy)_
💰 399.000₫ ~~499.000₫~~ (-20%)
🔗 [Xem & mua ngay](link)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
## LƯU Ý CUỐI
━━━━━━━━━━━━━━━━━━━━━━━━━━━━
- KHÔNG tự bịa thêm tính năng, chất liệu, hay thông tin không có trong data tool trả về
- Data từ tool là nguồn duy nhất — chỉ dùng những gì tool cung cấp
- Tối đa 6 sản phẩm/lần, ưu tiên sản phẩm có ảnh + link đầy đủ
"""
"""
Tag Search Graph — 2-Agent LangGraph Architecture cho tư vấn sản phẩm.
Flow: User → Planner ⇄ Tools → Responder → END
2 con AI:
- Planner: Phân tích ý định, extract params, gọi tool. Nếu mơ hồ → hỏi lại.
Sau khi tool trả kết quả → pass-through → route tới Responder.
- Responder: Nhận kết quả tool, format câu trả lời tự nhiên, thân thiện.
KHÔNG gọi tools.
Architecture aligned with LeadStageGraph pattern (loop-back, diagnostics, RunnableConfig).
"""
import json
import logging
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode
from common.llm_factory import create_llm
from .tag_search_tool import tag_search_tool
from .prompts import PLANNER_PROMPT, RESPONDER_PROMPT
logger = logging.getLogger(__name__)
def _extract_text(content) -> str:
"""Extract plain text from msg.content — handles both str and Gemini list format."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
# ═══════════════════════════════════════════════
# State — enriched with diagnostics & routing flags
# ═══════════════════════════════════════════════
class TagSearchState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# Routing flag: True khi Planner trả lời trực tiếp (hỏi lại / greeting)
planner_responded: bool
# Pipeline diagnostics cho UI trace display
diagnostics: Annotated[list[dict[str, Any]], lambda x, y: x + y]
# ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture (with loop-back)
# Planner ⇄ Tools → Responder → END
# ═══════════════════════════════════════════════
class TagSearchGraph:
"""2-Agent LangGraph: Planner ⇄ Tools → Responder → END."""
# Tag Search dùng GPT-5-nano — nhanh nhất, tool calling tốt
TAG_SEARCH_MODEL = "gpt-5.4-nano"
def __init__(self, model_name: str | None = None):
self.model_name = model_name or self.TAG_SEARCH_MODEL
self.tools = [tag_search_tool]
# AI 1: Planner + Tool Caller (streaming off for tool calling reliability)
self.planner_llm = create_llm(model_name=self.model_name, streaming=False)
self.planner_with_tools = self.planner_llm.bind_tools(self.tools)
# AI 2: Responder — response only (streaming ON, NO tools)
self.responder_llm = create_llm(model_name=self.model_name, streaming=True)
self._compiled = None
logger.info(f"✅ TagSearchGraph 2-Agent initialized with model: {self.model_name}")
# ─────────────────────────────────────
# Node 1: PLANNER — Phân tích + gọi tool (with loop-back)
# ─────────────────────────────────────
async def _planner_node(self, state: TagSearchState, config: RunnableConfig | None = None) -> dict:
"""
Planner AI: phân tích câu hỏi, quyết định gọi tool hoặc hỏi lại.
Pass 1: Nhận câu hỏi user → gọi tool hoặc trả lời trực tiếp.
Pass 2+: Tool results đã có → pass-through → route tới Responder.
"""
messages = state["messages"]
has_tool_results = any(isinstance(m, ToolMessage) for m in messages)
# ── PASS 2+: Tool results back → skip LLM, go to Responder ──
if has_tool_results:
logger.info("🔄 Planner pass-through → Responder")
return {
"planner_responded": False,
"diagnostics": [],
}
# ── PASS 1: Single LLM call — analyze + tools ──
planner_messages = [SystemMessage(content=PLANNER_PROMPT)]
for msg in messages:
if not isinstance(msg, SystemMessage):
planner_messages.append(msg)
start = time.time()
response = await self.planner_with_tools.ainvoke(planner_messages, config=config)
elapsed_ms = (time.time() - start) * 1000
response_text = _extract_text(response.content)
has_tool_calls = hasattr(response, "tool_calls") and response.tool_calls
# ── Build diagnostic ──
diag: dict[str, Any] = {"elapsed_ms": round(elapsed_ms)}
if has_tool_calls:
tool_names = ', '.join(tc['name'] for tc in response.tool_calls)
diag["step"] = "planner_tool_call"
diag["label"] = "🧠 Planner AI (Tool Call)"
diag["content"] = f"Tools: {tool_names}"
diag["tool_calls"] = [
{"name": tc["name"], "args": tc["args"]}
for tc in response.tool_calls
]
else:
diag["step"] = "planner_direct"
diag["label"] = "💬 Planner AI (Direct)"
diag["content"] = response_text[:500]
# planner_responded = True → Planner hỏi lại hoặc greeting → skip Responder
is_direct = not has_tool_calls
return {
"messages": [response],
"planner_responded": is_direct,
"diagnostics": [diag],
}
# ─────────────────────────────────────
# Router: Planner → tools | responder | end
# ─────────────────────────────────────
def _after_planner(self, state: TagSearchState) -> str:
"""
Sau Planner:
- Có tool_calls → đi tools
- Planner trả lời trực tiếp (hỏi lại, greeting) → end
- Mọi trường hợp khác (sau tools loop-back) → responder
"""
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
if state.get("planner_responded"):
return "end"
return "responder"
# ─────────────────────────────────────
# Node 2: RESPONDER — Format kết quả (NO tools)
# ─────────────────────────────────────
async def _responder_node(self, state: TagSearchState, config: RunnableConfig | None = None) -> dict:
"""
Responder AI: Nhận kết quả tool, format câu trả lời tư vấn đẹp.
KHÔNG gọi tools — chỉ viết response.
"""
messages = state["messages"]
# Collect tool results for cleaner context
tool_context_parts = []
for msg in messages:
if isinstance(msg, ToolMessage):
tool_name = getattr(msg, "name", "tool")
tool_content = _extract_text(msg.content)
try:
parsed = json.loads(tool_content)
if parsed.get("status") == "success":
products = parsed.get("products", [])
if products:
product_lines = []
for p in products[:8]:
line = f"- {p.get('name', '')} | {p.get('price', '')} | SKU: {p.get('sku_code', '')}"
if p.get("url"):
line += f" | Link: {p['url']}"
product_lines.append(line)
tool_context_parts.append(
f"[{tool_name}] Tìm thấy {len(products)} sản phẩm:\n" + "\n".join(product_lines)
)
else:
tool_context_parts.append(f"[{tool_name}] {json.dumps(parsed, ensure_ascii=False)[:500]}")
else:
tool_context_parts.append(f"[{tool_name}] {parsed.get('message', tool_content[:300])}")
except Exception:
tool_context_parts.append(f"[{tool_name}] {tool_content[:500]}")
# Build responder messages
responder_messages = [SystemMessage(content=RESPONDER_PROMPT)]
# Add user's original question
for msg in messages:
if isinstance(msg, HumanMessage):
responder_messages.append(msg)
break # Only first human message (the actual query)
# Add tool results as context
if tool_context_parts:
tool_summary = "=== KẾT QUẢ TRA CỨU TỪ TOOLS ===\n" + "\n\n".join(tool_context_parts)
responder_messages.append(HumanMessage(content=tool_summary))
start = time.time()
response = await self.responder_llm.ainvoke(responder_messages, config=config)
elapsed_ms = (time.time() - start) * 1000
response_text = _extract_text(response.content)
diag = {
"step": "responder",
"label": "💬 Responder AI (Response)",
"content": response_text[:500] + ("..." if len(response_text) > 500 else ""),
"elapsed_ms": round(elapsed_ms),
}
return {
"messages": [response],
"diagnostics": [diag],
}
# ─────────────────────────────────────
# Build Graph
# ─────────────────────────────────────
def build(self):
"""Build 2-agent graph: Planner ⇄ Tools → Responder → END."""
if self._compiled:
return self._compiled
workflow = StateGraph(TagSearchState)
# 3 Nodes
workflow.add_node("planner", self._planner_node)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("responder", self._responder_node)
# Flow: Entry → Planner
workflow.set_entry_point("planner")
# Planner → (tools | responder | end)
workflow.add_conditional_edges(
"planner",
self._after_planner,
{"tools": "tools", "responder": "responder", "end": END},
)
# Tools → Planner (LOOP BACK — planner sees tool results, routes to responder)
workflow.add_edge("tools", "planner")
# Responder → END
workflow.add_edge("responder", END)
self._compiled = workflow.compile()
logger.info("✅ TagSearchGraph compiled: Planner ⇄ Tools → Responder → END")
return self._compiled
# ─────────────────────────────────────
# Main Entry Point
# ─────────────────────────────────────
async def chat(self, user_message: str, history: list[BaseMessage] | None = None, config: RunnableConfig | None = None) -> dict:
"""
Main entry point — gửi message và nhận response.
Returns pipeline trace for UI display.
"""
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
messages.append(HumanMessage(content=user_message))
initial_state = {
"messages": messages,
"planner_responded": False,
"diagnostics": [],
}
result = await graph.ainvoke(initial_state, config=config)
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
# Extract AI response (last AI message without tool_calls)
ai_response = ""
for msg in reversed(all_messages):
if isinstance(msg, AIMessage) and not (hasattr(msg, "tool_calls") and msg.tool_calls):
ai_response = _extract_text(msg.content)
break
# Build pipeline trace
pipeline = [{"step": "user", "label": "👤 User", "content": user_message}]
if result.get("diagnostics"):
for diag in result["diagnostics"]:
pipeline.append(diag)
# Parse tool results for product cards
all_products = []
for msg in all_messages:
if isinstance(msg, ToolMessage):
tool_content = _extract_text(msg.content)
summary = tool_content[:600]
raw_json = tool_content
try:
parsed = json.loads(tool_content)
if parsed.get("status") == "success":
result_count = parsed.get("count", len(parsed.get("products", [])))
tier = parsed.get("tier", 0)
tier_info = f"[Tier {tier}]" if tier > 0 else "[SKU Direct]"
summary = f"✅ Tìm thấy {result_count} sản phẩm ({parsed.get('elapsed_ms', '?')}ms) {tier_info}"
products = parsed.get("products", [])
all_products.extend(products)
if products:
names = [f"• {p.get('name', '')}" for p in products[:5]]
summary += "\n" + "\n".join(names)
if len(products) > 5:
summary += f"\n... và {len(products) - 5} sản phẩm khác"
elif parsed.get("status") == "error":
summary = f"❌ {parsed.get('message', 'Error')}"
raw_json = json.dumps(parsed, ensure_ascii=False, indent=2, default=str)
except Exception:
pass
pipeline.append({
"step": "tool_result",
"label": f"📦 Tool Result ({getattr(msg, 'name', 'tool')})",
"content": summary,
"raw_json": raw_json,
})
# Determine agent path for logging
has_tool_calls = any(
isinstance(msg, AIMessage) and hasattr(msg, "tool_calls") and msg.tool_calls
for msg in all_messages
)
agent_path = "planner→tools→responder" if has_tool_calls else "planner_only"
logger.info(
"🏷️ [TAG SEARCH] query='%s' | path=%s | products=%d | time=%.2fms",
user_message[:50], agent_path, len(all_products), elapsed_ms,
)
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"tool_calls": [
{"name": tc["name"], "args": tc["args"]}
for msg in all_messages
if isinstance(msg, AIMessage) and hasattr(msg, "tool_calls") and msg.tool_calls
for tc in msg.tool_calls
],
"agent_path": agent_path,
"pipeline": pipeline,
"products": all_products,
}
# ═══════════════════════════════════════════════
# Singleton
# ═══════════════════════════════════════════════
_instance: TagSearchGraph | None = None
def get_tag_search_agent(model_name: str | None = None) -> TagSearchGraph:
"""Get or create TagSearchGraph singleton."""
global _instance
if _instance is None:
_instance = TagSearchGraph(model_name)
return _instance
......@@ -8,10 +8,14 @@ import os
import platform
import sys
import time
import json
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from common.pool_wrapper import get_pooled_connection_compat
from config import (
AI_SUPABASE_URL,
......@@ -45,6 +49,85 @@ _SERVER_START_TIME = time.time()
APP_VERSION = "2.5.0"
APP_CODENAME = "Canifa AI Stylist"
def _ensure_links_table():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS dashboard_links (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
description TEXT,
category TEXT,
icon TEXT,
pinned INTEGER DEFAULT 0
)
""")
except Exception as e:
logger.error(f"Error creating dashboard_links table: {e}")
_ensure_links_table()
DEFAULT_TOOL_LINKS = [
{"id": "tool_stress_test", "title": "Stress Test Dashboard", "url": "/static/stress-test.html",
"description": "Bắn N request đồng thời — đo RPS, latency p95, error rate", "category": "tool", "icon": "🔥"},
{"id": "tool_regression_test", "title": "Regression Test", "url": "/static/regression-test.html",
"description": "Chạy bộ test cases qua bất kỳ chatbot URL — con người đọc kết quả", "category": "tool", "icon": "🧪"},
{"id": "tool_user_simulator", "title": "User Simulator", "url": "/static/user-simulator.html",
"description": "AI đóng vai persona test chatbot — đánh giá từ góc user thật", "category": "tool", "icon": "🎭"},
{"id": "tool_prompt_optimizer", "title": "Prompt Optimizer", "url": "/static/prompt-optimizer.html",
"description": "AI Judge chấm điểm chatbot + tự động gợi ý cải thiện prompt", "category": "tool", "icon": "⚡"},
{"id": "tool_competitor_research", "title": "Competitor Research", "url": "/static/competitor-research.html",
"description": "Nghiên cứu đối thủ — phân tích Zalando, YaMe, H&M", "category": "doc", "icon": "🔍"},
]
def _seed_default_links():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id FROM dashboard_links")
existing_ids = {r["id"] for r in cur.fetchall()}
for link in DEFAULT_TOOL_LINKS:
if link["id"] not in existing_ids:
cur.execute("""
INSERT INTO dashboard_links (id, title, url, description, category, icon, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (link["id"], link["title"], link["url"], link["description"], link["category"], link["icon"], 0))
except Exception as e:
logger.error(f"Error seeding dashboard_links: {e}")
def _load_links():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id, title, url, description, category, icon, pinned FROM dashboard_links")
rows = cur.fetchall()
return [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"description": r["description"] or "",
"category": r["category"] or "tool",
"icon": r["icon"] or "",
"pinned": bool(r["pinned"])
} for r in rows
]
except Exception as e:
logger.error(f"Error loading dashboard_links: {e}")
return []
class LinkItem(BaseModel):
title: str
url: str
description: str = ""
category: str = "tool"
icon: str = ""
pinned: bool = False
def _mask_key(key: str | None) -> str:
"""Mask API key for safe display."""
......@@ -181,3 +264,87 @@ async def dashboard_info():
"routes": routes,
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
}
# =====================================================================
# Dashboard Links API
# =====================================================================
@router.get("/api/dashboard/links", summary="Get all dashboard links")
async def get_dashboard_links():
_seed_default_links()
links = _load_links()
return {"links": links}
@router.post("/api/dashboard/links", summary="Create a new link")
async def create_dashboard_link(item: LinkItem):
link_id = str(uuid.uuid4())
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
INSERT INTO dashboard_links (id, title, url, description, category, icon, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (link_id, item.title, item.url, item.description, item.category, item.icon, 1 if item.pinned else 0))
except Exception as e:
logger.error(f"Error creating dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
new_link = item.dict()
new_link["id"] = link_id
return {"status": "success", "link": new_link}
@router.put("/api/dashboard/links/{link_id}", summary="Update a link")
async def update_dashboard_link(link_id: str, item: LinkItem):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
UPDATE dashboard_links
SET title = ?, url = ?, description = ?, category = ?, icon = ?, pinned = ?
WHERE id = ?
""", (item.title, item.url, item.description, item.category, item.icon, 1 if item.pinned else 0, link_id))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Link not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error updating dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
updated_link = item.dict()
updated_link["id"] = link_id
return {"status": "success", "link": updated_link}
@router.delete("/api/dashboard/links/{link_id}", summary="Delete a link")
async def delete_dashboard_link(link_id: str):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("DELETE FROM dashboard_links WHERE id = ?", (link_id,))
if cur.rowcount == 0:
raise HTTPException(status_code=404, detail="Link not found")
except HTTPException:
raise
except Exception as e:
logger.error(f"Error deleting dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
return {"status": "success"}
@router.patch("/api/dashboard/links/{link_id}/pin", summary="Toggle pin status")
async def toggle_pin_dashboard_link(link_id: str):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT pinned FROM dashboard_links WHERE id = ?", (link_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Link not found")
new_pin = 0 if row["pinned"] else 1
cur.execute("UPDATE dashboard_links SET pinned = ? WHERE id = ?", (new_pin, link_id))
return {"status": "success", "pinned": bool(new_pin)}
except HTTPException:
raise
except Exception as e:
logger.error(f"Error toggling pin on dashboard_link: {e}")
raise HTTPException(status_code=500, detail="Database error")
"""
FAQ Knowledge Base API
CRUD + AI variant generation for FAQ entries stored in SQLite.
"""
import logging
import uuid
import httpx
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException, Query
from pydantic import BaseModel
from typing import Optional
from common.pool_wrapper import get_pooled_connection_compat
logger = logging.getLogger(__name__)
router = APIRouter()
# ── AI Config ──
AI_BASE_URL = "http://172.16.2.210:3002/v1/chat/completions"
AI_API_KEY = "freellmapi-a858c30561987b8aa2a58343c7fe48a6a52e65cadb9df6d5"
# ── DB Setup ──
def _ensure_faqs_table():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
CREATE TABLE IF NOT EXISTS faqs (
id TEXT PRIMARY KEY,
question TEXT NOT NULL,
answer TEXT NOT NULL,
category TEXT DEFAULT 'chung',
is_variant INTEGER DEFAULT 0,
parent_id TEXT,
created_at TEXT
)
""")
except Exception as e:
logger.error(f"Error creating faqs table: {e}")
_ensure_faqs_table()
# ── Pydantic Models ──
class FaqItem(BaseModel):
question: str
answer: str
category: str = "chung"
class GenerateVariantsRequest(BaseModel):
faq_id: str
count: int = 20
class BatchGenerateRequest(BaseModel):
category: Optional[str] = None
count_per_faq: int = 10
# ── CRUD ──
@router.get("/api/faqs", summary="List FAQs")
async def list_faqs(
category: Optional[str] = None,
search: Optional[str] = None,
variants: bool = True,
page: int = Query(1, ge=1),
limit: int = Query(50, ge=1, le=500),
):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
where, params = [], []
if category:
where.append("category = ?"); params.append(category)
if search:
where.append("question LIKE ?"); params.append(f"%{search}%")
if not variants:
where.append("is_variant = 0")
clause = ("WHERE " + " AND ".join(where)) if where else ""
cur.execute(f"SELECT COUNT(*) as cnt FROM faqs {clause}", params)
total = cur.fetchone()["cnt"]
offset = (page - 1) * limit
cur.execute(f"""
SELECT id, question, answer, category, is_variant, parent_id, created_at
FROM faqs {clause} ORDER BY created_at DESC LIMIT ? OFFSET ?
""", params + [limit, offset])
rows = [dict(r) for r in cur.fetchall()]
return {"faqs": rows, "total": total, "page": page, "limit": limit}
except Exception as e:
logger.error(f"Error listing faqs: {e}")
raise HTTPException(500, "Database error")
@router.post("/api/faqs", summary="Create FAQ")
async def create_faq(item: FaqItem):
faq_id = str(uuid.uuid4())[:8]
now = datetime.now(tz=timezone.utc).isoformat()
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("""
INSERT INTO faqs (id, question, answer, category, is_variant, parent_id, created_at)
VALUES (?, ?, ?, ?, 0, NULL, ?)
""", (faq_id, item.question, item.answer, item.category, now))
return {"status": "success", "id": faq_id}
except Exception as e:
logger.error(f"Error creating faq: {e}")
raise HTTPException(500, "Database error")
@router.put("/api/faqs/{faq_id}", summary="Update FAQ")
async def update_faq(faq_id: str, item: FaqItem):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("UPDATE faqs SET question=?, answer=?, category=? WHERE id=?",
(item.question, item.answer, item.category, faq_id))
if cur.rowcount == 0:
raise HTTPException(404, "FAQ not found")
return {"status": "success"}
except HTTPException: raise
except Exception as e:
logger.error(f"Error updating faq: {e}")
raise HTTPException(500, "Database error")
@router.delete("/api/faqs/{faq_id}", summary="Delete FAQ")
async def delete_faq(faq_id: str):
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("DELETE FROM faqs WHERE parent_id = ?", (faq_id,))
cur.execute("DELETE FROM faqs WHERE id = ?", (faq_id,))
if cur.rowcount == 0:
raise HTTPException(404, "FAQ not found")
return {"status": "success"}
except HTTPException: raise
except Exception as e:
logger.error(f"Error deleting faq: {e}")
raise HTTPException(500, "Database error")
# ── Stats ──
@router.get("/api/faqs/stats", summary="FAQ statistics")
async def faq_stats():
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) as total FROM faqs")
total = cur.fetchone()["total"]
cur.execute("SELECT COUNT(*) as cnt FROM faqs WHERE is_variant = 0")
originals = cur.fetchone()["cnt"]
cur.execute("SELECT category, COUNT(*) as cnt FROM faqs GROUP BY category ORDER BY cnt DESC")
by_cat = [dict(r) for r in cur.fetchall()]
return {"total": total, "originals": originals, "variants": total - originals, "by_category": by_cat}
except Exception as e:
logger.error(f"Error getting faq stats: {e}")
raise HTTPException(500, "Database error")
# ── AI Variant Generation ──
async def _call_ai(prompt: str) -> str:
async with httpx.AsyncClient(timeout=60) as client:
resp = await client.post(AI_BASE_URL, json={
"model": "gpt-4.1-mini",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.8,
}, headers={"Authorization": f"Bearer {AI_API_KEY}"})
resp.raise_for_status()
return resp.json()["choices"][0]["message"]["content"]
@router.post("/api/faqs/generate-variants", summary="AI generate question variants")
async def generate_variants(req: GenerateVariantsRequest):
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id, question, answer, category FROM faqs WHERE id = ?", (req.faq_id,))
faq = cur.fetchone()
if not faq:
raise HTTPException(404, "FAQ not found")
prompt = f"""Bạn là chuyên gia ngôn ngữ. Từ câu hỏi gốc dưới đây, hãy sinh ra {req.count} biến thể câu hỏi khác nhau mà khách hàng Canifa có thể hỏi.
Mỗi biến thể trên 1 dòng, không đánh số, không giải thích thêm.
Yêu cầu: đa dạng phong cách (formal, casual, viết tắt, có typo nhẹ, dùng emoji), giữ nguyên ý nghĩa.
Câu hỏi gốc: {faq['question']}
Danh mục: {faq['category']}"""
try:
result = await _call_ai(prompt)
variants = [line.strip() for line in result.strip().split("\n") if line.strip() and len(line.strip()) > 5]
now = datetime.now(tz=timezone.utc).isoformat()
inserted = 0
conn2 = get_pooled_connection_compat()
with conn2.cursor() as cur2:
for v in variants:
vid = str(uuid.uuid4())[:8]
cur2.execute("""
INSERT INTO faqs (id, question, answer, category, is_variant, parent_id, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
""", (vid, v, faq["answer"], faq["category"], faq["id"], now))
inserted += 1
return {"status": "success", "inserted": inserted, "variants": variants}
except Exception as e:
logger.error(f"Error generating variants: {e}")
raise HTTPException(500, f"AI generation error: {str(e)}")
@router.post("/api/faqs/batch-generate", summary="Batch generate variants for all originals")
async def batch_generate(req: BatchGenerateRequest):
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
where = "WHERE is_variant = 0"
params = []
if req.category:
where += " AND category = ?"
params.append(req.category)
cur.execute(f"SELECT id, question, answer, category FROM faqs {where}", params)
originals = [dict(r) for r in cur.fetchall()]
total_inserted = 0
errors = []
for faq in originals:
try:
prompt = f"""Sinh ra {req.count_per_faq} biến thể câu hỏi từ: "{faq['question']}"
Mỗi biến thể 1 dòng, không đánh số. Đa dạng phong cách."""
result = await _call_ai(prompt)
variants = [l.strip() for l in result.strip().split("\n") if l.strip() and len(l.strip()) > 5]
now = datetime.now(tz=timezone.utc).isoformat()
conn2 = get_pooled_connection_compat()
with conn2.cursor() as cur2:
for v in variants:
vid = str(uuid.uuid4())[:8]
cur2.execute("""
INSERT INTO faqs (id, question, answer, category, is_variant, parent_id, created_at)
VALUES (?, ?, ?, ?, 1, ?, ?)
""", (vid, v, faq["answer"], faq["category"], faq["id"], now))
total_inserted += 1
except Exception as e:
errors.append({"faq_id": faq["id"], "error": str(e)})
return {"status": "success", "originals_processed": len(originals), "total_variants_inserted": total_inserted, "errors": errors}
# ── Simulator Match ──
class SimulateMatchRequest(BaseModel):
query: str
@router.post("/api/faqs/simulate-match", summary="Simulate User Search matching FAQs")
async def simulate_match_endpoint(req: SimulateMatchRequest):
from common.match_algo import find_best_matches
import time
start_t = time.time()
try:
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT id, question, answer, category, is_variant FROM faqs")
rows = cur.fetchall()
faq_list = []
for r in rows:
faq_list.append({
"id": r["id"],
"question": r["question"],
"answer": r["answer"],
"category": r["category"],
"is_variant": r["is_variant"]
})
matches = find_best_matches(req.query, faq_list, top_k=3)
elapsed_ms = int((time.time() - start_t) * 1000)
return {
"query": req.query,
"matches": matches,
"elapsed_ms": elapsed_ms
}
except Exception as e:
logger.error(f"Error in simulate-match: {e}")
raise HTTPException(500, str(e))
"""
Lead Flow Simulate — SSE endpoint cho Interactive Logic Flow Simulator.
Nhận query text → Chạy Classifier → Stream từng tier cascade search qua SSE.
Frontend nhận events để animate từng node trên sơ đồ logic.
"""
import asyncio
import json
import logging
import time
from fastapi import APIRouter, Query
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from common.starrocks_connection import get_db_connection
from agent.lead_stage_agent.lead_search_tool import (
LeadSearchInput,
_build_fixed_clauses,
_build_search_clause,
_build_tag_clauses,
_build_exclusion_clauses,
_build_full_query,
_enrich_with_stock,
)
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/lead-flow", tags=["Lead Flow Simulate"])
def _sse_event(event: str, data: dict) -> str:
"""Format SSE event string."""
payload = json.dumps(data, ensure_ascii=False, default=str)
return f"event: {event}\ndata: {payload}\n\n"
async def _run_tier_query(db, sql: str, params: tuple) -> list:
"""Execute a single tier query safely."""
try:
return await db.execute_query_async(sql, params=params)
except Exception as e:
logger.error("Tier query error: %s", e)
return []
async def _simulate_stream(query: str):
"""Generator that yields SSE events for each step of the search pipeline."""
t_global = time.time()
# ── Step 0: Start ──
yield _sse_event("start", {"query": query, "timestamp": time.time()})
await asyncio.sleep(0.05)
# ── Step 1: Classifier ──
yield _sse_event("status", {"step": "classifier", "state": "running"})
t0 = time.time()
try:
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
from agent.lead_stage_agent.graph import ClassifierOutput, ClassifierLeadSearchArgs
from agent.lead_stage_agent.prompts import LEAD_STAGE_CLASSIFIER_PROMPT
from langchain_core.messages import SystemMessage, HumanMessage
_base = create_llm(model_name=DEFAULT_MODEL, streaming=False)
classifier_llm = _base.with_structured_output(ClassifierOutput, method="function_calling")
classifier_messages = [
SystemMessage(content=LEAD_STAGE_CLASSIFIER_PROMPT),
HumanMessage(content=f"=== TIN NHẮN HIỆN TẠI ===\n{query}\n\n=== USER INSIGHT ===\nChưa có thông tin.\n\n=== LỊCH SỬ ===\nChưa có lịch sử."),
]
output: ClassifierOutput = await classifier_llm.ainvoke(classifier_messages)
classifier_ms = round((time.time() - t0) * 1000)
# Parse args
if output.tool_name == "lead_search_tool" and output.lead_search_args:
args_dict = output.lead_search_args.model_dump(exclude_none=False)
elif output.tool_name == "lead_search_tool" and output.tool_args:
args_dict = output.tool_args
else:
args_dict = {}
classifier_data = {
"reasoning": output.reasoning,
"tool_name": output.tool_name,
"ai_response": output.ai_response,
"args": args_dict,
"elapsed_ms": classifier_ms,
}
# Early exit
if not output.tool_name:
yield _sse_event("classifier", {**classifier_data, "early_exit": True})
yield _sse_event("done", {
"early_exit": True,
"ai_response": output.ai_response,
"total_ms": round((time.time() - t_global) * 1000),
})
return
yield _sse_event("classifier", {**classifier_data, "early_exit": False})
await asyncio.sleep(0.05)
except Exception as e:
logger.error("Classifier error: %s", e, exc_info=True)
yield _sse_event("classifier", {
"error": str(e)[:300],
"elapsed_ms": round((time.time() - t0) * 1000),
})
yield _sse_event("done", {"error": True, "total_ms": round((time.time() - t_global) * 1000)})
return
# ── Step 2: Build LeadSearchInput ──
try:
req = LeadSearchInput(**{k: v for k, v in args_dict.items() if k in LeadSearchInput.model_fields})
except Exception:
req = LeadSearchInput()
yield _sse_event("status", {"step": "cascade", "state": "running"})
await asyncio.sleep(0.05)
# ── Step 3: Cascade search — ALL tiers ──
db = get_db_connection()
ex_params_base = []
exclusions = _build_exclusion_clauses(req.keywords, ex_params_base)
hit_tier = None
hit_products = []
# Tier 1: Fixed + Keywords
t1 = time.time()
tier1_products = []
tier1_sql = ""
if req.keywords:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_search_clause(req.keywords, params)
if search:
tier1_sql = _build_full_query(fixed, search, exclusions)
tier1_products = await _run_tier_query(db, tier1_sql, tuple(params + ex_params_base))
tier1_ms = round((time.time() - t1) * 1000)
tier1_hit = len(tier1_products) > 0
if tier1_hit and hit_tier is None:
hit_tier = 1
hit_products = tier1_products
yield _sse_event("tier", {
"tier": 1,
"label": "Fixed + Keywords (NGRAMBF)",
"count": len(tier1_products),
"hit": tier1_hit,
"is_winner": hit_tier == 1,
"elapsed_ms": tier1_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier1_products[:5]],
"filters_used": {"keywords": req.keywords},
})
await asyncio.sleep(0.1)
# Tier 2: Fixed + Tags
t2 = time.time()
tier2_products = []
if req.tags:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_tag_clauses(req.tags, params)
if search:
sql = _build_full_query(fixed, search, exclusions)
tier2_products = await _run_tier_query(db, sql, tuple(params + ex_params_base))
tier2_ms = round((time.time() - t2) * 1000)
tier2_hit = len(tier2_products) > 0
if tier2_hit and hit_tier is None:
hit_tier = 2
hit_products = tier2_products
yield _sse_event("tier", {
"tier": 2,
"label": "Fixed + Tags (BITMAP)",
"count": len(tier2_products),
"hit": tier2_hit,
"is_winner": hit_tier == 2,
"elapsed_ms": tier2_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier2_products[:5]],
"filters_used": {"tags": req.tags},
})
await asyncio.sleep(0.1)
# Tier 3: Fixed Only
t3 = time.time()
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
tier3_products = await _run_tier_query(db, sql, tuple(params + ex_params_base))
tier3_ms = round((time.time() - t3) * 1000)
tier3_hit = len(tier3_products) > 0
if tier3_hit and hit_tier is None:
hit_tier = 3
hit_products = tier3_products
yield _sse_event("tier", {
"tier": 3,
"label": "Fixed Only (type+color+gender+price)",
"count": len(tier3_products),
"hit": tier3_hit,
"is_winner": hit_tier == 3,
"elapsed_ms": tier3_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier3_products[:5]],
"filters_used": {"fixed_only": True},
})
await asyncio.sleep(0.1)
# Tier 4: Drop gender
t4 = time.time()
tier4_products = []
if req.product_line_vn and req.gender_by_product:
saved = req.gender_by_product
req.gender_by_product = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
tier4_products = await _run_tier_query(db, sql, tuple(params + ex_params_base))
req.gender_by_product = saved
tier4_ms = round((time.time() - t4) * 1000)
tier4_hit = len(tier4_products) > 0
if tier4_hit and hit_tier is None:
hit_tier = 4
hit_products = tier4_products
yield _sse_event("tier", {
"tier": 4,
"label": "Drop Gender",
"count": len(tier4_products),
"hit": tier4_hit,
"is_winner": hit_tier == 4,
"elapsed_ms": tier4_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier4_products[:5]],
"filters_used": {"dropped": "gender_by_product"},
})
await asyncio.sleep(0.1)
# Tier 5: Price ×1.5
t5 = time.time()
tier5_products = []
if req.price_max is not None and req.product_line_vn:
saved_max = req.price_max
saved_min = req.price_min
req.price_max = int(req.price_max * 1.5)
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
tier5_products = await _run_tier_query(db, sql, tuple(params))
req.price_max = saved_max
req.price_min = saved_min
tier5_ms = round((time.time() - t5) * 1000)
tier5_hit = len(tier5_products) > 0
if tier5_hit and hit_tier is None:
hit_tier = 5
hit_products = tier5_products
yield _sse_event("tier", {
"tier": 5,
"label": "Price Relaxation ×1.5",
"count": len(tier5_products),
"hit": tier5_hit,
"is_winner": hit_tier == 5,
"elapsed_ms": tier5_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier5_products[:5]],
"filters_used": {"price_max_relaxed": int((req.price_max or 0) * 1.5)},
})
await asyncio.sleep(0.1)
# Tier 6: Price ×2.0
t6 = time.time()
tier6_products = []
if req.price_max is not None and req.product_line_vn:
saved_max = req.price_max
saved_min = req.price_min
req.price_max = int(req.price_max * 2.0)
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
tier6_products = await _run_tier_query(db, sql, tuple(params))
req.price_max = saved_max
req.price_min = saved_min
tier6_ms = round((time.time() - t6) * 1000)
tier6_hit = len(tier6_products) > 0
if tier6_hit and hit_tier is None:
hit_tier = 6
hit_products = tier6_products
yield _sse_event("tier", {
"tier": 6,
"label": "Price Relaxation ×2.0",
"count": len(tier6_products),
"hit": tier6_hit,
"is_winner": hit_tier == 6,
"elapsed_ms": tier6_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier6_products[:5]],
"filters_used": {"price_max_relaxed": int((req.price_max or 0) * 2.0)},
})
await asyncio.sleep(0.1)
# Tier 7: Drop price
t7 = time.time()
tier7_products = []
if req.price_max is not None:
saved_max = req.price_max
saved_min = req.price_min
req.price_max = None
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
tier7_products = await _run_tier_query(db, sql, tuple(params + ex_params_base))
req.price_max = saved_max
req.price_min = saved_min
tier7_ms = round((time.time() - t7) * 1000)
tier7_hit = len(tier7_products) > 0
if tier7_hit and hit_tier is None:
hit_tier = 7
hit_products = tier7_products
yield _sse_event("tier", {
"tier": 7,
"label": "Drop Price (no price filter)",
"count": len(tier7_products),
"hit": tier7_hit,
"is_winner": hit_tier == 7,
"elapsed_ms": tier7_ms,
"products": [{"name": p.get("product_name", ""), "sku": p.get("internal_ref_code", ""), "price": p.get("sale_price", 0)} for p in tier7_products[:5]],
"filters_used": {"dropped": "price_min, price_max"},
})
await asyncio.sleep(0.1)
# ── Step 4: Stock Enrichment ──
yield _sse_event("status", {"step": "stock", "state": "running"})
stock_data = {"in_stock": 0, "dropped": 0, "timed_out": False, "elapsed_ms": 0}
if hit_products:
try:
in_stock_list, filtered, stock_ms, timed_out = await _enrich_with_stock(hit_products[:10])
stock_data = {
"in_stock": len(in_stock_list),
"dropped": len(hit_products[:10]) - len(in_stock_list),
"timed_out": timed_out,
"elapsed_ms": round(stock_ms),
}
except Exception as e:
stock_data["error"] = str(e)[:200]
yield _sse_event("stock", stock_data)
await asyncio.sleep(0.05)
# ── Done ──
total_ms = round((time.time() - t_global) * 1000)
yield _sse_event("done", {
"hit_tier": hit_tier,
"total_products": len(hit_products),
"total_ms": total_ms,
})
@router.get("/simulate", summary="SSE Simulate Lead Search Pipeline")
async def simulate_lead_search(query: str = Query(..., description="User query text")):
"""
SSE endpoint — stream từng bước của lead search pipeline.
Frontend dùng EventSource để nhận events realtime.
"""
return StreamingResponse(
_simulate_stream(query),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
......@@ -9,8 +9,6 @@ from api.common.feedback_route import router as feedback_router
from api.feedback_agent.feedback_agent_route import router as feedback_agent_router
from api.diagram_agent.ai_diagram_route import router as diagram_router
from api.sku_search.ai_answer_sku import router as sku_search_router
from api.tag_search.ai_tag_search import router as tag_search_router
# History & Auth
from api.history.check_history_route import router as check_history_router
from api.history.conservation_route import router as conservation_router
......@@ -38,6 +36,7 @@ from api.api_sql.user_insight_route import router as user_insight_router
# Dashboards & Monitoring
from api.common.dashboard_route import router as dashboard_router
from api.common.faq_route import router as faq_router
from api.cache.cache_route import router as cache_router
from api.live_monitor.live_monitor_route import router as live_monitor_router
from api.ai_report.report_html_route import router as report_html_router
......@@ -61,6 +60,7 @@ from api.regression_test.regression_test_route import router as regression_test_
from api.stress_test.stress_test_route import router as stress_test_router
from api.reaction_simulator.reaction_simulator_route import router as reaction_simulator_router
from api.lead_flow.lead_flow_route import router as lead_flow_router
from api.lead_flow.simulate_route import router as lead_flow_simulate_router
from api.limit.limit_route import router as limit_router
# -------------------------------------------------------------------------
......@@ -77,8 +77,6 @@ api_router.include_router(feedback_router)
api_router.include_router(feedback_agent_router)
api_router.include_router(diagram_router)
api_router.include_router(sku_search_router)
api_router.include_router(tag_search_router)
# History & Auth
api_router.include_router(check_history_router)
api_router.include_router(conservation_router)
......@@ -106,6 +104,7 @@ api_router.include_router(user_insight_router)
# Dashboards & Monitoring
api_router.include_router(dashboard_router)
api_router.include_router(faq_router)
api_router.include_router(cache_router)
api_router.include_router(live_monitor_router)
api_router.include_router(report_html_router)
......@@ -129,4 +128,5 @@ api_router.include_router(regression_test_router)
api_router.include_router(stress_test_router)
api_router.include_router(reaction_simulator_router)
api_router.include_router(lead_flow_router)
api_router.include_router(lead_flow_simulate_router)
api_router.include_router(limit_router)
......@@ -27,25 +27,11 @@ logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/product-desc", tags=["n8n Ultra Description"])
# ── Groq config (reuse tu agent) ──
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_idx = 0
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
WRITER_MODEL = "openai/gpt-oss-120b"
# ── FreeLLMAPI config ──
FREE_LLM_API_KEY = "freellmapi-a858c30561987b8aa2a58343c7fe48a6a52e65cadb9df6d5"
def _next_key():
global _groq_idx
key = GROQ_API_KEYS[_groq_idx % len(GROQ_API_KEYS)]
_groq_idx += 1
return key
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
WRITER_MODEL = "openai/gpt-oss-20b"
# ── Helpers ──
......@@ -83,7 +69,7 @@ def _extract_json(raw) -> dict | None:
async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
temperature: float = 0.7, is_vision: bool = False) -> str:
"""Round-robin Groq call voi fallback chain."""
"""FreeLLMAPI call voi fallback chain."""
fallbacks = [model]
if not is_vision:
if model != "openai/gpt-oss-120b":
......@@ -92,13 +78,12 @@ async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
fallbacks.append("openai/gpt-oss-20b")
for m in fallbacks:
for _ in range(len(GROQ_API_KEYS)):
key = _next_key()
for _ in range(2): # 2 retries
try:
async with httpx.AsyncClient(timeout=45) as c:
resp = await c.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={"Authorization": f"Bearer {key}",
"http://172.16.2.210:3002/v1/chat/completions",
headers={"Authorization": f"Bearer {FREE_LLM_API_KEY}",
"Content-Type": "application/json"},
json={"model": m, "max_tokens": max_tokens,
"temperature": temperature, "messages": messages},
......@@ -108,15 +93,16 @@ async def _groq_call(messages: list, model: str, max_tokens: int = 2000,
if "error" not in data:
return data["choices"][0]["message"]["content"]
elif resp.status_code == 429:
continue # rotate key
await asyncio.sleep(1)
continue # retry
else:
break # loi khac -> thu model tiep
except Exception as e:
logger.warning(f"Groq error ({m}): {e}")
logger.warning(f"FreeLLMAPI error ({m}): {e}")
break
await asyncio.sleep(2)
raise RuntimeError("All Groq models/keys exhausted")
raise RuntimeError("All FreeLLMAPI models/retries exhausted")
# ═══════════════════════════════════════════════════════════════
......
......@@ -513,7 +513,11 @@ async def product_desc_list(
p["desc_status"] = code_status_map[magento_code] # 0 = pending, 1 = approved
row = UltraDescriptionDB.get_by_magento_code(magento_code)
if row:
p["tags"] = row.get("tags") or []
tags_raw = row.get("tags") or []
if isinstance(tags_raw, str):
try: tags_raw = json.loads(tags_raw)
except: tags_raw = []
p["tags"] = tags_raw
dd = row.get("description_data", {})
if isinstance(dd, str):
try: dd = json.loads(dd)
......
"""
Tag Search API — FastAPI route cho tìm kiếm sản phẩm bằng AI tags.
Gọi TagSearchGraph 2-Agent (Planner ⇄ Tool → Responder) qua Controller.
Controller có Langfuse tracing + timing breakdown.
History lưu Redis, tự expire sau 30 phút.
"""
import json
import logging
from pydantic import BaseModel
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
from langchain_core.messages import AIMessage, HumanMessage
# ── Controller v2 (có Langfuse tracing + timing) ──
from agent.tag_search_agent_v2.controller import tag_search_chat_controller
from common.cache import redis_cache
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tag-search", tags=["Tag Search"])
HISTORY_KEY_PREFIX = "tagsearch:hist:"
HISTORY_TTL = 1800 # 30 phút tự chết
class TagChatRequest(BaseModel):
query: str
session_id: str | None = None
# ─── Helpers: serialize/deserialize LangChain messages ↔ Redis ───
def _serialize_messages(messages: list) -> str:
"""LangChain messages → JSON string cho Redis."""
data = []
for msg in messages:
content = msg.content if isinstance(msg.content, str) else str(msg.content)
if isinstance(msg, HumanMessage):
data.append({"role": "human", "content": content})
elif isinstance(msg, AIMessage):
data.append({"role": "ai", "content": content})
return json.dumps(data, ensure_ascii=False)
def _deserialize_messages(raw: str) -> list:
"""JSON string từ Redis → LangChain messages."""
try:
data = json.loads(raw)
except Exception:
return []
messages = []
for item in data:
if item["role"] == "human":
messages.append(HumanMessage(content=item["content"]))
elif item["role"] == "ai":
messages.append(AIMessage(content=item["content"]))
return messages
async def _load_history(session_id: str) -> list:
"""Load history từ Redis. Trả [] nếu không có hoặc Redis tắt."""
try:
client = redis_cache.get_client()
if not client:
return []
raw = await client.get(f"{HISTORY_KEY_PREFIX}{session_id}")
if raw:
msgs = _deserialize_messages(raw)
logger.debug("📜 Loaded %d history messages for session %s", len(msgs), session_id[:8])
return msgs
except Exception as e:
logger.warning("Redis load history error: %s", e)
return []
async def _save_history(session_id: str, messages: list):
"""Save history vào Redis với TTL. Tự chết sau HISTORY_TTL."""
try:
client = redis_cache.get_client()
if not client:
return
# Giới hạn 20 cặp cuối (40 messages) để không phình Redis
trimmed = messages[-40:]
raw = _serialize_messages(trimmed)
await client.setex(f"{HISTORY_KEY_PREFIX}{session_id}", HISTORY_TTL, raw)
logger.debug("💾 Saved %d messages for session %s (TTL=%ds)", len(trimmed), session_id[:8], HISTORY_TTL)
except Exception as e:
logger.warning("Redis save history error: %s", e)
async def _clear_history(session_id: str):
"""Xoá history khỏi Redis."""
try:
client = redis_cache.get_client()
if client:
await client.delete(f"{HISTORY_KEY_PREFIX}{session_id}")
logger.info("🗑️ Cleared history for session %s", session_id[:8])
except Exception as e:
logger.warning("Redis clear history error: %s", e)
# ─── Endpoints ───
@router.post("/chat", summary="Chat with Tag Search Agent")
async def tag_chat(req: TagChatRequest, background_tasks: BackgroundTasks):
"""
Gửi câu hỏi cho Tag Search Agent.
Nếu có session_id, history được load/save tự động từ Redis.
"""
query = req.query.strip()
if not query:
return JSONResponse(status_code=400, content={"status": "error", "message": "Query trống"})
session_id = req.session_id
try:
# Load history từ Redis (nếu có session)
history = []
if session_id:
history = await _load_history(session_id)
# Delegate to controller — có Langfuse tracing + timing
result = await tag_search_chat_controller(
query=query,
session_id=session_id,
background_tasks=background_tasks,
history=history if history else None,
)
if result.get("status") == "error":
return JSONResponse(
status_code=500,
content={
"status": "error",
"message": result.get("message", "Unknown error"),
"trace_id": result.get("trace_id"),
}
)
# Save history to Redis (append user + AI)
if session_id and result.get("response"):
history.append(HumanMessage(content=query))
history.append(AIMessage(content=result["response"]))
await _save_history(session_id, history)
return {
"status": "success",
"response": result["response"],
"products": result.get("products", []),
"product_ids": result.get("product_ids", []),
"product_count": result.get("product_count", 0),
"pipeline": result.get("pipeline", []),
"agent_path": result.get("agent_path", ""),
"tool_calls": result.get("tool_calls", []),
"trace_id": result.get("trace_id"),
"timing": result.get("timing", {}),
"session_id": session_id,
"history_count": len(history),
}
except Exception as e:
logger.error(f"❌ Tag Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.post("/clear", summary="Clear chat history")
async def tag_clear(req: TagChatRequest):
"""Xoá history của session."""
if req.session_id:
await _clear_history(req.session_id)
return {"status": "success", "message": "History cleared"}
@router.get("/history", summary="Get chat history")
async def tag_history(session_id: str):
"""Load history từ Redis theo session_id cho frontend (khi f5 reload lại)."""
if not session_id:
return {"status": "success", "history": []}
history_objs = await _load_history(session_id)
formatted = []
for msg in history_objs:
if isinstance(msg, HumanMessage):
formatted.append({"role": "user", "content": msg.content})
elif isinstance(msg, AIMessage):
formatted.append({"role": "ai", "content": msg.content})
return {"status": "success", "history": formatted, "count": len(formatted)}
import unicodedata
import difflib
from typing import List, Dict
def normalize_text(text: str) -> str:
"""Loại bỏ dấu tiếng Việt, chuyển chữ thường, bỏ ký tự đặc biệt."""
if not text:
return ""
text = str(text).lower()
# Remove accents
text = unicodedata.normalize('NFD', text).encode('ascii', 'ignore').decode('utf-8')
# Keep only alphanumeric and space
text = "".join(c for c in text if c.isalnum() or c.isspace())
return " ".join(text.split())
def calculate_similarity(query: str, document: str) -> float:
"""Tính điểm tương đồng kết hợp Keyword Overlap (70%) và Fuzzy Match (30%)."""
norm_q = normalize_text(query)
norm_d = normalize_text(document)
if not norm_q or not norm_d:
return 0.0
# 1. Fuzzy Match (bù trừ sai chính tả dính liền, gõ tắt)
fuzzy_ratio = difflib.SequenceMatcher(None, norm_q, norm_d).ratio()
# 2. Word Overlap
q_words = set(norm_q.split())
d_words = set(norm_d.split())
if not q_words:
return 0.0
intersection = q_words.intersection(d_words)
overlap_score = len(intersection) / len(q_words) # % từ của query xuất hiện trong document
# Tính điểm
final_score = (overlap_score * 0.7) + (fuzzy_ratio * 0.3)
return round(final_score * 100, 2)
def find_best_matches(query: str, faq_list: List[Dict], top_k: int = 3) -> List[Dict]:
"""Tìm Top K FAQ khớp nhất. faq_list = [{"id": ..., "question": ..., "answer": ...}, ...]"""
results = []
for faq in faq_list:
q_text = faq.get("question", "")
if not q_text:
continue
score = calculate_similarity(query, q_text)
# Nếu điểm > 0, đưa vào ds
if score > 5.0: # Ngưỡng thấp để lọc
results.append({
"id": faq.get("id"),
"question": q_text,
"answer": faq.get("answer", ""),
"category": faq.get("category", ""),
"is_variant": faq.get("is_variant", 0),
"score": score
})
# Sort giảm dần theo điểm
results.sort(key=lambda x: x["score"], reverse=True)
return results[:top_k]
......@@ -36,7 +36,7 @@ class UltraDescriptionDB:
cur = conn.cursor()
if USE_LOCAL_SQLITE:
# SQLite: Create full table at once with all columns
# SQLite: execute each statement separately (SQLite only allows one at a time)
cur.execute(f"""
CREATE TABLE IF NOT EXISTS {TABLE} (
id INTEGER PRIMARY KEY AUTOINCREMENT,
......@@ -54,11 +54,11 @@ class UltraDescriptionDB:
ai_matches TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ud_internal ON {TABLE} (internal_ref_code);
CREATE INDEX IF NOT EXISTS idx_ud_magento ON {TABLE} (magento_ref_code);
CREATE INDEX IF NOT EXISTS idx_ud_base ON {TABLE} (base_ref_code);
)
""")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_internal ON {TABLE} (internal_ref_code)")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_magento ON {TABLE} (magento_ref_code)")
cur.execute(f"CREATE INDEX IF NOT EXISTS idx_ud_base ON {TABLE} (base_ref_code)")
else:
# PostgreSQL: Original logic
cur.execute(f"""
......@@ -403,7 +403,7 @@ class UltraDescriptionDB:
COUNT(*) AS total,
COUNT(CASE WHEN status = 1 THEN 1 END) AS approved,
COUNT(CASE WHEN status = 0 THEN 1 END) AS pending,
COUNT(CASE WHEN clean_description IS NOT NULL AND BTRIM(clean_description) != '' THEN 1 END) AS has_clean_desc,
COUNT(CASE WHEN clean_description IS NOT NULL AND TRIM(clean_description) != '' THEN 1 END) AS has_clean_desc,
COUNT(CASE WHEN phase = 'enriched' THEN 1 END) AS enriched,
COUNT(CASE WHEN phase = 'raw' THEN 1 END) AS raw_only,
MIN(created_at) AS first_created,
......
# Semantic Cache Performance Comparison
## Current Implementation vs Optimized
### ❌ Current Problem (Version A - No Index)
```python
# Scan ALL cache keys (O(n) complexity)
async for key in redis.scan_iter(match=f"semantic_cache:{user_id}:*"):
cache_keys.append(key)
# Calculate cosine similarity with EACH entry
for cache_key in cache_keys:
similarity = cosine_similarity(query_embedding, cached_embedding)
```
**Performance:**
- 10 cached queries: ~20ms
- 100 cached queries: ~150ms
- 1,000 cached queries: ~1,500ms (1.5s!) ❌
- 10,000 cached queries: ~15,000ms (15s!) ❌❌❌
**Bottleneck**: Linear scan + manual cosine calculation
---
### ✅ Optimized Solution (Version B - With Vector Index)
#### **Option 1: Redis VSS (RediSearch Module)**
```python
# Create vector index (one-time setup)
await redis.ft("cache_idx").create_index([
VectorField("embedding",
"HNSW", # Hierarchical Navigable Small World
{
"TYPE": "FLOAT32",
"DIM": 1536,
"DISTANCE_METRIC": "COSINE"
}
),
TextField("user_id"),
TextField("query"),
TextField("response")
])
# Search with KNN (K-Nearest Neighbors)
results = await redis.ft("cache_idx").search(
Query(f"@user_id:{user_id} *=>[KNN 1 @embedding $vec AS score]")
.sort_by("score")
.return_fields("query", "response", "product_ids", "score")
.dialect(2),
query_params={"vec": np.array(query_embedding).astype(np.float32).tobytes()}
)
if results.docs and results.docs[0].score >= similarity_threshold:
return results.docs[0] # CACHE HIT in ~5-10ms!
```
**Performance:**
- 10 cached queries: ~5ms
- 100 cached queries: ~8ms
- 1,000 cached queries: ~12ms
- 10,000 cached queries: ~15ms
- 1,000,000 cached queries: ~20ms ✅✅✅
**Speedup**: **100-1000X faster** with large cache!
---
#### **Option 2: Upstash Vector (Managed Service)**
```python
from upstash_vector import Index
# Initialize Upstash Vector
vector_index = Index(
url=os.getenv("UPSTASH_VECTOR_URL"),
token=os.getenv("UPSTASH_VECTOR_TOKEN")
)
# Store cache entry
await vector_index.upsert(
vectors=[{
"id": f"{user_id}:{query_hash}",
"vector": query_embedding,
"metadata": {
"query": query,
"response": response,
"product_ids": product_ids,
"user_id": user_id,
"timestamp": int(time.time())
}
}]
)
# Search (FAST with HNSW index)
results = await vector_index.query(
vector=query_embedding,
top_k=1,
filter=f"user_id = '{user_id}'", # Filter by user
include_metadata=True
)
if results and results[0].score >= similarity_threshold:
return results[0].metadata # CACHE HIT!
```
**Performance**: Similar to Redis VSS (~5-20ms)
**Pros:**
- ✅ Managed service (no setup)
- ✅ Built for vector search
- ✅ Automatic scaling
**Cons:**
- ❌ Additional cost (~$10/month for 100K vectors)
- ❌ External dependency
- ❌ Network latency
---
## 🎯 Recommendation for Canifa
### **Short-term (Now)**: Keep Current Implementation
- Works with existing Redis
- Good enough for <100 cached queries per user
- No additional setup needed
### **Long-term (When cache grows)**: Upgrade to Redis VSS
**When to upgrade?**
- Cache hit lookup time > 100ms
- Users have >100 cached queries
- Cache size > 10,000 entries
---
## 🔧 How to Check Redis Version
```bash
# Check if Redis supports vector search
redis-cli -h 172.16.2.192 -p 6379 INFO modules
# Look for:
# module:name=search,ver=20612 ← RediSearch module installed ✅
```
If you have RediSearch module, we can upgrade to Version B!
---
## 📊 Comparison Table
| Metric | Current (No Index) | Redis VSS | Upstash Vector |
|--------|-------------------|-----------|----------------|
| **Setup Complexity** | ⭐ Simple | ⭐⭐⭐ Complex | ⭐⭐ Medium |
| **Performance (10 entries)** | 20ms | 5ms | 8ms |
| **Performance (1K entries)** | 1,500ms ❌ | 12ms ✅ | 15ms ✅ |
| **Performance (100K entries)** | 150,000ms ❌❌❌ | 20ms ✅ | 25ms ✅ |
| **Scalability** | ❌ Poor | ✅ Excellent | ✅ Excellent |
| **Cost** | Free | Free (if Redis has module) | ~$10/month |
| **Maintenance** | Low | Medium | Low (managed) |
---
## 💡 Hybrid Approach (Best of Both Worlds)
```python
class RedisClient:
def __init__(self):
self._has_vector_search = None # Auto-detect
async def _detect_vector_search_support(self):
"""Check if Redis supports vector search"""
try:
redis = self.get_client()
info = await redis.execute_command("MODULE", "LIST")
self._has_vector_search = any("search" in str(m).lower() for m in info)
except:
self._has_vector_search = False
logger.info(f"Redis Vector Search: {'✅ Enabled' if self._has_vector_search else '❌ Disabled'}")
async def get_cached_llm_response(self, query, user_id, threshold):
if self._has_vector_search:
return await self._get_cached_with_vector_search(...) # Fast O(log n)
else:
return await self._get_cached_with_scan(...) # Slow O(n) but works
```
This way:
- ✅ Works with any Redis version
- ✅ Automatically uses fastest method available
- ✅ Easy to upgrade later
---
## 🚀 Next Steps
1. **Check Redis version**: `redis-cli INFO modules`
2. **If RediSearch available**: Upgrade to Version B
3. **If not**: Keep Version A, monitor performance
4. **When cache grows**: Consider Upstash Vector or upgrade Redis
---
**Bottom Line**: Bạn đúng 100%! Current implementation không optimal cho large cache. Nhưng:
- ✅ **OK for now** (small cache size)
- ⚠️ **Need upgrade later** (when cache grows)
- 🎯 **Hybrid approach** = best solution
# 📋 TÀI LIỆU KỸ THUẬT HỆ THỐNG CANIFA AI STYLIST CHATBOT
> **Phiên bản**: 2.0 — Cập nhật: 09/03/2026
> **Tác giả**: Team AI Canifa
> **Mục đích**: Tài liệu chi tiết kiến trúc, chức năng, và vận hành hệ thống Chatbot AI Tư vấn Thời trang CANIFA
---
## 📑 MỤC LỤC
1. [Tổng quan hệ thống](#1-tổng-quan-hệ-thống)
2. [Kiến trúc kỹ thuật](#2-kiến-trúc-kỹ-thuật)
3. [Tech Stack & Infrastructure](#3-tech-stack--infrastructure)
4. [Chatbot V1 — Agent + Vector Search](#4-chatbot-v1--agent--vector-search)
5. [Chatbot V2 — Embedding + Vector Search](#5-chatbot-v2--embedding--vector-search)
6. [Database & Data Pipeline](#6-database--data-pipeline)
7. [API Endpoints](#7-api-endpoints)
8. [Prompt Engineering](#8-prompt-engineering)
9. [Agent Tools](#9-agent-tools)
10. [Hệ thống Feedback & Đánh giá](#10-hệ-thống-feedback--đánh-giá)
11. [Observability & Monitoring (Langfuse)](#11-observability--monitoring-langfuse)
12. [Automated Testing (n8n)](#12-automated-testing-n8n)
13. [Caching & Performance](#13-caching--performance)
14. [Security & Middleware](#14-security--middleware)
15. [So sánh V1 vs V2](#15-so-sánh-v1-vs-v2)
16. [Hướng phát triển](#16-hướng-phát-triển)
---
## 1. TỔNG QUAN HỆ THỐNG
### 1.1 Mô tả
Canifa AI Stylist là chatbot tư vấn thời trang thông minh, giúp khách hàng CANIFA:
- **Tìm kiếm sản phẩm** theo nhu cầu (áo, quần, phụ kiện...)
- **Tư vấn phối đồ** theo dịp, giới tính, lứa tuổi
- **Kiểm tra tồn kho** real-time theo size, màu
- **Gợi ý sản phẩm** dựa trên sở thích và ngân sách
- **Cung cấp thông tin khuyến mãi** và chính sách bán hàng
### 1.2 Hai phiên bản Chatbot
| | V1 (Agent) | V2 (Direct Search) |
| ---------------- | ----------------------------- | ------------------------------- |
| **Kiến trúc** | LangGraph Agent + Tools | Embedding → Vector Search → LLM |
| **Số bước** | Agent tự quyết định gọi tools | 3 bước cố định |
| **LLM calls** | 2-5 lần (agent loop) | 1 lần |
| **Embedding** | 1 lần (search) | 1 lần (search) |
| **Thời gian TB** | 3-6 giây | 2-3 giây |
| **Độ linh hoạt** | Cao (multi-tool) | Trung bình (chỉ search) |
| **Use case** | Câu hỏi phức tạp, multi-step | Tìm kiếm sản phẩm nhanh |
---
## 2. KIẾN TRÚC KỸ THUẬT
### 2.1 System Architecture
```
┌─────────────────────────────────────────────────────────┐
│ FRONTEND (HTML/JS) │
│ index.html │ test_sql.html │ test_db.html │ history.html│
└──────────────────────┬──────────────────────────────────┘
│ HTTP/REST API
┌──────────────────────▼──────────────────────────────────┐
│ FastAPI Server (:5000) │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │ Chat V1 │ │ Chat V2 │ │ Feedback │ │ Stock API │ │
│ │ /api/ │ │ /api/v2/ │ │ /api/ │ │ /api/stock │ │
│ │ agent/ │ │ chat │ │ feedback │ │ /check │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └─────┬──────┘ │
│ │ │ │ │ │
│ ┌────▼─────┐ ┌────▼─────┐ ┌───▼──────┐ │ │
│ │LangGraph │ │Embedding │ │GPT-4.1 │ │ │
│ │ Agent │ │+ Vector │ │Classifier│ │ │
│ │(Gemini) │ │ Search │ │ │ │ │
│ └────┬─────┘ └────┬─────┘ └──────────┘ │ │
└───────┼────────────┼───────────────────────────┼─────────┘
│ │ │
┌───────▼────────────▼───────────────────────────▼─────────┐
│ DATA LAYER │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌────────────┐ │
│ │StarRocks │ │ Redis │ │PostgreSQL│ │ Langfuse │ │
│ │(Products │ │ (Cache) │ │(History) │ │(Observ.) │ │
│ │+Vectors) │ │ │ │ │ │ │ │
│ └──────────┘ └──────────┘ └──────────┘ └────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 2.2 Cấu trúc thư mục
```
backend/
├── server.py # FastAPI main app
├── config.py # Environment config
├── api/ # API Routes (10 files)
│ ├── chatbot_route.py # V1 Chat endpoints
│ ├── text_to_sql_route.py # V2 Chat endpoint
│ ├── feedback_route.py # Feedback endpoints
│ ├── stock_route.py # Stock check API
│ ├── prompt_route.py # Prompt management
│ ├── n8n_api_route.py # n8n testing APIs
│ ├── check_history_route.py # Chat history
│ ├── conservation_route.py # Conversation mgmt
│ ├── mock_api_route.py # Mock endpoints
│ └── tool_prompt_route.py # Tool prompt config
├── agent/
│ ├── graph.py # LangGraph agent (V1)
│ ├── controller.py # Chat controller logic
│ ├── text_to_sql.py # V2: Embed→Search→LLM
│ ├── streaming_callback.py # SSE streaming
│ ├── prompt_module/ # 10 prompt modules
│ └── tools/ # 10+ agent tools
├── common/
│ ├── llm_factory.py # LLM instance factory
│ ├── embedding_service.py # OpenAI embedding
│ ├── starrocks_connection.py # StarRocks DB
│ ├── cache.py # Redis cache (2 layers)
│ ├── conversation_manager.py # Chat history (PostgreSQL)
│ ├── middleware.py # Auth, CORS, Rate limit
│ └── feedback_tracker.py # Feedback storage
└── static/ # Frontend test pages
```
---
## 3. TECH STACK & INFRASTRUCTURE
### 3.1 AI/LLM
| Component | Technology | Mục đích |
| ------------------- | --------------------------------- | ------------------------------ |
| LLM chính | **Google Gemini 3.1 Flash-Lite** | Sinh response, agent reasoning |
| Embedding | **OpenAI text-embedding-3-small** | Vector hóa câu hỏi (1536 dims) |
| Feedback Classifier | **GPT-4.1-mini** | Phân loại complaint |
| AI Judge (n8n) | **GPT-5-mini** | Auto-test chấm điểm |
| Framework | **LangChain + LangGraph** | Agent framework |
### 3.2 Database & Storage
| Component | Technology | Server | Mục đích |
| ------------- | ------------------ | ------------------ | -------------------------------- |
| Product Data | **StarRocks** | 172.16.2.100:9030 | Product catalog + Vector search |
| Cache | **Redis** | 172.16.2.192:6379 | Response cache + Embedding cache |
| Chat History | **PostgreSQL** | 172.16.2.190:15433 | Conversation persistence |
| Observability | **Langfuse Cloud** | cloud.langfuse.com | Tracing, scoring, prompts |
### 3.3 Backend
| Component | Technology |
| ------------- | ------------------------- |
| Web Framework | **FastAPI** (Python 3.12) |
| ASGI Server | **Uvicorn** (hot reload) |
| Auth | **JWT + Clerk** |
| Rate Limiting | Custom middleware |
---
## 4. CHATBOT V1 — AGENT + VECTOR SEARCH
### 4.1 Luồng xử lý
```
User gửi câu hỏi
↓
[1] Load prompt từ Langfuse (10 modules)
↓
[2] Load conversation history (PostgreSQL)
↓
[3] LangGraph Agent nhận câu hỏi + history + prompt
↓
[4] Agent quyết định gọi tool nào:
├── data_retrieval_tool → Vector Search sản phẩm
├── check_is_stock → Kiểm tra tồn kho
├── brand_knowledge_tool → Kiến thức thương hiệu
├── promotion_canifa_tool → Khuyến mãi
├── store_search_tool → Tìm cửa hàng
└── customer_info_tool → Thông tin khách
↓
[5] Agent nhận kết quả → sinh response
↓
[6] Streaming response (SSE) → Frontend
↓
[7] Log vào Langfuse + Lưu history PostgreSQL
```
### 4.2 Endpoint
```
POST /api/agent/chat-dev
Body: { "message": "...", "user_id": "...", "device_id": "..." }
Response: SSE stream
```
---
## 5. CHATBOT V2 — EMBEDDING + VECTOR SEARCH
### 5.1 Luồng xử lý
```
User gửi câu hỏi
↓
[1] OpenAI Embedding (~200ms) → 1536 dims
↓
[2] Vector Search StarRocks (~200ms) → Top 30 SP
↓
[3] Gemini viết response (~2s) → Top 10 SP cho LLM
↓
[4] JSON response (products + timing)
```
### 5.2 Endpoint
```
POST /api/v2/chat
Body: { "user_query": "tìm áo lót nữ" }
Response: { ai_response, products[30], timing, product_count }
```
---
## 6. DATABASE — STARROCKS SCHEMA
**Bảng**: `shared_source.magento_product_dimension_with_text_embedding`
| Column | Type | Mô tả |
| ----------------- | ------------ | ------------------------- |
| magento_ref_code | VARCHAR | Mã SKU |
| product_name | VARCHAR | Tên SP |
| master_color | VARCHAR | Màu chính |
| sale_price | DECIMAL | Giá bán (VND) |
| original_price | DECIMAL | Giá gốc |
| gender_by_product | VARCHAR | women/men/boy/girl/unisex |
| age_by_product | VARCHAR | adult/kid/others |
| product_line_vn | VARCHAR | Dòng SP |
| vector | ARRAY<FLOAT> | Embedding 1536 dims |
| size_scale | VARCHAR | S,M,L,XL |
| quantity_sold | INT | Đã bán |
---
## 7. API ENDPOINTS (Tóm tắt)
| Method | Endpoint | Mô tả |
| ------ | ------------------------- | ------------------- |
| POST | `/api/agent/chat-dev` | V1 Chat (streaming) |
| POST | `/api/v2/chat` | V2 Chat (JSON) |
| POST | `/api/feedback/score` | Like/Dislike |
| POST | `/api/feedback/complaint` | Khiếu nại |
| GET | `/api/prompt/current` | Xem prompt |
| POST | `/api/prompt/refresh` | Refresh prompt |
| GET | `/api/history/{user_id}` | Lịch sử chat |
| POST | `/api/stock/check` | Tồn kho |
| POST | `/api/n8n/chat` | n8n test |
---
## 8. PROMPT ENGINEERING (10 Modules)
| # | File | Mục đích |
| ----- | ------------------- | -------------------------- |
| 01 | `core.txt` | Persona AI Stylist |
| 02 | `rules.txt` | Quy tắc ứng xử |
| 03 | `context.txt` | Context sản phẩm |
| 04a-d | `sales_*.txt` | Kỹ năng bán hàng (4 files) |
| 05 | `tool_routing.txt` | Logic chọn tool |
| 06 | `user_insight.txt` | Phân tích sở thích |
| 07 | `output_format.txt` | Format response |
Prompt quản lý qua **Langfuse** → version-controlled, không cần deploy lại.
---
## 9. FEEDBACK & AUTO-TEST
### User Feedback
- 👍/👎 Like/Dislike → Langfuse score
- 📢 Complaint → GPT-4.1-mini phân loại → Langfuse
### n8n Auto-test (hàng ngày 8:00 AM)
- Sinh 20 câu hỏi test → Gọi chatbot → AI Judge chấm điểm → Log Google Sheets
---
## 10. CACHING (Redis 2 Layers)
| Layer | TTL | Mục đích |
| ----------------- | ------ | ---------------------- |
| **L1: Response** | 5 phút | Cache full response |
| **L2: Embedding** | 24 giờ | Cache OpenAI embedding |
---
## 11. SO SÁNH V1 VS V2
| Tiêu chí | V1 (Agent) | V2 (Direct) |
| ------------ | ------------- | --------------- |
| Tốc độ | 3-6s | ~2.5s ✅ |
| LLM calls | 2-5 | 1 ✅ |
| Cost/request | ~$0.003-0.008 | ~$0.001-0.002 ✅ |
| Tồn kho | ✅ | ❌ |
| Cửa hàng | ✅ | ❌ |
| History | ✅ | ❌ |
| Streaming | ✅ | ❌ |
---
## 12. HƯỚNG PHÁT TRIỂN
- [ ] Streaming cho V2
- [ ] Cache Redis cho V2
- [ ] A/B test V1 vs V2
- [ ] Dashboard quản lý (khi BA yêu cầu)
- [ ] Multi-modal (gửi ảnh tìm SP)
---
> Cập nhật: 09/03/2026
# Semantic Caching Implementation Guide
## 📋 Overview
Semantic caching đã được tích hợp vào Canifa Chatbot để:
- ✅ **Tăng tốc độ phản hồi**: 15X nhanh hơn (50-100ms thay vì 2-3s)
- ✅ **Giảm chi phí**: 60-80% cho các queries tương tự
- ✅ **Cải thiện UX**: Real-time responses cho người dùng
## 🏗️ Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ USER QUERY │
└──────────────────────┬──────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ LAYER 1: LLM Response Cache │
│ • Semantic similarity search (cosine > 0.95) │
│ • TTL: 1 hour │
│ • Key: semantic_cache:{user_id}:{query_hash} │
└──────────────────────┬──────────────────────────────────────┘
│
┌────────────┴────────────┐
│ │
CACHE HIT CACHE MISS
(50-100ms) │
│ ▼
│ ┌─────────────────────────────┐
│ │ LAYER 2: Embedding Cache │
│ │ • Exact match (MD5 hash) │
│ │ • TTL: 24 hours │
│ └──────────┬──────────────────┘
│ │
│ ┌─────┴──────┐
│ CACHE HIT CACHE MISS
│ │ │
│ │ ▼
│ │ Generate Embedding
│ │ (OpenAI API)
│ │ │
│ └────────────┘
│ │
│ ▼
│ ┌─────────────────────────────┐
│ │ LLM Call (GPT-4) │
│ │ (2-3 seconds) │
│ └──────────┬──────────────────┘
│ │
│ ▼
│ ┌─────────────────────────────┐
│ │ Cache Response │
│ │ (Background Task) │
│ └──────────┬──────────────────┘
│ │
└────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ RETURN RESPONSE │
└─────────────────────────────────────────────────────────────┘
```
## 🚀 How It Works
### 1. Cache Check (Layer 1 - LLM Response Cache)
```python
# In agent/controller.py
cached_result = await get_cached_llm_response(
query="áo sơ mi nam",
user_id="user123",
similarity_threshold=0.95, # 95% similarity required
)
if cached_result:
# CACHE HIT - Return in 50-100ms
return {
"ai_response": cached_result["response"],
"product_ids": cached_result["product_ids"],
"cached": True,
"cache_metadata": {
"similarity": 0.97, # How similar to original query
"original_query": "áo sơ mi cho nam giới"
}
}
```
**Example Queries That Will Hit Cache:**
- Original: "áo sơ mi nam"
- Similar: "áo sơ mi cho nam giới" → Similarity: 0.97 ✅
- Similar: "shirt for men" → Similarity: 0.96 ✅
- Different: "quần jean nữ" → Similarity: 0.45 ❌
### 2. Embedding Cache (Layer 2)
```python
# In common/cache.py
async def _get_or_create_embedding(text: str):
text_hash = hashlib.md5(text.encode()).hexdigest()
embedding_key = f"embedding_cache:{text_hash}"
# Try cache first
cached_embedding = await redis.get(embedding_key)
if cached_embedding:
return json.loads(cached_embedding) # ✅ Cache hit
# Generate new embedding
embedding = await create_embedding_async(text)
# Cache for 24 hours
await redis.setex(embedding_key, 86400, json.dumps(embedding))
return embedding
```
### 3. Cache Storage (After LLM Response)
```python
# Store response in background (non-blocking)
background_tasks.add_task(
set_cached_llm_response,
query="áo sơ mi nam",
user_id="user123",
response="Dạ, chúng tôi có nhiều mẫu áo sơ mi nam...",
product_ids=[...],
metadata={"model": "gpt-4", "timestamp": 1705234567},
ttl=3600, # 1 hour
)
```
## 📊 Configuration
### Cache Settings (in `common/cache.py`)
```python
# Similarity threshold for cache hit
DEFAULT_SIMILARITY_THRESHOLD = 0.95 # 0.0 - 1.0
# Time to live (TTL)
DEFAULT_LLM_CACHE_TTL = 3600 # 1 hour for LLM responses
EMBEDDING_CACHE_TTL = 86400 # 24 hours for embeddings
# Redis key prefixes
CACHE_KEY_PREFIX = "semantic_cache"
EMBEDDING_KEY_PREFIX = "embedding_cache"
```
### Tuning Similarity Threshold
| Threshold | Behavior | Use Case |
|-----------|----------|----------|
| **0.99** | Very strict - almost exact match | High accuracy required |
| **0.95** | Balanced (recommended) | General use |
| **0.90** | More lenient - broader matches | FAQ-style queries |
| **0.85** | Very lenient | Experimental |
### Adjusting TTL
```python
# In controller.py
await set_cached_llm_response(
query=query,
user_id=user_id,
response=response,
ttl=7200, # 2 hours instead of 1
)
```
## 📈 Monitoring & Analytics
### Get Cache Statistics
```bash
GET /cache/stats
```
**Response:**
```json
{
"status": "success",
"data": {
"total_queries": 150,
"llm_cache": {
"hits": 90,
"misses": 60,
"hit_rate_percent": 60.0,
"cost_saved_usd": 0.09
},
"embedding_cache": {
"hits": 120,
"misses": 30,
"hit_rate_percent": 80.0,
"cost_saved_usd": 0.012
},
"performance": {
"avg_saved_time_ms": 1850,
"total_time_saved_seconds": 166.5
},
"total_cost_saved_usd": 0.102
}
}
```
### Clear User Cache
```bash
DELETE /cache/user/{user_id}
```
**Use cases:**
- User requests data deletion
- User reports incorrect cached responses
- Manual cache invalidation for testing
### Reset Statistics
```bash
POST /cache/stats/reset
```
## 🔧 Redis Configuration
### Current Setup
```yaml
# From .env
REDIS_HOST: 172.16.2.192
REDIS_PORT: 6379
REDIS_DB: 2
```
### Redis Data Structure
```
# LLM Response Cache
semantic_cache:user123:a1b2c3d4e5f6...
{
"query": "áo sơ mi nam",
"embedding": [0.123, -0.456, ...], # 1536 dimensions
"response": "Dạ, chúng tôi có nhiều mẫu...",
"product_ids": [...],
"metadata": {"model": "gpt-4"},
"timestamp": 1705234567,
"user_id": "user123"
}
# Embedding Cache
embedding_cache:a1b2c3d4e5f6...
[0.123, -0.456, 0.789, ...] # 1536 dimensions
```
## 💰 Cost Savings Calculation
### Assumptions
- **LLM call**: ~$0.001 per query (GPT-4 pricing)
- **Embedding call**: ~$0.0001 per query
- **Average query**: 500 tokens
### Example Savings (60% hit rate)
```
Total queries: 1000
Cache hits: 600
Cache misses: 400
LLM cost saved: 600 × $0.001 = $0.60
Embedding cost saved: 600 × $0.0001 = $0.06
Total saved: $0.66
Monthly (assuming 30K queries):
Total saved: $19.80/month
```
## 🎯 Best Practices
### 1. Cache Invalidation Strategy
```python
# Clear cache when product data updates
async def on_product_update(product_id: str):
# Option 1: Clear all cache (nuclear option)
await redis.flushdb()
# Option 2: Clear specific user cache
await clear_user_cache(user_id)
# Option 3: Let TTL handle it (recommended)
# Cache expires after 1 hour automatically
```
### 2. Monitoring Cache Performance
```python
# Log cache hits/misses
logger.info(f"✅ LLM CACHE HIT | Similarity: 0.97 | Time: 85ms")
logger.info(f"❌ LLM CACHE MISS | Best similarity: 0.82 | Time: 120ms")
```
### 3. A/B Testing Different Thresholds
```python
# Test different thresholds for different user segments
if user.is_premium:
threshold = 0.98 # Higher accuracy for premium users
else:
threshold = 0.95 # Standard threshold
```
## 🐛 Troubleshooting
### Issue: Low Cache Hit Rate
**Possible causes:**
1. Threshold too high (0.99+)
2. Queries too diverse
3. TTL too short
**Solution:**
```python
# Lower threshold slightly
similarity_threshold = 0.92 # Instead of 0.95
# Increase TTL
ttl = 7200 # 2 hours instead of 1
```
### Issue: Redis Connection Errors
**Check:**
```python
# Test Redis connection
redis = get_redis()
await redis.ping() # Should return True
```
### Issue: Embedding Generation Fails
**Fallback:**
```python
# Cache service has built-in fallback
# If cache fails, it will still generate embedding
# Check logs for errors
```
## 📝 Testing
### Manual Test
```bash
# 1. First query (cache miss)
curl -X POST http://localhost:5000/chat \
-H "Content-Type: application/json" \
-d '{"query": "áo sơ mi nam", "user_id": "test123"}'
# Response: {"cached": false, ...}
# 2. Similar query (cache hit)
curl -X POST http://localhost:5000/chat \
-H "Content-Type: application/json" \
-d '{"query": "áo sơ mi cho nam giới", "user_id": "test123"}'
# Response: {"cached": true, "cache_metadata": {"similarity": 0.97}, ...}
```
### Check Cache Stats
```bash
curl http://localhost:5000/cache/stats
```
## 🚀 Next Steps
### Potential Enhancements
1. **Redis Vector Search** (RedisVL)
- Use native vector search instead of scanning all keys
- Much faster for large cache sizes
2. **Multi-level TTL**
- Popular queries: 24 hours
- Rare queries: 1 hour
3. **Cache Warming**
- Pre-cache common queries on startup
4. **Distributed Caching**
- Use Redis Cluster for horizontal scaling
## 📚 References
- [Redis Semantic Caching Blog](https://redis.io/blog/semantic-caching/)
- [LangCache Documentation](https://redis.io/docs/langcache/)
- [OpenAI Embeddings](https://platform.openai.com/docs/guides/embeddings)
---
**Implementation Date**: 2026-01-14
**Version**: 1.0
**Author**: Canifa AI Team
# Semantic Caching - Implementation Summary
## ✅ What Was Implemented
### 1. **Unified Cache Service** (`common/cache.py`)
Đã mở rộng `RedisClient` class để bao gồm:
#### **Layer 1: LLM Response Cache**
- Semantic similarity search using cosine similarity
- Threshold: 0.95 (configurable)
- TTL: 1 hour (configurable)
- Key format: `semantic_cache:{user_id}:{query_hash}`
#### **Layer 2: Embedding Cache**
- Cache embeddings to avoid duplicate OpenAI calls
- Exact match using MD5 hash
- TTL: 24 hours
- Key format: `embedding_cache:{text_hash}`
#### **Layer 3: Analytics & Monitoring**
- Track cache hits/misses
- Calculate cost savings
- Performance metrics (time saved)
### 2. **Controller Integration** (`agent/controller.py`)
```python
# Flow:
1. Check semantic cache → If hit, return in 50-100ms
2. If miss → Call LLM (2-3s)
3. Cache response in background
```
### 3. **Cache Analytics API** (`api/cache_analytics_route.py`)
- `GET /cache/stats` - View cache performance
- `DELETE /cache/user/{user_id}` - Clear user cache
- `POST /cache/stats/reset` - Reset statistics
### 4. **Documentation** (`docs/SEMANTIC_CACHE.md`)
- Architecture diagrams
- Configuration guide
- Monitoring instructions
- Best practices
- Troubleshooting
---
## 📊 Expected Performance
| Metric | Before | After | Improvement |
|--------|--------|-------|-------------|
| **Response Time (cache hit)** | 2-3s | 50-100ms | **15-20X faster** |
| **Response Time (cache miss)** | 2-3s | 2-3s | Same |
| **Cost per query (60% hit rate)** | $0.001 | $0.0004 | **60% reduction** |
| **Monthly cost (30K queries)** | $30 | $12 | **$18 saved** |
---
## 🎯 How to Use
### Basic Usage (Already Integrated)
Semantic caching is **automatically enabled** in `chat_controller`. No code changes needed!
```python
# User query 1: "áo sơ mi nam" → CACHE MISS → Call LLM (2s)
# User query 2: "áo sơ mi cho nam giới" → CACHE HIT → Return cached (80ms)
```
### Monitor Cache Performance
```bash
# Get statistics
curl http://localhost:5000/cache/stats
# Response:
{
"llm_cache": {
"hits": 90,
"misses": 60,
"hit_rate_percent": 60.0,
"cost_saved_usd": 0.09
},
"embedding_cache": {
"hits": 120,
"misses": 30,
"hit_rate_percent": 80.0
}
}
```
### Clear Cache (if needed)
```bash
# Clear specific user cache
curl -X DELETE http://localhost:5000/cache/user/user123
```
---
## ⚙️ Configuration
### Adjust Similarity Threshold
In `agent/controller.py`:
```python
cached_result = await get_cached_llm_response(
query=query,
user_id=user_id,
similarity_threshold=0.92, # Lower = more lenient (more cache hits)
)
```
### Adjust TTL
In `agent/controller.py`:
```python
await set_cached_llm_response(
query=query,
user_id=user_id,
response=response,
ttl=7200, # 2 hours instead of 1
)
```
### Global Settings
In `common/cache.py`:
```python
DEFAULT_SIMILARITY_THRESHOLD = 0.95 # Change default threshold
DEFAULT_LLM_CACHE_TTL = 3600 # Change default TTL
EMBEDDING_CACHE_TTL = 86400 # Change embedding cache TTL
```
---
## 🚀 Next Steps
### 1. Install Dependencies
```bash
cd d:\cnf\chatbot_canifa\backend
pip install -r requirements.txt
```
### 2. Verify Redis Connection
```bash
# Check .env file has:
REDIS_HOST=172.16.2.192
REDIS_PORT=6379
REDIS_DB=2
```
### 3. Test the Implementation
```bash
# Start server
python run.py
# Test cache miss (first query)
curl -X POST http://localhost:5000/chat \
-H "Content-Type: application/json" \
-d '{"query": "áo sơ mi nam", "user_id": "test123"}'
# Test cache hit (similar query)
curl -X POST http://localhost:5000/chat \
-H "Content-Type: application/json" \
-d '{"query": "áo sơ mi cho nam giới", "user_id": "test123"}'
# Check stats
curl http://localhost:5000/cache/stats
```
### 4. Monitor in Production
```bash
# View logs for cache hits/misses
tail -f logs/app.log | grep "CACHE"
# Output:
✅ LLM CACHE HIT | Similarity: 0.97 | Time: 85ms | User: user123
❌ LLM CACHE MISS | Best similarity: 0.82 | Time: 120ms | User: user456
```
---
## 🔧 Troubleshooting
### Issue: "ModuleNotFoundError: No module named 'redis'"
**Solution:**
```bash
pip install redis[hiredis]==5.2.1
```
### Issue: "ModuleNotFoundError: No module named 'numpy'"
**Solution:**
```bash
pip install numpy==2.4.0
```
### Issue: Redis connection failed
**Check:**
1. Redis server is running: `redis-cli -h 172.16.2.192 -p 6379 ping`
2. Network connectivity to Redis server
3. Credentials in `.env` are correct
### Issue: Low cache hit rate
**Solutions:**
1. Lower similarity threshold (0.92 instead of 0.95)
2. Increase TTL (2 hours instead of 1)
3. Check if queries are too diverse
---
## 📝 Files Modified/Created
### Modified Files:
1. ✅ `common/cache.py` - Added semantic caching methods
2. ✅ `agent/controller.py` - Integrated cache check and storage
3. ✅ `requirements.txt` - Added redis package
### New Files:
1. ✅ `api/cache_analytics_route.py` - Cache monitoring API
2. ✅ `docs/SEMANTIC_CACHE.md` - Comprehensive documentation
3. ✅ `docs/SEMANTIC_CACHE_SUMMARY.md` - This file
---
## 💡 Key Benefits
### For Users:
- ⚡ **15X faster responses** for similar queries
- 🎯 **Better UX** with real-time interactions
- 📱 **Consistent answers** for similar questions
### For Business:
- 💰 **60-80% cost reduction** on repeated queries
- 📊 **Scalability** - handle more users with same infrastructure
- 🔍 **Analytics** - understand query patterns
### For Developers:
- 🛠️ **Easy to configure** - just adjust threshold and TTL
- 📈 **Observable** - built-in monitoring and stats
- 🔌 **Plug-and-play** - automatically integrated
---
## 📚 Additional Resources
- Full documentation: `docs/SEMANTIC_CACHE.md`
- Redis Semantic Caching: https://redis.io/blog/semantic-caching/
- LangCache: https://redis.io/docs/langcache/
---
**Implementation Date**: 2026-01-14
**Status**: ✅ Ready for Testing
**Next Action**: Install dependencies and test
Subproject commit a1afa9d892df6239b4f5614d87afe5bbbef2d762
import os
from docx import Document
from docx.shared import Pt, Inches
from docx.enum.text import WD_ALIGN_PARAGRAPH
def create_doc():
doc = Document()
# Title
title = doc.add_heading('Canifa AI Stylist - Lead Search Tool Architecture', 0)
title.alignment = WD_ALIGN_PARAGRAPH.CENTER
doc.add_paragraph('Bản vẽ chi tiết luồng hoạt động & Kiến trúc tối ưu Token (Cập nhật T4/2026)', style='Subtitle')
# Section 1
doc.add_heading('1. Tổng quan (Overview)', level=1)
doc.add_paragraph('Lead Search Tool là trung tâm truy xuất dữ liệu sản phẩm của Canifa AI Stylist. Sau đợt tái cấu trúc, hệ thống đã loại bỏ hoàn toàn OpenAI Embedding, thay thế bằng cơ chế Cascading Search 7 tầng (NGRAMBF + BITMAP Tags), mang lại tốc độ truy vấn < 300ms. Đồng thời, cấu trúc dữ liệu trả về được tối ưu hóa để giảm thiểu Token tiêu thụ cho LLM.')
# Section 2
doc.add_heading('2. Kiến trúc Tối ưu Token (Token-Saving Architecture)', level=1)
p = doc.add_paragraph()
p.add_run('Thay vì trả về 15-20 sản phẩm chi tiết (gây tràn Context Window), hệ thống hiện tại sử dụng chiến lược: ').bold = True
p.add_run('"Lấy ít sản phẩm chính, móc nhiều sản phẩm gợi ý".')
doc.add_paragraph('• Main Products (Top 3): Trả về tối đa 3 sản phẩm match nhất với đầy đủ thông tin chi tiết (Size, Giá, Mô tả ngắn <200 ký tự, Tồn kho thực tế, Lý do phối đồ AI).', style='List Bullet')
doc.add_paragraph('• Suggest Items (Phối cùng): Cho mỗi sản phẩm chính, lấy thêm tối đa 2 sản phẩm phối cùng (chỉ gồm SKU, Tên, Giá).', style='List Bullet')
doc.add_paragraph('• Similar Items (Tương tự): Cho mỗi sản phẩm chính, lấy thêm tối đa 3 sản phẩm thay thế (chỉ gồm SKU, Tên, Giá).', style='List Bullet')
doc.add_paragraph('Kết quả: LLM vẫn có 3 lựa chọn chính và 15 lựa chọn phụ để tư vấn, nhưng số lượng Token giảm xuống 75%.')
# Section 3
doc.add_heading('3. Luồng dữ liệu (Data Flow)', level=1)
flow_steps = [
("Bước 1: Phân tích Ý định (Input parsing)", "Nhận các tham số từ Agent: keywords, tags, color, size, price_min, price_max."),
("Bước 2: Cascading Search (7 Tiers)", "Truy xuất StarRocks qua 7 tầng rớt mạng. Tầng 1 (Keywords + Hard Filters), Tầng 2 (Tags + Filters), Tầng 5-6 (Nới giá 1.5x - 2.0x)."),
("Bước 3: Lọc Tồn Kho (Stock API)", "Gửi các mã sản phẩm sang Canifa Stock API qua httpx. Chỉ giữ lại những mã đang CÒN HÀNG, kèm theo danh sách chi tiết các size còn trống."),
("Bước 4: Bổ sung Styling (SQLite 123.db)", "Đọc file SQLite local để lấy `ai_description` và `ai_matches` (lý do phối đồ), cung cấp 'bộ não' thời trang cho LLM chốt sale."),
("Bước 5: Định dạng Đầu ra (Output Formatter)", "Gom nhóm dữ liệu, cắt ngắn mô tả, fetch suggest/similar items và trả về JSON nhẹ nhất cho AI.")
]
for step, desc in flow_steps:
p = doc.add_paragraph(style='List Number')
p.add_run(step + ": ").bold = True
p.add_run(desc)
# Section 4
doc.add_heading('4. Đầu ra của hệ thống (JSON Format)', level=1)
doc.add_paragraph('Dưới đây là cấu trúc minh hoạ cho 1 sản phẩm chính sau khi được format gọn nhẹ:')
json_example = """{
"sku": "8TP24S001-SB060",
"name": "Áo polo nam phom regular",
"price": 349000,
"in_stock": true,
"stock": [{"size": "M", "qty": 10}, {"size": "L", "qty": 14}],
"ai_matches": {
"phoi_voi": ["Quần khaki nam"],
"ly_do": "Màu áo hợp quần khaki tạo vẻ thanh lịch..."
},
"suggest_items": [
{"sku": "8QK24S002", "name": "Quần khaki nam", "price": 450000}
],
"similar_items": [
{"sku": "8TP24S003", "name": "Áo polo sọc", "price": 399000}
]
}"""
code_p = doc.add_paragraph(json_example)
code_p.style.font.name = 'Courier New'
code_p.style.font.size = Pt(9)
# Save document
os.makedirs(r"D:\cnf\chatbot-canifa-feedback\backend\static\lead_flow", exist_ok=True)
out_path = r"D:\cnf\chatbot-canifa-feedback\backend\static\lead_flow\lead_search_logic.docx"
doc.save(out_path)
print(f"Created docx at {out_path}")
if __name__ == "__main__":
create_doc()
import requests
links_to_restore = [
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"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
},
{
"id": "2912d187",
"title": "tool test conservation",
"url": "http://172.16.2.207:5004/static/test_conversation.html",
"description": "Tools test conservations, user có thể nhập url (trở tới bản dev hoặc stage) ném vào 1 file excel đã chuẩn bị sẵn",
"category": "tool",
"icon": "🔧",
"pinned": False
}
]
url = "http://localhost:5000/api/dashboard/links"
for link in links_to_restore:
# use their original ID by issuing a POST but actually the backend POST ignores ID and generates a new one.
# We will just insert them fresh!
res = requests.post(url, json=link)
print(res.status_code)
"""Seed initial FAQ data into SQLite."""
import uuid, sys, os
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from datetime import datetime, timezone
from common.pool_wrapper import get_pooled_connection_compat
SEED_FAQS = [
# ── Chính sách đổi trả ──
("Canifa có chính sách đổi trả không?", "Canifa có chính sách đổi trả trong vòng 30 ngày kể từ ngày mua. Sản phẩm phải còn nguyên tem, nhãn và chưa qua sử dụng.", "đổi trả"),
("Đổi hàng Canifa cần mang theo gì?", "Bạn cần mang theo hóa đơn mua hàng hoặc xuất trình mã đơn hàng online và sản phẩm còn nguyên tem nhãn.", "đổi trả"),
("Canifa có hoàn tiền không?", "Canifa chỉ hỗ trợ đổi sản phẩm hoặc hoàn tiền vào tài khoản thành viên. Không hoàn tiền mặt trực tiếp.", "đổi trả"),
("Hàng sale có được đổi trả không?", "Hàng giảm giá từ 50% trở lên không được đổi trả. Hàng giảm dưới 50% vẫn áp dụng chính sách đổi trả bình thường.", "đổi trả"),
("Mua online có đổi ở cửa hàng được không?", "Có, bạn có thể mang sản phẩm mua online đến bất kỳ cửa hàng Canifa nào để đổi trả.", "đổi trả"),
("Đổi size có mất phí không?", "Đổi size trong vòng 30 ngày là miễn phí. Bạn chỉ cần mang sản phẩm và hóa đơn đến cửa hàng.", "đổi trả"),
("Hàng bị lỗi thì xử lý thế nào?", "Sản phẩm bị lỗi do nhà sản xuất sẽ được đổi mới hoặc hoàn tiền 100% trong vòng 30 ngày.", "đổi trả"),
# ── Vận chuyển ──
("Canifa giao hàng mất bao lâu?", "Đơn hàng nội thành Hà Nội/HCM giao trong 1-2 ngày. Các tỉnh khác từ 3-5 ngày làm việc.", "vận chuyển"),
("Phí ship bao nhiêu?", "Miễn phí vận chuyển cho đơn từ 499K. Đơn dưới 499K phí ship từ 25K-40K tùy khu vực.", "vận chuyển"),
("Có giao hàng COD không?", "Có, Canifa hỗ trợ thanh toán khi nhận hàng (COD) trên toàn quốc.", "vận chuyển"),
("Kiểm tra đơn hàng ở đâu?", "Bạn có thể kiểm tra trạng thái đơn hàng tại canifa.com/tra-cuu-don-hang hoặc gọi hotline 1800 6426.", "vận chuyển"),
("Giao hàng ngày lễ không?", "Canifa vẫn giao hàng vào ngày lễ, tuy nhiên thời gian có thể chậm hơn 1-2 ngày so với bình thường.", "vận chuyển"),
# ── Khuyến mãi ──
("Canifa có chương trình giảm giá nào không?", "Canifa thường xuyên có chương trình sale theo mùa, Black Friday, 11.11, 12.12 và các dịp lễ lớn. Theo dõi canifa.com để cập nhật.", "khuyến mãi"),
("Mã giảm giá Canifa nhập ở đâu?", "Bạn nhập mã giảm giá tại bước thanh toán trên website canifa.com hoặc app Canifa.", "khuyến mãi"),
("Thẻ thành viên Canifa có ưu đãi gì?", "Thẻ thành viên Canifa được tích điểm 1% giá trị đơn hàng. Đạt mốc Silver/Gold/Platinum sẽ có ưu đãi sinh nhật và giảm giá riêng.", "khuyến mãi"),
("Làm thẻ thành viên Canifa ở đâu?", "Bạn có thể đăng ký thẻ thành viên miễn phí tại bất kỳ cửa hàng Canifa hoặc trên website canifa.com.", "khuyến mãi"),
("Tích điểm Canifa như nào?", "Mỗi 1,000đ thanh toán bạn được tích 1 điểm. Điểm tích lũy có thể dùng để đổi voucher giảm giá.", "khuyến mãi"),
# ── Sản phẩm ──
("Canifa bán những sản phẩm gì?", "Canifa chuyên thời trang cơ bản cho cả gia đình: nam, nữ, trẻ em. Bao gồm áo, quần, đầm, phụ kiện.", "sản phẩm"),
("Canifa có đồ trẻ em không?", "Có, Canifa có line sản phẩm dành riêng cho trẻ em từ 1-14 tuổi với chất liệu an toàn.", "sản phẩm"),
("Chất liệu Canifa có tốt không?", "Canifa sử dụng cotton tự nhiên, vải có chứng nhận Oekotex Standard 100, an toàn cho da.", "sản phẩm"),
("Size Canifa có bảng hướng dẫn không?", "Có, mỗi sản phẩm trên website đều có bảng size chi tiết. Bạn cũng có thể nhờ nhân viên cửa hàng tư vấn.", "sản phẩm"),
("Canifa có đồ mặc nhà không?", "Có, Canifa có dòng Homewear gồm pyjama, đồ mặc nhà thoải mái cho cả nam và nữ.", "sản phẩm"),
("Canifa có đồ lót không?", "Có, Canifa có line đồ lót cơ bản cho nam, nữ và trẻ em với chất liệu cotton cao cấp.", "sản phẩm"),
("Áo polo Canifa giá bao nhiêu?", "Áo polo Canifa có giá từ 299K-599K tùy chất liệu và bộ sưu tập.", "sản phẩm"),
("Canifa có bán online không?", "Có, bạn có thể mua hàng Canifa tại canifa.com, app Canifa và các sàn TMĐT như Shopee, Lazada.", "sản phẩm"),
# ── Cửa hàng ──
("Canifa có bao nhiêu cửa hàng?", "Canifa hiện có hơn 100 cửa hàng trên toàn quốc tại các thành phố lớn và trung tâm thương mại.", "cửa hàng"),
("Giờ mở cửa Canifa?", "Cửa hàng Canifa mở cửa từ 9:00 - 21:30 hàng ngày, kể cả ngày lễ.", "cửa hàng"),
("Tìm cửa hàng Canifa gần nhất ở đâu?", "Bạn có thể tìm cửa hàng gần nhất tại canifa.com/he-thong-cua-hang hoặc trên Google Maps.", "cửa hàng"),
("Canifa có ở Hà Nội không?", "Có, Canifa có hơn 30 cửa hàng tại Hà Nội, tập trung ở các quận trung tâm và TTTM lớn.", "cửa hàng"),
("Canifa có ở HCM không?", "Có, Canifa có hơn 20 cửa hàng tại TP.HCM, phân bố ở các quận trung tâm.", "cửa hàng"),
# ── Thông tin công ty ──
("Canifa là thương hiệu của nước nào?", "Canifa là thương hiệu thời trang Việt Nam, thuộc Công ty CP Thương mại và Dịch vụ Hoàng Dương.", "chung"),
("Canifa thành lập năm nào?", "Canifa được thành lập năm 2001, với hơn 20 năm kinh nghiệm trong ngành thời trang.", "chung"),
("Hotline Canifa là gì?", "Hotline Canifa: 1800 6426 (miễn phí cuộc gọi). Hoạt động từ 8:00 - 22:00 hàng ngày.", "chung"),
("Email liên hệ Canifa?", "Bạn có thể liên hệ qua email: cskh@canifa.com hoặc fanpage Facebook Canifa.", "chung"),
("Canifa có tuyển dụng không?", "Có, Canifa thường xuyên tuyển dụng. Xem vị trí tuyển dụng tại canifa.com/tuyen-dung.", "chung"),
("Website chính thức của Canifa?", "Website chính thức: https://canifa.com", "chung"),
("Canifa có app không?", "Có, Canifa có app trên cả iOS và Android. Tải tại App Store hoặc Google Play.", "chung"),
# ── Thanh toán ──
("Canifa chấp nhận thanh toán gì?", "Canifa chấp nhận tiền mặt, thẻ ngân hàng, ví điện tử (MoMo, ZaloPay, VNPay) và chuyển khoản.", "thanh toán"),
("Có trả góp được không?", "Hiện tại Canifa chưa hỗ trợ trả góp tại cửa hàng, nhưng bạn có thể trả góp qua thẻ tín dụng khi mua online.", "thanh toán"),
("Xuất hóa đơn VAT được không?", "Có, Canifa hỗ trợ xuất hóa đơn VAT. Bạn cần yêu cầu tại thời điểm mua hàng.", "thanh toán"),
# ── Bảo hành / chăm sóc ──
("Áo Canifa giặt máy được không?", "Hầu hết sản phẩm Canifa giặt máy được ở chế độ nhẹ, nước lạnh. Xem hướng dẫn trên nhãn sản phẩm.", "sản phẩm"),
("Quần jeans Canifa có bền không?", "Quần jeans Canifa sử dụng vải denim chất lượng cao, bền màu và giữ form tốt sau nhiều lần giặt.", "sản phẩm"),
]
def seed():
conn = get_pooled_connection_compat()
with conn.cursor() as cur:
cur.execute("SELECT COUNT(*) as cnt FROM faqs WHERE is_variant = 0")
existing = cur.fetchone()["cnt"]
if existing >= 30:
print(f"[SKIP] Already {existing} original FAQs in DB.")
return
now = datetime.now(tz=timezone.utc).isoformat()
inserted = 0
for q, a, cat in SEED_FAQS:
fid = str(uuid.uuid4())[:8]
cur.execute("""
INSERT INTO faqs (id, question, answer, category, is_variant, parent_id, created_at)
VALUES (?, ?, ?, ?, 0, NULL, ?)
""", (fid, q, a, cat, now))
inserted += 1
print(f"[OK] Seeded {inserted} original FAQs.")
if __name__ == "__main__":
seed()
body { margin:0; min-height:100vh; background:var(--bg); font-family:var(--font-sans); color:var(--foreground); }
body { margin:0; min-height:100vh; background:var(--bg); font-family:var(--font-sans); color:var(--foreground); }
.layout-wrapper { display:flex; max-width:1300px; margin:0 auto; padding:20px; gap:20px; }
.main-col { flex:1; min-width:0; }
.sim-col { width:340px; flex-shrink:0; }
.sim-panel { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; position:sticky; top:20px; box-shadow:0 4px 12px rgba(0,0,0,0.02); }
.sim-title { font-size:15px; font-weight:700; display:flex; align-items:center; gap:8px; margin-bottom:16px; color:var(--foreground); }
.sim-input { width:100%; min-height:80px; resize:none; margin-bottom:12px; font-size:13px; line-height:1.5; padding:12px; background:var(--bg); border:1px solid var(--border); border-radius:8px; transition:border-color 0.2s; }
.sim-input:focus { border-color:var(--primary); outline:none; box-shadow:0 0 0 3px rgba(37,99,235,0.1); }
.btn-sim-action { display:flex; align-items:center; justify-content:center; padding:10px 16px; font-weight:600; font-size:13px; letter-spacing:0.3px; border-radius:8px; background:var(--primary); color:white; transition:all 0.2s; border:none; cursor:pointer; }
.btn-sim-action:hover { background:#1d4ed8; transform:translateY(-1px); box-shadow:0 4px 12px rgba(37,99,235,0.2); }
.sim-meta { font-size:11px; color:var(--muted-fg); margin-top:12px; text-align:center; font-family:monospace; }
.sim-results { margin-top:20px; display:flex; flex-direction:column; gap:12px; max-height:calc(100vh - 280px); overflow-y:auto; padding-right:4px; }
.sim-results::-webkit-scrollbar { width: 4px; }
.sim-results::-webkit-scrollbar-thumb { background: #cbd5e1; border-radius: 4px; }
.sim-empty-state { text-align:center; color:var(--muted-fg); font-size:13px; padding:30px 10px; background:#f8fafc; border-radius:8px; border:1px dashed #cbd5e1; display:flex; flex-direction:column; align-items:center; justify-content:center; }
.sim-card { background:#ffffff; border:1px solid var(--border); border-radius:10px; padding:14px; box-shadow:0 2px 6px rgba(0,0,0,0.02); transition:transform 0.2s, box-shadow 0.2s; }
.sim-card:hover { transform:translateY(-2px); box-shadow:0 6px 16px rgba(0,0,0,0.05); border-color:#cbd5e1; }
.sim-score { font-size:11px; font-weight:700; color:#b45309; background:#fef3c7; padding:3px 8px; border-radius:12px; float:right; border:1px solid #fde68a; }
.sim-q { font-size:13px; font-weight:600; margin-bottom:6px; color:var(--foreground); padding-right:50px; line-height:1.4; }
.sim-a { font-size:12px; color:var(--muted-fg); display:-webkit-box; -webkit-line-clamp:3; -webkit-box-orient:vertical; overflow:hidden; line-height:1.5; }
/* Header */
.page-hdr { display:flex; align-items:center; justify-content:space-between; margin-bottom:16px; }
.page-hdr h1 { font-size:20px; font-weight:700; margin:0; }
.page-hdr p { font-size:13px; color:var(--muted-fg); margin:2px 0 0; }
/* Stats Row */
.stats-row { display:grid; grid-template-columns: repeat(4, 1fr); gap:16px; margin-bottom:20px; }
.stat-card { background:var(--card); border:1px solid var(--border); border-radius:12px; padding:20px; text-align:center; transition:transform .2s,box-shadow .2s; }
.stat-card:hover { transform:translateY(-2px); box-shadow:0 4px 12px rgba(0,0,0,.06); }
.stat-num { font-size:28px; font-weight:800; color:var(--gold,#B45309); margin-bottom:4px; }
.stat-label { font-size:12px; color:var(--muted-fg); font-weight:600; text-transform:uppercase; letter-spacing:.05em; }
/* Filter Bar */
.filter-bar { display:flex; gap:10px; align-items:center; flex-wrap:wrap; padding:14px 16px; background:var(--card); border:1px solid var(--border); border-radius:10px; margin-bottom:14px; }
.filter-bar label { font-size:11px; font-weight:700; text-transform:uppercase; letter-spacing:.04em; color:var(--muted-fg); }
.input-text, .input-select {
padding:8px 12px; border:1px solid var(--border); border-radius:8px; font:inherit; font-size:13px; background:var(--card); color:var(--foreground); outline:none; box-sizing:border-box;
}
.input-text:focus, .input-select:focus { border-color:var(--primary); box-shadow:0 0 0 3px rgba(59,89,152,0.08); }
.filter-bar .input-text { min-width:200px; }
/* Table */
.table-wrap { overflow-x:auto; background:var(--card); border:1px solid var(--border); border-radius:12px; }
.data-table { width:100%; border-collapse:collapse; font-size:13px; }
.data-table th { background:var(--bg); padding:12px 14px; text-align:left; font-weight:600; color:var(--muted-fg); border-bottom:1px solid var(--border); font-size:11px; text-transform:uppercase; letter-spacing:.04em; }
.data-table td { padding:12px 14px; border-bottom:1px solid var(--border); vertical-align:top; }
.data-table tr:hover { background:var(--bg); }
.data-table .q-text { font-weight:600; color:var(--foreground); }
.data-table .a-text { color:var(--muted-fg); font-size:12.5px; max-width:400px; line-height:1.5; white-space:pre-wrap; }
/* Badges */
.badge-cat { display:inline-block; padding:2px 8px; border-radius:20px; font-size:11px; font-weight:600; }
.badge-original { background:#dcfce7; color:#166534; }
.badge-variant { background:#fef3c7; color:#92400e; }
.cat-tag { display:inline-block; padding:2px 8px; border-radius:6px; font-size:11px; font-weight:600; background:var(--bg); color:var(--muted-fg); border:1px solid var(--border); }
/* Buttons */
.act-btn { background:none; border:none; cursor:pointer; padding:6px; border-radius:6px; font-size:14px; transition:background .2s; }
.act-btn:hover { background:var(--bg); }
.gen-badge { display:inline-flex; align-items:center; gap:4px; padding:4px 8px; border-radius:12px; font-size:11px; background:#ede9fe; color:#6d28d9; font-weight:600; cursor:pointer; transition:background .2s; }
.gen-badge:hover { background:#ddd6fe; }
/* Pagination */
.pagination { display:flex; gap:6px; justify-content:center; margin-top:16px; }
.page-btn { padding:6px 12px; border:1px solid var(--border); border-radius:8px; background:var(--card); cursor:pointer; font-size:13px; font-weight:600; color:var(--muted-fg); transition:all .2s; }
.page-btn:hover { border-color:var(--gold); color:var(--gold); }
.page-btn.active { background:var(--gold); color:#fff; border-color:var(--gold); }
/* Modal */
.modal-overlay { position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.5);z-index:1000;display:none;justify-content:center;align-items:center;backdrop-filter:blur(2px); }
.modal-overlay.show { display:flex; animation:fadeIn .2s; }
@keyframes fadeIn { from{opacity:0} to{opacity:1} }
.modal { background:var(--card); border-radius:14px; width:90%; box-shadow:0 10px 30px rgba(0,0,0,.15); border:1px solid var(--border); }
.modal-head { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid var(--border); }
.modal-head h3 { margin:0; font-size:16px; font-weight:700; color:var(--foreground); }
.modal-close { background:none; border:none; font-size:20px; cursor:pointer; color:var(--muted-fg); }
.modal-body { padding:20px; }
.modal-foot { display:flex; justify-content:flex-end; gap:8px; padding:12px 20px; border-top:1px solid var(--border); background:var(--bg); border-bottom-left-radius:14px; border-bottom-right-radius:14px; }
.form-group { margin-bottom:16px; }
.form-label { display:block; font-size:12px; font-weight:700; color:var(--muted-fg); margin-bottom:6px; }
@media(max-width:768px) {
.stats-row { grid-template-columns: repeat(2,1fr); }
}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FAQ Manager — Canifa AI</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script>
<link rel="stylesheet" href="/static/faqs/faqs.css">
<style>
.topbar h1 { display: flex; align-items: center; gap: 8px; }
.page-title-icon { width: 22px; height: 22px; color: var(--gold, #B45309); flex-shrink: 0; }
</style>
</head>
<body>
<div class="layout-wrapper">
<!-- Simulator Column -->
<div class="sim-col">
<div class="sim-panel">
<div class="sim-title">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="color:var(--primary)"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
Simulator (BM25 + Fuzzy)
</div>
<textarea class="input-text sim-input" id="simQuery" placeholder="Gõ câu hỏi giả lập (VD: 'cho m hoi doi tra ntnao')..."></textarea>
<button class="btn btn-primary btn-sim-action" style="width:100%" onclick="simulateMatch()" id="btnSim">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:6px"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
Test Match
</button>
<div id="simMeta" class="sim-meta"></div>
<div class="sim-results" id="simResults">
<div class="sim-empty-state">
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" style="opacity:0.5; margin-bottom:12px"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/><polyline points="10 9 9 9 8 9"/></svg>
<br>Nhập câu hỏi để test thuật toán.
</div>
</div>
</div>
</div> <!-- /sim-col -->
<!-- Main Column: FAQ Table -->
<div class="main-col">
<div class="page-hdr">
<div>
<h1><svg width="24" height="24" viewBox="0 0 24 24" fill="none" class="page-title-icon" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg> FAQ Manager</h1>
<p>Quản lý câu hỏi thường gặp & AI sinh biến thể</p>
</div>
</div>
<!-- Stats Cards -->
<div class="stats-row" id="statsRow">
<div class="stat-card"><div class="stat-num" id="statTotal">0</div><div class="stat-label">Tổng FAQ</div></div>
<div class="stat-card"><div class="stat-num" id="statOriginals">0</div><div class="stat-label">Câu gốc</div></div>
<div class="stat-card"><div class="stat-num" id="statVariants">0</div><div class="stat-label">Biến thể AI</div></div>
<div class="stat-card"><div class="stat-num" id="statCategories">0</div><div class="stat-label">Danh mục</div></div>
</div>
<!-- Filter Bar -->
<div class="filter-bar">
<label>Danh mục</label>
<select class="input-select" id="filterCat" onchange="loadFaqs()">
<option value="">Tất cả danh mục</option>
</select>
<label>Tìm kiếm</label>
<input type="text" class="input-text" id="searchInput" placeholder="Tìm câu hỏi..." oninput="debounceSearch()">
<label style="display:flex;align-items:center;gap:4px;cursor:pointer;text-transform:none;letter-spacing:normal;font-weight:600">
<input type="checkbox" id="showVariants" checked onchange="loadFaqs()"> Hiện biến thể
</label>
<div class="filter-actions" style="margin-left:auto">
<button class="btn btn-outline btn-sm" onclick="batchGenerate()" id="btnBatch"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:text-bottom"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg> Batch AI</button>
<button class="btn btn-primary btn-sm" onclick="openAddModal()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:text-bottom"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Thêm FAQ</button>
</div>
</div>
<!-- FAQ Table -->
<div class="table-wrap">
<table class="data-table" id="faqTable">
<thead>
<tr>
<th style="width:40px">#</th>
<th>Câu hỏi</th>
<th>Câu trả lời</th>
<th style="width:100px">Danh mục</th>
<th style="width:80px">Loại</th>
<th style="width:100px">Thao tác</th>
</tr>
</thead>
<tbody id="faqBody"><tr><td colspan="6" style="text-align:center;padding:40px;color:var(--muted-fg)">Đang tải...</td></tr></tbody>
</table>
</div>
<!-- Pagination -->
<div class="pagination" id="pagination"></div>
</div> <!-- /main-col -->
</div> <!-- /layout-wrapper -->
<!-- Add/Edit Modal -->
<div class="modal-overlay" id="faqModal">
<div class="modal" style="max-width:560px">
<div class="modal-head">
<h3 id="modalTitle">➕ Thêm FAQ</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<input type="hidden" id="editId">
<div class="form-group"><label class="form-label">Câu hỏi</label><textarea class="input-text" id="fQuestion" rows="2" placeholder="VD: Canifa có chính sách đổi trả không?" style="width:100%"></textarea></div>
<div class="form-group"><label class="form-label">Câu trả lời</label><textarea class="input-text" id="fAnswer" rows="4" placeholder="Nội dung trả lời..." style="width:100%"></textarea></div>
<div class="form-group"><label class="form-label">Danh mục</label>
<select class="input-select" id="fCategory" style="width:100%">
<option value="chung">Chung</option>
<option value="đổi trả">Đổi trả</option>
<option value="vận chuyển">Vận chuyển</option>
<option value="khuyến mãi">Khuyến mãi</option>
<option value="sản phẩm">Sản phẩm</option>
<option value="cửa hàng">Cửa hàng</option>
<option value="thanh toán">Thanh toán</option>
</select>
</div>
</div>
<div class="modal-foot">
<button class="btn btn-outline" onclick="closeModal()">Hủy</button>
<button class="btn btn-primary" onclick="saveFaq()"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px;vertical-align:text-bottom"><path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z"/><polyline points="17 21 17 13 7 13 7 21"/><polyline points="7 3 7 8 15 8"/></svg> Lưu lại</button>
</div>
</div>
</div>
<script src="/static/faqs/faqs.js"></script>
</body>
</html>
let faqs = [];
let currentPage = 1;
const PAGE_SIZE = 30;
let searchTimer = null;
function esc(s) { return s ? s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;') : ''; }
function truncate(s, n) { return s && s.length > n ? s.substring(0, n) + '…' : s; }
async function loadStats() {
try {
const r = await fetch('/api/faqs/stats');
const d = await r.json();
document.getElementById('statTotal').textContent = d.total || 0;
document.getElementById('statOriginals').textContent = d.originals || 0;
document.getElementById('statVariants').textContent = d.variants || 0;
document.getElementById('statCategories').textContent = (d.by_category || []).length;
// populate category filter
const sel = document.getElementById('filterCat');
const current = sel.value;
sel.innerHTML = '<option value="">Tất cả danh mục</option>';
(d.by_category || []).forEach(c => {
sel.innerHTML += `<option value="${esc(c.category)}" ${c.category === current ? 'selected' : ''}>${esc(c.category)} (${c.cnt})</option>`;
});
} catch(e) { console.error('Stats error', e); }
}
async function loadFaqs() {
const cat = document.getElementById('filterCat').value;
const search = document.getElementById('searchInput').value.trim();
const variants = document.getElementById('showVariants').checked;
let url = `/api/faqs?page=${currentPage}&limit=${PAGE_SIZE}`;
if (cat) url += `&category=${encodeURIComponent(cat)}`;
if (search) url += `&search=${encodeURIComponent(search)}`;
if (!variants) url += `&variants=false`;
try {
const r = await fetch(url);
const d = await r.json();
faqs = d.faqs || [];
renderTable(faqs, d.total || 0);
} catch(e) { console.error('Load error', e); }
}
function renderTable(items, total) {
const body = document.getElementById('faqBody');
if (!items.length) {
body.innerHTML = '<tr><td colspan="6" style="text-align:center;padding:40px;color:var(--m)">Không tìm thấy FAQ nào.</td></tr>';
document.getElementById('pagination').innerHTML = '';
return;
}
const offset = (currentPage - 1) * PAGE_SIZE;
body.innerHTML = items.map((f, i) => `
<tr>
<td style="color:var(--m)">${offset + i + 1}</td>
<td><div class="q-text">${esc(f.question)}</div></td>
<td><div class="a-text" title="${esc(f.answer)}">${esc(truncate(f.answer, 80))}</div></td>
<td><span class="cat-tag">${esc(f.category)}</span></td>
<td>${f.is_variant ? '<span class="badge-cat badge-variant">Biến thể</span>' : '<span class="badge-cat badge-original">Gốc</span>'}</td>
<td>
${!f.is_variant ? `<button class="act-btn gen-badge" onclick="generateVariants('${f.id}')" title="AI sinh biến thể"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg> Generate</button>` : ''}
<button class="act-btn" onclick="editFaq('${f.id}')" title="Sửa"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg></button>
<button class="act-btn" onclick="deleteFaq('${f.id}')" title="Xoá"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" y1="11" x2="10" y2="17"/><line x1="14" y1="11" x2="14" y2="17"/></svg></button>
</td>
</tr>
`).join('');
// Pagination
const totalPages = Math.ceil(total / PAGE_SIZE);
const pag = document.getElementById('pagination');
if (totalPages <= 1) { pag.innerHTML = ''; return; }
let html = '';
for (let p = 1; p <= totalPages; p++) {
html += `<button class="page-btn ${p === currentPage ? 'active' : ''}" onclick="goPage(${p})">${p}</button>`;
}
pag.innerHTML = html;
}
function goPage(p) { currentPage = p; loadFaqs(); }
function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => { currentPage = 1; loadFaqs(); }, 300); }
// ── Modal ──
function openAddModal() {
document.getElementById('faqModal').classList.add('show');
document.getElementById('editId').value = '';
document.getElementById('fQuestion').value = '';
document.getElementById('fAnswer').value = '';
document.getElementById('fCategory').value = 'chung';
document.getElementById('modalTitle').innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:4px;color:var(--primary)"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg> Thêm FAQ';
}
function closeModal() { document.getElementById('faqModal').classList.remove('show'); }
function editFaq(id) {
const f = faqs.find(x => x.id === id);
if (!f) return;
document.getElementById('faqModal').classList.add('show');
document.getElementById('editId').value = id;
document.getElementById('fQuestion').value = f.question;
document.getElementById('fAnswer').value = f.answer;
document.getElementById('fCategory').value = f.category;
document.getElementById('modalTitle').innerHTML = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" style="vertical-align:middle;margin-right:4px;color:var(--primary)"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg> Sửa FAQ';
}
async function saveFaq() {
const id = document.getElementById('editId').value;
const body = {
question: document.getElementById('fQuestion').value.trim(),
answer: document.getElementById('fAnswer').value.trim(),
category: document.getElementById('fCategory').value,
};
if (!body.question || !body.answer) { alert('Vui lòng điền đầy đủ câu hỏi và trả lời'); return; }
try {
await fetch(id ? `/api/faqs/${id}` : '/api/faqs', {
method: id ? 'PUT' : 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(body)
});
closeModal();
await loadStats();
await loadFaqs();
} catch(e) { alert('Lỗi: ' + e.message); }
}
async function deleteFaq(id) {
if (!confirm('Xoá FAQ này và tất cả biến thể liên quan?')) return;
try {
await fetch(`/api/faqs/${id}`, { method: 'DELETE' });
await loadStats();
await loadFaqs();
} catch(e) { alert('Lỗi: ' + e.message); }
}
// ── AI Generation ──
async function generateVariants(faqId) {
const btn = event.target;
const ogText = btn.innerHTML;
btn.innerHTML = '⏳ Đang sinh...';
btn.disabled = true;
try {
const r = await fetch('/api/faqs/generate-variants', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ faq_id: faqId, count: 20 })
});
const d = await r.json();
if (d.status === 'success') {
alert(`✅ Đã sinh ${d.inserted} biến thể!`);
await loadStats();
await loadFaqs();
} else {
alert('Lỗi: ' + JSON.stringify(d));
}
} catch(e) {
alert('Lỗi AI: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
async function batchGenerate() {
const cat = document.getElementById('filterCat').value;
const msg = cat ? `Sinh biến thể AI cho tất cả FAQ gốc trong danh mục "${cat}"?` : 'Sinh biến thể AI cho TẤT CẢ FAQ gốc? (có thể mất vài phút)';
if (!confirm(msg)) return;
const btn = document.getElementById('btnBatch');
btn.innerHTML = '⏳ Đang xử lý...';
btn.disabled = true;
try {
const r = await fetch('/api/faqs/batch-generate', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ category: cat || null, count_per_faq: 10 })
});
const d = await r.json();
alert(`✅ Đã xử lý ${d.originals_processed} FAQ gốc, sinh ${d.total_variants_inserted} biến thể.${d.errors?.length ? `\n⚠️ ${d.errors.length} lỗi.` : ''}`);
await loadStats();
await loadFaqs();
} catch(e) {
alert('Lỗi: ' + e.message);
} finally {
btn.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z"/></svg> Batch AI';
btn.disabled = false;
}
}
// ── Simulator ──
async function simulateMatch() {
const query = document.getElementById('simQuery').value.trim();
if (!query) return;
const btn = document.getElementById('btnSim');
const resDiv = document.getElementById('simResults');
const metaDiv = document.getElementById('simMeta');
btn.innerHTML = '⏳ Đang Match...';
btn.disabled = true;
resDiv.innerHTML = '<div style="text-align:center;padding:20px"><div class="spinner"></div></div>';
try {
const r = await fetch('/api/faqs/simulate-match', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ query })
});
const d = await r.json();
metaDiv.textContent = `⚡ Search Time: ${d.elapsed_ms || 0}ms`;
if (!d.matches || !d.matches.length) {
resDiv.innerHTML = '<div style="text-align:center;color:var(--error);font-size:12px;padding:20px">Không có kết quả Match nào > 5%.</div>';
return;
}
resDiv.innerHTML = d.matches.map(m => `
<div class="sim-card">
<span class="sim-score">${m.score}%</span>
<div class="sim-q">${esc(m.question)}</div>
<div class="sim-a" title="${esc(m.answer)}">${esc(m.answer)}</div>
<div style="margin-top:6px">
<span class="cat-tag">${esc(m.category)}</span>
${m.is_variant ? '<span class="badge-cat badge-variant" style="transform:scale(0.85);transform-origin:left">Biến thể</span>' : '<span class="badge-cat badge-original" style="transform:scale(0.85);transform-origin:left">Gốc</span>'}
</div>
</div>
`).join('');
} catch(e) {
resDiv.innerHTML = `<div style="color:var(--error);font-size:12px">Lỗi: ${e.message}</div>`;
} finally {
btn.innerHTML = '🔍 Test Match';
btn.disabled = false;
}
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') closeModal();
if (e.key === 'Enter' && e.ctrlKey && document.activeElement.id === 'simQuery') {
simulateMatch();
}
});
// ── Init ──
(async () => {
await loadStats();
await loadFaqs();
})();
......@@ -32,6 +32,19 @@
onchange="handleDeviceIdChange()">
</div>
<!-- Documentation Buttons -->
<div style="display: flex; gap: 8px; padding-right: 20px;">
<button onclick="window.open('/static/lead_flow/lead_docs.html', '_blank')" style="height:28px; padding:0 12px; border-radius:4px; font-size:12px; font-weight:600; background-color: #3b82f6; color: white; border: none; cursor: pointer;">
Tài liệu (Web) ▾
</button>
<button onclick="window.open('/static/lead_flow/lead_search_diagram.svg', '_blank')" style="height:28px; padding:0 12px; border-radius:4px; font-size:12px; font-weight:600; background-color: #10b981; color: white; border: none; cursor: pointer;">
Sơ đồ Kiến trúc ▾
</button>
<button onclick="window.open('/static/lead_flow/lead_search_logic_flow.html', '_blank')" style="height:28px; padding:0 12px; border-radius:4px; font-size:12px; font-weight:600; background-color: #8b5cf6; color: white; border: none; cursor: pointer;">
Luồng Logic ▾
</button>
</div>
<div class="stage-node stage-1" id="stageNode1">
<div class="stage-dot" id="stageDot1">1</div>
<span class="stage-label" id="stageLabel1">Awareness</span>
......@@ -236,8 +249,8 @@
</div>
<!-- ═══ PRODUCT POPUP ═══ -->
<div class="lf-overlay" id="lfOverlay" onclick="closeProductPopup()"></div>
<div class="lf-popup" id="lfPopup">
<div class="lf-overlay" id="lfOverlay" style="display: none;" onclick="closeProductPopup()"></div>
<div class="lf-popup" id="lfPopup" style="display: none;">
<div class="lf-popup-header">
<h3>THÔNG TIN SẢN PHẨM</h3>
<button class="lf-popup-close" onclick="closeProductPopup()">✕</button>
......
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI Stylist - Lead Search Tool Architecture</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 40px 20px;
background-color: #fcfcfc;
}
h1 {
font-size: 2.2em;
color: #111;
border-bottom: 2px solid #eaeaea;
padding-bottom: 10px;
margin-bottom: 30px;
}
h2 {
font-size: 1.5em;
color: #222;
margin-top: 40px;
}
p {
margin-bottom: 15px;
font-size: 1.05em;
}
.highlight {
background-color: #f0f7ff;
border-left: 4px solid #0066cc;
padding: 15px;
margin: 20px 0;
border-radius: 0 4px 4px 0;
}
ul {
padding-left: 20px;
}
li {
margin-bottom: 10px;
}
code {
background-color: #f4f4f4;
padding: 2px 6px;
border-radius: 4px;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
}
pre {
background-color: #1e1e1e;
color: #d4d4d4;
padding: 20px;
border-radius: 8px;
overflow-x: auto;
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo, monospace;
font-size: 0.9em;
line-height: 1.5;
}
</style>
</head>
<body>
<a href="/static/main.html?page=lead_flow/lead-flow.html" style="display: inline-block; margin-bottom: 20px; padding: 8px 16px; background: #f0f0f0; color: #333; text-decoration: none; border-radius: 6px; font-weight: 500; font-size: 0.9em; border: 1px solid #ddd;">
&larr; Quay lại trang Lead Flow
</a>
<h1>Canifa AI Stylist - Lead Search Architecture</h1>
<p class="highlight"><strong>Bản vẽ chi tiết luồng hoạt động & Kiến trúc tối ưu Token (Cập nhật T4/2026)</strong></p>
<h2>1. Tổng quan (Overview)</h2>
<p>Lead Search Tool là trung tâm truy xuất dữ liệu sản phẩm của Canifa AI Stylist. Sau đợt tái cấu trúc, hệ thống đã loại bỏ hoàn toàn OpenAI Embedding, thay thế bằng cơ chế <strong>Cascading Search 7 tầng</strong> (NGRAMBF + BITMAP Tags), mang lại tốc độ truy vấn &lt; 300ms. Đồng thời, cấu trúc dữ liệu trả về được tối ưu hóa để giảm thiểu Token tiêu thụ cho LLM.</p>
<h2>2. Kiến trúc Tối ưu Token (Token-Saving Architecture)</h2>
<p>Thay vì trả về 15-20 sản phẩm chi tiết (gây tràn Context Window), hệ thống hiện tại sử dụng chiến lược: <strong>"Lấy ít sản phẩm chính, móc nhiều sản phẩm gợi ý"</strong>.</p>
<ul>
<li><strong>Main Products (Top 3):</strong> Trả về tối đa 3 sản phẩm match nhất với đầy đủ thông tin chi tiết (Size, Giá, Mô tả ngắn &lt;200 ký tự, Tồn kho thực tế, Lý do phối đồ AI).</li>
<li><strong>Suggest Items (Phối cùng):</strong> Cho mỗi sản phẩm chính, lấy thêm tối đa 2 sản phẩm phối cùng (chỉ gồm SKU, Tên, Giá).</li>
<li><strong>Similar Items (Tương tự):</strong> Cho mỗi sản phẩm chính, lấy thêm tối đa 3 sản phẩm thay thế (chỉ gồm SKU, Tên, Giá).</li>
</ul>
<p class="highlight"><em>Kết quả: LLM vẫn có 3 lựa chọn chính và 15 lựa chọn phụ để tư vấn, nhưng số lượng Token giảm xuống 75%.</em></p>
<h2>3. Luồng dữ liệu (Data Flow)</h2>
<ol>
<li><strong>Phân tích Ý định (Input parsing):</strong> Nhận các tham số từ Agent: keywords, tags, color, size, price_min, price_max.</li>
<li><strong>Cascading Search (7 Tiers):</strong> Truy xuất StarRocks qua 7 tầng rớt mạng. Tầng 1 (Keywords + Hard Filters), Tầng 2 (Tags + Filters), Tầng 5-6 (Nới giá 1.5x - 2.0x).</li>
<li><strong>Lọc Tồn Kho (Stock API):</strong> Gửi các mã sản phẩm sang Canifa Stock API qua httpx. Chỉ giữ lại những mã đang CÒN HÀNG, kèm theo danh sách chi tiết các size còn trống.</li>
<li><strong>Bổ sung Styling (SQLite 123.db):</strong> Đọc file SQLite local để lấy <code>ai_description</code> và <code>ai_matches</code> (lý do phối đồ), cung cấp 'bộ não' thời trang cho LLM chốt sale.</li>
<li><strong>Định dạng Đầu ra (Output Formatter):</strong> Gom nhóm dữ liệu, cắt ngắn mô tả, fetch suggest/similar items và trả về JSON nhẹ nhất cho AI.</li>
</ol>
<h2>4. Đầu ra của hệ thống (JSON Format)</h2>
<p>Dưới đây là cấu trúc minh hoạ cho 1 sản phẩm chính sau khi được format gọn nhẹ:</p>
<pre><code>{
"sku": "8TP24S001-SB060",
"name": "Áo polo nam phom regular",
"price": 349000,
"in_stock": true,
"stock": [{"size": "M", "qty": 10}, {"size": "L", "qty": 14}],
"ai_matches": {
"phoi_voi": ["Quần khaki nam"],
"ly_do": "Màu áo hợp quần khaki tạo vẻ thanh lịch..."
},
"suggest_items": [
{"sku": "8QK24S002", "name": "Quần khaki nam", "price": 450000}
],
"similar_items": [
{"sku": "8TP24S003", "name": "Áo polo sọc", "price": 399000}
]
}</code></pre>
</body>
</html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI - Lead Search Flow</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
background-color: #f8f9fa; /* Changed to white background */
margin: 0;
padding: 40px;
display: flex;
flex-direction: column;
align-items: center;
}
.svg-container {
width: 100%;
max-width: 1200px;
display: flex;
justify-content: center;
}
img {
max-width: 100%;
height: auto;
border-radius: 12px;
box-shadow: 0 10px 30px rgba(0,0,0,0.5);
}
</style>
</head>
<body>
<div class="svg-container">
<img src="/static/lead_flow/lead_search_diagram.svg" alt="Lead Search Flow Diagram">
</div>
</body>
</html>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 820" width="1200" height="820">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial, sans-serif; }
.title { font-size: 20px; font-weight: 600; fill: #111827; }
.subtitle { font-size: 12px; font-weight: 400; fill: #6b7280; }
.node-label { font-size: 13px; font-weight: 500; fill: #111827; }
.node-sub { font-size: 11px; font-weight: 400; fill: #6b7280; }
.type-label { font-size: 10px; font-weight: 500; fill: #9ca3af; letter-spacing: 0.08em; }
.section-label { font-size: 11px; font-weight: 500; fill: #6b7280; letter-spacing: 0.06em; }
.arrow-label { font-size: 10px; font-weight: 500; fill: #6b7280; }
.legend-label { font-size: 11px; fill: #374151; }
.tier-num { font-size: 11px; font-weight: 600; fill: #374151; }
.tier-sub { font-size: 10px; fill: #9ca3af; }
</style>
<defs>
<marker id="arr-blue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#3b82f6"/>
</marker>
<marker id="arr-green" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#059669"/>
</marker>
<marker id="arr-gray" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#9ca3af"/>
</marker>
</defs>
<rect width="1200" height="820" fill="#ffffff"/>
<text x="48" y="44" class="title">Lead Search Tool — Kiến trúc 7-Tầng &amp; Tối ưu Token</text>
<text x="48" y="64" class="subtitle">Canifa AI Stylist · Cascading Search Strategy · T4/2026</text>
<rect x="32" y="82" width="1136" height="80" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="96" class="section-label">INPUT</text>
<rect x="60" y="102" width="160" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="140" y="123" class="type-label" text-anchor="middle">AGENT OUTPUT</text>
<text x="140" y="139" class="node-label" text-anchor="middle">AI Intent</text>
<rect x="260" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="330" y="123" class="type-label" text-anchor="middle">TEXT</text>
<text x="330" y="139" class="node-label" text-anchor="middle">Keywords</text>
<rect x="440" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="510" y="123" class="type-label" text-anchor="middle">ARRAY</text>
<text x="510" y="139" class="node-label" text-anchor="middle">Tags</text>
<rect x="620" y="102" width="300" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="770" y="123" class="type-label" text-anchor="middle">HARD FILTERS</text>
<text x="770" y="139" class="node-label" text-anchor="middle">Color · Size · Price · Gender · Line</text>
<line x1="220" y1="126" x2="256" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="220" y1="126" x2="436" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="220" y1="126" x2="616" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<rect x="32" y="182" width="1136" height="290" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="196" class="section-label">CASCADING SEARCH — STARROCKS</text>
<rect x="48" y="208" width="144" height="56" rx="4" fill="#dbeafe" stroke="#1d4ed8" stroke-width="1.5"/>
<text x="60" y="226" class="tier-num">T1 // Keywords</text>
<text x="60" y="242" class="tier-sub">NGRAMBF idx</text>
<line x1="192" y1="236" x2="203" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="206" y="208" width="144" height="56" rx="4" fill="#dbeafe" stroke="#1d4ed8" stroke-width="1.5"/>
<text x="218" y="226" class="tier-num">T2 // Tags</text>
<text x="218" y="242" class="tier-sub">BITMAP idx</text>
<line x1="350" y1="236" x2="361" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="364" y="208" width="144" height="56" rx="4" fill="#e0f2fe" stroke="#0369a1" stroke-width="1.5"/>
<text x="376" y="226" class="tier-num">T3 // Hard Filters</text>
<text x="376" y="242" class="tier-sub">Fixed attrs only</text>
<line x1="508" y1="236" x2="519" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="522" y="208" width="144" height="56" rx="4" fill="#fef3c7" stroke="#b45309" stroke-width="1.5"/>
<text x="534" y="226" class="tier-num">T4 // Drop Gender</text>
<text x="534" y="242" class="tier-sub">Bo gender filter</text>
<line x1="666" y1="236" x2="677" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="680" y="208" width="144" height="56" rx="4" fill="#fce7f3" stroke="#9d174d" stroke-width="1.5"/>
<text x="692" y="226" class="tier-num">T5 // Price x1.5</text>
<text x="692" y="242" class="tier-sub">Noi rong gia</text>
<line x1="824" y1="236" x2="835" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="838" y="208" width="144" height="56" rx="4" fill="#fce7f3" stroke="#9d174d" stroke-width="1.5"/>
<text x="850" y="226" class="tier-num">T6 // Price x2.0</text>
<text x="850" y="242" class="tier-sub">Noi rong hon</text>
<line x1="982" y1="236" x2="993" y2="236" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>
<rect x="996" y="208" width="144" height="56" rx="4" fill="#f3f4f6" stroke="#374151" stroke-width="1.5"/>
<text x="1008" y="226" class="tier-num">T7 // Bo Price</text>
<text x="1008" y="242" class="tier-sub">Khong gioi han</text>
<rect x="200" y="296" width="800" height="48" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>
<text x="600" y="317" class="type-label" text-anchor="middle">RESULT</text>
<text x="600" y="333" class="node-label" text-anchor="middle">Top 12 rows tu tang co ket qua → deduplicate → top 3 base_codes</text>
<line x1="120" y1="264" x2="120" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="278" y1="264" x2="278" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="436" y1="264" x2="436" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="594" y1="264" x2="594" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="752" y1="264" x2="752" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="910" y1="264" x2="910" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<line x1="1068" y1="264" x2="1068" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>
<rect x="32" y="492" width="1136" height="120" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="506" class="section-label">ENRICHMENT</text>
<rect x="60" y="516" width="220" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="170" y="535" class="type-label" text-anchor="middle">EXTERNAL API</text>
<text x="170" y="551" class="node-label" text-anchor="middle">Canifa Stock API</text>
<text x="170" y="567" class="node-sub" text-anchor="middle">canifa.com/v1/middleware/...</text>
<text x="170" y="580" class="node-sub" text-anchor="middle">Loc con hang · Size · Qty</text>
<rect x="340" y="516" width="200" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="440" y="535" class="type-label" text-anchor="middle">LOCAL DB</text>
<text x="440" y="551" class="node-label" text-anchor="middle">SQLite 123.db</text>
<text x="440" y="567" class="node-sub" text-anchor="middle">ai_description</text>
<text x="440" y="580" class="node-sub" text-anchor="middle">ai_matches (phoi do)</text>
<line x1="600" y1="344" x2="600" y2="490" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="560" y1="492" x2="282" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<line x1="600" y1="492" x2="442" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>
<rect x="32" y="632" width="1136" height="160" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>
<text x="46" y="646" class="section-label">JSON OUTPUT — TOKEN-SAVING STRUCTURE</text>
<rect x="60" y="658" width="240" height="120" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="180" y="676" class="type-label" text-anchor="middle">MAIN RESULTS</text>
<text x="180" y="693" class="node-label" text-anchor="middle">Main Products (Max 3)</text>
<text x="180" y="710" class="node-sub" text-anchor="middle">sku · name · price · description</text>
<text x="180" y="724" class="node-sub" text-anchor="middle">stock[ ] · in_stock: bool</text>
<text x="180" y="738" class="node-sub" text-anchor="middle">ai_matches · reason</text>
<text x="180" y="752" class="node-sub" text-anchor="middle">~900 tokens / SP</text>
<rect x="360" y="658" width="200" height="120" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>
<text x="460" y="676" class="type-label" text-anchor="middle">SUGGEST</text>
<text x="460" y="693" class="node-label" text-anchor="middle">Suggest Items</text>
<text x="460" y="710" class="node-sub" text-anchor="middle">Max 2 SP phoi cung</text>
<text x="460" y="726" class="node-sub" text-anchor="middle">sku · name · price</text>
<text x="460" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>
<rect x="620" y="658" width="200" height="120" rx="4" fill="#fefce8" stroke="#ca8a04" stroke-width="1"/>
<text x="720" y="676" class="type-label" text-anchor="middle">SIMILAR</text>
<text x="720" y="693" class="node-label" text-anchor="middle">Similar Items</text>
<text x="720" y="710" class="node-sub" text-anchor="middle">Max 3 SP thay the</text>
<text x="720" y="726" class="node-sub" text-anchor="middle">sku · name · price</text>
<text x="720" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>
<rect x="880" y="658" width="260" height="120" rx="4" fill="#fff7ed" stroke="#ea580c" stroke-width="1.5"/>
<text x="1010" y="676" class="type-label" text-anchor="middle">TOKEN SAVING</text>
<text x="1010" y="693" class="node-label" text-anchor="middle">Giam 75% Token</text>
<text x="1010" y="710" class="node-sub" text-anchor="middle">Cu: 15-20 SP full detail</text>
<text x="1010" y="726" class="node-sub" text-anchor="middle">Moi: 3 SP + 15 SP phu</text>
<text x="1010" y="742" class="node-sub" text-anchor="middle">Context Window: an toan</text>
<line x1="170" y1="588" x2="180" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>
<line x1="440" y1="588" x2="460" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>
<text x="48" y="808" class="legend-label">Legend:</text>
<line x1="108" y1="803" x2="138" y2="803" stroke="#3b82f6" stroke-width="2" marker-end="url(#arr-blue)"/>
<text x="144" y="808" class="legend-label">Primary data flow</text>
<line x1="278" y1="803" x2="308" y2="803" stroke="#059669" stroke-width="2" marker-end="url(#arr-green)"/>
<text x="314" y="808" class="legend-label">Enrich / Store</text>
<line x1="430" y1="803" x2="460" y2="803" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arr-gray)"/>
<text x="466" y="808" class="legend-label">Fallback (tang rot)</text>
</svg>
\ No newline at end of file
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Canifa AI - Lead Search Simulator</title>
<style>
*{margin:0;padding:0;box-sizing:border-box;}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;background:#ffffff;color:#111827;height:100vh;overflow:hidden;}
.top-bar{position:sticky;top:0;z-index:100;background:rgba(255,255,255,.95);backdrop-filter:blur(8px);border-bottom:1px solid #e5e7eb;padding:10px 20px;display:flex;align-items:center;gap:14px;height:48px;}
.back-btn{padding:6px 12px;background:#f9fafb;border:1px solid #e5e7eb;border-radius:6px;color:#374151;font-size:12px;cursor:pointer;text-decoration:none;}
.back-btn:hover{background:#f3f4f6;}
.top-title{font-size:14px;font-weight:600;}
.top-sub{font-size:11px;color:#6b7280;}
.layout{display:flex;height:calc(100vh - 48px);}
/* LEFT PANEL — Chat Input */
.left-panel{width:340px;min-width:300px;border-right:1px solid #e5e7eb;display:flex;flex-direction:column;background:#fafafa;}
.left-header{padding:14px 16px 10px;border-bottom:1px solid #e5e7eb;background:#fff;}
.left-header h3{font-size:13px;font-weight:600;margin-bottom:6px;}
.input-wrap{display:flex;gap:6px;}
.input-wrap input{flex:1;padding:8px 12px;border:1.5px solid #e5e7eb;border-radius:8px;font-size:13px;outline:none;}
.input-wrap input:focus{border-color:#3b82f6;}
.input-wrap button{padding:8px 14px;background:#3b82f6;color:#fff;border:none;border-radius:8px;font-size:12px;font-weight:600;cursor:pointer;}
.input-wrap button:hover{background:#2563eb;}
.input-wrap button:disabled{background:#9ca3af;cursor:not-allowed;}
/* Log stream */
.log-stream{flex:1;overflow-y:auto;padding:10px 14px;display:flex;flex-direction:column;gap:8px;}
.log-item{padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.5;border:1px solid #e5e7eb;background:#fff;}
.log-item.running{border-color:#f59e0b;background:#fffbeb;animation:pulse 1s infinite alternate;}
@keyframes pulse{from{opacity:.85}to{opacity:1}}
.log-item.done{border-color:#10b981;background:#f0fdf4;}
.log-item.miss{border-color:#ef4444;background:#fef2f2;opacity:.7;}
.log-item.exit{border-color:#f59e0b;background:#fffbeb;}
.log-item .tag{display:inline-block;padding:1px 6px;border-radius:3px;font-size:10px;font-weight:600;margin-right:4px;}
.tag-green{background:#dcfce7;color:#166534;}
.tag-red{background:#fee2e2;color:#991b1b;}
.tag-blue{background:#dbeafe;color:#1e40af;}
.tag-amber{background:#fef3c7;color:#92400e;}
.tag-gray{background:#f3f4f6;color:#6b7280;}
.log-item strong{color:#111827;}
.log-item .detail{color:#6b7280;margin-top:4px;}
.log-item .products{margin-top:6px;display:flex;flex-direction:column;gap:3px;}
.log-item .product-row{font-size:11px;padding:3px 6px;background:#f9fafb;border-radius:4px;border:1px solid #e5e7eb;}
/* RIGHT PANEL — SVG Diagram */
.right-panel{flex:1;overflow:auto;padding:12px;background:#fff;}
.right-panel object{width:100%;height:auto;}
/* SVG highlight classes — injected into SVG DOM */
.svg-glow-green{filter:drop-shadow(0 0 6px rgba(16,185,129,.6));transition:filter .3s;}
.svg-glow-amber{filter:drop-shadow(0 0 6px rgba(245,158,11,.6));transition:filter .3s;}
.svg-glow-red{filter:drop-shadow(0 0 6px rgba(239,68,68,.5));transition:filter .3s;}
</style>
</head>
<body>
<div class="top-bar">
<a class="back-btn" href="/static/main.html?page=lead_flow/lead-flow.html">← Quay lại</a>
<div>
<div class="top-title">🧪 Lead Search Simulator</div>
<div class="top-sub">Nhập query · SSE Streaming · Real StarRocks DB</div>
</div>
</div>
<div class="layout">
<!-- LEFT: Chat input + log -->
<div class="left-panel">
<div class="left-header">
<h3>💬 Nhập tin nhắn khách hàng</h3>
<div class="input-wrap">
<input type="text" id="queryInput" placeholder="VD: tìm áo phông cotton cho nữ dưới 300k" />
<button id="runBtn" onclick="runSimulate()">▶ Go</button>
</div>
</div>
<div class="log-stream" id="logStream"></div>
</div>
<!-- RIGHT: SVG Diagram (inline via object) -->
<div class="right-panel">
<object id="svgObj" type="image/svg+xml" data="lead_search_logic_flow.svg" style="width:100%;"></object>
</div>
</div>
<script>
const logStream = document.getElementById('logStream');
const queryInput = document.getElementById('queryInput');
const runBtn = document.getElementById('runBtn');
let evtSource = null;
let svgDoc = null;
// Wait for SVG to load
document.getElementById('svgObj').addEventListener('load', function() {
svgDoc = this.contentDocument;
});
function highlight(id, color) {
if (!svgDoc) return;
const el = svgDoc.getElementById(id);
if (!el) return;
if (color === 'green') { el.setAttribute('stroke', '#10b981'); el.setAttribute('stroke-width', '3'); el.style.filter = 'drop-shadow(0 0 8px rgba(16,185,129,.5))'; }
else if (color === 'amber') { el.setAttribute('stroke', '#f59e0b'); el.setAttribute('stroke-width', '3'); el.style.filter = 'drop-shadow(0 0 8px rgba(245,158,11,.5))'; }
else if (color === 'red') { el.setAttribute('stroke', '#ef4444'); el.setAttribute('stroke-width', '3'); el.style.filter = 'drop-shadow(0 0 6px rgba(239,68,68,.4))'; }
else if (color === 'gray') { el.style.opacity = '0.3'; }
}
function resetSvg() {
if (!svgDoc) return;
['svg-user','svg-classifier','svg-early-exit','svg-tool','svg-tier-1','svg-tier-2','svg-tier-3','svg-tier-4','svg-tier-5','svg-tier-6','svg-tier-7','svg-stock','svg-stylist'].forEach(id => {
const el = svgDoc.getElementById(id);
if (el) { el.style.filter = ''; el.style.opacity = ''; el.removeAttribute('stroke-width'); }
});
}
function addLog(html, cls) {
const d = document.createElement('div');
d.className = 'log-item ' + (cls||'');
d.innerHTML = html;
logStream.appendChild(d);
logStream.scrollTop = logStream.scrollHeight;
return d;
}
function runSimulate() {
const q = queryInput.value.trim();
if (!q) return;
if (evtSource) { evtSource.close(); evtSource = null; }
runBtn.disabled = true; runBtn.textContent = '⏳';
logStream.innerHTML = '';
resetSvg();
addLog(`<span class="tag tag-blue">USER</span> <strong>"${esc(q)}"</strong>`, 'done');
highlight('svg-user', 'green');
const classifierLog = addLog(`<span class="tag tag-amber">AI #1</span> 🧠 Classifier đang phân tích...`, 'running');
highlight('svg-classifier', 'amber');
const url = `/api/lead-flow/simulate?query=${encodeURIComponent(q)}`;
evtSource = new EventSource(url);
evtSource.addEventListener('classifier', e => {
const d = JSON.parse(e.data);
highlight('svg-classifier', 'green');
classifierLog.className = 'log-item done';
if (d.early_exit) {
classifierLog.innerHTML = `<span class="tag tag-amber">EARLY EXIT</span> <strong>⚡ Không cần tool</strong>
<div class="detail">${esc(d.reasoning||'')}</div>
<div style="margin-top:6px;padding:6px 8px;background:#fffbeb;border-radius:6px;border:1px solid #fde68a;font-size:11px;">${esc(d.ai_response||'')}</div>
<div class="detail">${d.elapsed_ms}ms</div>`;
highlight('svg-early-exit', 'amber');
for (let i=1;i<=7;i++) highlight('svg-tier-'+i, 'gray');
highlight('svg-tool', 'gray');
highlight('svg-stock', 'gray');
highlight('svg-stylist', 'gray');
} else {
let tags = '';
const a = d.args || {};
if (a.product_line_vn?.length) tags += `<span class="tag tag-blue">📦 ${a.product_line_vn.join(', ')}</span> `;
if (a.keywords?.length) tags += `<span class="tag tag-green">🔑 ${a.keywords.join(', ')}</span> `;
if (a.gender_by_product) tags += `<span class="tag tag-gray">👤 ${a.gender_by_product}</span> `;
if (a.master_color) tags += `<span class="tag tag-amber">🎨 ${a.master_color}</span> `;
if (a.price_max) tags += `<span class="tag tag-gray">💰 ≤${Number(a.price_max).toLocaleString()}đ</span> `;
classifierLog.innerHTML = `<span class="tag tag-green">AI #1</span> <strong>→ ${d.tool_name}</strong> <span class="detail">${d.elapsed_ms}ms</span>
<div class="detail">${esc(d.reasoning||'')}</div>
<div style="margin-top:6px;display:flex;flex-wrap:wrap;gap:4px;">${tags}</div>`;
highlight('svg-tool', 'green');
}
});
evtSource.addEventListener('tier', e => {
const d = JSON.parse(e.data);
const svgId = 'svg-tier-' + d.tier;
if (d.is_winner) {
highlight(svgId, 'green');
let prodHtml = '';
if (d.products?.length) {
prodHtml = '<div class="products">' + d.products.map(p => `<div class="product-row">🏷️ <strong>${esc(p.name)}</strong> · ${p.sku} · <span style="color:#059669">${Number(p.price).toLocaleString()}đ</span></div>`).join('') + '</div>';
}
addLog(`<span class="tag tag-green">T${d.tier} ✓ WINNER</span> <strong>${d.count} sản phẩm</strong> <span class="detail">${d.elapsed_ms}ms</span>${prodHtml}`, 'done');
} else if (d.count > 0) {
highlight(svgId, 'green');
addLog(`<span class="tag tag-blue">T${d.tier}</span> ${d.count} SP (not winner) · ${d.elapsed_ms}ms`, '');
} else {
highlight(svgId, 'red');
addLog(`<span class="tag tag-red">T${d.tier} ✗</span> 0 sản phẩm · ${d.elapsed_ms}ms`, 'miss');
}
});
evtSource.addEventListener('stock', e => {
const d = JSON.parse(e.data);
highlight('svg-stock', d.timed_out ? 'red' : 'green');
addLog(`<span class="tag tag-green">📦 STOCK</span> Còn hàng: <strong>${d.in_stock}</strong> · Hết: ${d.dropped} · ${d.elapsed_ms}ms${d.timed_out ? ' ⏰ Timeout' : ''}`, d.timed_out ? 'miss' : 'done');
});
evtSource.addEventListener('done', e => {
const d = JSON.parse(e.data);
evtSource.close(); evtSource = null;
runBtn.disabled = false; runBtn.textContent = '▶ Go';
if (!d.early_exit) {
addLog(`<span class="tag tag-green">✅ DONE</span> Winner: <strong>T${d.hit_tier||'—'}</strong> · ${d.total_products||0} SP · <strong>${d.total_ms}ms</strong>`, 'done');
}
// Gray out tiers that never ran
for (let i=1;i<=7;i++) {
if (svgDoc) {
const el = svgDoc.getElementById('svg-tier-'+i);
if (el && !el.style.filter) highlight('svg-tier-'+i, 'gray');
}
}
});
evtSource.onerror = () => { evtSource.close(); evtSource=null; runBtn.disabled=false; runBtn.textContent='▶ Go'; };
}
function esc(s){const d=document.createElement('div');d.textContent=s;return d.innerHTML;}
queryInput.addEventListener('keydown', e => { if(e.key==='Enter') runSimulate(); });
</script>
</body>
</html>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 1100" width="960" height="1100">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Helvetica Neue', Arial, sans-serif; }
.title { font-size: 18px; font-weight: 600; fill: #111827; }
.subtitle { font-size: 12px; fill: #6b7280; }
.node-label { font-size: 13px; font-weight: 500; fill: #111827; }
.node-sub { font-size: 11px; fill: #6b7280; }
.type-label { font-size: 10px; font-weight: 600; fill: #9ca3af; letter-spacing: 0.1em; }
.tier-label { font-size: 11px; font-weight: 500; fill: #374151; }
.tier-desc { font-size: 10px; fill: #6b7280; }
.legend-text { font-size: 11px; fill: #374151; }
.decision-text { font-size: 11px; font-weight: 500; fill: #111827; }
.arrow-label { font-size: 10px; fill: #3b82f6; font-weight: 500; }
.yes-label { font-size: 10px; fill: #059669; font-weight: 600; }
.no-label { font-size: 10px; fill: #dc2626; font-weight: 600; }
</style>
<defs>
<marker id="arrow-blue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#3b82f6"/>
</marker>
<marker id="arrow-green" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#059669"/>
</marker>
<marker id="arrow-red" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#dc2626"/>
</marker>
<marker id="arrow-gray" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#9ca3af"/>
</marker>
</defs>
<!-- Background -->
<rect width="960" height="1100" fill="#ffffff"/>
<!-- Title -->
<text x="480" y="36" text-anchor="middle" class="title">Lead Search Tool — Logic Flow</text>
<text x="480" y="54" text-anchor="middle" class="subtitle">Canifa AI Stylist · 2-Agent Architecture · 7-Tier Cascading Search</text>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 1: USER INPUT -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="92" class="type-label">INPUT</text>
<!-- User node -->
<circle id="svg-user" cx="480" cy="100" r="22" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1.5"/>
<text x="480" y="104" text-anchor="middle" class="node-label">👤 User</text>
<!-- Arrow: User → Classifier -->
<line x1="480" y1="122" x2="480" y2="150" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<rect x="442" y="130" width="76" height="14" fill="#ffffff" rx="2"/>
<text x="480" y="140" text-anchor="middle" class="arrow-label">query + insight</text>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 2: AI #1 — CLASSIFIER -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="172" class="type-label">AI #1 — CLASSIFIER</text>
<!-- Classifier box (hexagon-style rounded rect with double border) -->
<rect id="svg-classifier" x="340" y="155" width="280" height="54" rx="8" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5"/>
<text x="480" y="177" text-anchor="middle" class="node-label">🧠 Classifier (Tool Router)</text>
<text x="480" y="198" text-anchor="middle" class="node-sub">with_structured_output → ClassifierOutput</text>
<!-- Decision Diamond: Has Tool? -->
<polygon points="480,230 540,260 480,290 420,260" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1.5"/>
<text x="480" y="263" text-anchor="middle" class="decision-text">tool_name?</text>
<!-- Arrow: Classifier → Decision -->
<line x1="480" y1="209" x2="480" y2="230" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- YES branch (down): tool_name = "lead_search_tool" -->
<line x1="480" y1="290" x2="480" y2="320" stroke="#059669" stroke-width="1.5" marker-end="url(#arrow-green)"/>
<rect x="488" y="293" width="64" height="14" fill="#ffffff" rx="2"/>
<text x="520" y="303" text-anchor="middle" class="yes-label">YES → Tool</text>
<!-- NO branch (right): Early Exit -->
<line x1="540" y1="260" x2="730" y2="260" stroke="#dc2626" stroke-width="1.5" marker-end="url(#arrow-red)"/>
<rect x="610" y="248" width="58" height="14" fill="#ffffff" rx="2"/>
<text x="639" y="258" text-anchor="middle" class="no-label">NO → Exit</text>
<!-- Early Exit box -->
<rect id="svg-early-exit" x="730" y="242" width="160" height="40" rx="6" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<text x="810" y="258" text-anchor="middle" class="node-label">⚡ Early Exit</text>
<text x="810" y="274" text-anchor="middle" class="node-sub">ai_response trực tiếp</text>
<!-- Arrow from Early Exit to END -->
<line x1="810" y1="282" x2="810" y2="1060" stroke="#dc2626" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-red)"/>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 3: TOOL EXECUTION — lead_search_tool -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="338" class="type-label">TOOL EXECUTION</text>
<!-- Tool box -->
<rect id="svg-tool" x="340" y="322" width="280" height="48" rx="6" fill="#eff6ff" stroke="#93c5fd" stroke-width="1.5"/>
<text x="480" y="343" text-anchor="middle" class="node-label">🔧 lead_search_tool</text>
<text x="480" y="360" text-anchor="middle" class="node-sub">Parse args → Build SQL → Execute cascade</text>
<!-- Arrow: Tool → Cascade -->
<line x1="480" y1="370" x2="480" y2="400" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 4: CASCADING SEARCH (7 Tiers) -->
<!-- ════════════════════════════════════════════ -->
<rect x="80" y="395" width="800" height="380" rx="6" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="6,3"/>
<text x="100" y="415" class="type-label">7-TIER CASCADING SEARCH</text>
<text x="872" y="415" text-anchor="end" class="node-sub">StarRocks SQL</text>
<!-- Fixed Filters box (left side) -->
<rect x="100" y="430" width="200" height="130" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="200" y="450" text-anchor="middle" class="node-label">🔒 Fixed Filters</text>
<text x="110" y="468" class="tier-desc">• product_line_vn (IN)</text>
<text x="110" y="483" class="tier-desc">• gender_by_product</text>
<text x="110" y="498" class="tier-desc">• master_color (LIKE)</text>
<text x="110" y="513" class="tier-desc">• price_min / price_max</text>
<text x="110" y="528" class="tier-desc">• size (FIND_IN_SET)</text>
<text x="110" y="543" class="tier-desc">• discovery_mode</text>
<!-- Tier 1 -->
<rect id="svg-tier-1" x="340" y="430" width="520" height="36" rx="4" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1"/>
<text x="350" y="451" class="tier-label">T1</text>
<text x="376" y="451" class="tier-desc">Fixed + Keywords (NGRAMBF trên product_name / LIKE description_text)</text>
<rect x="808" y="435" width="44" height="22" rx="3" fill="#059669"/>
<text x="830" y="450" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 2 -->
<rect id="svg-tier-2" x="340" y="474" width="520" height="36" rx="4" fill="#ecfdf5" stroke="#6ee7b7" stroke-width="1"/>
<text x="350" y="495" class="tier-label">T2</text>
<text x="376" y="495" class="tier-desc">Fixed + Tags (BITMAP: style/fitting/season + LIKE text fallback)</text>
<rect x="808" y="479" width="44" height="22" rx="3" fill="#059669"/>
<text x="830" y="494" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 3 -->
<rect id="svg-tier-3" x="340" y="518" width="520" height="36" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="350" y="539" class="tier-label">T3</text>
<text x="376" y="539" class="tier-desc">Fixed Only (product_type + color + gender + size + price)</text>
<rect x="808" y="523" width="44" height="22" rx="3" fill="#059669"/>
<text x="830" y="538" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 4 -->
<rect id="svg-tier-4" x="340" y="562" width="520" height="36" rx="4" fill="#fefce8" stroke="#fde68a" stroke-width="1"/>
<text x="350" y="583" class="tier-label">T4</text>
<text x="376" y="583" class="tier-desc">Drop Gender (bỏ filter giới tính để mở rộng tìm kiếm)</text>
<rect x="808" y="567" width="44" height="22" rx="3" fill="#f59e0b"/>
<text x="830" y="582" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 5 -->
<rect id="svg-tier-5" x="340" y="606" width="520" height="36" rx="4" fill="#fefce8" stroke="#fde68a" stroke-width="1"/>
<text x="350" y="627" class="tier-label">T5</text>
<text x="376" y="627" class="tier-desc">Price Relaxation ×1.5 (nới budget +50%)</text>
<rect x="808" y="611" width="44" height="22" rx="3" fill="#f59e0b"/>
<text x="830" y="626" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 6 -->
<rect id="svg-tier-6" x="340" y="650" width="520" height="36" rx="4" fill="#fff7ed" stroke="#fdba74" stroke-width="1"/>
<text x="350" y="671" class="tier-label">T6</text>
<text x="376" y="671" class="tier-desc">Price Relaxation ×2.0 (nới budget +100%)</text>
<rect x="808" y="655" width="44" height="22" rx="3" fill="#ea580c"/>
<text x="830" y="670" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">HIT? ✓</text>
<!-- Tier 7 -->
<rect id="svg-tier-7" x="340" y="694" width="520" height="36" rx="4" fill="#fef2f2" stroke="#fca5a5" stroke-width="1"/>
<text x="350" y="715" class="tier-label">T7</text>
<text x="376" y="715" class="tier-desc">Drop Price (bỏ hoàn toàn filter giá → tìm mẫu rẻ nhất)</text>
<rect x="808" y="699" width="44" height="22" rx="3" fill="#dc2626"/>
<text x="830" y="714" text-anchor="middle" font-size="9" fill="#ffffff" font-weight="600">LAST</text>
<!-- Cascade arrow (left, connecting all tiers) -->
<line x1="330" y1="448" x2="330" y2="712" stroke="#9ca3af" stroke-width="1" stroke-dasharray="3,3"/>
<text x="322" y="580" text-anchor="end" class="node-sub" transform="rotate(-90, 322, 580)">Cascade fallback ↓</text>
<!-- Arrow: Cascade → Stock Enrichment -->
<line x1="480" y1="775" x2="480" y2="805" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<rect x="439" y="782" width="82" height="14" fill="#ffffff" rx="2"/>
<text x="480" y="792" text-anchor="middle" class="arrow-label">products[] list</text>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 5: ENRICHMENT -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="825" class="type-label">ENRICHMENT</text>
<!-- Stock API box -->
<rect id="svg-stock" x="200" y="810" width="220" height="46" rx="6" fill="#f0fdf4" stroke="#86efac" stroke-width="1.5"/>
<text x="310" y="831" text-anchor="middle" class="node-label">📦 Stock API (Canifa)</text>
<text x="310" y="848" text-anchor="middle" class="node-sub">Filter hết hàng · Timeout 1.5s</text>
<!-- Outfit SQLite box -->
<rect x="540" y="810" width="220" height="46" rx="6" fill="#faf5ff" stroke="#c4b5fd" stroke-width="1.5"/>
<text x="650" y="831" text-anchor="middle" class="node-label">👗 Outfit Enrichment</text>
<text x="650" y="848" text-anchor="middle" class="node-sub">SQLite 123.db · ai_matches</text>
<!-- Arrow: Stock + Outfit → Format -->
<line x1="310" y1="856" x2="310" y2="886" stroke="#3b82f6" stroke-width="1.5"/>
<line x1="650" y1="856" x2="650" y2="886" stroke="#3b82f6" stroke-width="1.5"/>
<line x1="310" y1="886" x2="650" y2="886" stroke="#3b82f6" stroke-width="1.5"/>
<line x1="480" y1="886" x2="480" y2="908" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- Format Products box -->
<rect x="370" y="908" width="220" height="40" rx="6" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="480" y="928" text-anchor="middle" class="node-label">📋 _format_products()</text>
<text x="480" y="942" text-anchor="middle" class="node-sub">JSON output → tool_result</text>
<!-- Arrow: Format → Stylist -->
<line x1="480" y1="948" x2="480" y2="978" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 6: AI #2 — STYLIST -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="996" class="type-label">AI #2 — STYLIST</text>
<!-- Stylist box -->
<rect id="svg-stylist" x="340" y="980" width="280" height="54" rx="8" fill="#fef3c7" stroke="#f59e0b" stroke-width="1.5"/>
<text x="480" y="1002" text-anchor="middle" class="node-label">💬 Stylist (Response + Insight)</text>
<text x="480" y="1020" text-anchor="middle" class="node-sub">with_structured_output → StylistOutput</text>
<!-- Arrow: Stylist → Output -->
<line x1="480" y1="1034" x2="480" y2="1055" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<!-- ════════════════════════════════════════════ -->
<!-- LAYER 7: OUTPUT -->
<!-- ════════════════════════════════════════════ -->
<text x="60" y="1074" class="type-label">OUTPUT</text>
<!-- Output boxes -->
<rect x="160" y="1055" width="150" height="36" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="235" y="1076" text-anchor="middle" class="node-label">ai_response</text>
<rect x="330" y="1055" width="130" height="36" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="395" y="1076" text-anchor="middle" class="node-label">product_ids[]</text>
<rect x="480" y="1055" width="140" height="36" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="550" y="1076" text-anchor="middle" class="node-label">user_insight{}</text>
<rect x="640" y="1055" width="130" height="36" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="705" y="1076" text-anchor="middle" class="node-label">lead_stage{}</text>
<!-- Legend -->
<rect x="60" y="10" width="180" height="56" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>
<text x="70" y="26" class="type-label">LEGEND</text>
<line x1="70" y1="38" x2="100" y2="38" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arrow-blue)"/>
<text x="110" y="42" class="legend-text">Data flow</text>
<line x1="70" y1="54" x2="100" y2="54" stroke="#dc2626" stroke-width="1" stroke-dasharray="4,3" marker-end="url(#arrow-red)"/>
<text x="110" y="58" class="legend-text">Early exit path</text>
</svg>
......@@ -225,6 +225,11 @@ body{margin:0;display:flex;min-height:100vh}
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></span>
<span>Resources</span>
</a>
<a data-page="faqs/faqs.html" class="nav-item" onclick="navigateTo(this)" title="FAQ Manager">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span>FAQ Manager</span>
<span class="nav-badge badge-new">NEW</span>
</a>
<a data-page="notes/notes.html" class="nav-item" onclick="navigateTo(this)" title="Team Notes">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg></span>
<span>Team Notes</span>
......@@ -282,11 +287,7 @@ body{margin:0;display:flex;min-height:100vh}
<span>SKU Search</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="tag-search/tag-search.html" class="nav-item" onclick="navigateTo(this)" title="Tag Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg></span>
<span>Tag Search</span>
<span class="nav-badge badge-beta">DEV</span>
</a>
<a data-page="store-search/store-search.html" class="nav-item" onclick="navigateTo(this)" title="Store Search">
<span class="nav-icon"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 2 3 6v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2V6l-3-4z"/><line x1="3" y1="6" x2="21" y2="6"/><path d="M16 10a4 4 0 0 1-8 0"/></svg></span>
<span>Store Search</span>
......
......@@ -21,32 +21,32 @@
<body>
<aside class="sidebar">
<div class="sidebar-brand">
<div class="brand-icon">??</div>
<div class="brand-icon">🤖</div>
<div class="brand-text"><h2>Canifa AI</h2><span>Admin Console</span></div>
</div>
<div class="nav-group">
<div class="nav-group-label">Main</div>
<a href="/static/flow.html" class="nav-item"><span class="nav-icon">??</span><span>So d? ho?t d?ng</span></a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item"><span class="nav-icon">??</span><span>Chatbot</span><span class="nav-badge badge-live">LIVE</span></a>
<a href="/static/history.html" class="nav-item"><span class="nav-icon">??</span><span>History</span></a>
<a href="/static/flow.html" class="nav-item"><span class="nav-icon">📊</span><span>Sơ đồ hoạt động</span></a>
<a href="/static/experiment_detail.html?id=exp_chatbot_prod" class="nav-item"><span class="nav-icon">💬</span><span>Chatbot</span><span class="nav-badge badge-live">LIVE</span></a>
<a href="/static/history.html" class="nav-item"><span class="nav-icon">🕰️</span><span>History</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Workspace</div>
<a href="/static/resources.html" class="nav-item active"><span class="nav-icon">??</span><span>Resources</span><span class="nav-badge badge-count" id="linksBadge">0</span></a>
<a href="/static/notes.html" class="nav-item"><span class="nav-icon">??</span><span>Team Notes</span></a>
<a href="/static/changelog.html" class="nav-item"><span class="nav-icon">??</span><span>Changelog</span></a>
<a href="/static/guide.html" class="nav-item"><span class="nav-icon">??</span><span>Hu?ng d?n</span></a>
<a href="/static/resources.html" class="nav-item active"><span class="nav-icon">🔗</span><span>Resources</span><span class="nav-badge badge-count" id="linksBadge">0</span></a>
<a href="/static/notes.html" class="nav-item"><span class="nav-icon">📝</span><span>Team Notes</span></a>
<a href="/static/changelog.html" class="nav-item"><span class="nav-icon">📜</span><span>Changelog</span></a>
<a href="/static/guide.html" class="nav-item"><span class="nav-icon">📖</span><span>Hướng dẫn</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">Th? nghi?m</div>
<a href="/static/test_sql.html" class="nav-item"><span class="nav-icon">???</span><span>Text-to-SQL</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/test_db.html" class="nav-item"><span class="nav-icon">??</span><span>DB Test</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/feedback_demo.html" class="nav-item"><span class="nav-icon">??</span><span>Feedback Demo</span><span class="nav-badge badge-new">NEW</span></a>
<div class="nav-group-label">Thử nghiệm</div>
<a href="/static/test_sql.html" class="nav-item"><span class="nav-icon">🔍</span><span>Text-to-SQL</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/test_db.html" class="nav-item"><span class="nav-icon">🧪</span><span>DB Test</span><span class="nav-badge badge-beta">BETA</span></a>
<a href="/static/feedback_demo.html" class="nav-item"><span class="nav-icon">⭐</span><span>Feedback Demo</span><span class="nav-badge badge-new">NEW</span></a>
</div>
<div class="nav-group">
<div class="nav-group-label">External</div>
<a href="/docs" target="_blank" class="nav-item"><span class="nav-icon">??</span><span>API Docs</span></a>
<a href="/redoc" target="_blank" class="nav-item"><span class="nav-icon">??</span><span>ReDoc</span></a>
<a href="/docs" target="_blank" class="nav-item"><span class="nav-icon">📘</span><span>API Docs</span></a>
<a href="/redoc" target="_blank" class="nav-item"><span class="nav-icon">📙</span><span>ReDoc</span></a>
</div>
<div class="sidebar-footer">
<div class="version-info"><span class="version-dot"></span><span class="version-text"><strong>v2.5.0</strong> � Online</span></div>
......@@ -65,16 +65,16 @@
<div class="filter-bar" id="filterBar">
<button class="filter-chip active" onclick="filter('all', this)">All</button>
<button class="filter-chip" onclick="filter('pinned', this)">?? Pinned</button>
<button class="filter-chip" onclick="filter('tool', this)">?? Tools</button>
<button class="filter-chip" onclick="filter('doc', this)">?? Docs</button>
<button class="filter-chip" onclick="filter('api', this)">?? APIs</button>
<button class="filter-chip" onclick="filter('design', this)">?? Design</button>
<button class="filter-chip" onclick="filter('repo', this)">?? Repos</button>
<button class="filter-chip" onclick="filter('pinned', this)">📌 Pinned</button>
<button class="filter-chip" onclick="filter('tool', this)">🔧 Tools</button>
<button class="filter-chip" onclick="filter('doc', this)">📄 Docs</button>
<button class="filter-chip" onclick="filter('api', this)">🔌 APIs</button>
<button class="filter-chip" onclick="filter('design', this)">🎨 Design</button>
<button class="filter-chip" onclick="filter('repo', this)">📦 Repos</button>
</div>
<div class="links-grid" id="linksGrid">
<div class="empty-state"><div class="empty-icon">??</div><p>No resources yet. Add your first link!</p></div>
<div class="empty-state"><div class="empty-icon">📭</div><p>No resources yet. Add your first link!</p></div>
</div>
......@@ -85,8 +85,8 @@
<div class="modal-overlay" id="modal">
<div class="modal">
<div class="modal-head">
<h3 id="modalTitle">?? Add Resource</h3>
<button class="modal-close" onclick="closeModal()">�</button>
<h3 id="modalTitle">➕ Add Resource</h3>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
<div class="modal-body">
<input type="hidden" id="editId">
......@@ -96,17 +96,17 @@
<div class="form-row">
<div class="form-group"><label class="form-label">Category</label>
<select class="form-select" id="fCat">
<option value="tool">?? Tool</option><option value="doc">?? Document</option><option value="api">?? API</option>
<option value="design">?? Design</option><option value="repo">?? Repository</option><option value="other">?? Other</option>
<option value="tool">🔧 Tool</option><option value="doc">📄 Document</option><option value="api">🔌 API</option>
<option value="design">🎨 Design</option><option value="repo">📦 Repository</option><option value="other">🔗 Other</option>
</select>
</div>
<div class="form-group"><label class="form-label">Icon (emoji)</label><input type="text" class="form-input" id="fIcon" placeholder="??" maxlength="4"></div>
<div class="form-group"><label class="form-label">Icon (emoji)</label><input type="text" class="form-input" id="fIcon" placeholder="🔗" maxlength="4"></div>
</div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="fPin"> <span class="form-label" style="margin:0">?? Pin this resource</span></label></div>
<div class="form-group"><label style="display:flex;align-items:center;gap:8px;cursor:pointer;"><input type="checkbox" id="fPin"> <span class="form-label" style="margin:0">📌 Pin this resource</span></label></div>
</div>
<div class="modal-foot">
<button class="btn" onclick="closeModal()">Cancel</button>
<button class="btn btn-primary" onclick="save()">?? Save</button>
<button class="btn btn-primary" onclick="save()">💾 Save</button>
</div>
</div>
</div>
......
......@@ -20,11 +20,11 @@ let data = [];
else if (activeFilter !== 'all') f = data.filter(l => l.category === activeFilter);
f.sort((a,b) => (a.pinned&&!b.pinned?-1:!a.pinned&&b.pinned?1:0));
if (!f.length) { grid.innerHTML = `<div class="empty-state"><div class="empty-icon">??</div><p>No resources found.</p></div>`; return; }
if (!f.length) { grid.innerHTML = `<div class="empty-state"><div class="empty-icon">📭</div><p>No resources found.</p></div>`; return; }
grid.innerHTML = f.map(l => `
<div class="link-card ${l.pinned?'pinned':''}">
<div class="link-icon-box">${l.icon||'??'}</div>
<div class="link-icon-box">${l.icon||'🔗'}</div>
<div class="link-info">
<div class="link-cat-tag lcat-${l.category}">${l.category}</div>
<div class="link-title">${esc(l.title)}</div>
......@@ -32,19 +32,19 @@ let data = [];
${l.description?`<div class="link-desc">${esc(l.description)}</div>`:''}
</div>
<div class="link-actions-row">
<button class="link-act" title="Open" onclick="window.open('${l.url}','_blank')">?</button>
<button class="link-act" title="Pin" onclick="togglePin('${l.id}')">??</button>
<button class="link-act" title="Edit" onclick="edit('${l.id}')">??</button>
<button class="link-act" title="Delete" onclick="del('${l.id}')">???</button>
<button class="link-act" title="Open" onclick="window.open('${l.url}','_blank')">🌐</button>
<button class="link-act" title="Pin" onclick="togglePin('${l.id}')">📌</button>
<button class="link-act" title="Edit" onclick="edit('${l.id}')">✏️</button>
<button class="link-act" title="Delete" onclick="del('${l.id}')">🗑️</button>
</div>
</div>`).join('');
}
function filter(f, el) { activeFilter=f; document.querySelectorAll('.filter-chip').forEach(c=>c.classList.remove('active')); el.classList.add('active'); render(); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fUrl').value=''; document.getElementById('fDesc').value=''; document.getElementById('fCat').value='tool'; document.getElementById('fIcon').value=''; document.getElementById('fPin').checked=false; document.getElementById('modalTitle').textContent='?? Add Resource'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function openModal() { document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=''; document.getElementById('fTitle').value=''; document.getElementById('fUrl').value=''; document.getElementById('fDesc').value=''; document.getElementById('fCat').value='tool'; document.getElementById('fIcon').value=''; document.getElementById('fPin').checked=false; document.getElementById('modalTitle').textContent='➕ Add Resource'; setTimeout(()=>document.getElementById('fTitle').focus(),100); }
function closeModal() { document.getElementById('modal').classList.remove('show'); }
function edit(id) { const l=data.find(x=>x.id===id); if(!l)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=l.title; document.getElementById('fUrl').value=l.url; document.getElementById('fDesc').value=l.description||''; document.getElementById('fCat').value=l.category; document.getElementById('fIcon').value=l.icon||''; document.getElementById('fPin').checked=l.pinned; document.getElementById('modalTitle').textContent='?? Edit Resource'; }
function edit(id) { const l=data.find(x=>x.id===id); if(!l)return; document.getElementById('modal').classList.add('show'); document.getElementById('editId').value=id; document.getElementById('fTitle').value=l.title; document.getElementById('fUrl').value=l.url; document.getElementById('fDesc').value=l.description||''; document.getElementById('fCat').value=l.category; document.getElementById('fIcon').value=l.icon||''; document.getElementById('fPin').checked=l.pinned; document.getElementById('modalTitle').textContent='✏️ Edit Resource'; }
async function save() {
const id=document.getElementById('editId').value, title=document.getElementById('fTitle').value.trim(), url=document.getElementById('fUrl').value.trim();
......
body { margin:0; font-family:var(--font-sans, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif); background:var(--background,#faf9f7); color:var(--foreground,#1c1917); }
.page-wrap { max-width:1400px; margin:0 auto; padding:16px 20px; }
/* Header */
.page-header { margin-bottom:12px; display:flex; justify-content:space-between; align-items:center; flex-wrap:wrap; gap:8px; }
.page-header h1 { font-size:20px; font-weight:700; margin:0; }
.page-header p { font-size:12px; color:var(--muted-fg,#78716c); margin:2px 0 0; }
.header-badges { display:flex; gap:6px; align-items:center; }
.header-badge { padding:3px 10px; font-size:11px; font-weight:600; border-radius:12px; background:#f0efec; color:#78716c; }
.session-badge { padding:3px 10px; font-size:10px; font-weight:500; border-radius:12px; background:#dbeafe; color:#3b82f6; font-family:'Consolas',monospace; }
.tag-list { display:flex; flex-wrap:wrap; gap:4px; margin-top:6px; }
.tag-list .tag { display:inline-block; padding:2px 9px; font-size:10px; background:var(--muted,#f0efec); border-radius:10px; color:var(--muted-fg); }
.tag-list .tag.occ { background:#fef3c7; color:#92400e; }
.tag-list .tag.wthr { background:#dbeafe; color:#1e40af; }
.tag-list .tag.func { background:#d1fae5; color:#065f46; }
.tag-list .tag.style { background:#fce7f3; color:#9d174d; }
.tag-list .tag.fit { background:#ede9fe; color:#5b21b6; }
/* ═══════ 2-Column Layout ═══════ */
.split-layout { display:grid; grid-template-columns:1fr 380px; gap:12px; height:calc(100vh - 150px); min-height:450px; }
/* Left: Chat */
.chat-panel { display:flex; flex-direction:column; background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; overflow:hidden; }
.chat-messages { flex:1; overflow-y:auto; padding:16px; display:flex; flex-direction:column; gap:10px; }
.chat-messages::-webkit-scrollbar { width:3px; }
.chat-messages::-webkit-scrollbar-thumb { background:rgba(0,0,0,.08); border-radius:4px; }
.msg { max-width:88%; padding:9px 13px; border-radius:12px; font-size:13px; line-height:1.5; word-break:break-word; }
.msg.user { align-self:flex-end; background:var(--primary,#1c1917); color:#fff; border-bottom-right-radius:4px; }
.msg.ai { align-self:flex-start; background:var(--muted,#f0efec); color:var(--foreground); border-bottom-left-radius:4px; }
.msg.ai .meta { margin-top:5px; font-size:10px; color:var(--muted-fg,#78716c); display:flex; gap:6px; }
.msg.system { align-self:center; font-size:11px; color:var(--muted-fg); font-style:italic; background:none; padding:4px 0; }
.msg.error { align-self:center; font-size:11px; color:#dc2626; background:rgba(220,38,38,.05); padding:6px 12px; }
/* Quick actions */
.quick-actions { padding:6px 12px; display:flex; gap:5px; flex-wrap:wrap; border-top:1px solid var(--border); background:var(--card); }
.quick-btn { padding:4px 10px; font-size:11px; border:1px solid var(--border); border-radius:14px; background:var(--background); cursor:pointer; color:var(--foreground); transition:all .15s; }
.quick-btn:hover { background:var(--muted); border-color:var(--muted-fg); }
/* Input */
.chat-input { border-top:1px solid var(--border); padding:10px 12px; display:flex; gap:6px; background:var(--card); }
.chat-input input { flex:1; padding:9px 12px; border:1px solid var(--border); border-radius:8px; font-size:13px; outline:none; background:var(--background); transition:border-color .2s; }
.chat-input input:focus { border-color:var(--primary); }
.chat-input button { padding:9px 16px; background:var(--primary,#1c1917); color:#fff; border:none; border-radius:8px; font-size:12px; font-weight:600; cursor:pointer; transition:opacity .2s; white-space:nowrap; }
.chat-input button:hover { opacity:.85; }
.chat-input button:disabled { opacity:.5; cursor:not-allowed; }
.btn-clear { background:#dc2626 !important; }
.btn-clear:hover { background:#b91c1c !important; }
.btn-new { background:#2563eb !important; }
.btn-new:hover { background:#1d4ed8 !important; }
/* Thinking */
.thinking { display:flex; gap:4px; padding:8px 13px; }
.thinking span { width:5px; height:5px; border-radius:50%; background:var(--muted-fg); animation:bounce 1.4s ease-in-out infinite; }
.thinking span:nth-child(2) { animation-delay:.2s; }
.thinking span:nth-child(3) { animation-delay:.4s; }
@keyframes bounce { 0%,80%,100%{transform:translateY(0)} 40%{transform:translateY(-7px)} }
/* Markdown */
.msg.ai a { color:var(--primary); text-decoration:underline; }
.msg.ai strong { font-weight:600; }
.msg.ai code { background:var(--muted); padding:1px 4px; border-radius:3px; font-size:11.5px; }
.msg.ai p { margin: 0 0 8px 0; }
.msg.ai p:last-child { margin: 0; }
.msg.ai ul, .msg.ai ol { margin: 0 0 8px 0; padding-left: 20px; }
.msg.ai li { margin-bottom: 2px; }
/* Markdown Tables */
.msg.ai table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 12px; }
.msg.ai th, .msg.ai td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; }
.msg.ai th { background-color: rgba(0,0,0,.03); font-weight: 600; }
.msg.ai tbody tr:nth-child(even) { background-color: rgba(0,0,0,.01); }
/* ═══════ Right: Pipeline ═══════ */
.pipeline-panel { display:flex; flex-direction:column; background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:12px; overflow:hidden; }
.pipeline-header { padding:10px 14px; border-bottom:1px solid var(--border); display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.pipeline-header h3 { font-size:13px; font-weight:700; margin:0; }
.pipeline-header .path-badge { font-size:10px; padding:2px 8px; background:var(--muted); border-radius:10px; color:var(--muted-fg); font-weight:500; }
.history-count { font-size:10px; padding:2px 8px; background:#dbeafe; border-radius:10px; color:#3b82f6; font-weight:500; }
.timing-badge { font-size:10px; padding:2px 8px; background:#d1fae5; border-radius:10px; color:#065f46; font-weight:600; font-family:'Consolas',monospace; }
.trace-badge { font-size:10px; padding:2px 8px; background:#fce7f3; border-radius:10px; color:#9d174d; font-weight:500; font-family:'Consolas',monospace; cursor:pointer; }
.trace-badge:hover { background:#fbcfe8; }
.pipeline-steps { flex:1; overflow-y:auto; padding:12px; display:flex; flex-direction:column; gap:0; }
.pipeline-steps::-webkit-scrollbar { width:3px; }
.pipeline-steps::-webkit-scrollbar-thumb { background:rgba(0,0,0,.08); border-radius:4px; }
.pipeline-empty { display:flex; align-items:center; justify-content:center; flex:1; color:var(--muted-fg); font-size:12px; font-style:italic; }
/* Pipeline Step Card */
.p-step { position:relative; padding:10px 12px 10px 28px; margin:0; }
.p-step:not(:last-child) { border-left:2px solid var(--border); margin-left:7px; padding-bottom:14px; }
.p-step:last-child { border-left:2px solid transparent; margin-left:7px; }
.p-step::before { content:''; position:absolute; left:-1px; top:13px; width:10px; height:10px; border-radius:50%; border:2px solid var(--border); background:var(--card); z-index:1; margin-left:-4px; }
.p-step.s-user::before { background:#6366f1; border-color:#6366f1; }
.p-step.s-planner::before { background:#f59e0b; border-color:#f59e0b; }
.p-step.s-tool_result::before { background:#10b981; border-color:#10b981; }
.p-step.s-responder::before { background:#3b82f6; border-color:#3b82f6; }
.p-step .step-label { font-size:11px; font-weight:700; margin-bottom:3px; }
.p-step.s-user .step-label { color:#6366f1; }
.p-step.s-planner .step-label { color:#f59e0b; }
.p-step.s-tool_result .step-label { color:#10b981; }
.p-step.s-responder .step-label { color:#3b82f6; }
.p-step .step-content { font-size:11.5px; line-height:1.45; color:var(--foreground); white-space:pre-wrap; word-break:break-word; }
.p-step .step-content.truncated { max-height:120px; overflow:hidden; position:relative; }
.p-step .step-content.truncated::after { content:'...'; position:absolute; bottom:0; right:0; padding:0 4px; background:var(--card); color:var(--muted-fg); font-weight:600; }
/* Tool call args */
.tool-args { margin-top:5px; padding:6px 8px; background:rgba(0,0,0,.03); border-radius:6px; font-size:11px; font-family:'Consolas','Monaco',monospace; }
.tool-args .arg-row { display:flex; gap:4px; margin:1px 0; }
.tool-args .arg-key { color:#9333ea; font-weight:600; min-width:110px; }
.tool-args .arg-val { color:var(--foreground); }
/* Pipeline separator */
.pipeline-sep { display:flex; align-items:center; gap:8px; padding:8px 0; margin:4px 0; }
.pipeline-sep::before, .pipeline-sep::after { content:''; flex:1; height:1px; background:var(--border); }
.pipeline-sep span { font-size:10px; color:var(--muted-fg); white-space:nowrap; }
@keyframes slideIn { from { opacity:0; transform:translateY(8px); } to { opacity:1; transform:translateY(0); } }
.p-step.new { animation:slideIn .3s ease-out; }
/* Raw JSON toggle */
.raw-json-toggle { margin-top:6px; }
.raw-json-toggle summary { cursor:pointer; font-size:10px; font-weight:600; color:var(--muted-fg,#78716c); user-select:none; padding:2px 0; }
.raw-json-toggle summary:hover { color:var(--foreground); }
.raw-json-toggle pre { margin:4px 0 0; padding:8px; background:rgba(0,0,0,.04); border-radius:6px; font-size:10px; line-height:1.4; font-family:'Consolas','Monaco',monospace; overflow-x:auto; max-height:400px; overflow-y:auto; white-space:pre-wrap; word-break:break-all; color:var(--foreground); border:1px solid var(--border,#e7e5e2); }
.raw-json-toggle pre::-webkit-scrollbar { width:3px; height:3px; }
.raw-json-toggle pre::-webkit-scrollbar-thumb { background:rgba(0,0,0,.1); border-radius:4px; }
/* ═══════ Product Cards ═══════ */
.products-grid { display:grid; grid-template-columns:repeat(auto-fill, minmax(150px, 1fr)); gap:8px; margin-top:8px; max-width:88%; }
.product-card { background:var(--card,#fff); border:1px solid var(--border,#e7e5e2); border-radius:10px; overflow:hidden; transition:all .2s; cursor:pointer; text-decoration:none; color:inherit; display:flex; flex-direction:column; }
.product-card:hover { border-color:var(--primary,#1c1917); box-shadow:0 4px 12px rgba(0,0,0,.08); transform:translateY(-2px); }
.product-card .pc-img { width:100%; aspect-ratio:1; object-fit:cover; background:#f5f5f4; }
.product-card .pc-body { padding:8px 10px; flex:1; display:flex; flex-direction:column; gap:3px; }
.product-card .pc-name { font-size:11px; font-weight:600; line-height:1.35; display:-webkit-box; -webkit-line-clamp:2; line-clamp:2; -webkit-box-orient:vertical; overflow:hidden; color:var(--foreground); }
.product-card .pc-price-row { display:flex; align-items:center; gap:5px; flex-wrap:wrap; margin-top:auto; }
.product-card .pc-price { font-size:12px; font-weight:700; color:#dc2626; }
.product-card .pc-orig { font-size:10px; color:#a8a29e; text-decoration:line-through; }
.product-card .pc-disc { font-size:9px; font-weight:700; color:#fff; background:#dc2626; padding:1px 5px; border-radius:8px; }
.product-card .pc-meta { font-size:10px; color:var(--muted-fg,#78716c); display:flex; gap:4px; flex-wrap:wrap; }
.product-card .pc-meta span { background:var(--muted,#f0efec); padding:1px 6px; border-radius:8px; }
.product-card .pc-sku { font-size:9px; color:#a8a29e; font-family:'Consolas',monospace; }
/* ═══════ Product Detail Popup (C-Stylist) ═══════ */
.pd-overlay { position:fixed; inset:0; background:rgba(0,0,0,.45); z-index:9998; opacity:0; pointer-events:none; transition:opacity .25s; }
.pd-overlay.active { opacity:1; pointer-events:auto; }
.pd-popup { position:fixed; top:50%; left:50%; transform:translate(-50%,-50%) scale(.92); z-index:9999; background:#fff; border-radius:12px; max-width:520px; width:92vw; max-height:85vh; overflow-y:auto; box-shadow:0 20px 60px rgba(0,0,0,.25); opacity:0; pointer-events:none; transition:all .3s cubic-bezier(.4,0,.2,1); }
.pd-popup.active { opacity:1; pointer-events:auto; transform:translate(-50%,-50%) scale(1); }
.pd-popup::-webkit-scrollbar { width:4px; }
.pd-popup::-webkit-scrollbar-thumb { background:rgba(0,0,0,.1); border-radius:4px; }
.pd-header { display:flex; justify-content:space-between; align-items:center; padding:16px 20px; border-bottom:1px solid #e8e8e8; }
.pd-header h3 { margin:0; font-size:14px; font-weight:700; letter-spacing:0.5px; color:#1a1a1a; }
.pd-close { width:28px; height:28px; border:none; background:none; font-size:18px; cursor:pointer; color:#999; border-radius:50%; transition:all .2s; }
.pd-close:hover { background:#f0f0f0; color:#333; }
.pd-body { padding:20px; }
.pd-main { display:flex; gap:20px; }
.pd-img-col { flex:0 0 180px; }
.pd-img-main { width:180px; height:240px; border-radius:8px; object-fit:cover; background:#f5f5f5; border:1px solid #eee; }
.pd-info-col { flex:1; min-width:0; }
.pd-name { font-size:16px; font-weight:600; color:#1a1a1a; margin:0 0 4px; line-height:1.4; }
.pd-sku { font-size:12px; color:#999; font-family:'Consolas',monospace; margin-bottom:14px; display:flex; align-items:center; gap:6px; }
.pd-sku-copy { border:none; background:none; cursor:pointer; font-size:13px; color:#999; padding:2px; }
.pd-sku-copy:hover { color:#333; }
.pd-price-block { margin-bottom:16px; display:flex; align-items:baseline; gap:10px; flex-wrap:wrap; }
.pd-orig-price { font-size:14px; color:#999; text-decoration:line-through; }
.pd-discount { font-size:13px; font-weight:700; color:#c8102e; }
.pd-final-price { font-size:20px; font-weight:700; color:#c8102e; }
.pd-detail-link { font-size:12px; color:#3b82f6; text-decoration:none; font-weight:500; margin-left:auto; }
.pd-detail-link:hover { text-decoration:underline; }
.pd-section { margin-bottom:14px; }
.pd-section-label { font-size:12px; font-weight:600; color:#666; margin-bottom:8px; }
.pd-colors { display:flex; gap:8px; flex-wrap:wrap; }
.pd-color-thumb { width:44px; height:44px; border-radius:6px; object-fit:cover; border:2px solid #e8e8e8; cursor:pointer; transition:all .2s; background:#f5f5f5; }
.pd-color-thumb.selected, .pd-color-thumb:hover { border-color:#1a1a1a; box-shadow:0 0 0 1px #1a1a1a; }
.pd-color-name { font-size:11px; color:#666; margin-top:4px; }
.pd-sizes { display:flex; gap:6px; flex-wrap:wrap; }
.pd-size-btn { min-width:40px; height:36px; border:1px solid #ddd; border-radius:4px; background:#fff; font-size:13px; font-weight:500; cursor:pointer; color:#333; transition:all .2s; padding:0 10px; }
.pd-size-btn:hover { border-color:#1a1a1a; }
.pd-size-btn.selected { background:#1a1a1a; color:#fff; border-color:#1a1a1a; }
.pd-size-btn.out { opacity:.35; cursor:not-allowed; text-decoration:line-through; }
.pd-add-cart { width:100%; padding:14px; background:#e8e8e8; color:#666; border:none; border-radius:6px; font-size:13px; font-weight:700; letter-spacing:1px; cursor:pointer; transition:all .2s; margin-top:6px; }
.pd-add-cart.ready { background:#1a1a1a; color:#fff; }
.pd-add-cart.ready:hover { background:#c8102e; }
.pd-loading { text-align:center; padding:40px; color:#999; font-size:13px; }
.pd-loading .spin { display:inline-block; width:24px; height:24px; border:3px solid #eee; border-top-color:#999; border-radius:50%; animation:spin .8s linear infinite; margin-bottom:8px; }
@keyframes spin { to { transform:rotate(360deg); } }
\ No newline at end of file
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tag Search — Canifa AI</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<link rel="stylesheet" href="/static/common/cart.css?v=2">
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="/static/tag-search/tag-search.css">
</head>
<body>
<div class="page-wrap">
<div class="page-header">
<div>
<h1>🏷️ Tag Search Agent</h1>
<p>Tìm sản phẩm CANIFA bằng ngôn ngữ tự nhiên — AI tự nhận diện tags/filters · <strong>History in Redis (30min TTL)</strong></p>
<div class="tag-list">
<span class="tag occ">occ:di_lam</span>
<span class="tag occ">occ:di_choi</span>
<span class="tag occ">occ:di_tiec</span>
<span class="tag occ">occ:the_thao</span>
<span class="tag occ">occ:di_bien</span>
<span class="tag wthr">wthr:mua_he</span>
<span class="tag wthr">wthr:mua_dong</span>
<span class="tag func">func:thoang_mat</span>
<span class="tag func">func:giu_am</span>
<span class="tag style">style:thanh_lich</span>
<span class="tag style">style:nang_dong</span>
<span class="tag fit">fit:oversize</span>
<span class="tag fit">fit:slim</span>
</div>
</div>
<div class="header-badges">
<span class="session-badge" id="sessionBadge" title="Session ID">—</span>
<span class="header-badge">2-Agent LangGraph</span>
</div>
</div>
<div class="split-layout">
<!-- ═══════ LEFT: CHAT ═══════ -->
<div class="chat-panel">
<div class="chat-messages" id="chatMessages">
<div class="msg system">💬 Chat history lưu Redis, reload thoải mái! Tự hết hạn sau 30 phút.</div>
<div class="msg system">Hỏi tự nhiên để tìm sản phẩm — VD: "Áo mùa đông cho bé gái", "Quần đi biển nam"</div>
</div>
<div class="quick-actions">
<button class="quick-btn" onclick="sendQuick('Áo mùa đông cho bé gái')">🧒 Áo mùa đông bé gái</button>
<button class="quick-btn" onclick="sendQuick('Quần đi biển cho nam')">🏖️ Quần đi biển nam</button>
<button class="quick-btn" onclick="sendQuick('Váy đi tiệc cho nữ')">👗 Váy đi tiệc nữ</button>
<button class="quick-btn" onclick="sendQuick('Áo phông cotton bé trai')">👕 Áo phông bé trai</button>
<button class="quick-btn" onclick="sendQuick('Đồ thể thao nữ')">🏃 Thể thao nữ</button>
<button class="quick-btn" onclick="sendQuick('Hàng mới bán chạy')">🔥 Hàng mới</button>
</div>
<div class="chat-input">
<input type="text" id="chatInput" placeholder="Tìm sản phẩm... VD: Áo đi chơi 30/4 cho nữ"
onkeydown="if(event.key==='Enter')sendMsg()">
<button id="sendBtn" onclick="sendMsg()">Gửi</button>
<button class="btn-new" onclick="newSession()" title="Tạo session mới, bỏ history cũ">🔄 New</button>
<button class="btn-clear" onclick="clearChat()" title="Xoá history Redis & tạo session mới">🗑️ Clear</button>
</div>
</div>
<!-- ═══════ RIGHT: PIPELINE ═══════ -->
<div class="pipeline-panel">
<div class="pipeline-header">
<h3>⚡ Agent Pipeline</h3>
<span class="path-badge" id="pathBadge">—</span>
<span class="timing-badge" id="timingBadge" style="display:none"></span>
<span class="history-count" id="historyCount" title="Số messages trong Redis">hist: 0</span>
<span class="trace-badge" id="traceBadge" style="display:none" title="Click copy Langfuse trace ID" onclick="copyTrace()"></span>
</div>
<div class="pipeline-steps" id="pipelineSteps">
<div class="pipeline-empty">Gửi tin nhắn để xem luồng xử lý AI</div>
</div>
</div>
</div>
</div>
<!-- Shopping Cart FAB + Drawer -->
<button class="cart-fab" id="cartFab" onclick="toggleCart()" style="position:fixed; bottom:40px; right:40px; z-index:99999; display:flex;">
🛒<span class="cart-badge" id="cartBadge" style="display:none">0</span>
</button>
<div class="cart-overlay" id="cartOverlay" onclick="toggleCart()"></div>
<div class="cart-drawer" id="cartDrawer">
<div class="cart-drawer-header">
<span>GIỎ HÀNG CỦA BẠN</span>
<button onclick="toggleCart()">✕</button>
</div>
<div class="cart-drawer-items" id="cartItems"></div>
<div class="cart-drawer-footer">
<div class="cart-total">
<span>Tổng cộng:</span>
<span class="cart-total-price" id="cartTotal">0đ</span>
</div>
<button class="cart-send-btn" id="cartSendBtn" onclick="sendCartToBot()" disabled>GỬI YÊU CẦU ĐẶT HÀNG</button>
<button class="cart-send-btn" id="cartClearBtn" onclick="clearCart()" style="background:#444;margin-top:10px;">XÓA XẠCH GIỎ HÀNG</button>
</div>
</div>
<!-- Size Picker Modal -->
<div class="size-modal-overlay" id="sizeModalOverlay" onclick="closeSizeModal()"></div>
<div class="size-modal" id="sizeModal">
<div class="size-modal-header">
<span>CHỌN SIZE VÀ MÀU SẮC</span>
<button class="size-modal-close" onclick="closeSizeModal()">✕</button>
</div>
<div class="size-modal-body" id="sizeModalBody"></div>
</div>
<!-- Product Detail Popup (C-Stylist) -->
<div class="pd-overlay" id="pdOverlay" onclick="closeProductPopup()"></div>
<div class="pd-popup" id="pdPopup">
<div class="pd-header">
<h3>THÔNG TIN SẢN PHẨM</h3>
<button class="pd-close" onclick="closeProductPopup()">✕</button>
</div>
<div class="pd-body" id="pdBody"></div>
</div>
<div class="cart-toast" id="cartToast"></div>
<script src="/static/common/cart.js"></script>
<script src="/static/tag-search/tag-search.js"></script>
</body>
</html>
const messagesEl = document.getElementById('chatMessages');
const inputEl = document.getElementById('chatInput');
const sendBtn = document.getElementById('sendBtn');
const pipelineEl = document.getElementById('pipelineSteps');
const pathBadge = document.getElementById('pathBadge');
const sessionBadge = document.getElementById('sessionBadge');
const historyCountEl = document.getElementById('historyCount');
const timingBadge = document.getElementById('timingBadge');
const traceBadge = document.getElementById('traceBadge');
let requestCount = 0;
let _lastTraceId = null;
// ═══════ SESSION ID — persist across reloads ═══════
function getSessionId() {
let sid = sessionStorage.getItem('tag_search_session_id');
if (!sid) {
sid = 'ts_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
sessionStorage.setItem('tag_search_session_id', sid);
}
return sid;
}
const SESSION_ID = getSessionId();
sessionBadge.textContent = SESSION_ID.slice(0, 16) + '…';
sessionBadge.title = 'Session: ' + SESSION_ID;
function addMsg(type, html, meta) {
const div = document.createElement('div');
div.className = 'msg ' + type;
div.innerHTML = html;
if (meta && type === 'ai') {
const metaDiv = document.createElement('div');
metaDiv.className = 'meta';
metaDiv.innerHTML = meta;
div.appendChild(metaDiv);
}
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function showThinking() {
const div = document.createElement('div');
div.className = 'msg ai thinking';
div.id = 'thinkingMsg';
div.innerHTML = '<span></span><span></span><span></span>';
messagesEl.appendChild(div);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
function hideThinking() {
const el = document.getElementById('thinkingMsg');
if (el) el.remove();
}
function md(text) {
if (!text) return '';
try {
marked.setOptions({ breaks: true, gfm: true });
return marked.parse(text);
} catch(e) {
return text
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\[(.*?)\]\((.*?)\)/g, '<a href="$2" target="_blank">$1</a>')
.replace(/`(.*?)`/g, '<code>$1</code>')
.replace(/~~(.*?)~~/g, '<del>$1</del>')
.replace(/^- (.*)/gm, '• $1')
.replace(/\n/g, '<br>');
}
}
function escHtml(s) { return (s||'').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
// ═══════ PIPELINE RENDERING ═══════
function renderPipeline(pipeline, elapsedMs, agentPath) {
requestCount++;
pipelineEl.innerHTML = '';
pathBadge.textContent = agentPath + ' · ' + elapsedMs + 'ms';
pipeline.forEach((step, i) => {
const div = document.createElement('div');
div.className = `p-step s-${step.step} new`;
let contentHtml = '';
if (step.step === 'user') {
contentHtml = `<div class="step-content">${escHtml(step.content)}</div>`;
} else if (step.step === 'planner') {
if (step.content && step.content !== '(gọi tool...)') {
contentHtml += `<div class="step-content">${escHtml(step.content)}</div>`;
}
if (step.tool_calls && step.tool_calls.length) {
step.tool_calls.forEach(tc => {
contentHtml += `<div class="tool-args"><div class="arg-row"><span class="arg-key">🔧 tool:</span><span class="arg-val">${escHtml(tc.name)}</span></div>`;
if (tc.args) {
Object.entries(tc.args).forEach(([k,v]) => {
if (v !== null && v !== undefined && v !== '') {
const display = Array.isArray(v) ? v.join(', ') : String(v);
if (k === 'reasoning') {
contentHtml += `<div class="arg-row" style="margin-top:4px;padding:4px 6px;background:rgba(168,85,247,0.1);border-radius:4px;border-left:3px solid #a855f7;"><span class="arg-key" style="color:#a855f7;">💡 suy luận:</span><span class="arg-val" style="font-style:italic;color:#7c3aed;">${escHtml(display)}</span></div>`;
} else {
contentHtml += `<div class="arg-row"><span class="arg-key">${escHtml(k)}:</span><span class="arg-val">${escHtml(display)}</span></div>`;
}
}
});
}
contentHtml += '</div>';
});
}
} else if (step.step === 'tool_result') {
try {
marked.setOptions({ breaks: true, gfm: true });
contentHtml = `<div class="step-content truncated md-content">${marked.parse(step.content)}</div>`;
} catch(e) {
contentHtml = `<div class="step-content">${escHtml(step.content)}</div>`;
}
if (step.raw_json) {
contentHtml += `<details class="raw-json-toggle"><summary>📋 Xem raw JSON</summary><pre>${escHtml(step.raw_json)}</pre></details>`;
}
} else if (step.step === 'responder') {
contentHtml = `<div class="step-content truncated">${escHtml(step.content)}</div>`;
}
div.innerHTML = `<div class="step-label">${step.label}</div>${contentHtml}`;
pipelineEl.appendChild(div);
setTimeout(() => div.classList.remove('new'), 350);
});
pipelineEl.scrollTop = pipelineEl.scrollHeight;
}
// ═══════ SEND MESSAGE ═══════
async function sendMsg() {
const query = inputEl.value.trim();
if (!query) return;
inputEl.value = '';
sendBtn.disabled = true;
addMsg('user', escHtml(query));
showThinking();
try {
const token = (parent && parent.localStorage) ? parent.localStorage.getItem('canifa_token') : localStorage.getItem('canifa_token');
const resp = await fetch('/api/tag-search/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? {'Authorization': 'Bearer ' + token} : {}),
},
body: JSON.stringify({ query, session_id: SESSION_ID }),
});
const data = await resp.json();
hideThinking();
if (data.status === 'success') {
// ── Timing từ controller v2 ──
const timing = data.timing || {};
const wallMs = timing.wall_ms || timing.total_ms || data.elapsed_ms || '?';
const plannerMs = timing.planner_ms || 0;
const responderMs = timing.responder_ms || 0;
// ── Meta line cho AI bubble ──
const metaParts = [
`⏱ ${wallMs}ms`,
data.agent_path || null,
data.product_count ? `📦 ${data.product_count} SP` : null,
].filter(Boolean);
addMsg('ai', md(data.response), metaParts.join(' · '));
// ── Product cards ──
if (data.products && data.products.length) {
renderProductCards(data.products);
}
// ── History count ──
if (data.history_count !== undefined) {
historyCountEl.textContent = `hist: ${data.history_count}`;
}
// ── Timing badge ──
if (timing.wall_ms || timing.total_ms) {
timingBadge.style.display = '';
const parts = [];
if (plannerMs) parts.push(`P:${plannerMs}ms`);
if (responderMs) parts.push(`R:${responderMs}ms`);
parts.push(`T:${wallMs}ms`);
timingBadge.textContent = parts.join(' | ');
}
// ── Trace badge (Langfuse) ──
if (data.trace_id) {
_lastTraceId = data.trace_id;
traceBadge.style.display = '';
traceBadge.textContent = '🔍 ' + data.trace_id.slice(0,8) + '…';
traceBadge.title = 'Langfuse: ' + data.trace_id + ' (click to copy)';
}
// ── Pipeline ──
if (data.pipeline && data.pipeline.length) {
renderPipeline(data.pipeline, wallMs, data.agent_path);
}
} else {
addMsg('error', '❌ ' + (data.message || 'Lỗi'));
}
} catch (e) {
hideThinking();
addMsg('error', '❌ Lỗi kết nối: ' + e.message);
} finally {
sendBtn.disabled = false;
inputEl.focus();
}
}
function copyTrace() {
if (!_lastTraceId) return;
navigator.clipboard.writeText(_lastTraceId).then(() => {
const orig = traceBadge.textContent;
traceBadge.textContent = '✅ Copied!';
setTimeout(() => traceBadge.textContent = orig, 1500);
});
}
function renderProductCards(products) {
// Max 6 per turn
const shown = products.slice(0, 6);
const grid = document.createElement('div');
grid.className = 'products-grid';
shown.forEach(p => {
const card = document.createElement('div');
card.className = 'product-card';
card.onclick = () => openProductPopup(p);
// Fields từ tag_search_tool._format_products():
// sku, name, price (sale), original_price, discount (string '-xx%'),
// color, gender, product_line, image, url, in_stock, total_qty
const salePrice = p.price || 0;
const origPrice = p.original_price || 0;
const hasDiscount = origPrice > salePrice && salePrice > 0;
const discStr = p.discount || (hasDiscount ? `-${Math.round((1-salePrice/origPrice)*100)}%` : null);
const priceStr = salePrice ? salePrice.toLocaleString('vi-VN') + '₫' : 'Liên hệ';
const origStr = (origPrice && hasDiscount) ? origPrice.toLocaleString('vi-VN') + '₫' : '';
const imgUrl = p.image || '';
const stockDot = p.in_stock === false ? '<span style="color:#dc2626">● Hết</span>' : (p.in_stock === true ? '<span style="color:#16a34a">● Còn</span>' : '');
card.innerHTML = `
<img class="pc-img" src="${escHtml(imgUrl)}" alt="${escHtml(p.name || '')}" loading="lazy"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect fill=%22%23f5f5f4%22 width=%22100%22 height=%22100%22/><text x=%2250%22 y=%2252%22 text-anchor=%22middle%22 fill=%22%23a8a29e%22 font-size=%2212%22>No img</text></svg>'">
<div class="pc-body">
<div class="pc-name">${escHtml(p.name || '')}</div>
<div class="pc-meta">
${p.color ? `<span>🎨 ${escHtml(p.color)}</span>` : ''}
${p.gender ? `<span>${escHtml(p.gender)}</span>` : ''}
${stockDot}
</div>
<div class="pc-price-row">
<span class="pc-price">${priceStr}</span>
${origStr ? `<span class="pc-orig">${origStr}</span>` : ''}
${discStr ? `<span class="pc-disc">${escHtml(discStr)}</span>` : ''}
</div>
<div class="pc-sku">${escHtml(p.sku || '')}</div>
</div>
`;
grid.appendChild(card);
});
if (products.length > 6) {
const more = document.createElement('div');
more.style.cssText = 'grid-column:1/-1;text-align:center;font-size:11px;color:var(--muted-fg);padding:4px 0;font-style:italic;';
more.textContent = `+${products.length - 6} sản phẩm khác trong response`;
grid.appendChild(more);
}
messagesEl.appendChild(grid);
messagesEl.scrollTop = messagesEl.scrollHeight;
}
// ═══════ PRODUCT DETAIL POPUP (C-Stylist style) ═══════
// Lazy refs — HTML is below the script tag
function _pd(id) { return document.getElementById(id); }
let _pdSelectedSize = null;
function closeProductPopup() {
_pd('pdOverlay')?.classList.remove('active');
_pd('pdPopup')?.classList.remove('active');
_pdSelectedSize = null;
}
async function openProductPopup(product) {
_pd('pdOverlay').classList.add('active');
_pd('pdPopup').classList.add('active');
_pd('pdBody').innerHTML = '<div class="pd-loading"><div class="spin"></div><br>Đang tải...</div>';
_pdSelectedSize = null;
// Field mapping từ JSON mới (tag_search_tool._format_products)
const sku = product.sku || '';
const name = (product.name || '').replace(/[\uFFFD]/g, '');
const imgUrl = product.image || '';
const origPrice = product.original_price || 0;
const finalPrice = product.price || 0;
const discStr = product.discount || '';
const discPct = discStr ? parseInt(discStr.replace(/[^0-9]/g,'')) || 0
: (origPrice > finalPrice ? Math.round((1-finalPrice/origPrice)*100) : 0);
const webUrl = product.url || '#';
const skuDisplay = sku;
const inStock = product.in_stock ?? true;
// Sizes from our DB (pipe-separated or array)
let sizes = product.available_sizes || product.sizes || [];
if (typeof sizes === 'string') sizes = sizes.split('|').map(s=>s.trim()).filter(Boolean);
// Colors from our DB
const colors = product.colors || [];
// Render popup HTML
const fmtP = (n) => n ? Number(n).toLocaleString('vi-VN') + ' đ' : '';
_pd('pdBody').innerHTML = `
<div class="pd-main">
<div class="pd-img-col">
<img class="pd-img-main" src="${escHtml(imgUrl)}" alt="${escHtml(name)}"
onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 180 240%22><rect fill=%22%23f5f5f5%22 width=%22180%22 height=%22240%22/><text x=%2290%22 y=%22125%22 text-anchor=%22middle%22 fill=%22%23ccc%22 font-size=%2214%22>No image</text></svg>'">
</div>
<div class="pd-info-col">
<h4 class="pd-name">${escHtml(name)}</h4>
<div class="pd-sku">
Mã: ${escHtml(skuDisplay)}
<button class="pd-sku-copy" onclick="navigator.clipboard.writeText('${escHtml(skuDisplay)}')" title="Copy mã">📋</button>
</div>
<div class="pd-price-block">
${origPrice > finalPrice ? `<span class="pd-orig-price">${fmtP(origPrice)}</span>` : ''}
${discPct > 0 ? `<span class="pd-discount">-${discPct}%</span>` : ''}
</div>
<div class="pd-final-price">${fmtP(finalPrice)}</div>
<a class="pd-detail-link" href="${escHtml(webUrl)}" target="_blank" rel="noopener">Chi tiết &gt;&gt;</a>
</div>
</div>
${colors.length > 0 ? `
<div class="pd-section">
<div class="pd-section-label">Màu sắc: ${escHtml(colors[0]?.color || '')}</div>
<div class="pd-colors">
${colors.map(c => `
<img class="pd-color-thumb" src="${escHtml(c.thumbnail || imgUrl)}" alt="${escHtml(c.color || '')}" title="${escHtml(c.color || '')}">
`).join('')}
</div>
</div>` : ''}
<div class="pd-section">
<div class="pd-section-label">Kích cỡ: <span style="color:#3b82f6;cursor:pointer;font-weight:400;">Chọn kích cỡ</span></div>
<div class="pd-sizes">
${sizes.length > 0 ? sizes.map(s => `
<button class="pd-size-btn" onclick="selectPdSize(this,'${escHtml(s)}')">${escHtml(s)}</button>
`).join('') : '<span style="font-size:12px;color:#999;">Đang cập nhật...</span>'}
</div>
</div>
<button class="pd-add-cart" id="pdAddCartBtn" onclick="addPdToCart()">
THÊM VÀO GIỎ HÀNG
</button>
`;
// Store current product data for cart
_pd('pdPopup')._currentProduct = { name, sku: skuDisplay, price: finalPrice, originalPrice: origPrice, image: imgUrl, url: webUrl };
}
function selectPdSize(btn, size) {
document.querySelectorAll('.pd-size-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_pdSelectedSize = size;
const addBtn = document.getElementById('pdAddCartBtn');
if (addBtn) { addBtn.classList.add('ready'); }
}
function addPdToCart() {
if (!_pdSelectedSize) {
alert('Vui lòng chọn kích cỡ!');
return;
}
const p = _pd('pdPopup')?._currentProduct;
if (!p) return;
// Use cart.js addToCart function
if (typeof addToCart === 'function') {
addToCart({
sku: p.sku,
cartKey: `${p.sku}:${_pdSelectedSize}`,
name: p.name,
size: _pdSelectedSize,
price: p.price,
originalPrice: p.originalPrice || p.price,
image: p.image,
});
} else {
alert(`Đã thêm ${p.name} - Size ${_pdSelectedSize} vào giỏ hàng!`);
}
closeProductPopup();
}
// ═══════ NEW SESSION (tạo mới, bỏ cũ) ═══════
function newSession() {
// Tạo session mới, KHÔNG xoá Redis cũ (để nó tự expire)
const newSid = 'ts_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
sessionStorage.setItem('tag_search_session_id', newSid);
location.reload();
}
// ═══════ CLEAR CHAT (xoá Redis + tạo mới) ═══════
async function clearChat() {
try {
const token = (parent && parent.localStorage) ? parent.localStorage.getItem('canifa_token') : localStorage.getItem('canifa_token');
await fetch('/api/tag-search/clear', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(token ? {'Authorization': 'Bearer ' + token} : {}),
},
body: JSON.stringify({ query: '', session_id: SESSION_ID }),
});
} catch(e) {
console.warn('Clear error:', e);
}
// Tạo session mới + reload
newSession();
}
function sendQuick(text) {
inputEl.value = text;
sendMsg();
}
// ═══════ LOAD HISTORY ON STARTUP ═══════
async function loadHistory() {
try {
const token = (parent && parent.localStorage) ? parent.localStorage.getItem('canifa_token') : localStorage.getItem('canifa_token');
const res = await fetch(`/api/tag-search/history?session_id=${SESSION_ID}`, {
headers: {
...(token ? {'Authorization': 'Bearer ' + token} : {}),
}
});
const data = await res.json();
if (data.status === 'success' && data.history && data.history.length > 0) {
historyCountEl.textContent = `hist: ${data.count}`;
data.history.forEach(msg => {
const type = msg.role === 'user' ? 'user' : 'ai';
// If it's AI, we can just print the content directly. For old messages there's no meta block.
addMsg(type, msg.content);
});
messagesEl.scrollTop = messagesEl.scrollHeight;
}
} catch(e) {
console.warn('Load history error:', e);
}
}
inputEl.focus();
loadHistory();
\ No newline at end of file
......@@ -90,12 +90,12 @@ async def generate_tags_for_product(client: httpx.AsyncClient, code: str, name:
}
base_url = "https://api.groq.com/openai/v1/chat/completions"
models_to_try = [
"gpt-oss-120b", "gpt-oss-20b", "gpt-oss-safeguard-20b",
"llama-4-scout", "qwen3-32b"
]
for model_name in models_to_try:
payload["model"] = model_name
try:
......@@ -138,7 +138,7 @@ async def handle_tags_job(redis, job_data, client, conn):
logger.info(f"🚀 Found Tags Job {job_id} with {len(codes)} items")
progress_key = f"tags:progress:{job_id}"
sem = asyncio.Semaphore(BATCH_CONCURRENCY)
done_count = err_count = 0
......@@ -204,7 +204,7 @@ async def handle_ai_edit_job(redis, job_data, client, conn):
done_count = err_count = 0
results_history = []
# We do sequential for high safety so we don't spam codex completely.
# 5 concurrent workers max for ai-edit
sem = asyncio.Semaphore(5)
......@@ -222,9 +222,9 @@ async def handle_ai_edit_job(redis, job_data, client, conn):
err_count += 1
results_history.append({"code": code, "status": "not_found"})
return
name, p_line, desc_data = row
if isinstance(desc_data, str):
try: desc_data = json.loads(desc_data)
except: desc_data = {}
......@@ -232,7 +232,7 @@ async def handle_ai_edit_job(redis, job_data, client, conn):
desc_data = {}
current_value = desc_data.get(target_field, "")
context_fields = {
"ten_san_pham": desc_data.get("ten_san_pham", name or ""),
"phong_cach": desc_data.get("phong_cach", ""),
......@@ -346,7 +346,7 @@ Trả về chỉ nội dung trường, không có gì khác."""
# create tasks and run
await asyncio.gather(*[_worker(code) for code in codes])
await redis.hset(progress_key, "is_running", "false")
logger.info(f"🏁 AI Edit Job {job_id} completed: {done_count}/{len(codes)} (errors: {err_count})")
......@@ -372,7 +372,7 @@ async def main():
job_data = json.loads(payload)
conn = get_pooled_connection_compat()
try:
if queue_name == "tags:queue":
await handle_tags_job(redis, job_data, client, conn)
......
lines = []
lines.append('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 820" width="1200" height="820">')
lines.append(' <style>')
lines.append(' text { font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Helvetica Neue, Arial, sans-serif; }')
lines.append(' .title { font-size: 20px; font-weight: 600; fill: #111827; }')
lines.append(' .subtitle { font-size: 12px; font-weight: 400; fill: #6b7280; }')
lines.append(' .node-label { font-size: 13px; font-weight: 500; fill: #111827; }')
lines.append(' .node-sub { font-size: 11px; font-weight: 400; fill: #6b7280; }')
lines.append(' .type-label { font-size: 10px; font-weight: 500; fill: #9ca3af; letter-spacing: 0.08em; }')
lines.append(' .section-label { font-size: 11px; font-weight: 500; fill: #6b7280; letter-spacing: 0.06em; }')
lines.append(' .arrow-label { font-size: 10px; font-weight: 500; fill: #6b7280; }')
lines.append(' .legend-label { font-size: 11px; fill: #374151; }')
lines.append(' .tier-num { font-size: 11px; font-weight: 600; fill: #374151; }')
lines.append(' .tier-sub { font-size: 10px; fill: #9ca3af; }')
lines.append(' </style>')
lines.append(' <defs>')
lines.append(' <marker id="arr-blue" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">')
lines.append(' <polygon points="0 0, 8 3, 0 6" fill="#3b82f6"/>')
lines.append(' </marker>')
lines.append(' <marker id="arr-green" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">')
lines.append(' <polygon points="0 0, 8 3, 0 6" fill="#059669"/>')
lines.append(' </marker>')
lines.append(' <marker id="arr-gray" markerWidth="8" markerHeight="6" refX="7" refY="3" orient="auto">')
lines.append(' <polygon points="0 0, 8 3, 0 6" fill="#9ca3af"/>')
lines.append(' </marker>')
lines.append(' </defs>')
lines.append(' <rect width="1200" height="820" fill="#ffffff"/>')
lines.append(' <text x="48" y="44" class="title">Lead Search Tool \u2014 Ki\u1ebfn tr\u00fac 7-T\u1ea7ng &amp; T\u1ed1i \u01b0u Token</text>')
lines.append(' <text x="48" y="64" class="subtitle">Canifa AI Stylist \u00b7 Cascading Search Strategy \u00b7 T4/2026</text>')
lines.append(' <rect x="32" y="82" width="1136" height="80" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>')
lines.append(' <text x="46" y="96" class="section-label">INPUT</text>')
lines.append(' <rect x="60" y="102" width="160" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="140" y="123" class="type-label" text-anchor="middle">AGENT OUTPUT</text>')
lines.append(' <text x="140" y="139" class="node-label" text-anchor="middle">AI Intent</text>')
lines.append(' <rect x="260" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="330" y="123" class="type-label" text-anchor="middle">TEXT</text>')
lines.append(' <text x="330" y="139" class="node-label" text-anchor="middle">Keywords</text>')
lines.append(' <rect x="440" y="102" width="140" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="510" y="123" class="type-label" text-anchor="middle">ARRAY</text>')
lines.append(' <text x="510" y="139" class="node-label" text-anchor="middle">Tags</text>')
lines.append(' <rect x="620" y="102" width="300" height="48" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="770" y="123" class="type-label" text-anchor="middle">HARD FILTERS</text>')
lines.append(' <text x="770" y="139" class="node-label" text-anchor="middle">Color \u00b7 Size \u00b7 Price \u00b7 Gender \u00b7 Line</text>')
lines.append(' <line x1="220" y1="126" x2="256" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <line x1="220" y1="126" x2="436" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <line x1="220" y1="126" x2="616" y2="126" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <rect x="32" y="182" width="1136" height="290" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>')
lines.append(' <text x="46" y="196" class="section-label">CASCADING SEARCH \u2014 STARROCKS</text>')
tier_data = [
('T1', 'Keywords', 'NGRAMBF idx', '#dbeafe', '#1d4ed8'),
('T2', 'Tags', 'BITMAP idx', '#dbeafe', '#1d4ed8'),
('T3', 'Hard Filters', 'Fixed attrs only', '#e0f2fe', '#0369a1'),
('T4', 'Drop Gender', 'Bo gender filter', '#fef3c7', '#b45309'),
('T5', 'Price x1.5', 'Noi rong gia', '#fce7f3', '#9d174d'),
('T6', 'Price x2.0', 'Noi rong hon', '#fce7f3', '#9d174d'),
('T7', 'Bo Price', 'Khong gioi han', '#f3f4f6', '#374151'),
]
for i, (num, name, sub, bg, stroke) in enumerate(tier_data):
x = 48 + i * 158
y = 208
lines.append(f' <rect x="{x}" y="{y}" width="144" height="56" rx="4" fill="{bg}" stroke="{stroke}" stroke-width="1.5"/>')
lines.append(f' <text x="{x+12}" y="{y+18}" class="tier-num">{num} // {name}</text>')
lines.append(f' <text x="{x+12}" y="{y+34}" class="tier-sub">{sub}</text>')
if i < 6:
lines.append(f' <line x1="{x+144}" y1="{y+28}" x2="{x+155}" y2="{y+28}" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-gray)"/>')
lines.append(' <rect x="200" y="296" width="800" height="48" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>')
lines.append(' <text x="600" y="317" class="type-label" text-anchor="middle">RESULT</text>')
lines.append(' <text x="600" y="333" class="node-label" text-anchor="middle">Top 12 rows tu tang co ket qua → deduplicate → top 3 base_codes</text>')
for i in range(7):
x_mid = 48 + i * 158 + 72
lines.append(f' <line x1="{x_mid}" y1="264" x2="{x_mid}" y2="293" stroke="#059669" stroke-width="1" stroke-dasharray="3,2" marker-end="url(#arr-green)"/>')
lines.append(' <rect x="32" y="492" width="1136" height="120" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>')
lines.append(' <text x="46" y="506" class="section-label">ENRICHMENT</text>')
lines.append(' <rect x="60" y="516" width="220" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="170" y="535" class="type-label" text-anchor="middle">EXTERNAL API</text>')
lines.append(' <text x="170" y="551" class="node-label" text-anchor="middle">Canifa Stock API</text>')
lines.append(' <text x="170" y="567" class="node-sub" text-anchor="middle">canifa.com/v1/middleware/...</text>')
lines.append(' <text x="170" y="580" class="node-sub" text-anchor="middle">Loc con hang \u00b7 Size \u00b7 Qty</text>')
lines.append(' <rect x="340" y="516" width="200" height="72" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="440" y="535" class="type-label" text-anchor="middle">LOCAL DB</text>')
lines.append(' <text x="440" y="551" class="node-label" text-anchor="middle">SQLite 123.db</text>')
lines.append(' <text x="440" y="567" class="node-sub" text-anchor="middle">ai_description</text>')
lines.append(' <text x="440" y="580" class="node-sub" text-anchor="middle">ai_matches (phoi do)</text>')
lines.append(' <line x1="600" y1="344" x2="600" y2="490" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <line x1="560" y1="492" x2="282" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <line x1="600" y1="492" x2="442" y2="514" stroke="#3b82f6" stroke-width="1.5" marker-end="url(#arr-blue)"/>')
lines.append(' <rect x="32" y="632" width="1136" height="160" rx="4" fill="none" stroke="#e5e7eb" stroke-width="1" stroke-dasharray="4,3"/>')
lines.append(' <text x="46" y="646" class="section-label">JSON OUTPUT \u2014 TOKEN-SAVING STRUCTURE</text>')
lines.append(' <rect x="60" y="658" width="240" height="120" rx="4" fill="#f9fafb" stroke="#e5e7eb" stroke-width="1"/>')
lines.append(' <text x="180" y="676" class="type-label" text-anchor="middle">MAIN RESULTS</text>')
lines.append(' <text x="180" y="693" class="node-label" text-anchor="middle">Main Products (Max 3)</text>')
lines.append(' <text x="180" y="710" class="node-sub" text-anchor="middle">sku \u00b7 name \u00b7 price \u00b7 description</text>')
lines.append(' <text x="180" y="724" class="node-sub" text-anchor="middle">stock[ ] \u00b7 in_stock: bool</text>')
lines.append(' <text x="180" y="738" class="node-sub" text-anchor="middle">ai_matches \u00b7 reason</text>')
lines.append(' <text x="180" y="752" class="node-sub" text-anchor="middle">~900 tokens / SP</text>')
lines.append(' <rect x="360" y="658" width="200" height="120" rx="4" fill="#f0fdf4" stroke="#059669" stroke-width="1"/>')
lines.append(' <text x="460" y="676" class="type-label" text-anchor="middle">SUGGEST</text>')
lines.append(' <text x="460" y="693" class="node-label" text-anchor="middle">Suggest Items</text>')
lines.append(' <text x="460" y="710" class="node-sub" text-anchor="middle">Max 2 SP phoi cung</text>')
lines.append(' <text x="460" y="726" class="node-sub" text-anchor="middle">sku \u00b7 name \u00b7 price</text>')
lines.append(' <text x="460" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>')
lines.append(' <rect x="620" y="658" width="200" height="120" rx="4" fill="#fefce8" stroke="#ca8a04" stroke-width="1"/>')
lines.append(' <text x="720" y="676" class="type-label" text-anchor="middle">SIMILAR</text>')
lines.append(' <text x="720" y="693" class="node-label" text-anchor="middle">Similar Items</text>')
lines.append(' <text x="720" y="710" class="node-sub" text-anchor="middle">Max 3 SP thay the</text>')
lines.append(' <text x="720" y="726" class="node-sub" text-anchor="middle">sku \u00b7 name \u00b7 price</text>')
lines.append(' <text x="720" y="742" class="node-sub" text-anchor="middle">~50 tokens / SP</text>')
lines.append(' <rect x="880" y="658" width="260" height="120" rx="4" fill="#fff7ed" stroke="#ea580c" stroke-width="1.5"/>')
lines.append(' <text x="1010" y="676" class="type-label" text-anchor="middle">TOKEN SAVING</text>')
lines.append(' <text x="1010" y="693" class="node-label" text-anchor="middle">Giam 75% Token</text>')
lines.append(' <text x="1010" y="710" class="node-sub" text-anchor="middle">Cu: 15-20 SP full detail</text>')
lines.append(' <text x="1010" y="726" class="node-sub" text-anchor="middle">Moi: 3 SP + 15 SP phu</text>')
lines.append(' <text x="1010" y="742" class="node-sub" text-anchor="middle">Context Window: an toan</text>')
lines.append(' <line x1="170" y1="588" x2="180" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>')
lines.append(' <line x1="440" y1="588" x2="460" y2="656" stroke="#059669" stroke-width="1.5" marker-end="url(#arr-green)"/>')
lines.append(' <text x="48" y="808" class="legend-label">Legend:</text>')
lines.append(' <line x1="108" y1="803" x2="138" y2="803" stroke="#3b82f6" stroke-width="2" marker-end="url(#arr-blue)"/>')
lines.append(' <text x="144" y="808" class="legend-label">Primary data flow</text>')
lines.append(' <line x1="278" y1="803" x2="308" y2="803" stroke="#059669" stroke-width="2" marker-end="url(#arr-green)"/>')
lines.append(' <text x="314" y="808" class="legend-label">Enrich / Store</text>')
lines.append(' <line x1="430" y1="803" x2="460" y2="803" stroke="#9ca3af" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arr-gray)"/>')
lines.append(' <text x="466" y="808" class="legend-label">Fallback (tang rot)</text>')
lines.append('</svg>')
with open(r'd:\cnf\chatbot-canifa-feedback\backend\static\lead_flow\lead_search_diagram.svg', 'w', encoding='utf-8') as f:
f.write('\n'.join(lines))
print('SVG generated successfully, lines:', len(lines))
# 🚀 DONE: Refactor Lead Search Tool
## 📌 Context
- **Status:** ✅ Done
## 🎯 Goal
Replace the OpenAI Embedding logic in `lead_search_tool.py` with the highly optimized NGRAMBF + BITMAP Tags + Stock API architecture from `tag_search_tool.py`, while preserving Lead-specific features like `size` filtering and price relaxation.
## ⚠️ Active Trade-offs
- Skipping: OpenAI Embedding — To reduce latency from >1000ms to <300ms.
- Changing Data Source: Move outfit data from Postgres to local SQLite `123.db` for faster reads.
## 🛠 Impact & Files Touched
| File | Symbol | Change | Blast Radius |
|------|--------|--------|--------------|
| `backend/agent/lead_stage_agent/lead_search_tool.py` | `LeadSearchInput`, `_cascading_search`, etc. | MODIFY | Lead Stage Agent |
**Risk:** Medium
## 📋 Execution Checklist
### Phase 1: Update Schema & Builders
- [x] Task 1.1: Add `tags` field to `LeadSearchInput`. Update `keywords` prompt. Keep `size`.
- [x] Task 1.2: Merge `_build_fixed_clauses`, `_build_tag_clauses`, `_build_search_clause` from Tag Search. Add `size` to `_build_fixed_clauses`.
### Phase 2: Stock API & Outfits
- [x] Task 2.1: Port `_fetch_stock_batch` and `_enrich_with_stock` using `httpx`.
- [x] Task 2.2: Port `_enrich_with_outfit` reading from `123.db`.
### Phase 3: Cascading Search
- [x] Task 3.1: Replace OpenAI embedding in `_cascading_search` with Tag Search's 3 tiers (NGRAMBF, Tags BITMAP, Hard Filters).
- [x] Task 3.2: Re-attach Lead's specific tiers: Drop gender, Price Relaxation 1.5x, 2.0x, Unlimited.
- [x] Task 3.3: Re-attach `suggest_items` and `similar_items` formatting in `_format_products`.
## ✅ Completion Gate
- [x] All tasks [x] | [x] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28 | Updated: 2026-04-28_
# 🧪 DOING: Manual UI Testing for FAQ System
## 📌 Context
- **Objective:** Verify the new FAQ Manager & AI Variant Generation features through the frontend UI.
- **URL:** [http://localhost:5000/static/main.html?page=faqs/faqs.html](http://localhost:5000/static/main.html?page=faqs/faqs.html)
- **Status:** 🚧 Awaiting User Verification
## 📋 QA Checklist dành cho Bro
### 1. Kiểm tra Giao diện & Data ban đầu
- [ ] Mở tab **FAQ Manager** ở sidebar (dưới phần Resources).
- [ ] Kiểm tra 4 thẻ thống kê trên cùng (Tổng FAQ, Câu gốc, Biến thể AI, Danh mục) hiển thị đúng số liệu (~42 câu gốc).
- [ ] Bảng FAQ hiển thị danh sách các câu hỏi, phân chia cột rõ ràng.
### 2. Kiểm tra Thao tác CRUD (Create, Read, Update, Delete)
- [ ] **Thêm mới (Create):** Bấm nút `➕ Thêm FAQ`, điền nội dung và lưu. Kiểm tra xem nó có hiện ra ở bảng không.
- [ ] **Sửa (Update):** Bấm nút `✏️` ở một dòng bất kỳ, sửa nội dung và lưu. Kiểm tra xem đã cập nhật thành công chưa.
- [ ] **Xoá (Delete):** Bấm nút `🗑️` để xoá 1 FAQ vừa tạo thử, xác nhận bảng đã cập nhật.
### 3. Kiểm tra Tính năng Lọc & Tìm kiếm
- [ ] Thử chọn một danh mục trong dropdown (VD: `vận chuyển`).
- [ ] Gõ từ khóa vào ô tìm kiếm (VD: `đổi trả`), kiểm tra xem có lọc đúng không.
- [ ] Check/Uncheck ô "Hiện biến thể" (Lúc này chưa có biến thể nên sẽ không thay đổi nhiều).
### 4. Kiểm tra AI Sinh Biến thể (Quan trọng nhất 🔥)
- [ ] Bấm nút **`🤖 Generate`** ở một câu gốc bất kỳ (VD: "Phí ship bao nhiêu?").
- [ ] Đợi khoảng 5-15 giây để gọi FreeLLM API (Nút sẽ hiện "⏳ Đang sinh...").
- [ ] Khi có thông báo thành công (VD: Đã sinh 15 biến thể), đóng popup.
- [ ] Nhìn lại bảng: bạn sẽ thấy hàng loạt các câu hỏi có badge **`Biến thể`** màu vàng, liên kết với câu gốc.
- [ ] Thử tính năng **`🤖 AI Batch Generate`** (Sinh cho tất cả) — _Cảnh báo: Tính năng này gọi API liên tục nên có thể mất 1-3 phút. Chỉ test khi cần._
---
> 💡 **Báo cáo kết quả:** Nếu tất cả các bước trên đều chạy mượt mà, API không báo lỗi 500 thì báo lại để tôi chuyển task này sang DONE nhé!
_Started: 2026-04-28_
# 🚀 DOING: n8n Generate API Migration
## 📌 Context
- **Idea:** `plan/ideas/07_n8n_generate_api.md`
- **Report:** Skipped (Direct implementation requested)
- **Status:** 🚧 In Progress
## 🎯 Goal
Migrate `/api/product-desc/n8n-generate` to use the unified FreeLLMAPI router.
## ⚠️ Active Trade-offs
- None
## 🛠 Impact & Files Touched
| File | Symbol | Change | Blast Radius |
|------|--------|--------|--------------|
| `backend/api/product_desc/n8n_desc.py` | `_groq_call` | MODIFY | 1 caller |
**Risk:** Low
## 🔙 Rollback Plan
Revert the URL and API key changes in `n8n_desc.py`.
## 📋 Execution Checklist
### Phase 1: API Configuration
- [x] Task 1.1: Replace Groq API keys with the single FreeLLMAPI key.
- [x] Task 1.2: Update the endpoint URL from Groq to `http://172.16.2.210:3002/v1/chat/completions` in `_groq_call`.
### Phase 2: Testing
- [x] Task 2.1: Test API with curl command to ensure it connects to FreeLLMAPI.
## 🔄 Sub-Doings
_None yet_
## ✅ Completion Gate
- [x] All tasks [x] | [x] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28 | Updated: 2026-04-28_
# 🚀 DOING: Dashboard Links SQLite Migration
## 📌 Context
- **Idea:** Migrate dashboard links from JSON file to SQLite database.
- **Status:** 🚧 In Progress
## 🎯 Goal
Migrate `dashboard_links` to use the standard local `canifa_ai_dump.sqlite` DB instead of a JSON file, and ensure CRUD endpoints work via the `get_pooled_connection_compat()` wrapper.
## 🛠 Impact & Files Touched
| File | Symbol | Change | Blast Radius |
|------|--------|--------|--------------|
| `backend/api/common/dashboard_route.py` | `_load_links`, `_save_links` | MODIFY | 1 API Route |
**Risk:** Low
## 📋 Execution Checklist
### Phase 1: SQLite Integration (~5m)
- [x] Task 1.1: Create table `dashboard_links` in SQLite via `get_pooled_connection_compat()`
- [x] Task 1.2: Update GET `/api/dashboard/links` to read from DB
- [x] Task 1.3: Update POST/PUT/DELETE/PATCH to write to DB
### Phase 2: Testing (~2m)
- [x] Task 2.1: Write to DB via `curl -X POST`
- [x] Task 2.2: Read from DB via `curl -X GET`
## ✅ Completion Gate
- [x] All tasks [x] | [x] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28 | Updated: 2026-04-28_
# 🚀 DOING: Seed Default Dashboard Links
## 📌 Context
- **Idea:** Migrate the hardcoded `DEFAULT_TOOL_LINKS` from the detached `notes_route.py` into the newly created SQLite `dashboard_links` table in `dashboard_route.py`.
- **Status:** 🚧 In Progress
## 🎯 Goal
Ensure that the default tools (Stress Test, Regression Test, User Simulator, Prompt Optimizer, Competitor Research) are properly seeded into SQLite so they appear on the Resources page instead of an empty list.
## 📋 Execution Checklist
### Phase 1: Code Migration
- [x] Task 1.1: Copy `DEFAULT_TOOL_LINKS` to `dashboard_route.py`.
- [x] Task 1.2: Write a `_seed_default_links()` function using `get_pooled_connection_compat()`.
- [x] Task 1.3: Call the seed function inside the `GET /api/dashboard/links` route.
### Phase 2: Verification
- [x] Task 2.1: Use `curl` to fetch `/api/dashboard/links` and verify all default links are returned.
## ✅ Completion Gate
- [x] All tasks [x] | [x] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28_
# 🚀 DOING: Fix UI Emoji Encoding
## 📌 Context
- **Idea:** Restore the corrupted emojis (rendered as `??`) in `resources.html` and `resources.js` caused by ANSI encoding.
- **Status:** 🚧 In Progress
## 🎯 Goal
Replace `??` with the correct emojis (📌, 🔧, 📄, 🔌, 🎨, 📦, etc.) in the dashboard UI files and explain the data situation to the user.
## 📋 Execution Checklist
### Phase 1: Fix HTML
- [x] Task 1.1: Replace `??` in sidebar navigation.
- [x] Task 1.2: Replace `??` in filter chips.
- [x] Task 1.3: Replace `??` in modal and empty states.
### Phase 2: Fix JS
- [x] Task 2.1: Replace `??` in JS string templates (empty state, actions).
## ✅ Completion Gate
- [x] All tasks [x] | [x] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28_
# 🚀 DOING: FAQ Knowledge Base + AI Variant Generator
## 📌 Context
- **Idea:** `plan/ideas/08_faq_knowledge_base.md`
- **Status:** 🚧 In Progress
## 📋 Execution Checklist
### Phase 1: Database
- [x] Task 1.1: Create `faqs` table in SQLite via `_ensure_faqs_table()`.
- [x] Task 1.2: Seed ~50-100 FAQ gốc về Canifa (chính sách, vận chuyển, đổi trả...).
### Phase 2: Backend API
- [x] Task 2.1: Create `faq_route.py` with CRUD endpoints (GET/POST/PUT/DELETE).
- [x] Task 2.2: Add `/api/faqs/generate-variants` endpoint (AI integration).
- [x] Task 2.3: Add `/api/faqs/stats` endpoint.
- [x] Task 2.4: Register router in `server.py`.
### Phase 3: Frontend
- [x] Task 3.1: Create `faqs/` directory with `faqs.html` + `faqs.js` + `faqs.css`.
- [x] Task 3.2: Add FAQ Manager link to sidebar in `main.html`.
### Phase 4: Verification
- [x] Task 4.1: curl test CRUD endpoints.
- [x] Task 4.2: curl test AI variant generation.
## ✅ Completion Gate
- [x] All tasks [x] | [ ] Sub-Doings resolved | [x] Smoke test passed | [x] Trade-offs confirmed
---
_Started: 2026-04-28_
# Idea 07: Migrate n8n-generate API to FreeLLMAPI
## Goal
Update the endpoint `/api/product-desc/n8n-generate` to use the unified FreeLLMAPI proxy instead of direct Groq keys.
## Context
The endpoint receives image URLs and text from n8n (Google Sheets). It uses a vision model to analyze the image, and a writer model to generate an ultra description.
Currently, it uses multiple Groq API keys in a round-robin fashion. We want to switch this to the local FreeLLMAPI router.
## Requirements
- Endpoint: `http://localhost:3001/v1/chat/completions`
- API Key: `freellmapi-a858c30561987b8aa2a58343c7fe48a6a52e65cadb9df6d5`
- Must maintain the existing fallback/retry logic but point to the new provider.
- Test the endpoint after implementation.
# 💡 Idea 08: FAQ Knowledge Base + AI Variant Generator
## Tóm tắt
Xây dựng hệ thống FAQ (Frequently Asked Questions) cho Canifa AI Chatbot, lưu trữ trong SQLite DB. Từ các câu hỏi gốc (seed), sử dụng AI (FreeLLM API tại `http://172.16.2.210:3002/v1/chat/completions`) để tự động sinh ra các **biến thể câu hỏi** (paraphrases) nhằm mở rộng coverage — khi user hỏi phá hoặc hỏi lệch, chatbot vẫn map được về câu trả lời chuẩn.
## Yêu cầu chi tiết
### 1. Database (SQLite)
- Tạo bảng `faqs` trong `canifa_ai_dump.sqlite`:
- `id` (TEXT PRIMARY KEY)
- `question` (TEXT) — câu hỏi gốc
- `answer` (TEXT) — câu trả lời chuẩn
- `category` (TEXT) — phân loại (e.g. "chính sách", "sản phẩm", "vận chuyển", "đổi trả", "khuyến mãi", "chung")
- `is_variant` (INTEGER DEFAULT 0) — 0 = gốc, 1 = biến thể AI sinh ra
- `parent_id` (TEXT) — nếu là biến thể thì trỏ về id câu gốc
- `created_at` (TEXT)
### 2. Seed Data (vài nghìn câu)
- Viết sẵn ~50-100 câu hỏi gốc về Canifa (chính sách đổi trả, vận chuyển, khuyến mãi, thông tin công ty...)
- AI sẽ sinh ~20-50 biến thể cho mỗi câu gốc → tổng ~2000-5000 FAQ entries
### 3. API Endpoints
- `GET /api/faqs` — lấy danh sách FAQs (filter by category, search, pagination)
- `POST /api/faqs` — thêm FAQ mới
- `PUT /api/faqs/{id}` — sửa FAQ
- `DELETE /api/faqs/{id}` — xoá FAQ
- `POST /api/faqs/generate-variants` — AI sinh biến thể cho 1 câu hỏi gốc
- `POST /api/faqs/batch-generate` — AI sinh biến thể cho nhiều câu hỏi cùng lúc
- `GET /api/faqs/stats` — thống kê số FAQs theo category
### 4. AI Integration
- Sử dụng FreeLLM API: `http://172.16.2.210:3002/v1/chat/completions`
- API Key: `freellmapi-a858c30561987b8aa2a58343c7fe48a6a52e65cadb9df6d5`
- Prompt: "Từ câu hỏi gốc, sinh ra N biến thể khác nhau mà khách hàng có thể hỏi..."
### 5. Frontend (gắn vào sidebar main.html)
- Trang quản lý FAQ: xem, thêm, sửa, xoá, filter theo category
- Nút "Generate Variants" gọi AI sinh biến thể
- Hiển thị thống kê tổng quan
- Gắn vào sidebar group "Workspace" trong `main.html`
## Phân loại
- **Type:** Feature
- **Complexity:** Medium
- **Impact:** High — giúp chatbot trả lời chính xác hơn khi user hỏi lệch
Subproject commit 44af4fbec40276b94e54a275162e2ae803e5e13c
This source diff could not be displayed because it is too large. You can view the blob instead.
"""
Tag Search Tool — LangChain @tool cho AI gọi.
AI sinh keywords + tags → Tool build SQL → 3 tầng cascade → Trả kết quả.
+ Tự động gọi Canifa Stock API → chỉ trả SP CÒN HÀNG.
import os
with open('backend/agent/tag_search_agent_v2/tag_search_tool.py', 'r', encoding='utf-8') as f:
tag_code = f.read()
with open('backend/agent/lead_stage_agent/lead_search_tool.py', 'r', encoding='utf-8') as f:
lead_code = f.read()
# We want to use the LeadSearchInput from lead_code, but add tags to it.
# We want to use _build_fixed_clauses from lead_code because it has size and color diacritic handling.
# We want to use _cascading_search but heavily modify it to use tag_search_tool tiers.
# Actually, the easiest way is to construct it from strings to get exactly what we want.
new_code = """\"\"\"
Lead Search Tool - LangChain @tool cho AI goi.
AI sinh keywords + tags -> Tool search -> cascade -> Tra ket qua.
+ Tu dong goi Canifa Stock API -> chi tra SP CON HANG.
+ Doc outfit tu SQLite 123.db
Architecture:
CỐ ĐỊNH (luôn giữ): product_type + color + gender + price
BIẾN ĐỔI (fallback):
Tầng 1: CỐ ĐỊNH + keywords (từ nguyên văn user)
Tầng 2: CỐ ĐỊNH + tags (AI suy luận → tags chung)
Tầng 3: CHỈ CỐ ĐỊNH (product_type + color + gender)
"""
CO DINH (luon giu): product_type + color + gender + price + size
BIEN DOI (fallback):
Tang 1: CO DINH + keywords (NGRAMBF tren product_name hoac LIKE tren description)
Tang 2: CO DINH + tags (BITMAP hoac LIKE full phrase)
Tang 3: CHI CO DINH (product_type + color + gender + size + price)
Tang 4: Drop gender
Tang 5-6: Price relaxation (1.5x, 2x)
Tang 7: Bo price hoan toan
\"\"\"
import json
import logging
import time
from datetime import datetime, timezone
import os as _os
import sqlite3
import httpx
from langchain_core.tools import tool
from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from .product_mapping import SYNONYM_TO_DB, get_related_lines
from .product_mapping import SYNONYM_TO_DB, get_related_lines, resolve_product_line
logger = logging.getLogger(__name__)
# Langfuse client — optional, graceful fallback nếu chưa init
try:
from langfuse import get_client as _get_langfuse
_LANGFUSE_AVAILABLE = True
except ImportError:
_get_langfuse = None
_LANGFUSE_AVAILABLE = False
# ═══════════════════════════════════════════════
# Stock API — Kiểm tra tồn kho Canifa
# ═══════════════════════════════════════════════
CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent"
_STOCK_TIMEOUT = 1.5 # seconds — giữ tổng < 200ms
_STOCK_TIMEOUT = 1.5 # seconds
async def _fetch_stock_batch(base_codes: list[str]) -> dict[str, list[dict]]:
"""
Gọi Canifa Stock API batch → trả dict: product_color_code → [{size, qty, status}].
Giữ ALL sizes (kể cả hết hàng) để AI biết full thông tin.
Timeout 1.5s. Nếu lỗi → trả dict rỗng (graceful fallback).
Returns: (stock_map, elapsed_ms, timed_out)
"""
if not base_codes:
return {}, 0.0, False
......@@ -60,58 +73,49 @@ async def _fetch_stock_batch(base_codes: list[str]) -> dict[str, list[dict]]:
elapsed = round((time.time() - t0) * 1000, 1)
# Parse: gom ALL sizes theo product_color_code (kể cả hết hàng)
stock_map: dict[str, list[dict]] = {}
results = data.get("result", [])
if isinstance(results, list):
for item in results:
sku_full = item.get("sku", "") # "6TS25S018-SB055-S"
sku_full = item.get("sku", "")
qty = item.get("qty", 0) or 0
if not sku_full:
continue
parts = sku_full.rsplit("-", 1)
if len(parts) == 2:
color_code = parts[0] # "6TS25S018-SB055"
size = parts[1] # "S"
color_code = parts[0]
size = parts[1]
status = "còn hàng" if qty > 0 else "hết hàng"
stock_map.setdefault(color_code, []).append({
"size": size, "qty": qty, "status": status
})
logger.info("📦 Stock API: %d base_codes → %d color_codes | %.0fms", len(base_codes), len(stock_map), elapsed)
logger.info("📦 Stock API: %d base_codes -> %d color_codes | %.0fms", len(base_codes), len(stock_map), elapsed)
return stock_map, elapsed, False
except httpx.TimeoutException:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⏰ Stock API timeout (%.1fs=%.0fms) — skip stock filter", _STOCK_TIMEOUT, elapsed)
logger.warning("⏰ Stock API timeout (%.1fs=%.0fms)", _STOCK_TIMEOUT, elapsed)
return {}, elapsed, True
except Exception as e:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⚠️ Stock API error (%.0fms): %s — skip stock filter", elapsed, e)
logger.warning("⚠️ Stock API error (%.0fms): %s", elapsed, e)
return {}, elapsed, False
async def _enrich_with_stock(products: list[dict]) -> tuple[list[dict], bool, float, bool]:
"""
Gọi stock API cho batch products → lọc chỉ giữ SP còn hàng + kèm chi tiết tồn.
Returns: (filtered_products, stock_checked, stock_api_ms, timed_out)
"""
if not products:
return [], False, 0.0, False
# Gom base_codes (deduplicate) — API nhận base code
base_codes = list({p.get("internal_ref_code", "") for p in products if p.get("internal_ref_code")})
if not base_codes:
return products, False, 0.0, False
stock_map, stock_api_ms, timed_out = await _fetch_stock_batch(base_codes)
# Nếu stock API fail → trả nguyên, đánh dấu unchecked
if not stock_map:
return products, False, stock_api_ms, timed_out
# Lọc: giữ SP có ÍT NHẤT 1 size còn hàng (qty > 0)
# SP nào ALL sizes = 0 → bỏ, không tư vấn
in_stock = []
dropped = 0
for p in products:
......@@ -120,23 +124,26 @@ async def _enrich_with_stock(products: list[dict]) -> tuple[list[dict], bool, fl
sizes_detail = stock_map[color_code]
total_qty = sum(s["qty"] for s in sizes_detail)
if total_qty > 0:
# Có ít nhất 1 size còn hàng → giữ, kèm FULL sizes (cả hết hàng)
p["_stock_detail"] = sizes_detail
p["_total_qty"] = total_qty
in_stock.append(p)
else:
dropped += 1 # ALL sizes hết → bỏ
# Không có trong stock_map = API không trả data → bỏ
logger.info("📦 Stock filter: %d → %d in-stock, %d dropped | api=%.0fms timeout=%s",
len(products), len(in_stock), dropped, stock_api_ms, timed_out)
return in_stock if in_stock else products[:5], True, stock_api_ms, timed_out # fallback top 5 nếu tất cả hết hàng
dropped += 1
logger.info("📦 Stock filter: %d -> %d in-stock, %d dropped | api=%.0fms", len(products), len(in_stock), dropped, stock_api_ms)
return in_stock if in_stock else products[:5], True, stock_api_ms, timed_out
# ═══════════════════════════════════════════════
# SQLite local DB path
# ═══════════════════════════════════════════════
_DB_123_PATH = _os.path.normpath(
_os.path.join(_os.path.dirname(__file__), "..", "..", "123.db")
)
TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding"
SELECT_COLUMNS = """
SELECT_COLUMNS = \"\"\"
internal_ref_code,
magento_ref_code,
product_color_code,
......@@ -157,37 +164,26 @@ SELECT_COLUMNS = """
COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product,
size_scale,
description_text
"""
description_text,
suggest_items,
similar_items
\"\"\"
# ═══════════════════════════════════════════════
# SQLite local DB path
# ═══════════════════════════════════════════════
import os as _os
_DB_123_PATH = _os.path.normpath(
_os.path.join(_os.path.dirname(__file__), "..", "..", "123.db")
)
# ═══════════════════════════════════════════════
# Occasion tag → ai_matches key mapping
# ═══════════════════════════════════════════════
TAGS_TO_OCCASION: dict[str, str] = {
"occ:di_lam": "di_lam",
"occ:di_choi": "di_choi",
"occ:di_tiec": "di_tiec",
"occ:di_hoc": "di_choi", # map sang gần nhất
"occ:di_hoc": "di_choi",
"occ:mac_nha": "mac_nha",
"occ:the_thao": "the_thao",
"occ:di_bien": "di_choi", # map sang gần nhất
"occ:di_bien": "di_choi",
"occ:du_lich": "di_choi",
"occ:da_ngoai": "di_choi",
"occ:di_ngu": "mac_nha",
"occ:hang_ngay": "hang_ngay",
}
def _resolve_occasion(tags: list[str]) -> str:
"""Chọn occasion key từ tags của user. Fallback: 'hang_ngay'."""
for tag in (tags or []):
occ = TAGS_TO_OCCASION.get(tag.lower())
if occ:
......@@ -195,47 +191,39 @@ def _resolve_occasion(tags: list[str]) -> str:
return "hang_ngay"
# ═══════════════════════════════════════════════
# Pydantic Schema — AI sẽ fill vào đây
# ═══════════════════════════════════════════════
class TagSearchInput(BaseModel):
"""Schema cho hybrid search: keywords (user) + tags (AI suy luận)."""
class LeadSearchInput(BaseModel):
model_config = {"extra": "forbid"}
# ① KEYWORDS — từ NGUYÊN VĂN của user (ưu tiên cao, search TRƯỚC)
keywords: list[str] = Field(
default=[],
description=(
"Từ khoá NGUYÊN VĂN từ câu hỏi khách. "
"VD: khách nói '30/4' → keywords=['30/4']. "
"Khách nói 'cờ đỏ sao vàng' → keywords=['cờ đỏ sao vàng']. "
"Khách nói 'cotton ống rộng' → keywords=['cotton', 'ống rộng']. "
"VD: khách nói '30/4' -> keywords=['30/4']. "
"Khách nói 'cờ đỏ sao vàng' -> keywords=['cờ đỏ sao vàng']. "
"Khách nói 'cotton ống rộng' -> keywords=['cotton', 'ống rộng']. "
"Tối đa 2. Để [] nếu khách không nhắc gì đặc biệt."
)
)
# ② TAGS — AI suy luận từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!)
tags: list[str] = Field(
default=[],
description=(
"AI suy luận ý định khách → chọn từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!): "
"AI suy luận ý định khách -> chọn từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!): "
"Trục 1 (occ:): occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu. "
"Trục 2a (wthr:): wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang. "
"Trục 2b (func:): func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio. "
"Trục 3 (style:): style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual. "
"Trục 4 (fit:): fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed. "
"VD: 'đi tiệc' → tags=['occ:di_tiec']. 'mùa đông' → tags=['wthr:mua_dong', 'func:giu_am']. "
"VD: 'đi tiệc' -> tags=['occ:di_tiec']. 'mùa đông' -> tags=['wthr:mua_dong', 'func:giu_am']. "
"KHÔNG tự nghĩ tag mới! PHẢI giữ prefix! Tối đa 3."
)
)
# ═══ CỐ ĐỊNH — Luôn giữ trong mọi tầng ═══
product_line_vn: list[str] = Field(
default=[],
description=(
"Dòng sản phẩm. CHÍNH XÁC lời user nói: 'áo phông', 'váy liền', 'đồ lót', 'quần jean'... "
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' → 'Áo phông'). "
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' -> 'Áo phông'). "
"Nếu khách nói chung chung ('đồ mùa đông', 'đồ tập') hoặc không nhắc, để []. "
),
)
......@@ -251,99 +239,66 @@ class TagSearchInput(BaseModel):
default=None,
description=(
"Màu sắc. Ghi CHÍNH XÁC từ khách nói: 'trắng', 'đen', 'đỏ'... "
"VD: 'áo cờ đỏ sao vàng' → color='đỏ'."
"VD: 'áo cờ đỏ sao vàng' -> color='đỏ'."
),
)
price_min: int | None = Field(default=None, description="Giá thấp nhất (VND)")
price_max: int | None = Field(default=None, description="Giá cao nhất (VND)")
discount_min: int | None = Field(
default=None,
description="% giảm giá TỐI THIỂU. VD: 'giảm 50%' → 50. 'hàng sale' → 1."
)
discount_max: int | None = Field(
default=None,
description="% giảm giá TỐI ĐA. VD: 'giảm dưới 30%' → 30."
)
discovery_mode: str | None = Field(
default=None,
description="'new' = hàng mới, 'best_seller' = bán chạy. Chỉ dùng khi khách nói rõ.",
)
magento_ref_code: str | None = Field(
default=None,
description="Mã SKU cụ thể. Chỉ điền khi khách cung cấp.",
)
reasoning: str | None = Field(
discount_min: int | None = Field(default=None, description="% giảm giá TỐI THIỂU.")
discount_max: int | None = Field(default=None, description="% giảm giá TỐI ĐA.")
discovery_mode: str | None = Field(default=None, description="'new' = hàng mới, 'best_seller' = bán chạy.")
size: str | None = Field(
default=None,
description=(
"SUY LUẬN: Giải thích ngắn gọn TẠI SAO bạn chọn params này. "
"VD: '2/9 = Quốc Khánh → áo đỏ đi chơi', 'khách muốn sịp mặc đi biển → giữ nguyên sịp, tag đi biển'. "
"Viết 1-2 câu bằng tiếng Việt."
"Size san pham khach yeu cau. "
"Nguoi lon: XS, S, M, L, XL, XXL, 3XL, 4XL. "
"Tre em (chieu cao cm): 80, 86, 92, 98, 104, 110, 116, 122, 128, 134, 140, 152, 164. "
"Tre so sinh (thang): 1/3, 4/6, 7/9, 10/12, 13/14. "
"VD: 'ao size L' -> size='L'."
),
)
magento_ref_code: str | None = Field(default=None, description="Ma SKU cu the.")
reasoning: str | None = Field(default=None, description="SUY LUẬN TẠI SAO bạn chọn params này.")
# ═══════════════════════════════════════════════
# ======================================================
# SQL Builder
# ═══════════════════════════════════════════════
def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]:
"""Build các filter CỐ ĐỊNH (luôn giữ trong mọi tầng)."""
# ======================================================
def _build_fixed_clauses(req: LeadSearchInput, params: list) -> list[str]:
clauses = []
# Product line (list) — resolve synonym → search cột product_line_vn
if req.product_line_vn:
resolved_types = []
for pt_raw in req.product_line_vn:
pt = pt_raw.strip()
if not pt:
continue
mapped = SYNONYM_TO_DB.get(pt.lower(), pt)
related = get_related_lines(mapped)
resolved_types.extend(related)
resolved_types = list(dict.fromkeys(resolved_types))
if resolved_types:
in_vals = []
like_clauses = []
# Broad terms (áo, quần, váy...) → LIKE vì không phải exact product_line
# VD: khách nói 'quần' → LIKE '%quần%' → match 'Quần jean', 'Quần lót', etc.
# KHÔNG map 'quần' sang product_line_vn BITMAP vì sẽ 0 kết quả
BROAD_TERMS = {"áo", "quần", "váy", "đồ", "bộ", "phụ kiện", "giày", "dép", "túi", "balo", "mũ", "nón", "tất"}
for rt in resolved_types:
if rt.lower() in BROAD_TERMS:
# Broad term: dùng LOWER(product_name) LIKE để rộng hơn
# (broad terms sẽ không match chính xác product_line_vn column)
like_clauses.append("LOWER(product_name) LIKE %s")
params.append(f"%{rt.lower()}%")
else:
# Exact product line → BITMAP index (không LOWER)
in_vals.append(rt)
params.append(rt)
sub_clauses = []
if in_vals:
placeholders = ", ".join(["%s"] * len(in_vals))
sub_clauses.append(f"product_line_vn IN ({placeholders})")
sub_clauses.extend(like_clauses)
if sub_clauses:
clauses.append("(" + " OR ".join(sub_clauses) + ")")
logger.info("🏷️ [MAPPING] product_line_vn %s → resolved %s", req.product_line_vn, resolved_types)
# Gender — BITMAP: KHÔNG wrap LOWER()
lines = []
for line in req.product_line_vn:
if not line: continue
resolved = resolve_product_line(line)
for r in resolved:
expanded = get_related_lines(r)
lines.extend(expanded)
if lines:
placeholders = ", ".join(["%s"] * len(lines))
params.extend(lines)
clauses.append(f"product_line_vn IN ({placeholders})")
if req.gender_by_product:
gender_lower = req.gender_by_product.lower().strip()
if gender_lower in ("women", "nữ", "female"):
gender_db = "female"
genders_to_search = []
if gender_lower in ("women", "nu", "female", "nữ"):
genders_to_search = ["female", "women", "nu", "nữ", "unisex"]
elif gender_lower in ("men", "nam", "male"):
gender_db = "male"
genders_to_search = ["male", "men", "nam", "unisex"]
elif gender_lower in ("boy", "bé trai", "be trai"):
genders_to_search = ["boy", "bé trai", "be trai", "unisex"]
elif gender_lower in ("girl", "bé gái", "be gai"):
genders_to_search = ["girl", "bé gái", "be gai", "unisex"]
else:
gender_db = gender_lower
if gender_db in ("female", "male"):
params.append(gender_db)
params.append("unisex")
clauses.append("gender_by_product IN (%s, %s)")
else:
params.append(gender_db)
clauses.append("gender_by_product = %s")
genders_to_search = [gender_lower, "unisex"]
placeholders = ", ".join(["%s"] * len(genders_to_search))
params.extend(genders_to_search)
clauses.append(f"gender_by_product IN ({placeholders})")
# Age — BITMAP
if req.age_by_product:
age_lower = req.age_by_product.lower().strip()
if age_lower in ("baby", "newborn"):
......@@ -352,37 +307,61 @@ def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]:
params.append(age_lower)
clauses.append("age_by_product = %s")
# Color — sau khi bạn đánh NGRAMBF trên master_color:
# product_color_name LIKE không LOWER → NGRAMBF hoạt động
# master_color LIKE không LOWER → NGRAMBF hoạt động
if req.master_color:
c = req.master_color.strip() # giữ nguyên case để NGRAMBF match
params.append(f"%{c}%")
params.append(f"%{c}%")
clauses.append("(master_color LIKE %s OR product_color_name LIKE %s)")
c_raw = req.master_color.strip()
COLOR_EN_MAP = {
"do": "red", "trang": "white", "den": "black", "xanh": "blue",
"hong": "pink", "tim": "purple", "cam": "orange", "vang": "yellow",
"nau": "brown", "xam": "gray", "be": "beige", "kem": "cream",
"xanh la": "green", "bac": "silver", "vang dong": "gold",
"đo": "red", "đỏ": "red", "trắng": "white", "đen": "black",
"xanh lá": "green", "hồng": "pink", "tím": "purple", "nâu": "brown",
"xám": "gray", "vàng": "yellow", "bạc": "silver"
}
c_key = c_raw.lower().strip()
en_color = COLOR_EN_MAP.get(c_key)
if not en_color:
import unicodedata
c_no_acc = "".join(
ch for ch in unicodedata.normalize("NFD", c_key)
if unicodedata.category(ch) != "Mn"
).strip()
en_color = COLOR_EN_MAP.get(c_no_acc)
c_no_accent = c_key
color_parts = []
color_parts.append("(master_color LIKE %s OR product_color_name LIKE %s)")
params.append(f"%{c_raw}%")
params.append(f"%{c_raw}%")
if c_no_accent != c_raw.lower():
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{c_no_accent}%")
params.append(f"%{c_no_accent}%")
if en_color:
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{en_color}%")
params.append(f"%{en_color}%")
clauses.append("(" + " OR ".join(color_parts) + ")")
# Price
if req.price_min is not None:
params.append(req.price_min)
clauses.append(f"sale_price >= %s")
clauses.append("sale_price >= %s")
if req.price_max is not None:
params.append(req.price_max)
clauses.append(f"sale_price <= %s")
clauses.append("sale_price <= %s")
# Discount
if req.discount_min is not None:
params.append(req.discount_min)
# discount_percent is usually derived as ((original - sale) / original * 100)
# Let's assume there is a discount_percent column, but looking at SELECT_COLUMNS,
# discount_percent is a calculated field. We should calculate it in the WHERE clause or use original_price > sale_price.
# Actually in magento_product_dimension_with_text_embedding there probably IS some discount logic, but let's use:
# WHERE original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= X
clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= %s)")
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= %s)")
if req.discount_max is not None:
params.append(req.discount_max)
clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
if req.size:
s = req.size.strip().upper()
params.append(s)
clauses.append("FIND_IN_SET(%s, REPLACE(size_scale, '|', ',')) > 0")
# Discovery mode
if req.discovery_mode:
mode = req.discovery_mode.lower().strip()
if mode == "new":
......@@ -392,17 +371,7 @@ def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]:
return clauses
# ═══════════════════════════════════════════════
# Tag → Indexed column mapping (ƯU TIÊN)
# Thay vì LIKE description_text → dùng BITMAP cột riêng
# ═══════════════════════════════════════════════
# Tags map thẳng vào cột BITMAP riêng biệt
# format: tag → (column_name, value_in_db)
TAG_TO_BITMAP_COL: dict[str, tuple[str, str]] = {
# Trục 3 — style (cột `style` có BITMAP index)
# DB values: Street, Basic Update, Feminine, Basic, Athleisure, Cute, Essential, Smart Casual, Utility, Trend, Dynamic, Preppy
"style:thanh_lich": ("style", "Feminine"),
"style:nang_dong": ("style", "Dynamic"),
"style:basic": ("style", "Basic"),
......@@ -411,30 +380,20 @@ TAG_TO_BITMAP_COL: dict[str, tuple[str, str]] = {
"style:tre_trung": ("style", "Trend"),
"style:toi_gian": ("style", "Essential"),
"style:smart_casual":("style", "Smart Casual"),
# Trục 4 — fitting (cột `fitting` có BITMAP index)
# DB values: Boxy, Skinny, Oversize, Relax, Slimfit, Regular, Slim
"fit:oversize": ("fitting", "Oversize"),
"fit:slim": ("fitting", "Slim"),
"fit:regular": ("fitting", "Regular"),
"fit:wide_leg": ("fitting", "Relax"),
"fit:cropped": ("fitting", "Boxy"),
"fit:relaxed": ("fitting", "Relax"),
# Trục 2a — season_sale (cột `season_sale` có BITMAP index)
# DB values thực tế: Year, Winter, Summer
"wthr:mua_he": ("season_sale", "Summer"),
"wthr:mua_dong": ("season_sale", "Winter"),
# Trục 1 — occ: tags map sang season_sale khi có thể
# Đi biển/du lịch mùa hè → Summer; thể thao → Athleisure style
"occ:di_bien": ("season_sale", "Summer"), # Biển = mùa hè
"occ:du_lich": ("season_sale", "Summer"), # Du lịch = mùa hè
"occ:the_thao": ("style", "Athleisure"), # Thể thao = Athleisure style
"occ:di_bien": ("season_sale", "Summer"),
"occ:du_lich": ("season_sale", "Summer"),
"occ:the_thao": ("style", "Athleisure"),
}
# Tags fallback LIKE description_text (không có cột riêng)
# Chỉ dùng khi KHÔNG có BITMAP mapping ở trên.
# QUAN TRỌNG: search FULL PHRASE trong description_text (không tách word)
TAG_TO_SEARCH_TEXT: dict[str, str] = {
# Trục 1 — Hoàn cảnh & Dịp (chỉ các tag chưa có BITMAP)
"occ:di_lam": "đi làm",
"occ:di_choi": "đi chơi",
"occ:di_tiec": "đi tiệc",
......@@ -442,11 +401,9 @@ TAG_TO_SEARCH_TEXT: dict[str, str] = {
"occ:mac_nha": "mặc nhà",
"occ:da_ngoai": "dã ngoại",
"occ:di_ngu": "ngủ",
# Trục 2a — thời tiết không map được season_sale
"wthr:giao_mua": "giao mùa",
"wthr:troi_mua": "mưa",
"wthr:troi_nang": "nắng",
# Trục 2b — Tính năng (search trong description_text)
"func:thoang_mat": "thoáng mát",
"func:giu_am": "giữ ấm",
"func:tham_hut": "thấm hút",
......@@ -455,21 +412,10 @@ TAG_TO_SEARCH_TEXT: dict[str, str] = {
"func:can_gio": "cản gió",
}
def _build_tag_clauses(tags: list[str], params: list) -> str:
"""
Smart tag routing:
- Tags có cột BITMAP riêng → dùng BITMAP (= trực tiếp, cực nhanh)
- Tags còn lại → search description_text FULL PHRASE (không tách từ)
Lý do: description_text chứa câu mô tả, search FULL PHRASE 'đi biển' sẽ match
hơn là search từng chữ riêng với AND.
Returns: OR clause kết hợp cả 2 path.
"""
if not tags:
return ""
bitmap_by_col: dict[str, list[str]] = {} # col → [values]
text_terms: list[str] = [] # full phrases cho LIKE search
if not tags: return ""
bitmap_by_col: dict[str, list[str]] = {}
text_terms: list[str] = []
for tag in tags:
tag_lower = tag.strip().lower()
......@@ -479,12 +425,9 @@ def _build_tag_clauses(tags: list[str], params: list) -> str:
elif tag_lower in TAG_TO_SEARCH_TEXT:
text_terms.append(TAG_TO_SEARCH_TEXT[tag_lower])
else:
# Unknown tag → text search thẳng
text_terms.append(tag_lower.replace(":", " ").replace("_", " "))
all_clauses = []
# ① BITMAP clauses — cực nhanh, ưu tiên cao nhất
for col, values in bitmap_by_col.items():
if len(values) == 1:
params.append(values[0])
......@@ -494,16 +437,11 @@ def _build_tag_clauses(tags: list[str], params: list) -> str:
params.extend(values)
all_clauses.append(f"{col} IN ({placeholders})")
# ② Text fallback — search FULL PHRASE trong description_text
# KHÔNG tách từ, KHÔNG dùng AND — chỉ search nguyên cụm
# vì description_text là câu mô tả đủ ngữ cảnh
if text_terms:
term_clauses = []
for term in text_terms:
term = term.strip()
if not term:
continue
# Search full phrase trong description_text + product_name
if not term: continue
params.append(f"%{term}%")
params.append(f"%{term}%")
term_clauses.append(f"(LOWER(description_text) LIKE %s OR product_name LIKE %s)")
......@@ -514,34 +452,19 @@ def _build_tag_clauses(tags: list[str], params: list) -> str:
return f"({' OR '.join(all_clauses)})"
return ""
def _resolve_tag(tag: str) -> str:
"""Legacy helper — giữ cho backward compat."""
return TAG_TO_SEARCH_TEXT.get(tag.strip().lower(), tag)
def _build_search_clause(search_terms: list[str], params: list, resolve_tags: bool = False) -> str:
"""
Build OR clause cho keywords (user input).
Dùng product_name LIKE (NGRAMBF) — KHÔNG LOWER() để dùng được index.
description_text chỉ search nếu keyword ngắn (< 4 chars, không dùng NGRAMBF được).
"""
def _build_search_clause(search_terms: list[str], params: list) -> str:
term_clauses = []
for term in search_terms:
t = _resolve_tag(term) if resolve_tags else term.strip()
if not t:
continue
t = term.strip()
if not t: continue
words = t.split()
if not words:
continue
if not words: continue
word_clauses = []
for word in words:
if len(word) >= 4:
# NGRAMBF gram_num=4: KHÔNG wrap LOWER() → index được dùng
params.append(f"%{word}%")
word_clauses.append("product_name LIKE %s")
else:
# Short word: NGRAMBF không match được gram < 4 → fallback cả 2 cột
params.append(f"%{word}%")
params.append(f"%{word}%")
word_clauses.append("(product_name LIKE %s OR LOWER(description_text) LIKE %s)")
......@@ -551,153 +474,158 @@ def _build_search_clause(search_terms: list[str], params: list, resolve_tags: bo
return f"({' OR '.join(term_clauses)})"
return ""
def _build_exclusion_clauses(keywords: list[str], params: list) -> list[str]:
clauses = []
kws_str = " ".join(keywords).lower()
if any(k in kws_str for k in ["đông", "lạnh", "winter", "tuyết", "giữ ấm"]):
clauses.append("product_line_vn NOT IN (%s, %s)")
params.extend(["Quần soóc", "Áo ba lỗ"])
def _build_full_query(fixed_clauses: list[str], search_clause: str | None) -> str:
"""Combine fixed + search clauses thành SQL hoàn chỉnh."""
if any(k in kws_str for k in ["đi làm", "công sở", "văn phòng", "office"]):
forbidden = ["cartoon", "hoạt hình", "manga", "anime", "demon slayer", "naruto", "disney", "marvel"]
for f in forbidden:
clauses.append("LOWER(description_text) NOT LIKE %s")
params.append(f"%{f}%")
return clauses
def _build_full_query(fixed_clauses: list[str], search_clause: str | None, exclusion_clauses: list[str] = None) -> str:
all_clauses = list(fixed_clauses)
if search_clause:
all_clauses.append(search_clause)
if exclusion_clauses:
all_clauses.extend(exclusion_clauses)
where = " AND ".join(all_clauses) if all_clauses else "1=1"
return f"""
return f\"\"\"
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE {where}
ORDER BY quantity_sold DESC NULLS LAST, sale_price ASC
LIMIT 20
"""
# ═══════════════════════════════════════════════
# 3-Tier Cascading Search
# ═══════════════════════════════════════════════
async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
"""
3 tầng fallback:
Tầng 1: CỐ ĐỊNH + keywords (từ nguyên văn user)
Tầng 2: CỐ ĐỊNH + tags (AI suy luận → tags chung)
Tầng 3: CHỈ CỐ ĐỊNH (product_type + color + gender)
\"\"\"
async def _price_relaxed_search(req: LeadSearchInput, db, multiplier: float) -> list:
if req.price_max is None:
return []
saved_max = req.price_max
saved_min = req.price_min
req.price_max = int(req.price_max * multiplier)
req.price_min = None
params: list = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
req.price_max = saved_max
req.price_min = saved_min
return products
Returns: (products, tier_matched)
"""
async def _cascading_search(req: LeadSearchInput, db) -> tuple[list, int, str | None]:
ex_params = []
exclusions = _build_exclusion_clauses(req.keywords, ex_params)
# ---- Tầng 1: CỐ ĐỊNH + KEYWORDS ----
# Tang 1: Fixed + Keywords (NGRAMBF)
if req.keywords:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_search_clause(req.keywords, params) # dùng NGRAMBF product_name
search = _build_search_clause(req.keywords, params)
if search:
sql = _build_full_query(fixed, search)
products = await db.execute_query_async(sql, params=tuple(params))
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("🎯 Tầng 1 (keywords→NGRAMBF) match! %d kết quả", len(products))
return products, 1
return products, 1, None
# ---- Tầng 2: CỐ ĐỊNH + TAGS (BITMAP/LIKE hybrid) ----
# Tang 2: Fixed + Tags (BITMAP)
if req.tags:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_tag_clauses(req.tags, params) # BITMAP-first routing!
search = _build_tag_clauses(req.tags, params)
if search:
sql = _build_full_query(fixed, search)
products = await db.execute_query_async(sql, params=tuple(params))
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("🎯 Tầng 2 (tags→BITMAP) match! %d kết quả", len(products))
return products, 2
return products, 2, None
# ---- Tầng 3: CHỈ CỐ ĐỊNH ----
# Tang 3: Chỉ cố định
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
logger.info("🎯 Tầng 3 (chỉ cố định) match! %d kết quả", len(products))
return products, 3
logger.info("🎯 Tầng 3 (chỉ cố định) match! 0 kết quả")
return products, 3, None
# ---- Tầng 4: DROP GENDER, chỉ giữ product_line ----
# VD: "sịp nam" → gender=men returns 0 → try again without gender
# Tang 4: Drop gender
if req.product_line_vn and req.gender_by_product:
logger.info("🔄 Tầng 4: thử drop gender '%s' để tìm rộng hơn...", req.gender_by_product)
saved_gender = req.gender_by_product
req.gender_by_product = None # drop gender
req.gender_by_product = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
req.gender_by_product = saved_gender # restore
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.gender_by_product = saved_gender
if products:
logger.info("🎯 Tầng 4 (drop gender) match! %d kết quả", len(products))
return products, 4
return products, 4, None
return [], 3
# Tang 5-7: Price relaxation
if req.price_max is not None and req.product_line_vn:
original_max = req.price_max
product_label = "/".join(req.product_line_vn)
for multiplier, tier, label in [(1.5, 5, "1.5x"), (2.0, 6, "2x")]:
new_max = int(original_max * multiplier)
products = await _price_relaxed_search(req, db, multiplier)
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
diff = int(cheapest - original_max)
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f"Mau re nhat: \\"{cheapest_name}\\" gia {cheapest:,.0f}d "
f"(chi them {diff:,}d so voi budget). "
f"Tong co {len(products)} mau trong tam {new_max:,.0f}d. "
f"-> HAY goi y SP nay cho khach va noi kheo: them chut xiu la co mau rat dep!"
)
return products, tier, msg
def _format_products(products: list, stock_checked: bool = False) -> list[dict]:
"""Format output cho AI đọc. Kèm stock info + clean_description + outfit nếu có."""
formatted = []
for p in products[:15]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0)
item = {
"sku": p.get("magento_ref_code", ""),
"name": p.get("product_name", ""),
"price": int(sale),
"original_price": int(orig),
"discount": f"-{disc_pct}%" if has_discount else None,
"color": p.get("master_color", ""),
"gender": p.get("gender_by_product", ""),
"product_line": p.get("product_line_vn", ""),
"image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""),
}
# Stock info nếu đã check
if stock_checked:
stock_detail = p.get("_stock_detail", [])
total_qty = p.get("_total_qty", 0)
item["in_stock"] = total_qty > 0
item["total_qty"] = total_qty
item["stock"] = stock_detail
# Clean description từ ultra_descriptions (có size guide + styling)
clean_desc = p.get("_clean_description")
if clean_desc:
item["clean_description"] = clean_desc[:800] # giới hạn token
# Outfit matches từ ai_matches
outfit_matches = p.get("_outfit_matches")
if outfit_matches:
item["ai_outfit_suggestions"] = outfit_matches
# Tang 7: bo price hoan toan
saved_max = req.price_max
saved_min = req.price_min
req.price_max = None
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.price_max = saved_max
req.price_min = saved_min
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f"Mau re nhat hien co: \\"{cheapest_name}\\" gia {cheapest:,.0f}d. "
f"Tong co {len(products)} mau. "
f"-> HAY goi y SP nay va noi: budget them chut la co mau rat dang!"
)
return products, 7, msg
return [], 3, None
formatted.append(item)
return formatted
async def _enrich_with_outfit(
products: list[dict],
db,
tags: list[str] | None = None,
) -> list[dict]:
"""
Đọc ultra_descriptions.ai_matches (occasion-aware) → inject outfit suggestions
kèm clean_description vào top 3 SP.
Fallback: nếu ai_matches null → dùng ai_outfit_product_matches cũ.
"""
if not products:
return products
import sqlite3
if not _os.path.exists(_DB_123_PATH):
logger.warning("⚠️ 123.db not found at %s", _DB_123_PATH)
return products
top_products = products[:3]
# Normalize: magento_ref_code "5BP25W003-SK010" → base "5BP25W003"
anchor_base_codes = [
(p.get("internal_ref_code") or p.get("magento_ref_code", "").split("-")[0]).strip()
for p in top_products
......@@ -706,7 +634,6 @@ async def _enrich_with_outfit(
if not anchor_base_codes:
return products
# Resolve occasion từ tags
occasion = _resolve_occasion(tags or [])
try:
......@@ -716,11 +643,11 @@ async def _enrich_with_outfit(
placeholders = ",".join(["?"] * len(anchor_base_codes))
ultra_rows = cursor.execute(
f"""
f\"\"\"
SELECT base_ref_code, magento_ref_code, ai_matches, clean_description
FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders})
""",
\"\"\",
anchor_base_codes,
).fetchall()
conn.close()
......@@ -728,7 +655,6 @@ async def _enrich_with_outfit(
logger.error("❌ SQLite ultra_desc read error: %s", e)
return products
# Map base_ref_code → ultra data
ultra_map: dict[str, dict] = {}
for row in ultra_rows:
ultra_map[row["base_ref_code"]] = {
......@@ -736,197 +662,119 @@ async def _enrich_with_outfit(
"clean_description": row["clean_description"],
}
# Collect all match codes cần fetch từ StarRocks
# Structure: {anchor_magento_code: {role: [match_item_dict]}}
matches_by_anchor: dict[str, list] = {}
all_match_codes: set[str] = set()
for p in top_products:
anchor_magento = p.get("magento_ref_code", "")
base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip()
ultra = ultra_map.get(base)
if not ultra or not ultra["ai_matches"]:
# Fallback: sẽ xử lý bên dưới
continue
# Inject clean_description vào anchor product
if ultra["clean_description"]:
p["_clean_description"] = ultra["clean_description"]
p["ai_description"] = ultra["clean_description"]
try:
ai_matches_json = json.loads(ultra["ai_matches"])
p["ai_matches"] = ai_matches_json
except (json.JSONDecodeError, TypeError):
continue
occasion_data = ai_matches_json.get(occasion) or ai_matches_json.get("hang_ngay", {})
if not occasion_data:
continue
# Flatten all roles: bottom, top, accessories
flat_matches = []
for role_key, role_items in occasion_data.items():
if not isinstance(role_items, list):
continue
for item in role_items:
code = item.get("sku", "") or item.get("code", "")
if not code:
continue
flat_matches.append({
"code": code,
"reason": item.get("reason", ""),
"role": role_key,
})
all_match_codes.add(code)
# Lấy top 4 (đã sort sẵn theo score từ database)
flat_matches = flat_matches[:4]
matches_by_anchor[anchor_magento] = flat_matches[:4]
# Nếu không có ai_matches nào → fallback về logic cũ
if not matches_by_anchor and not all_match_codes:
return await _enrich_with_outfit_legacy(products, db)
# Fetch price + url từ StarRocks cho match codes
match_product_map: dict[str, dict] = {}
if all_match_codes:
try:
phs = ",".join(["%s"] * len(all_match_codes))
sql = f"SELECT {SELECT_COLUMNS} FROM {TABLE_NAME} WHERE magento_ref_code IN ({phs})"
match_products = await db.execute_query_async(sql, params=tuple(all_match_codes))
# Stock check
match_products, _, _, _ = await _enrich_with_stock(match_products)
match_product_map = {p["magento_ref_code"]: p for p in match_products}
except Exception as e:
logger.error("❌ StarRocks match fetch error: %s", e)
# Inject outfit suggestions vào anchor products
for p in products:
anchor_magento = p.get("magento_ref_code", "")
flat = matches_by_anchor.get(anchor_magento, [])
suggestions = []
seen_roles: set[str] = set()
for m in flat:
code = m["code"]
star_p = match_product_map.get(code)
if not star_p:
continue
# Deduplicate by role (1 role = 1 SP duy nhất)
role = m["role"]
if role in seen_roles:
continue
seen_roles.add(role)
suggestions.append({
"sku": code,
"name": star_p.get("product_name", ""),
"color": star_p.get("master_color", ""),
"role": role,
"ai_reason": m["reason"],
"occasion": occasion
})
if suggestions:
p["_outfit_matches"] = suggestions
return products
async def _format_products(products: list, db) -> list[dict]:
formatted = []
# ── Fetch Suggest/Similar from DB ──
all_related_skus: set = set()
raw_parsed_map: dict = {}
def parse_item_list(raw_val) -> list[str]:
if not raw_val: return []
if isinstance(raw_val, list): return [str(x) for x in raw_val[:3]]
if isinstance(raw_val, str):
try:
parsed = json.loads(raw_val)
if isinstance(parsed, list): return [str(x) for x in parsed[:3]]
except Exception: pass
return []
async def _enrich_with_outfit_legacy(products: list[dict], db) -> list[dict]:
"""
Fallback: logic cũ dùng ai_outfit_product_matches khi ultra_desc chưa có ai_matches.
Giữ nguyên để backward-compatible.
"""
top_products = products[:3]
anchor_codes = [p.get("magento_ref_code") for p in top_products if p.get("magento_ref_code")]
if not anchor_codes:
return products
try:
import sqlite3
conn = sqlite3.connect(_DB_123_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
phs = ",".join(["?"] * len(anchor_codes))
rows = cursor.execute(
f"""
SELECT anchor_product_code, match_product_code, match_role, score, ai_reason
FROM pg__dashboard_canifa__ai_outfit_product_matches
WHERE anchor_product_code IN ({phs})
ORDER BY anchor_product_code, score DESC
""",
anchor_codes,
).fetchall()
conn.close()
if not rows:
return products
match_codes = list({row["match_product_code"] for row in rows})
if match_codes:
phs_db = ",".join(["%s"] * len(match_codes))
sql = f"SELECT {SELECT_COLUMNS} FROM {TABLE_NAME} WHERE magento_ref_code IN ({phs_db})"
match_products = await db.execute_query_async(sql, params=tuple(match_codes))
match_products, _, _, _ = await _enrich_with_stock(match_products)
match_product_map = {p["magento_ref_code"]: p for p in match_products}
else:
match_product_map = {}
matches_by_anchor: dict[str, list] = {}
for row in rows:
anchor = row["anchor_product_code"]
mc = row["match_product_code"]
if mc not in match_product_map:
continue
if anchor not in matches_by_anchor:
matches_by_anchor[anchor] = []
mp = match_product_map[mc]
if any(m["sku"] == mc for m in matches_by_anchor[anchor]):
continue
matches_by_anchor[anchor].append({
"sku": mc,
"name": mp.get("product_name"),
"color": mp.get("master_color"),
"price": int(mp.get("sale_price") or 0),
"original_price": int(mp.get("original_price") or 0),
"url": mp.get("product_web_url"),
"image": mp.get("product_image_url_thumbnail"),
"role": row["match_role"],
"ai_reason": row["ai_reason"],
"in_stock": True,
"stock": mp.get("_stock_detail", []),
})
for p in products:
code = p.get("magento_ref_code")
if code in matches_by_anchor:
p["_outfit_matches"] = matches_by_anchor[code][:3]
except Exception as e:
logger.error("❌ Legacy outfit error: %s", e, exc_info=True)
return products
for p in products[:15]:
sku = p.get("magento_ref_code", "")
sug_skus = parse_item_list(p.get("suggest_items"))[:2]
sim_skus = parse_item_list(p.get("similar_items"))[:2]
raw_parsed_map[sku] = {"suggest": sug_skus, "similar": sim_skus}
all_related_skus.update(sug_skus)
all_related_skus.update(sim_skus)
sku_lookup: dict = {}
if all_related_skus:
phs = ", ".join(["%s"] * len(all_related_skus))
q = f"SELECT magento_ref_code, product_name, sale_price FROM {TABLE_NAME} WHERE magento_ref_code IN ({phs})"
related_products = await db.execute_query_async(q, params=tuple(all_related_skus))
for rp in related_products:
rpsku = rp.get("magento_ref_code")
if rpsku:
sku_lookup[rpsku] = {
"sku": rpsku,
"name": rp.get("product_name"),
"price": int(rp.get("sale_price") or 0),
}
for p in products[:15]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0)
main_sku = p.get("magento_ref_code", "")
parsed_rel = raw_parsed_map.get(main_sku, {"suggest": [], "similar": []})
rich_suggest = [sku_lookup[s] for s in parsed_rel["suggest"] if s in sku_lookup]
rich_similar = [sku_lookup[s] for s in parsed_rel["similar"] if s in sku_lookup]
item = {
"sku": main_sku,
"name": p.get("product_name", ""),
"price": int(sale),
"original_price": int(orig),
"discount": f"-{disc_pct}%" if has_discount else None,
"color": p.get("master_color", ""),
"gender": p.get("gender_by_product", ""),
"product_line": p.get("product_line_vn", ""),
"image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""),
"suggest_items": rich_suggest,
"similar_items": rich_similar,
}
if "in_stock" in p:
item["in_stock"] = p["in_stock"]
item["total_qty"] = p["total_qty"]
item["stock"] = p["stock"]
if "ai_description" in p:
item["ai_description"] = p["ai_description"]
if "ai_matches" in p:
item["ai_matches"] = p["ai_matches"]
formatted.append(item)
return formatted
# ═══════════════════════════════════════════════
# SKU Lookup (đặc biệt, bypass cascade)
# ═══════════════════════════════════════════════
def _build_sku_query(code: str) -> tuple[str, list]:
"""Build query tìm theo mã SKU.
code đã được .upper() → KHÔNG wrap UPPER() trong SQL để dùng được PRIMARY KEY index.
"""
code = code.strip().upper()
sql = f"""
sql = f\"\"\"
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE (magento_ref_code = %s
OR internal_ref_code = %s
OR magento_ref_code LIKE %s)
WHERE (UPPER(magento_ref_code) = %s
OR UPPER(internal_ref_code) = %s
OR UPPER(magento_ref_code) LIKE %s)
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 20
"""
\"\"\"
return sql, [code, code, f"{code}%"]
# ═══════════════════════════════════════════════
# Tool Entry Point
# ═══════════════════════════════════════════════
@tool(args_schema=TagSearchInput)
async def tag_search_tool(
@tool(args_schema=LeadSearchInput)
async def lead_search_tool(
keywords: list[str] | None = None,
tags: list[str] | None = None,
product_line_vn: list[str] | None = None,
......@@ -938,17 +786,16 @@ async def tag_search_tool(
discount_min: int | None = None,
discount_max: int | None = None,
discovery_mode: str | None = None,
size: str | None = None,
magento_ref_code: str | None = None,
reasoning: str | None = None,
) -> str:
"""
Tìm kiếm sản phẩm CANIFA + tự động check tồn kho.
Chỉ trả về sản phẩm CÒN HÀNG (nếu stock API available).
"""
tool_start = time.time()
now_utc = datetime.now(timezone.utc)
req = TagSearchInput(
\"\"\"
Tim kiem san pham CANIFA theo tags va tu khoa. Toi uu hoa de chay cuc nhanh bang BITMAP index.
\"\"\"
start = time.time()
req = LeadSearchInput(
keywords=keywords or [],
tags=tags or [],
product_line_vn=product_line_vn or [],
......@@ -960,132 +807,79 @@ async def tag_search_tool(
discount_min=discount_min,
discount_max=discount_max,
discovery_mode=discovery_mode,
size=size,
magento_ref_code=magento_ref_code,
)
db = get_db_connection()
# ── Langfuse: get current observation context (set by controller) ──
lf = None
try:
lf = _get_langfuse() if _get_langfuse else None
except Exception:
pass
try:
# ═══ [1] DB SEARCH ═══
db_start = time.time()
db_span = None
if lf:
try:
db_span = lf.start_span(
name="tag-search-db",
start_time=datetime.now(timezone.utc),
input={
"keywords": keywords,
"tags": tags,
"product_line": product_line_vn,
"gender": gender_by_product,
"color": master_color,
"sku": magento_ref_code,
},
metadata={"tier": "cascade", "table": "magento_product_dimension"},
)
except Exception as _e:
logger.debug("Langfuse span init failed: %s", _e)
fallback_msg = None
if req.magento_ref_code:
sql, params = _build_sku_query(req.magento_ref_code)
products = await db.execute_query_async(sql, params=tuple(params))
tier = 0
else:
products, tier = await _cascading_search(req, db)
db_ms = round((time.time() - db_start) * 1000, 1)
if db_span:
try:
db_span.end(
end_time=datetime.now(timezone.utc),
output={"count": len(products), "tier": tier, "elapsed_ms": db_ms},
metadata={"db_ms": db_ms, "tier": tier},
)
except Exception:
pass
logger.info("📊 [DB] tier=%d found=%d in %.0fms", tier, len(products), db_ms)
products, tier, fallback_msg = await _cascading_search(req, db)
# ═══ [2] STOCK API ═══
stock_span = None
if lf and products:
try:
stock_span = lf.start_span(
name="tag-search-stock-api",
start_time=datetime.now(timezone.utc),
input={"product_count": len(products),
"base_codes": [p.get("internal_ref_code") for p in products[:5]]},
metadata={"api": CANIFA_STOCK_API, "timeout_s": _STOCK_TIMEOUT},
)
except Exception as _e:
logger.debug("Langfuse stock span init failed: %s", _e)
products, stock_checked, stock_ms, stock_timed_out = await _enrich_with_stock(products)
# Kiem tra ton kho (chi lay SP con hang)
products, stock_checked, stock_api_ms, timed_out = await _enrich_with_stock(products)
# ═══ [2.5] OUTFIT MATCHING INJECTION ═══
outfit_start = time.time()
products = await _enrich_with_outfit(products, db, tags=tags or [])
outfit_ms = round((time.time() - outfit_start) * 1000, 1)
occasion_used = _resolve_occasion(tags or [])
if stock_span:
try:
stock_span.end(
end_time=datetime.now(timezone.utc),
output={
"in_stock_count": len(products),
"stock_checked": stock_checked,
"timed_out": stock_timed_out,
"elapsed_ms": stock_ms,
},
metadata={"stock_ms": stock_ms, "timed_out": stock_timed_out},
level="WARNING" if stock_timed_out else "DEFAULT",
)
except Exception:
pass
total_ms = round((time.time() - tool_start) * 1000, 1)
# Inject thong tin outfit neu co
products = await _enrich_with_outfit(products, db, tags=tags)
# Danh dau de formatter lay them data
for p in products:
if stock_checked and p.get("_stock_detail"):
p["in_stock"] = True
p["total_qty"] = p.get("_total_qty")
p["stock"] = p.get("_stock_detail")
formatted = await _format_products(products, db)
elapsed_ms = round((time.time() - start) * 1000, 2)
_search_modes = [
"sku_lookup",
"keywords_ngrambf",
"tags_bitmap",
"hard_filters_only",
"drop_gender",
"price_1.5x",
"price_2x",
"price_unlimited",
]
logger.info(
"🏷️ [SEARCH+STOCK] keywords=%s tags=%s type=%s color=%s gender=%s | "
"tier=%d results=%d db=%.0fms stock=%.0fms total=%.0fms checked=%s timeout=%s",
keywords, tags, product_line_vn, master_color, gender_by_product,
tier, len(products), db_ms, stock_ms, total_ms, stock_checked, stock_timed_out,
"[LEAD SEARCH] REQUEST | tags=%s | kw=%s | line=%s | price=%s~%s | size=%s",
tags, keywords, product_line_vn, price_min, price_max, size
)
logger.info(
"[LEAD SEARCH] RESULT | tier=%d (%s) | results=%d | time=%.0fms | stock=%.0fms",
tier, _search_modes[min(tier, 7)], len(products), elapsed_ms, stock_api_ms
)
formatted = _format_products(products, stock_checked=stock_checked)
return json.dumps({
result = {
"status": "success",
"count": len(formatted),
"count": len(products),
"tier": tier,
"occasion_used": occasion_used,
"stock_checked": stock_checked,
"stock_timed_out": stock_timed_out,
"elapsed_ms": total_ms,
"timing": {
"db_ms": db_ms,
"stock_api_ms": stock_ms,
"outfit_api_ms": outfit_ms,
"total_ms": total_ms,
},
"search_mode": _search_modes[min(tier, 7)],
"elapsed_ms": elapsed_ms,
"reasoning": reasoning or "",
"keywords_used": keywords or [],
"tags_used": tags or [],
"products": formatted,
}, ensure_ascii=False, default=str)
}
if fallback_msg:
result["fallback_message"] = fallback_msg
return json.dumps(result, ensure_ascii=False, default=str)
except Exception as e:
logger.error("❌ Search tool error: %s", e, exc_info=True)
logger.error("Lead search tool error: %s", e, exc_info=True)
return json.dumps({"status": "error", "message": str(e)})
"""
with open('backend/agent/lead_stage_agent/lead_search_tool.py', 'w', encoding='utf-8') as f:
f.write(new_code)
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