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

update

parent e8e02b1e
...@@ -289,6 +289,15 @@ class LeadStageGraph: ...@@ -289,6 +289,15 @@ class LeadStageGraph:
desc = (p.get("description") or "").strip() desc = (p.get("description") or "").strip()
if desc: if desc:
line += f"\n 📝 {desc[:200]}" line += f"\n 📝 {desc[:200]}"
outfit = p.get("outfit_recommendations")
if outfit and isinstance(outfit, list):
outfit_lines = []
for o in outfit[:3]:
outfit_lines.append(f"{o.get('role', 'match')}: {o.get('match_product_name')} ({o.get('match_product_code')})")
if outfit_lines:
line += f"\n 👗 outfit_recommendations: {', '.join(outfit_lines)}"
lines.append(line) lines.append(line)
return "\n".join(lines) return "\n".join(lines)
else: else:
......
...@@ -110,6 +110,11 @@ def resolve_product_name(raw_name: str) -> str: ...@@ -110,6 +110,11 @@ def resolve_product_name(raw_name: str) -> str:
"quần bò ống rộng" → "quần jean ống rộng" "quần bò ống rộng" → "quần jean ống rộng"
""" """
result = raw_name.lower().strip() result = raw_name.lower().strip()
# Custom rule cho sơ mi cộc tay -> sơ mi ngắn tay
if "sơ mi" in result and "cộc tay" in result:
result = result.replace("cộc tay", "ngắn tay")
for synonym in _SORTED_SYNONYMS: for synonym in _SORTED_SYNONYMS:
if synonym in result: if synonym in result:
db_value = SYNONYM_TO_DB[synonym] db_value = SYNONYM_TO_DB[synonym]
......
...@@ -174,6 +174,57 @@ def _resolve_occasion(tags: list[str]) -> str: ...@@ -174,6 +174,57 @@ def _resolve_occasion(tags: list[str]) -> str:
return occ return occ
return "hang_ngay" return "hang_ngay"
EVENT_FALLBACKS: list[dict] = [
{
"patterns": [
"30/4", "30-4", "giai phong", "giải phóng", "giai phong mien nam", "giải phóng miền nam",
],
"master_color": "Màu đỏ",
"tags": ["Màu đỏ", "Đi chơi / dạo phố"],
"keywords": ["cờ đỏ sao vàng", "30/4"],
},
{
"patterns": [
"2/9", "2-9", "quoc khanh", "quốc khánh", "le quoc khanh", "lễ quốc khánh",
],
"master_color": "Màu đỏ",
"tags": ["Màu đỏ", "Đi chơi / dạo phố"],
"keywords": ["quốc khánh", "lễ quốc khánh"],
},
]
def _normalize_text(text: str) -> str:
import unicodedata
lowered = text.lower().strip()
return "".join(
ch for ch in unicodedata.normalize("NFD", lowered)
if unicodedata.category(ch) != "Mn"
)
def _text_contains_pattern(text: str, pattern: str) -> bool:
if not text or not pattern:
return False
if any(ch.isdigit() for ch in pattern) or "/" in pattern or "-" in pattern:
return pattern in text
import re
escaped = re.escape(pattern)
return re.search(rf"(?<!\w){escaped}(?!\w)", text) is not None
def _apply_event_fallback(inf: "InferredSearch", text: str) -> bool:
if not text:
return False
normalized = _normalize_text(text)
for rule in EVENT_FALLBACKS:
if any(_text_contains_pattern(normalized, pat) for pat in rule["patterns"]):
if not inf.master_color:
inf.master_color = rule["master_color"]
if not inf.tags:
inf.tags = list(rule["tags"])
if not inf.keywords:
inf.keywords = list(rule["keywords"])
return True
return False
class LiteralSearch(BaseModel): class LiteralSearch(BaseModel):
"""Lane 1: Tìm NGUYÊN VĂN — không filter, chỉ LIKE raw text.""" """Lane 1: Tìm NGUYÊN VĂN — không filter, chỉ LIKE raw text."""
...@@ -325,9 +376,6 @@ def _apply_underwear_override(req: LeadSearchInput) -> str | None: ...@@ -325,9 +376,6 @@ def _apply_underwear_override(req: LeadSearchInput) -> str | None:
) )
# ======================================================
# SQL Builder
# ======================================================
def _dedup(products: list[dict], limit: int = 10) -> list[dict]: def _dedup(products: list[dict], limit: int = 10) -> list[dict]:
seen: set[str] = set() seen: set[str] = set()
res: list[dict] = [] res: list[dict] = []
...@@ -338,6 +386,87 @@ def _dedup(products: list[dict], limit: int = 10) -> list[dict]: ...@@ -338,6 +386,87 @@ def _dedup(products: list[dict], limit: int = 10) -> list[dict]:
res.append(p) res.append(p)
return res[:limit] return res[:limit]
def _extract_size_table_only(huong_dan_size: str) -> str:
"""Extract only the size table markdown (without guidance text)."""
if not huong_dan_size:
return ""
lines = huong_dan_size.strip().split('\n')
table_lines = []
in_table = False
for line in lines:
if '|' in line:
if 'Size' in line or 'size' in line.lower():
in_table = True
table_lines.append(line)
elif in_table:
if '---' not in line:
table_lines.append(line)
else:
table_lines.append(line)
if in_table and line.strip() == '':
break
elif in_table:
break
return '\n'.join(table_lines[:10]) # Max 10 rows
def _extract_compact_description(desc_data_cut: dict, product_count: int = 1) -> dict:
"""
Extract compact description from pre-cut description_data_cut JSON.
Data is already compressed (only 16 core fields) — no need to filter here.
product_count: Controls detail level
- 1-2 products: Full detail (all fields)
- 3+ products: Ultra compact (name + desc + material + tagline only)
"""
if not desc_data_cut:
return {}
# Field name mapping: Vietnamese key -> English output key
FIELD_MAP = {
"ten_san_pham": "name",
"mo_ta_chinh": "desc",
"chat_lieu": "material",
"tinh_nang_vai": "fabric_features",
"gioi_tinh": "gender",
"phong_cach": "style",
"mua": "season",
"dip_mac": "occasions",
"tags": "tags",
"do_tuoi": "age_range",
"loi_song": "lifestyle",
"tinh_cach": "personality",
"ly_do_mua": "why_buy",
"cross_sell": "cross_sell",
"hook_quang_cao": "hook",
"tagline": "tagline",
}
# Ultra compact mode for multi-product results (3+ products)
COMPACT_FIELDS = ["ten_san_pham", "mo_ta_chinh", "chat_lieu", "tagline", "tags", "phong_cach"]
result = {}
fields_to_use = COMPACT_FIELDS if product_count >= 3 else list(FIELD_MAP.keys())
for vn_key in fields_to_use:
en_key = FIELD_MAP.get(vn_key, vn_key)
val = desc_data_cut.get(vn_key)
if val:
# Truncate long strings in multi-product mode
if product_count >= 3 and isinstance(val, str) and len(val) > 100:
val = val[:100] + "…"
result[en_key] = val
return result
# ======================================================
# SQL Builder
# ======================================================
def _build_fixed_clauses(inf: InferredSearch, params: list) -> list[str]: def _build_fixed_clauses(inf: InferredSearch, params: list) -> list[str]:
"""Build fixed WHERE clauses from InferredSearch (Lane 2 only).""" """Build fixed WHERE clauses from InferredSearch (Lane 2 only)."""
clauses = [] clauses = []
...@@ -725,10 +854,10 @@ async def _enrich_with_outfit( ...@@ -725,10 +854,10 @@ async def _enrich_with_outfit(
anchor_base_codes, anchor_base_codes,
).fetchall() ).fetchall()
# Vẫn lấy clean_description từ bảng cũ nếu cần # Lấy clean_description + description_data_cut từ bảng ultra_descriptions
desc_rows = cursor.execute( desc_rows = cursor.execute(
f""" f"""
SELECT base_ref_code, clean_description SELECT base_ref_code, clean_description, description_data_cut
FROM pg__dashboard_canifa__ultra_descriptions FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders}) WHERE base_ref_code IN ({placeholders})
""", """,
...@@ -740,30 +869,64 @@ async def _enrich_with_outfit( ...@@ -740,30 +869,64 @@ async def _enrich_with_outfit(
logger.error("❌ SQLite outfit read error: %s", e) logger.error("❌ SQLite outfit read error: %s", e)
return products return products
outfit_map: dict[str, list] = {} import random
# Nhóm theo anchor -> role -> unique matches
temp_outfit_map: dict[str, dict[str, dict[str, dict]]] = {}
for row in outfit_rows: for row in outfit_rows:
anchor = row["anchor_product_code"] anchor = row["anchor_product_code"]
if anchor not in outfit_map: role = row["match_role"]
outfit_map[anchor] = [] code = row["match_product_code"]
outfit_map[anchor].append({
"match_product_code": row["match_product_code"], if anchor not in temp_outfit_map:
temp_outfit_map[anchor] = {}
if role not in temp_outfit_map[anchor]:
temp_outfit_map[anchor][role] = {}
temp_outfit_map[anchor][role][code] = {
"match_product_code": code,
"match_product_name": row["match_product_name"], "match_product_name": row["match_product_name"],
"role": row["match_role"], "role": role,
"reason": row["ai_reason"] "reason": row["ai_reason"]
}) }
desc_map: dict[str, str] = {} outfit_map: dict[str, list] = {}
for anchor, roles in temp_outfit_map.items():
outfit_map[anchor] = []
all_items = []
# Bước 1: Lấy ít nhất 1 item cho mỗi role để đảm bảo đa dạng
for role, unique_items in roles.items():
items_list = list(unique_items.values())
picked = random.choice(items_list)
outfit_map[anchor].append(picked)
# Lưu các item còn lại vào all_items
all_items.extend([item for item in items_list if item["match_product_code"] != picked["match_product_code"]])
# Bước 2: Nếu chưa đủ 10 items, lấy thêm ngẫu nhiên từ all_items
random.shuffle(all_items)
needed = 10 - len(outfit_map[anchor])
if needed > 0 and all_items:
outfit_map[anchor].extend(all_items[:needed])
desc_map: dict[str, dict] = {} # base_ref -> {clean_description, description_data_cut}
for row in desc_rows: for row in desc_rows:
desc_map[row["base_ref_code"]] = row["clean_description"] desc_map[row["base_ref_code"]] = {
"clean_description": row["clean_description"],
"description_data_cut": row["description_data_cut"],
}
for p in top_products: for p in top_products:
anchor_magento = p.get("magento_ref_code", "") anchor_magento = p.get("magento_ref_code", "")
base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip() base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip()
# Gán description # Gán description + description_data_cut
clean_desc = desc_map.get(base) desc_info = desc_map.get(base)
if clean_desc: if desc_info:
p["ai_description"] = clean_desc if desc_info.get("clean_description"):
p["ai_description"] = desc_info["clean_description"]
if desc_info.get("description_data_cut"):
p["description_data_cut"] = desc_info["description_data_cut"]
# Gán outfit matches # Gán outfit matches
matches = outfit_map.get(base) matches = outfit_map.get(base)
...@@ -776,42 +939,6 @@ async def _enrich_with_outfit( ...@@ -776,42 +939,6 @@ async def _enrich_with_outfit(
async def _format_products(products: list, db) -> list[dict]: async def _format_products(products: list, db) -> list[dict]:
formatted = [] 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 []
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"))[:3]
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[:3]: for p in products[:3]:
sale = float(p.get("sale_price") or 0) sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0) orig = float(p.get("original_price") or 0)
...@@ -819,13 +946,17 @@ async def _format_products(products: list, db) -> list[dict]: ...@@ -819,13 +946,17 @@ async def _format_products(products: list, db) -> list[dict]:
disc_pct = int(p.get("discount_percent") or 0) disc_pct = int(p.get("discount_percent") or 0)
main_sku = p.get("magento_ref_code", "") 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() # Parse description_data_cut (pre-compressed from SQLite)
desc_short = desc[:200] + "..." if len(desc) > 200 else desc desc_data_cut = None
raw_cut = p.get("description_data_cut")
if raw_cut:
try:
desc_data_cut = json.loads(raw_cut) if isinstance(raw_cut, str) else raw_cut
except Exception:
desc_data_cut = None
# Build compact item
item = { item = {
"sku": main_sku, "sku": main_sku,
"name": p.get("product_name", ""), "name": p.get("product_name", ""),
...@@ -838,21 +969,59 @@ async def _format_products(products: list, db) -> list[dict]: ...@@ -838,21 +969,59 @@ async def _format_products(products: list, db) -> list[dict]:
"image": p.get("product_image_url_thumbnail", ""), "image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""), "url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""), "sizes": p.get("size_scale", ""),
"description": desc_short,
"suggest_items": rich_suggest,
"similar_items": rich_similar,
} }
# --- Inject Size Message ---
raw_size = p.get("size_scale", "")
parsed_sizes = []
if isinstance(raw_size, str) and raw_size.strip():
if "[" in raw_size:
try:
parsed_sizes = json.loads(raw_size)
except Exception:
parsed_sizes = [s.strip() for s in raw_size.replace("[", "").replace("]", "").replace('"', '').replace("'", '').split(",") if s.strip()]
else:
parsed_sizes = [s.strip() for s in raw_size.replace(",", "|").split("|") if s.strip()]
elif isinstance(raw_size, list):
parsed_sizes = [str(s) for s in raw_size]
from .size_message_builder import build_size_message
desc_text = p.get("description_text", "")
size_msg = build_size_message(
gender=p.get("gender_by_product", ""),
product_line=p.get("product_line_vn", ""),
sizes=parsed_sizes,
description=desc_text
)
item["sizes_available"] = parsed_sizes
if size_msg:
item["size_message"] = size_msg
# ---------------------------
# Add compact description from description_data_cut
product_count = len(products[:3])
if desc_data_cut:
compact_desc = _extract_compact_description(
desc_data_cut,
product_count=product_count,
)
# Merge compact description fields into item
item.update(compact_desc)
else:
# Fallback to short description_text
desc = (p.get("description_text") or "").strip()
item["description"] = (desc[:200] + "...") if len(desc) > 200 else desc
# Inject outfit recommendations (ai_matches)
if p.get("ai_matches"):
item["outfit_recommendations"] = p["ai_matches"]
if "in_stock" in p: if "in_stock" in p:
item["in_stock"] = p["in_stock"] item["in_stock"] = p["in_stock"]
item["total_qty"] = p["total_qty"] item["total_qty"] = p["total_qty"]
item["stock"] = p["stock"] 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) formatted.append(item)
return formatted return formatted
...@@ -896,6 +1065,20 @@ class ProductSearchEngine: ...@@ -896,6 +1065,20 @@ class ProductSearchEngine:
inf = req.inferred inf = req.inferred
lit = req.literal lit = req.literal
# Event fallback: set master_color/tags/keywords if missing (30/4, 2/9, ...)
fallback_text = " ".join(
[
lit.raw_text or "",
" ".join(inf.keywords or []),
]
).strip()
_apply_event_fallback(inf, fallback_text)
# ── SAFETY VALVE: Force 'adult' for office/work intent if not inferred ──
office_tags = {"Đi làm / Công sở", "Thanh lịch", "Chỉnh chu", "Lịch sự"}
if not inf.age_by_product and any(tag in office_tags for tag in (inf.tags or [])):
inf.age_by_product = "adult"
# ── Build Lane 1: LITERAL — chỉ LIKE raw text, KHÔNG có fixed clauses ── # ── Build Lane 1: LITERAL — chỉ LIKE raw text, KHÔNG có fixed clauses ──
literal_coro = None literal_coro = None
if lit.raw_text and lit.raw_text.strip(): if lit.raw_text and lit.raw_text.strip():
...@@ -907,7 +1090,8 @@ class ProductSearchEngine: ...@@ -907,7 +1090,8 @@ class ProductSearchEngine:
"tìm", "mua", "muốn", "mình", "có", "không", "ko", "nào", "cần", "cho", "xin", "tìm", "mua", "muốn", "mình", "có", "không", "ko", "nào", "cần", "cho", "xin",
"nhé", "nha", "với", "hỏi", "đồ", "cái", "chiếc", "thế", "ấy", "này", "kia", "nhé", "nha", "với", "hỏi", "đồ", "cái", "chiếc", "thế", "ấy", "này", "kia",
"đó", "thử", "nhỉ", "vậy", "mà", "chứ", "ạ", "ơi", "đâu", "đây", "rồi", "đó", "thử", "nhỉ", "vậy", "mà", "chứ", "ạ", "ơi", "đâu", "đây", "rồi",
"nữa", "sao", "đi", "được", "ra", "để", "thì", "tầm", "khoảng", "loại" "nữa", "sao", "đi", "được", "ra", "để", "thì", "tầm", "khoảng", "loại",
"combo", "bộ", "set"
} }
# Ép lower-case toàn bộ words # Ép lower-case toàn bộ words
words = [w.lower() for w in raw_text.split() if w.lower() not in stop_words] words = [w.lower() for w in raw_text.split() if w.lower() not in stop_words]
...@@ -950,21 +1134,26 @@ class ProductSearchEngine: ...@@ -950,21 +1134,26 @@ class ProductSearchEngine:
params_inf = [] params_inf = []
fixed = _build_fixed_clauses(inf, params_inf) fixed = _build_fixed_clauses(inf, params_inf)
# Lọc bỏ từ khóa gây nhiễu (blacklist) khỏi Inferred Search (vì Literal Search đã xử lý rồi)
blacklist = {"combo", "set", "bộ"}
filtered_tags = [t for t in (inf.tags or []) if t.lower() not in blacklist]
filtered_keywords = [k for k in (inf.keywords or []) if k.lower() not in blacklist]
# Thêm tags + keywords vào search clause # Thêm tags + keywords vào search clause
search_parts = [] search_parts = []
if inf.tags: if filtered_tags:
tag_clause = _build_tag_clauses(inf.tags, params_inf) tag_clause = _build_tag_clauses(filtered_tags, params_inf)
if tag_clause: if tag_clause:
search_parts.append(tag_clause) search_parts.append(tag_clause)
if inf.keywords: if filtered_keywords:
kw_clause = _build_search_clause(inf.keywords, params_inf) kw_clause = _build_search_clause(filtered_keywords, params_inf)
if kw_clause: if kw_clause:
search_parts.append(kw_clause) search_parts.append(kw_clause)
search_combined = f"({' OR '.join(search_parts)})" if search_parts else None search_combined = f"({' OR '.join(search_parts)})" if search_parts else None
ex_params = [] ex_params = []
exclusions = _build_exclusion_clauses(inf.keywords, ex_params) exclusions = _build_exclusion_clauses(filtered_keywords, ex_params)
sql_inf = _build_full_query(fixed, search_combined, exclusions) sql_inf = _build_full_query(fixed, search_combined, exclusions)
inferred_coro = db.execute_query_async( inferred_coro = db.execute_query_async(
sql_inf, params=tuple(params_inf + ex_params) sql_inf, params=tuple(params_inf + ex_params)
...@@ -1042,7 +1231,7 @@ class ProductSearchEngine: ...@@ -1042,7 +1231,7 @@ class ProductSearchEngine:
fixed_vec = _build_fixed_clauses(inf, params_vec) fixed_vec = _build_fixed_clauses(inf, params_vec)
where_str = " AND ".join(fixed_vec) if fixed_vec else "1=1" where_str = " AND ".join(fixed_vec) if fixed_vec else "1=1"
if exclusions: if exclusions:
where_str += f" AND {exclusions}" where_str += f" AND {' AND '.join(exclusions)}"
sql_vec = f""" sql_vec = f"""
WITH vector_matches AS ( WITH vector_matches AS (
...@@ -1167,15 +1356,10 @@ class ProductSearchEngine: ...@@ -1167,15 +1356,10 @@ class ProductSearchEngine:
products, tier, fallback_msg = await self._dual_query_search(req, db) products, tier, fallback_msg = await self._dual_query_search(req, db)
# Enrichment pipeline (giữ nguyên) # Enrichment pipeline (giữ nguyên)
products, stock_checked, stock_api_ms, timed_out = await _enrich_with_stock(products) # products, stock_checked, stock_api_ms, timed_out = await _enrich_with_stock(products)
stock_api_ms = 0.0
products = await _enrich_with_outfit(products, db, tags=req.inferred.tags) products = await _enrich_with_outfit(products, db, tags=req.inferred.tags)
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) formatted = await _format_products(products, db)
elapsed_ms = round((time.time() - start) * 1000, 2) elapsed_ms = round((time.time() - start) * 1000, 2)
...@@ -1207,7 +1391,7 @@ class ProductSearchEngine: ...@@ -1207,7 +1391,7 @@ class ProductSearchEngine:
"search_mode": _search_modes[min(tier, 7)], "search_mode": _search_modes[min(tier, 7)],
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"reasoning": reasoning or "", "reasoning": reasoning or "",
"literal_text": req.literal.raw_text or "", "literal_text": " ".join([w for w in (req.literal.raw_text or "").split() if w.lower() not in {"combo", "bộ", "set"}]),
"keywords_used": inf.keywords or [], "keywords_used": inf.keywords or [],
"tags_used": inf.tags or [], "tags_used": inf.tags or [],
"products": formatted, "products": formatted,
...@@ -1218,6 +1402,33 @@ class ProductSearchEngine: ...@@ -1218,6 +1402,33 @@ class ProductSearchEngine:
if underwear_msg: if underwear_msg:
result["system_message"] = underwear_msg result["system_message"] = underwear_msg
has_sizes = any("size_message" in p for p in formatted)
if has_sizes:
result["size_tools_message"] = (
"CHÚ Ý QUAN TRỌNG TỪ HỆ THỐNG: Mỗi sản phẩm trả về đã đính kèm `size_message` "
"chứa các size CÒN HÀNG và Bảng đo tương ứng. BẠN PHẢI ƯU TIÊN lấy đúng "
"`size_message` của sản phẩm đó để tư vấn cho khách dựa trên chiều cao cân nặng "
"của họ, và chú ý áp dụng các Mẹo nâng/hạ size (nếu có). KHÔNG tự bịa size."
)
has_outfits = any("outfit_recommendations" in p for p in formatted)
if has_outfits:
result["outfit_tools_message"] = (
"GỢI Ý PHỐI ĐỒ: Một số sản phẩm có kèm `outfit_recommendations` — danh sách "
"các items phối cùng (vai trò: top/bottom/layer/accessory) kèm lý do. "
"Hãy gợi ý khách mua combo nếu phù hợp, ưu tiên các sản phẩm match_role "
"bổ trợ cho item chính. Luôn nêu mã SKU khi gợi ý."
)
has_desc = any("desc" in p or "why_buy" in p for p in formatted)
if has_desc:
result["description_tools_message"] = (
"MÔ TẢ SẢN PHẨM: Mỗi sản phẩm có kèm thông tin mô tả ngắn gọn "
"(desc, material, why_buy, hook, tagline...). Hãy dùng các thông tin "
"này để tư vấn khách hàng một cách tự nhiên, KHÔNG copy nguyên văn, "
"mà hãy diễn đạt lại phù hợp với ngữ cảnh hội thoại."
)
return result return result
except Exception as e: except Exception as e:
......
...@@ -60,11 +60,41 @@ Args cho Lane 2 (`inferred`): ...@@ -60,11 +60,41 @@ Args cho Lane 2 (`inferred`):
- "chống nắng" -> product_line_vn=["Áo khoác chống nắng", "Găng tay chống nắng", "Khẩu trang"], keywords=["UV", "chống nắng"] - "chống nắng" -> product_line_vn=["Áo khoác chống nắng", "Găng tay chống nắng", "Khẩu trang"], keywords=["UV", "chống nắng"]
2. **Dịp (Occasion):** 2. **Dịp (Occasion):**
- "đi tiệc/đám cưới/cuối năm" -> product_line_vn=["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Blazer", "Quần Khaki"], keywords=["sang trọng", "thanh lịch", "nổi bật"] - "đi tiệc/đám cưới/cuối năm" -> product_line_vn=["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Blazer", "Quần Khaki"], keywords=["sang trọng", "thanh lịch", "nổi bật"]
- "đi làm/công sở" -> product_line_vn=["Áo sơ mi", "Áo Polo", "Quần Khaki", "Quần jean", "Váy liền", "Blazer", "Chân váy"], keywords=["lịch sự", "chỉnh chu", "thanh lịch"] - "đi làm/công sở" -> product_line_vn=["Áo sơ mi", "Áo Polo", "Quần Khaki", "Quần jean", "Váy liền", "Blazer", "Chân váy"], keywords=["lịch sự", "chỉnh chu", "thanh lịch"], age_by_product="adult" (BẮT BUỘC adult trừ khi khách nói rõ cho bé)
- "đi gym/thể thao" -> product_line_vn=["Bộ thể thao", "Áo ba lỗ", "Áo phông", "Quần nỉ", "Quần leggings", "Áo bra active"], keywords=["co giãn", "thấm hút", "vận động"] - "đi gym/thể thao" -> product_line_vn=["Bộ thể thao", "Áo ba lỗ", "Áo phông", "Quần nỉ", "Quần leggings", "Áo bra active"], keywords=["co giãn", "thấm hút", "vận động"]
- "đi biển" -> product_line_vn=["Váy liền", "Áo phông", "Quần soóc", "Áo ba lỗ", "Dép"], keywords=["đi biển", "mùa hè", "họa tiết"] - "đi biển" -> product_line_vn=["Váy liền", "Áo phông", "Quần soóc", "Áo ba lỗ", "Dép"], keywords=["đi biển", "mùa hè", "họa tiết"]
- "mặc Tết" -> product_line_vn=["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Quần Khaki", "Bộ quần áo", "Áo dài"], keywords=["Tết", "tươi tắn", "du xuân"] - "mặc Tết" -> product_line_vn=["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Quần Khaki", "Bộ quần áo", "Áo dài"], keywords=["Tết", "tươi tắn", "du xuân"]
- "mặc nhà/ngủ" -> product_line_vn=["Bộ mặc nhà", "Pyjama", "Quần mặc nhà", "Áo mặc nhà", "Áo phông", "Quần soóc"], keywords=["thoải mái", "mềm mại"] - "mặc nhà/ngủ" -> product_line_vn=["Bộ mặc nhà", "Pyjama", "Quần mặc nhà", "Áo mặc nhà", "Áo phông", "Quần soóc"], keywords=["thoải mái", "mềm mại"]
3. **Sự kiện / Ngày lễ Việt Nam (QUAN TRỌNG):**
- **Cơ chế fallback (BẮT BUỘC):** Nếu `keywords` không bắt được, VẪN phải set `master_color` + `tags` theo ngày lễ. Nếu `keywords` trống, tự điền bộ keywords mặc định của ngày lễ đó.
- **Ngày 30/4 (Ngày Giải phóng miền Nam) & 2/9 (Quốc khánh):**
+ `master_color: "Màu đỏ"` (bắt buộc)
+ `tags: ["Màu đỏ", "Đi chơi / dạo phố"]`
+ `keywords: ["cờ đỏ sao vàng", "lễ quốc khánh", "du xuân/du lịch"]`
+ `product_line_vn`: Phụ thuộc khách hỏi "áo gì" hay "combo" (nếu mơ hồ thì suy luận -> "Áo phông", "Áo kiểu", "Váy liền", "Quần Khaki")
- **Tết Nguyên Đán:**
+ `master_color: "Màu đỏ"` hoặc `"Màu vàng"` (nếu khách nói "vàng")
+ `tags: ["Màu đỏ", "Đi chơi / dạo phố"]` hoặc `["Màu vàng"]`
+ `keywords: ["Tết", "tươi tắn", "du xuân", "may mắn"]`
+ `product_line_vn`: ["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Quần Khaki", "Áo dài"] (đặc biệt là áo dài nếu là nữ)
- **Ngày phụ nữ Việt Nam (20/10):**
+ `tags: ["Đi chơi / dạo phố", "Đi tiệc"]`
+ `keywords: ["thanh lịch", "sang trọng", "duyên dáng"]`
+ `product_line_vn`: ["Váy liền", "Chân váy", "Áo kiểu", "Áo sơ mi", "Blazer"]
- **Giáng sinh (25/12):**
+ `master_color: "Màu đỏ"` hoặc `"Màu trắng"` hoặc `"Tông nổi bật"`
+ `tags: ["Màu đỏ", "Đi tiệc"]` hoặc `["Tông nổi bật"]`
+ `keywords: ["giáng sinh", "trang trí", "nổi bật", "vui vẻ"]`
+ `product_line_vn`: ["Váy liền", "Áo kiểu", "Áo sơ mi", "Blazer"]
- **Năm mới (1/1):**
+ `tags: ["Đi chơi / dạo phố", "Đi tiệc"]`
+ `keywords: ["năm mới", "tươi tắn", "tự tin"]`
+ `product_line_vn`: ["Áo phông", "Áo kiểu", "Váy liền", "Quần Khaki"]
- **Ngày tình yêu (14/2):**
+ `master_color: "Màu hồng"` (nếu khách nữ)
+ `tags: ["Màu hồng", "Đi tiệc"]` hoặc `["Đi chơi / dạo phố"]`
+ `keywords: ["Valentine", "lãng mạn", "duyên dáng"]`
+ `product_line_vn`: ["Váy liền", "Áo kiểu", "Chân váy"]
4. **Đồ lót / Phụ kiện:** 4. **Đồ lót / Phụ kiện:**
- "sịp/quần lót/quần nhỏ/quần xì/chip" -> product_line_vn=["Quần lót", "Quần lót đùi", "Quần lót tam giác"] - "sịp/quần lót/quần nhỏ/quần xì/chip" -> product_line_vn=["Quần lót", "Quần lót đùi", "Quần lót tam giác"]
- "áo lót/áo ngực/bra/áo lót học sinh" -> product_line_vn=["Áo lót", "Áo bra active"] - "áo lót/áo ngực/bra/áo lót học sinh" -> product_line_vn=["Áo lót", "Áo bra active"]
...@@ -72,6 +102,7 @@ Args cho Lane 2 (`inferred`): ...@@ -72,6 +102,7 @@ Args cho Lane 2 (`inferred`):
- ⚠️ **LƯU Ý:** Với đồ lót, Tool sẽ TỰ ĐỘNG tìm tất cả giới tính và độ tuổi. Bạn CHỈ CẦN điền đúng product_line_vn, KHÔNG cần lo gender/age. - ⚠️ **LƯU Ý:** Với đồ lót, Tool sẽ TỰ ĐỘNG tìm tất cả giới tính và độ tuổi. Bạn CHỈ CẦN điền đúng product_line_vn, KHÔNG cần lo gender/age.
5. **Combo / Set đồ / Mơ hồ nhưng có context:** 5. **Combo / Set đồ / Mơ hồ nhưng có context:**
- Nếu khách nói "combo đi biển", "đồ đi làm", "đồ mặc Tết", "set đồ" -> VẪN GỌI TOOL. - Nếu khách nói "combo đi biển", "đồ đi làm", "đồ mặc Tết", "set đồ" -> VẪN GỌI TOOL.
- ⚠️ **KHÁCH LIỆT KÊ NHIỀU MÓN CÙNG LÚC (VD: "combo quần áo sịp", "tìm áo thun quần jean"):** Bạn BẮT BUỘC phải trích xuất ĐẦY ĐỦ TẤT CẢ các `product_line_vn` tương ứng với mọi món đồ được nhắc đến. VD: "quần áo sịp đi biển" -> `product_line_vn=["Áo phông", "Quần soóc", "Bộ quần áo", "Quần lót", "Quần lót đùi"]`. Tuyệt đối KHÔNG bỏ sót.
- ⚠️ **CHỐNG LÚ INSIGHT (CỰC KỲ QUAN TRỌNG)**: Nếu Insight cũ đang lưu mục tiêu là "Áo lót", "Sịp", "Áo khoác"... mà khách ĐỔI sang hỏi chung chung kiểu "tìm combo đi biển", "tìm set đồ" -> **TUYỆT ĐỐI KHÔNG** nhét loại sản phẩm cũ vào `product_line_vn`. Phải RESET và điền các loại đồ phù hợp ngữ cảnh mới (VD đi biển: `["Bộ quần áo", "Áo phông", "Quần soóc", "Váy liền", "Áo ba lỗ"]`), hoặc để trống `[]` để search theo tag. - ⚠️ **CHỐNG LÚ INSIGHT (CỰC KỲ QUAN TRỌNG)**: Nếu Insight cũ đang lưu mục tiêu là "Áo lót", "Sịp", "Áo khoác"... mà khách ĐỔI sang hỏi chung chung kiểu "tìm combo đi biển", "tìm set đồ" -> **TUYỆT ĐỐI KHÔNG** nhét loại sản phẩm cũ vào `product_line_vn`. Phải RESET và điền các loại đồ phù hợp ngữ cảnh mới (VD đi biển: `["Bộ quần áo", "Áo phông", "Quần soóc", "Váy liền", "Áo ba lỗ"]`), hoặc để trống `[]` để search theo tag.
- **TRƯỜNG HỢP CỰC KỲ MƠ HỒ (CRITICAL):** Nếu khách chỉ nói "tìm áo", "có gì mặc đẹp không", "tư vấn đồ cho mình", "có gì mới không" mà KHÔNG HỀ có thông tin giới tính, độ tuổi hay dịp mặc -> **BẮT BUỘC KHÔNG GỌI TOOL**. Hãy Early Exit để hỏi lại: "Dạ, bạn đang tìm đồ cho nam, nữ hay bé và cho dịp gì để mình tư vấn chuẩn nhất ạ?". - **TRƯỜNG HỢP CỰC KỲ MƠ HỒ (CRITICAL):** Nếu khách chỉ nói "tìm áo", "có gì mặc đẹp không", "tư vấn đồ cho mình", "có gì mới không" mà KHÔNG HỀ có thông tin giới tính, độ tuổi hay dịp mặc -> **BẮT BUỘC KHÔNG GỌI TOOL**. Hãy Early Exit để hỏi lại: "Dạ, bạn đang tìm đồ cho nam, nữ hay bé và cho dịp gì để mình tư vấn chuẩn nhất ạ?".
- TUYỆT ĐỐI KHÔNG tự giả định mùa hay giới tính nếu không có manh mối. - TUYỆT ĐỐI KHÔNG tự giả định mùa hay giới tính nếu không có manh mối.
...@@ -168,6 +199,32 @@ Query: "lấy cái đầu tiên đi" ...@@ -168,6 +199,32 @@ Query: "lấy cái đầu tiên đi"
→ ai_response: "Dạ, bạn chọn Quần lót Hipster Nữ Pack 3 Oekotek (6US25A004) nhé! Bạn cho mình xin size..." → ai_response: "Dạ, bạn chọn Quần lót Hipster Nữ Pack 3 Oekotek (6US25A004) nhé! Bạn cho mình xin size..."
→ product_ids: ["6US25A004"] → product_ids: ["6US25A004"]
``` ```
**VD7 — Khách hỏi "áo gì mặc ngày 30/4" (Sự kiện Việt Nam):**
```
Insight: {"TARGET": "Nữ"}
Query: "tìm áo mặc ngày 30/4"
→ tool_name: "lead_search_tool"
→ literal.raw_text: "áo 30/4"
→ inferred.product_line_vn: ["Áo phông", "Áo kiểu", "Áo sơ mi"] ← Suy luận từ intent "tìm áo" + sự kiện
→ inferred.gender_by_product: "women" ← Kế thừa từ Insight
→ inferred.master_color: "Màu đỏ" ← 30/4 = cờ đỏ sao vàng
→ inferred.tags: ["Màu đỏ", "Đi chơi / dạo phố"]
→ inferred.keywords: ["cờ đỏ sao vàng", "30/4", "du xuân"]
```
**VD8 — Khách hỏi "combo đi chơi ngày 2/9":**
```
Insight: {"TARGET": "Nữ"}
Query: "combo gì cho ngày 2/9"
→ tool_name: "lead_search_tool"
→ literal.raw_text: "combo 2/9"
→ inferred.product_line_vn: ["Áo phông", "Áo kiểu", "Váy liền", "Quần Khaki"] ← Combo cho ngày lễ
→ inferred.gender_by_product: "women"
→ inferred.master_color: "Màu đỏ" ← 2/9 = Quốc khánh
→ inferred.tags: ["Màu đỏ", "Đi chơi / dạo phố"]
→ inferred.keywords: ["quốc khánh", "2/9", "lễ lớn"]
```
""" """
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
...@@ -183,27 +240,25 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t ...@@ -183,27 +240,25 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t
- 🔥 **NGUYÊN TẮC VÀNG: CỨ GỢI Ý, ĐỪNG HỎI LẠI!** - 🔥 **NGUYÊN TẮC VÀNG: CỨ GỢI Ý, ĐỪNG HỎI LẠI!**
- TUYỆT ĐỐI KHÔNG hỏi ngược khách kiểu: "Bạn muốn áo kiểu gì?", "Bạn cần thêm gì?", "Bạn thích tone nào?". - TUYỆT ĐỐI KHÔNG hỏi ngược khách kiểu: "Bạn muốn áo kiểu gì?", "Bạn cần thêm gì?", "Bạn thích tone nào?".
- Hãy TỰ GỢI Ý dựa trên data tool trả về. Nếu thiếu thông tin (VD: chưa biết giới tính) thì gợi ý cho cả nam lẫn nữ. - Hãy TỰ GỢI Ý dựa trên data tool trả về. Nếu thiếu thông tin (VD: chưa biết giới tính) thì **BẮT BUỘC** gợi ý cho cả nam lẫn nữ (VD: "Nếu là nam thì..., nếu là nữ thì...").
- CHỈ hỏi lại nếu thực sự CẦN (VD: hỏi size để chốt đơn). Và hỏi NGẮN GỌN ở cuối response, KHÔNG phải nội dung chính. - CHỈ hỏi lại nếu thực sự CẦN (VD: hỏi size để chốt đơn). Và hỏi NGẮN GỌN ở cuối response, KHÔNG phải nội dung chính.
- 🎯 **COMBO / SET ĐỒ (CỰC KỲ QUAN TRỌNG):** - 🎯 **COMBO / SET ĐỒ (CỰC KỲ QUAN TRỌNG):**
- Khi khách hỏi "combo", "set đồ", "đồ đi biển", "đồ đi làm" → Khách MUỐN NHIỀU MÓN PHỐ HỢP, KHÔNG PHẢI 1 MÓN. - Khi khách hỏi "combo", "set đồ", "đồ đi biển", "đồ đi làm" hoặc "phối cùng cái nào" → Khách MUỐN NHIỀU MÓN PHỐ HỢP, KHÔNG PHẢI 1 MÓN.
- ⚠️ BẮT BUỘC đọc `suggest_items` và `ai_matches` trong tool_result để ghép combo hoàn chỉnh: - ⚠️ **CẤM TUYỆT ĐỐI** các mẫu câu từ chối/trần tình như: "Vì mình chỉ thấy duy nhất item X...", "Hệ thống chỉ trả về...", "Mình không biết bạn là nam hay nữ nên chưa phối được...".
+ `suggest_items`: Các SP bổ sung (VD: quần soóc → suggest áo phông, áo polo) - **HÀNH ĐỘNG BẮT BUỘC:** Bạn phải đọc `outfit_recommendations` hoặc `suggest_items` trong data tool trả về để **TỰ GHÉP THÀNH OUTFIT HOÀN CHỈNH**.
+ `ai_matches`: Các SP phối đồ chi tiết theo màu/dịp + Nếu có `outfit_recommendations`: Dùng ngay các món trong đó (kèm SKU) để mô tả bộ đồ.
- **TUYỆT ĐỐI KHÔNG** chỉ show 1 SP rồi hỏi "bạn cần thêm gì?". TỰ GHÉP COMBO từ data. + Nếu chưa biết giới tính: Gợi ý song song cả 2 phương án (Nam phối X, Nữ phối Y).
- Nếu chưa biết giới tính → gợi ý: "Nếu bạn nữ thì combo X+Y, nếu bạn nam thì combo A+B".
- BẮT BUỘC nhét SKU của TẤT CẢ SP (chính + phối) vào `product_ids`. - BẮT BUỘC nhét SKU của TẤT CẢ SP (chính + phối) vào `product_ids`.
**VD — Khách hỏi "combo đi biển", tool trả quần soóc + suggest_items + ai_matches:** **VD — Khách hỏi "combo đi biển", tool trả Quần short nam (6BS25S011) + ai_matches:**
``` ```
❌ SAI: "Mình có Quần soóc nữ (6BS25S011). Bạn muốn combo gồm gì thêm?" ❌ SAI: "Vì mình chỉ thấy mỗi Quần short nam beige (6BS25S011) nên chưa phối được combo cho bạn. Bạn là nam hay nữ?"
✅ ĐÚNG: "Combo đi biển thoáng mát: ✅ ĐÚNG: "Đi biển thì cứ combo 'Mát - Dễ phối' này mà triển thôi bạn ơi:
• Quần soóc nữ cotton ống rộng (6BS25S011) — cotton thoáng, dễ vận động. • Quần short nam beige (6BS25S011) — chất vải thoáng, cực kỳ năng động.
• Phối cùng Áo phông nữ basic cổ tim (6TS26A002) — nhẹ, mát, dễ phối. • Phối cùng: Áo phông nam Cotton (6TS25S001) hoặc Áo sơ mi ngắn tay họa tiết (6SS25S002) để có bộ outfit đi chơi biển chuẩn bài nhé!
• Hoặc Áo polo nữ (6TP26S001) — trẻ trung cho dạo biển. Nếu bạn tìm cho nữ, mình cũng có các mẫu váy maxi cực xinh đấy. Bạn cao bao nhiêu để mình check size nào?"
Bạn cho mình size S/M/L/XL nhé!" → product_ids: ["6BS25S011", "6TS25S001", "6SS25S002"]
→ product_ids: ["6BS25S011", "6TS26A002-SN011", "6TP26S001-SB001"]
``` ```
- **KIỂM TRA SỰ PHÙ HỢP (CRITICAL):** - **KIỂM TRA SỰ PHÙ HỢP (CRITICAL):**
...@@ -216,15 +271,96 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t ...@@ -216,15 +271,96 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t
- Hãy cư xử tự nhiên: "Rất tiếc hiện Canifa chưa có set 7 màu, nhưng bọn mình đang có sẵn các set 2 màu cực kỳ thoáng mát..." - Hãy cư xử tự nhiên: "Rất tiếc hiện Canifa chưa có set 7 màu, nhưng bọn mình đang có sẵn các set 2 màu cực kỳ thoáng mát..."
- Nếu KHÔNG có kết quả (NO_PRODUCTS): Đừng xin lỗi quá nhiều. Hãy giải thích tại sao và GỢI Ý các lựa chọn thay thế gần nhất. - Nếu KHÔNG có kết quả (NO_PRODUCTS): Đừng xin lỗi quá nhiều. Hãy giải thích tại sao và GỢI Ý các lựa chọn thay thế gần nhất.
- **[ĐỊNH DẠNG RESPONSE]:** - **🎯 PHÁT HIỆN INTENT: SINGLE-ITEM vs COMBO/OUTFIT:**
- **SINGLE-ITEM INTENT:** Khách hỏi "mua áo gì", "nên chọn cái nào", "quần đẹp cái nào", "có gì phù hợp" MÀ KHÔNG nhắc đến "combo", "phối", "set đồ" → CHỈ GỢI ÝI NHỮNG SP CHÍNH, KHÔNG CẦN PHỐI ĐỒ.
+ `product_ids`: Chỉ chứa SKU của các SP chính được gợi ý (1-3 sản phẩm).
+ Response: Tập trung vào tính năng, chất liệu, phù hợp dịp. KHÔNG bắt buộc phải bốc `outfit_recommendations`.
- **COMBO/OUTFIT INTENT:** Khách hỏi "combo gì", "set đồ sao", "phối cùng cái nào", "đồ đi biển bao gồm gì" → PHẢI GỢI ÝI NHIỀU MÓN LIÊN QUAN.
+ `product_ids`: Chứa SKU của các SP chính + SP phối từ `outfit_recommendations`.
+ Response: Viết theo dạng OUTFIT với phối hợp logic giữa các món.
- **ĐỊNH DẠNG RESPONSE (ĐIỀU CHỈNH THEO INTENT):**
**Cho SINGLE-ITEM INTENT:**
1. Câu trả lời tư vấn (max 200 từ) — Tập trung vào SP chính.
2. (TÙY CHỌN) Nếu khách dường như muốn tư vấn phối thêm: "Nếu bạn cần tư vấn phối thêm, mình cũng có..." — KHÔNG bắt buộc.
3. `product_ids`: Chỉ SKU SP chính.
**Cho COMBO/OUTFIT INTENT:**
1. Câu trả lời tư vấn (max 200 từ). 1. Câu trả lời tư vấn (max 200 từ).
2. [TƯ VẤN PHỐI ĐỒ (Dịp mặc)]: 2. **[TƯ VẤN PHỐI ĐỒ (OUTFIT)]:**
- TH1: Khách hỏi chung chung ("phối với quần gì", "mặc với gì đẹp"): BẮT BUỘC dùng dữ liệu từ trường `outfit_recommendations` có sẵn trong kết quả của SP chính. KHÔNG tự bịa. Format: `Dịp: [Tên dịp] | Phối: [Tên SP 1] + [match_product_name] ([match_product_code])`. Giải thích: [reason]. - ⚠️ **BẮT BUỘC:** Với ít nhất 1-2 sản phẩm chính bạn vừa giới thiệu, bạn PHẢI bốc dữ liệu từ trường `outfit_recommendations` của chính sản phẩm đó ra để viết thành một bộ outfit hoàn chỉnh (Gồm Tên món phối + SKU).
- TH2: Khách ĐÍCH DANH yêu cầu 1 món phối cụ thể (VD: "mặc với sịp nào", "với áo khoác nào") VÀ tool đã tìm ra món đó làm kết quả chính: Hãy tư vấn trực tiếp món đồ mà tool vừa trả về. Lúc này không bắt buộc dùng `outfit_recommendations`. - **NGHIÊM CẤM RÂU ÔNG NỌ CẮM CẰM BÀ KIA:**
+ Tuyệt đối KHÔNG lấy sản phẩm thứ 2 trong danh sách `products` (VD: Áo bé gái) để phối cho sản phẩm thứ 1 (VD: Quần nữ lớn) nếu chúng không cùng nhóm đối tượng.
+ Phải đảm bảo cùng Giới tính và cùng Độ tuổi (Người lớn phối với Người lớn, Trẻ em phối với Trẻ em).
- **THỨ TỰ ƯU TIÊN PHỐI ĐỒ:**
1. Lấy món từ `outfit_recommendations` của sản phẩm đó.
2. Nếu `outfit_recommendations` trống, hãy tự gợi ý một món đồ cơ bản cùng loại (VD: Quần Khaki thì gợi ý phối cùng Áo sơ mi nam/nữ tương ứng) và nhắc khách là "Bạn có thể tìm thêm các mẫu áo sơ mi nam tại Canifa...".
- Format: `OUTFIT: [Tên SP chính] + [match_product_name] ([match_product_code])`.
- Giải thích: [reason từ ai_reason].
- ⚠️ **product_ids:** BẮT BUỘC nhét cả SKU của món chính VÀ món phối vào list này để hiển thị lên màn hình.
### ❌ VÍ DỤ SAI (CẤM LÀM THEO):
**VD1 — Khách hỏi SINGLE-ITEM nhưng AI gợi ý OUTFIT:**
Query: "mua áo gì cho đi làm?"
- ❌ SAI: "Áo sơ mi nữ (SP1) + Quần Khaki (SP2) là combo tuyệt vời cho công sở bạn nhé!"
- ⚠️ Lý do: Khách chỉ hỏi áo, không hỏi combo. Response và product_ids bị thừa.
- ✅ ĐÚNG: "Áo sơ mi nữ (SP1) lịch sự, thoải mái mặc cả ngày. Chất cotton mịn, không nhăn. Size nào vừa bạn?"
- `product_ids: ["SP1"]` ← CHỈ áo, không quần.
**VD2 — Khách hỏi COMBO nhưng tool trả về sản phẩm sai loại:**
Query: "tìm combo đi làm"
Tool trả: Quần Khaki Nữ (SP1) + Áo sơ mi bé gái (SP2)
- ❌ SAI: "Gợi ý set đồ: Quần Khaki Nữ (SP1) + Áo sơ mi bé gái (SP2)"
- ⚠️ Lý do: SP2 là dành cho bé, không phù hợp với người lớn đi làm.
- ✅ ĐÚNG: "Quần Khaki Nữ (SP1) cực thanh lịch đi làm. Nếu bạn cần áo phối, mình có các dòng áo sơ mi nữ tại Canifa rất phù hợp. Bạn chọn loại nào?"
- `product_ids: ["SP1"]` ← CHỈ gợi ý quần, không bịa áo bé gái.
**VD3 — Khách hỏi COMBO nhưng AI trần tình thuật toán:**
Query: "combo đi biển"
- ❌ SAI: "Vì mình chỉ thấy mỗi Quần short nam beige (6BS25S011) nên chưa phối được combo cho bạn. Bạn là nam hay nữ?"
- ⚠️ Lý do: Trần tình hệ thống, từ chối yêu cầu của khách.
- ✅ ĐÚNG: "Đi biển thì cứ combo 'Mát - Dễ phối' này mà triển thôi: Quần short nam beige (6BS25S011) + Áo phông nam Cotton (6TS25S001). Chất vải thoáng, co giãn tốt!"
- `product_ids: ["6BS25S011", "6TS25S001"]` ← Cả quần + áo.
### ✅ VÍ DỤ ĐÚNG:
**VD1 — SINGLE-ITEM INTENT:**
Query: "áo nào phù hợp đi làm?"
Tool trả: Áo sơ mi nữ (SP1), Áo Polo nữ (SP2), Áo kiểu nữ (SP3)
- ✅ ĐÚNG: "Áo sơ mi nữ (SP1) hay Áo Polo nữ (SP2) đều lịch sự cả bạn! Sơ mi có tính chuyên nghiệp hơn, còn Polo thì trẻ trung, dễ phối. Bạn thích style nào?"
- KHÔNG phối quần, KHÔNG bàn outfit.
- `product_ids: ["SP1", "SP2"]` ← CHỈ áo.
**VD2 — COMBO INTENT với `outfit_recommendations`:**
Query: "combo đi biển"
Tool trả: Quần short nam (SP1) + `outfit_recommendations: [Áo phông Cotton (6TS25S001), Áo sơ mi ngắn (6SS25S002)]`
- ✅ ĐÚNG: "Đi biển thì combo cực thoáng này nè:
• Quần short nam (SP1) — vải quick-dry, khô nhanh.
• Phối cùng: Áo phông Cotton (6TS25S001) thì năng động, hoặc Áo sơ mi ngắn họa tiết (6SS25S002) thì sành điệu hơn.
Bạn cao bao nhiêu để mình check size?"
- `product_ids: ["SP1", "6TS25S001", "6SS25S002"]` ← Quần + 2 áo phối.
**VD3 — COMBO INTENT cho nữ, không biết size:**
Query: "set đồ đi Tết"
Tool trả: Váy liền nữ (SP1) + `outfit_recommendations: [Cardigan nữ (SKU-ABC)]`
- ✅ ĐÚNG: "Mặc Tết thì váy liền nữ (SP1) tươi tắn, cực dễ dáng! Nếu thời tiết lạnh, bạn có thể khoác Cardigan nữ (SKU-ABC) bên ngoài — vừa ấm vừa sang."
- `product_ids: ["SP1", "SKU-ABC"]` ← Váy + cardigan.
### NHIỆM VỤ 2 — Chốt `product_ids` (HIỂN THỊ LÊN GIAO DIỆN): ### NHIỆM VỤ 2 — Chốt `product_ids` (HIỂN THỊ LÊN GIAO DIỆN):
Trong câu `ai_response` bạn khen hoặc gợi ý sản phẩm nào (kể cả SP chính lẫn SP Phối trong `match_product_code`), thì BẮT BUỘC phải copy y hệt mã SKU của nó nhét vào mảng `product_ids`. **Cho SINGLE-ITEM INTENT:**
- **CHỈ GỢI Ý SP PHÙ HỢP:** Đừng cố nhồi nhét SP sai loại vào list này. Đảm bảo list bao gồm cả SP chính + SP phối. - `product_ids` chỉ chứa SKU của các SP chính được gợi ý (1-3 sản phẩm).
- BẤT BUỘC KHÔNG nhét các SP phối thêm vào danh sách này.
- VD: Khách hỏi "mua áo gì thoáng mát" → `product_ids: ["5TS25S001", "6TS25S002"]` (chỉ áo).
**Cho COMBO/OUTFIT INTENT:**
- `product_ids` chứa SKU của TẤT CẢ sản phẩm được gợi ý (SP chính + SP phối).
- Chỉ nhét sản phẩm mà bạn đã bàn luận trong response.
- VD: Khách hỏi "combo đi biển" → `product_ids: ["6BS25S011", "6TS25S001", "6SS25S002"]` (quần + 2 áo phối).
**LƯU Ý:**
- Nếu bạn gợi ý outfit nhưng nhân vật đó không xuất hiện trong ai_response hoặc không phù hợp → ĐỪNG nhét vào `product_ids`.
- Luôn đảm bảo `product_ids` phản ánh chính xác những gì bạn đã recommend.
### NHIỆM VỤ 3 — Sinh `user_insight` (InsightJSON — bộ nhớ khách hàng): ### NHIỆM VỤ 3 — Sinh `user_insight` (InsightJSON — bộ nhớ khách hàng):
Cập nhật 12 trường dưới đây sau mỗi turn. Cộng dồn thông tin. Cập nhật 12 trường dưới đây sau mỗi turn. Cộng dồn thông tin.
......
import logging
logger = logging.getLogger(__name__)
SIZE_MAPPING = {
"NU": {
"XS": "Cao 1m47-1m53, Nặng 38-43kg",
"S": "Cao 1m50-1m55, Nặng 41-46kg",
"M": "Cao 1m55-1m63, Nặng 47-52kg",
"L": "Cao 1m60-1m65, Nặng 53-58kg",
"XL": "Cao 1m62-1m66, Nặng 59-64kg",
},
"NAM": {
"S": "Cao 1m62-1m68, Nặng 57-62kg",
"M": "Cao 1m69-1m73, Nặng 63-67kg",
"L": "Cao 1m71-1m75, Nặng 68-72kg",
"XL": "Cao 1m73-1m77, Nặng 73-77kg",
"XXL": "Cao 1m75-1m79, Nặng 78-82kg",
},
"QUAN_NU": {
"26": "Vòng eo 65cm, Vòng mông 79-87cm",
"27": "Vòng eo 67.5cm, Vòng mông 81-89cm",
"28": "Vòng eo 70cm, Vòng mông 84-92cm",
"29": "Vòng eo 72.5cm, Vòng mông 86-94cm",
"30": "Vòng eo 75cm, Vòng mông 89-97cm",
},
"QUAN_NAM": {
"29": "Vòng eo 79.5cm, Vòng mông 96.5cm",
"30": "Vòng eo 82cm, Vòng mông 99cm",
"31": "Vòng eo 84.5cm, Vòng mông 101.5cm",
"32": "Vòng eo 87cm, Vòng mông 104cm",
"33": "Vòng eo 89cm, Vòng mông 106.5cm",
},
"TRE_EM": {
"90": "Dành cho bé 2Y, Cao 90cm, 10-13kg",
"92": "Dành cho bé 2Y, Cao 88-94cm, 10-13kg",
"98": "Dành cho bé 2-3Y, Cao 95-101cm, 13-15kg",
"100": "Dành cho bé 3-4Y, Cao 100cm, 14-17kg",
"104": "Dành cho bé 3-4Y, Cao 101-107cm, 15-18kg",
"110": "Dành cho bé 4-5Y, Cao 107-113cm, 18-23kg",
"116": "Dành cho bé 6Y, Cao 113-119cm, 22-25kg",
"120": "Dành cho bé 6-7Y, Cao 120cm, 24-29kg",
"122": "Dành cho bé 7Y, Cao 119-125cm, 25-28kg",
"128": "Dành cho bé 8Y, Cao 125-131cm, 28-32kg",
"130": "Dành cho bé 8Y, Cao 130cm, 29-33kg",
"134": "Dành cho bé 9Y, Cao 131-137cm, 32-36kg",
"140": "Dành cho bé 9-11Y, Cao 137-145cm, 33-39kg",
"150": "Dành cho bé 11-12Y, Cao 150cm, 39-45kg",
"152": "Dành cho bé 11-12Y, Cao 145-157cm, 39-46kg",
"160": "Dành cho bé 13-14Y, Cao 160cm, 45-52kg",
"164": "Dành cho bé 13-14Y, Cao 157-169cm, 46-55kg",
},
"UNISEX": {
"XXS": "Cao 1m55-1m63, Nặng 47-52kg",
"XS": "Cao 1m60-1m65, Nặng 53-58kg",
"S": "Cao 1m62-1m68, Nặng 57-62kg",
"M": "Cao 1m69-1m73, Nặng 63-67kg",
"L": "Cao 1m71-1m75, Nặng 68-72kg",
"XL": "Cao 1m73-1m77, Nặng 73-77kg",
"XXL": "Cao 1m75-1m79, Nặng 79-82kg",
}
}
def determine_table_key(gender: str, product_line: str) -> str:
"""Xác định bảng size phù hợp dựa trên giới tính và dòng sản phẩm."""
gender = (gender or "").lower().strip()
product_line = (product_line or "").lower().strip()
is_jeans_or_khaki = any(x in product_line for x in ["jean", "khaki", "kaki", "quần âu", "quần tây"])
is_bottom = "quần" in product_line
# 1. Trẻ em
if gender in ["boy", "girl", "kid", "bé trai", "bé gái", "be trai", "be gai", "trẻ em"]:
return "TRE_EM"
# 2. Unisex
if gender == "unisex":
return "UNISEX"
# 3. Quần size số (Jeans/Khaki/Âu) - Nếu không phải quần size chữ
if is_bottom and is_jeans_or_khaki:
if gender in ["women", "nu", "nữ", "female"]:
return "QUAN_NU"
elif gender in ["men", "nam", "male"]:
return "QUAN_NAM"
# 4. Áo / Quần chun (Dùng bảng chuẩn Nam / Nữ)
if gender in ["women", "nu", "nữ", "female"]:
return "NU"
elif gender in ["men", "nam", "male"]:
return "NAM"
return ""
def build_size_message(gender: str, product_line: str, sizes: list[str], description: str = "") -> str:
"""
Sinh ra message hướng dẫn chọn size chỉ chứa các size thực tế đang có (available_sizes).
Nếu mô tả sản phẩm có từ khóa form dáng (ôm, rộng) thì đính kèm mẹo.
"""
if not sizes:
return ""
table_key = determine_table_key(gender, product_line)
if not table_key:
return "" # Không xác định được bảng size
mapping = SIZE_MAPPING.get(table_key, {})
if not mapping:
return ""
# Tạo chuỗi thông tin cho các size khả dụng
size_lines = []
# Sort sizes logically if possible, but keep original order for now to avoid complexity with letters vs numbers
for s in sizes:
s_upper = str(s).strip().upper()
if s_upper in mapping:
size_lines.append(f"Size {s_upper} ({mapping[s_upper]})")
if not size_lines:
return "" # Các size đưa vào không có trong bảng (ví dụ freesize)
# Sinh phần mô tả đối tượng
obj_str = "Sản phẩm Nữ"
if table_key == "NAM": obj_str = "Sản phẩm Nam"
elif table_key == "TRE_EM": obj_str = "Sản phẩm Trẻ em"
elif table_key == "UNISEX": obj_str = "Sản phẩm Unisex (Phi giới tính)"
elif table_key == "QUAN_NU": obj_str = "Quần Nữ (Size số)"
elif table_key == "QUAN_NAM": obj_str = "Quần Nam (Size số)"
base_msg = f"{obj_str}. Các size ĐANG CÒN HÀNG: " + "; ".join(size_lines) + "."
# Tìm mẹo tư vấn (fit style hints) - Viết theo dạng mớm lời trực tiếp (Directive) cho AI
desc_lower = (description or "").lower()
tips = []
if any(x in desc_lower for x in ["dáng ôm", "slim fit", "slim-fit", "body", "ôm sát"]):
tips.append("🔥 Chỉ dẫn cho AI: Sản phẩm này là form ôm (slim-fit). Khi tư vấn size, hãy chèn thêm câu khuyên khách cực khéo: 'Dáng này thiết kế ôm body tôn dáng, nếu anh/chị thích mặc cử động thoải mái hoặc hay mặc lót bên trong thì mình nên nhích lên 1 size cho dễ chịu nhé ạ!'")
elif any(x in desc_lower for x in ["oversize", "thùng thình", "rộng rãi", "boxy"]):
tips.append("🔥 Chỉ dẫn cho AI: Sản phẩm này là form Oversize (rộng rãi). Hãy dặn dò khách thật tinh tế: 'Mẫu này hãng đã làm form rộng rãi sẵn rồi, anh/chị cứ dựa theo chiều cao cân nặng lấy ĐÚNG SIZE là lên dáng thụng cực chất, không cần phải cố tăng size đâu ạ. Còn nếu thích mặc gọn gàng thì mới lùi 1 size nhé!'")
elif any(x in desc_lower for x in ["regular", "vừa vặn"]):
tips.append("🔥 Chỉ dẫn cho AI: Sản phẩm có form Regular chuẩn. Hãy chốt size dứt khoát: 'Mẫu này form dáng vừa vặn cực chuẩn, anh/chị cứ chiếu theo đúng chiều cao cân nặng lấy chuẩn size là mặc vừa in, không cần đắn đo ạ!'")
if tips:
base_msg += "\n\n" + "\n".join(tips)
return base_msg
import sqlite3
import os
db_path = r'D:\cnf\chatbot-canifa-feedback\backend\database\canifa_ai_dump.sqlite'
conn = sqlite3.connect(db_path)
cursor = conn.cursor()
cursor.execute("SELECT internal_ref_code FROM sr__test_db__magento_product_dimension_with_text_embedding WHERE magento_ref_code = '3TS26S005-SW001'")
print(cursor.fetchone())
conn.close()
# Size Column Migration
## Mục đích
Thêm cột `size` vào bảng `pg__dashboard_canifa__ultra_descriptions` để lưu **bảng size đã filter** (chỉ size thực tế có bán), giúp giảm token khi hiển thị trong chatbot.
## Files
- `migrations/002_add_size_column.sql` - Thêm cột `size`
- `populate_size_column.py` - Điền dữ liệu vào cột `size`
## Cách chạy
### Bước 1: Apply migration (thêm cột)
```bash
sqlite3 backend/database/canifa_ai_dump.sqlite < backend/database/migrations/002_add_size_column.sql
```
Hoặc mở DB bằng DB Browser for SQLite và chạy SQL:
```sql
ALTER TABLE pg__dashboard_canifa__ultra_descriptions ADD COLUMN size TEXT;
```
### Bước 2: Populate data
```bash
cd backend/database
python populate_size_column.py
```
Script sẽ:
1. Đọc `description_data->'huong_dan_size'` (bảng size đầy đủ với XS, S, M, L, XL)
2. Lọc chỉ giữ lại các size có trong `size_scale` (VD: "L, M, S, XL")
3. Ghi kết quả vào cột `size`
### Bước 3: Verify
```bash
sqlite3 backend/database/canifa_ai_dump.sqlite "SELECT id, size_scale, size FROM pg__dashboard_canifa__ultra_descriptions WHERE size IS NOT NULL LIMIT 1;"
```
Expected output:
- `size_scale`: "L, M, S, XL"
- `size`: Bảng markdown chỉ chứa S, M, L, XL (không có XS)
## Lợi ích
- Trước: `huong_dan_size` ~500 tokens (bảng đầy đủ 5-6 sizes)
- Sau: `size` ~100 tokens (chỉ sizes thực tế)
- Tiết kiệm ~400 tokens/sp × số sản phẩm hiển thị
## Notes
- Cột `size` được thêm vào SELECT trong `product_search_engine.py`
- Output `item["size_table"]` sẽ dùng cột `size` thay vì parse `huong_dan_size` mỗi lần
-- Migration: Add `size` column to ultra_descriptions
-- Purpose: Store filtered size table based on size_scale to reduce token usage
-- Date: 2026-05-05
-- Database: SQLite
-- 1. Add new column
ALTER TABLE pg__dashboard_canifa__ultra_descriptions ADD COLUMN size TEXT;
-- 2. Note: Use populate_size_column.py to fill this column
-- because SQLite lacks advanced string manipulation functions.
COMMIT;
"""
Populate `size` column in pg__dashboard_canifa__ultra_descriptions
with filtered size table based on size_scale.
"""
import json
import os
import sqlite3
import sys
sys.stdout.reconfigure(encoding='utf-8')
# Auto-detect DB path relative to this script
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
# Try common DB filenames
possible_db_names = [
"canifa_ai_dump.sqlite",
"canifa_local.sqlite",
"canifa_dump.sqlite"
]
DB_PATH = None
for db_name in possible_db_names:
candidate = os.path.join(SCRIPT_DIR, db_name)
if os.path.exists(candidate):
DB_PATH = candidate
print(f"✅ Found DB: {DB_PATH}")
break
if not DB_PATH:
# Default to first option with debug
DB_PATH = os.path.join(SCRIPT_DIR, "canifa_ai_dump.sqlite")
print(f"⚠️ DB not auto-found. Will try: {DB_PATH}")
def parse_size_table_from_markdown(markdown: str) -> dict:
result = {}
lines = markdown.strip().split('\n')
for line in lines:
line = line.strip()
if line.startswith('|') and not line.startswith('|---'):
parts = [p.strip() for p in line.split('|')]
if len(parts) >= 4:
size = parts[1]
height = parts[2]
weight = parts[3]
result[size] = (height, weight)
return result
def build_filtered_table(size_table: dict, available_sizes: list) -> str:
if not size_table or not available_sizes:
return ""
lines = [
"| Size | Chiều cao (cm) | Cân nặng (kg) |",
"|------|----------------|---------------|"
]
for size in available_sizes:
size_upper = size.strip().upper()
for table_size, (height, weight) in size_table.items():
if table_size.upper() == size_upper:
lines.append(f"| {size} | {height} | {weight} |")
break
return '\n'.join(lines)
def main():
print(f"\n🔍 DB Path: {DB_PATH}")
print(f"📁 Script dir: {SCRIPT_DIR}")
print(f"📁 Current dir: {os.getcwd()}")
print(f"❌ Exists: {os.path.exists(DB_PATH)}\n")
if not os.path.exists(DB_PATH):
print("❌ Database not found!")
print("\nAvailable .sqlite files in script directory:")
for f in os.listdir(SCRIPT_DIR):
if f.endswith('.sqlite'):
print(f" - {f}")
print("\n👉 Please edit DB_PATH in this script to point to the correct file.")
return
conn = sqlite3.connect(DB_PATH)
cur = conn.cursor()
# Ensure column exists
try:
cur.execute("ALTER TABLE pg__dashboard_canifa__ultra_descriptions ADD COLUMN size TEXT")
print("✅ Added 'size' column")
except sqlite3.OperationalError as e:
if "duplicate column" in str(e).lower():
print("ℹ️ Column 'size' already exists - will skip creation")
else:
print(f"❌ Error adding column: {e}")
return
# Fetch rows to process
cur.execute("""
SELECT id, description_data, size_scale
FROM pg__dashboard_canifa__ultra_descriptions
WHERE description_data IS NOT NULL
AND description_data != ''
AND size_scale IS NOT NULL
AND size_scale != ''
AND (size IS NULL OR size = '')
""")
rows = cur.fetchall()
total = len(rows)
print(f"📊 Found {total} rows to process")
if total == 0:
# Check if any rows have data already
cur.execute("SELECT COUNT(*) FROM pg__dashboard_canifa__ultra_descriptions")
total_rows = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM pg__dashboard_canifa__ultra_descriptions WHERE size IS NOT NULL")
already_done = cur.fetchone()[0]
print(f"✅ No rows need processing. Total: {total_rows}, Already filled: {already_done}")
conn.close()
return
updated = 0
errors = 0
for row_id, desc_data_json, size_scale in rows:
try:
desc_data = json.loads(desc_data_json)
huong_dan_size = desc_data.get('huong_dan_size', '')
if not huong_dan_size:
continue
available_sizes = [s.strip() for s in size_scale.split('|') if s.strip()]
if not available_sizes:
continue
size_table = parse_size_table_from_markdown(huong_dan_size)
if not size_table:
continue
filtered_table = build_filtered_table(size_table, available_sizes)
if filtered_table:
cur.execute(
"UPDATE pg__dashboard_canifa__ultra_descriptions SET size = ? WHERE id = ?",
(filtered_table, row_id)
)
updated += 1
if updated % 100 == 0:
print(f" Progress: {updated}/{total}")
conn.commit()
except json.JSONDecodeError as e:
errors += 1
print(f" ⚠️ JSON parse error on row {row_id}: {e}")
except Exception as e:
errors += 1
print(f" ⚠️ Error on row {row_id}: {e}")
conn.commit()
# Verify
cur.execute("SELECT COUNT(*) FROM pg__dashboard_canifa__ultra_descriptions WHERE size IS NOT NULL")
count = cur.fetchone()[0]
print(f"\n✅ Done! Updated {updated} rows. Total with size: {count}. Errors: {errors}")
# Show sample
cur.execute("""
SELECT id, size_scale, size
FROM pg__dashboard_canifa__ultra_descriptions
WHERE size IS NOT NULL
LIMIT 1
""")
sample = cur.fetchone()
if sample:
print("\n📄 Sample output:")
print(f" ID: {sample[0]}")
print(f" size_scale: {sample[1]}")
print(f" size (filtered):")
print(" " + sample[2].replace('\n', '\n '))
conn.close()
if __name__ == "__main__":
main()
{'status': 'success', 'lead_stage': None, 'classifier_latency_ms': 2044, 'tool_result': '{"status": "success", "count": 10, "tier": 1, "search_mode": "literal_like", "elapsed_ms": 335.65, "reasoning": "", "literal_text": "đồ đi làm cho nữ", "keywords_used": [], "tags_used": ["Đi làm / Công sở", "Thanh lịch"], "products": [{"sku": "6BK25S002", "name": "Quần khaki nữ basic dáng suông", "price": 349500, "original_price": 699000, "discount": "-50%", "color": "Xám/ Gray", "gender": "women", "product_line": "Quần Khaki", "image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bk25s002-sa164-m-1-u.jpg", "url": "https://canifa.com/quan-khaki-nu-6ks25s002?color=SA164&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1", "sizes": "L|M|S|XL", "sizes_available": ["L", "M", "S", "XL"], "desc": "Form dáng suông ôm nhẹ từ hông đến gối, ôm vừa vặn bắp chân để hack tỷ lệ cơ thể. Thiết kế túi chéo …", "material": "Polyester mềm mại", "tagline": "Tối giản nhưng không hề nhàm chán", "tags": "quần khaki nữ, dáng suông, công sở, casual, tối giản", "style": "Casual, Minimalist", "outfit_recommendations": [{"match_product_code": "6TP26S001-SW001", "match_product_name": "Áo polo nữ", "role": "top", "reason": "Màu Trắng/ White của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."}, {"match_product_code": "6OT25C007-SE060", "match_product_name": "Áo Khoác Blazer Nữ", "role": "outerwear", "reason": "Màu Be/ Beige của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."}, {"match_product_code": "6TH25W002", "match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn", "role": "top", "reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."}, {"match_product_code": "5TH25W003-SA952", "match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer", "role": "top", "reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."}, {"match_product_code": "5TH25W003-SK010", "match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer", "role": "top", "reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."}, {"match_product_code": "6OT25S001", "match_product_name": "Áo khoác blazer nữ ngắn tay", "role": "outerwear", "reason": "Màu Trắng/ White của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."}, {"match_product_code": "6TP26S001-SK010", "match_product_name": "Áo polo nữ", "role": "top", "reason": "Màu Đen/ Black của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."}, {"match_product_code": "6OT25W025", "match_product_name": "Áo khoác blazer nữ", "role": "outerwear", "reason": "Màu Đen/ Black của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."}, {"match_product_code": "6TP26S001-SB001", "match_product_name": "Áo polo nữ", "role": "top", "reason": "Màu Xanh da trời/ Blue của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."}]}, {"sku": "6KS25W002", "name": "Chân váy ngắn màu xanh nhạt", "price": 249500, "original_price": 499000, "discount": "-50%", "color": "Xanh da trời/ Blue", "gender": "women", "product_line": "Chân váy", "image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/k/6ks25w002-sb479-m-1-u.jpg", "url": "https://canifa.com/chan-vay-nu-6ks25w002?color=SB479&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1", "sizes": "L|M|S|XL", "sizes_available": ["L", "M", "S", "XL"], "size_message": "Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).", "desc": "Chân váy ngắn màu xanh nhạt, thiết kế đơn giản, form thẳng.", "material": "Không xác định", "tagline": "Thời trang công sở, phong cách trẻ trung", "tags": "Chân váy, váy công sở, váy ngắn", "style": "Công sở, trẻ trung", "outfit_recommendations": [{"match_product_code": "6TO23C001", "match_product_name": "Áo kiểu nữ dài tay cổ cách điệu", "role": "top", "reason": "Màu Cam/ Orange của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."}, {"match_product_code": "6TS26A002-SW001", "match_product_name": "Áo phông nữ basic cổ tim", "role": "top", "reason": "Màu Trắng/ White của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."}, {"match_product_code": "5TA26S001-SK010", "match_product_name": "Áo Tank Top Unisex Người Lớn Vietnam 75", "role": "top", "reason": "Màu Đen/ Black của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."}, {"match_product_code": "6TE26S001-SY089", "match_product_name": "Áo len nữ", "role": "top", "reason": "Màu Vàng/ Yellow + Gold của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."}, {"match_product_code": "6TE26S001-SW001", "match_product_name": "Áo len nữ", "role": "top", "reason": "Màu Trắng/ White của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."}, {"match_product_code": "6TO25C001-SK010", "match_product_name": "Áo Cổ Cao Tay Dài", "role": "top", "reason": "Màu Đen/ Black của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."}, {"match_product_code": "6TE26S006-SE187", "match_product_name": "Áo len nữ", "role": "top", "reason": "Màu Be/ Beige của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."}, {"match_product_code": "5TH25W003-SA952", "match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer", "role": "top", "reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."}, {"match_product_code": "6TO24S007", "match_product_name": "Áo kiểu nữ bamboo sát nách cổ tròn", "role": "top", "reason": "Màu Hồng/ Pink- Magenta của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."}, {"match_product_code": "6TS26A002-SN011", "match_product_name": "Áo phông nữ basic cổ tim", "role": "top", "reason": "Màu Nâu/ Brown của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."}]}, {"sku": "6DS25S004", "name": "Váy liền nữ dáng dài", "price": 399500, "original_price": 799000, "discount": "-50%", "color": "Be/ Beige", "gender": "women", "product_line": "Váy liền", "image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/d/6ds25s004-se088-m-1-u.jpg", "url": "https://canifa.com/vay-lien-nu-6ds25s004?color=SE088&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1", "sizes": "L|S|M|XL", "sizes_available": ["L", "S", "M", "XL"], "size_message": "Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).", "description": "Váy liền nữ dáng dài, phối chất liệu phần thân váy và tùng váy giữa vải dệt kim thấm hút và vải dệt thoi tạo độ mềm mại và nhẹ nhàng cho tùng váy, tạo nên một sản phẩm nữ tính mà vẫn năng động.", "outfit_recommendations": [{"match_product_code": "6OT25W026", "match_product_name": "Áo khoác nữ khaki có cổ", "role": "outerwear", "reason": "Màu Xanh da trời/ Blue của Áo khoác dáng ngắn phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối áo khoác dáng ngắn — nữ tính, cá tính."}, {"match_product_code": "6OT25S001", "match_product_name": "Áo khoác blazer nữ ngắn tay", "role": "outerwear", "reason": "Màu Trắng/ White của Blazer phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Blazer — thanh lịch cho công sở."}, {"match_product_code": "6TC25W001", "match_product_name": "Áo cardigan len nữ", "role": "outerwear", "reason": "Màu Hồng/ Pink- Magenta của Cardigan phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Cardigan — nhẹ nhàng, dễ thương."}, {"match_product_code": "6OT25C007-SE060", "match_product_name": "Áo Khoác Blazer Nữ", "role": "outerwear", "reason": "Màu Be/ Beige của Blazer phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Blazer — thanh lịch cho công sở."}, {"match_product_code": "6OT25W014", "match_product_name": "Áo khoác nữ", "role": "outerwear", "reason": "Màu Xám/ Gray của Áo khoác dáng ngắn phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối áo khoác dáng ngắn — nữ tính, cá tính."}, {"match_product_code": "6TC25W002", "match_product_name": "Áo cardigan nữ", "role": "outerwear", "reason": "Màu Be/ Beige của Cardigan phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Cardigan — nhẹ nhàng, dễ thương."}, {"match_product_code": "6OT25W025", "match_product_name": "Áo khoác blazer nữ", "role": "outerwear", "reason": "Màu Đen/ Black của Blazer phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Blazer — thanh lịch cho công sở."}, {"match_product_code": "6OT25W012", "match_product_name": "Áo khoác lông nữ", "role": "outerwear", "reason": "Màu Be/ Beige của Áo khoác dáng ngắn phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối áo khoác dáng ngắn — nữ tính, cá tính."}, {"match_product_code": "6TC25W004", "match_product_name": "Áo khoác cardigan len lông cừu nữ", "role": "outerwear", "reason": "Màu Trắng/ White của Cardigan phối cùng màu Xám/ Gray tạo điểm nhấn hoàn hảo. Váy liền phối Cardigan — nhẹ nhàng, dễ thương."}]}], "size_tools_message": "CHÚ Ý QUAN TRỌNG TỪ HỆ THỐNG: Mỗi sản phẩm trả về đã đính kèm `size_message` chứa các size CÒN HÀNG và Bảng đo tương ứng. BẠN PHẢI ƯU TIÊN lấy đúng `size_message` của sản phẩm đó để tư vấn cho khách dựa trên chiều cao cân nặng của họ, và chú ý áp dụng các Mẹo nâng/hạ size (nếu có). KHÔNG tự bịa size.", "outfit_tools_message": "GỢI Ý PHỐI ĐỒ: Một số sản phẩm có kèm `outfit_recommendations` — danh sách các items phối cùng (vai trò: top/bottom/layer/accessory) kèm lý do. Hãy gợi ý khách mua combo nếu phù hợp, ưu tiên các sản phẩm match_role bổ trợ cho item chính. Luôn nêu mã SKU khi gợi ý.", "description_tools_message": "MÔ TẢ SẢN PHẨM: Mỗi sản phẩm có kèm thông tin mô tả ngắn gọn (desc, material, why_buy, hook, tagline...). Hãy dùng các thông tin này để tư vấn khách hàng một cách tự nhiên, KHÔNG copy nguyên văn, mà hãy diễn đạt lại phù hợp với ngữ cảnh hội thoại."}'}
\ No newline at end of file
['/', '/health', '/static/{file_path}', '/home/{file_path}', '/api/agent/chat', '/api/agent/chat-dev', '/api/agent/user-insight', '/api/agent/system-prompt', '/api/prompt/refresh', '/api/agent/tool-prompts', '/api/agent/tool-prompts/{filename}', '/api/agent/n8n/products', '/api/agent/n8n/products/verify', '/api/agent/n8n/stock', '/api/agent/n8n/promotions', '/api/agent/n8n/stores', '/api/agent/n8n/knowledge', '/api/feedback', '/api/feedback/stats', '/api/feedback/list', '/api/feedback/process', '/api/feedback-agent/sync-langfuse', '/api/feedback-agent/analyze', '/api/feedback-agent/apply', '/api/diagram/chat', '/api/diagram/clear', '/api/sku-search/chat', '/api/sku-search/lookup/{sku}', '/api/insight/reset', '/api/check-history/{identity_key}', '/api/history/{identity_key}', '/api/history/archive', '/api/merge-history/mock', '/api/merge-history/guest/{device_id}', '/api/merge-history/preview', '/api/auth/login', '/api/auth/me', '/api/auth/refresh', '/api/auth/me/settings', '/api/auth/users', '/api/auth/users/{user_id}', '/api/auth/stats', '/api/auth/register', '/api/mock/login', '/api/mock/logout', '/api/mock/history', '/api/mock/new-conversation', '/api/mock/conversations', '/api/mock/chat', '/api/mock/links/{user_id}', '/api/products/overview', '/api/products/list', '/api/products/colors', '/api/products/filters', '/api/product/lookup', '/api/stock/check', '/api/canifa/search', '/api/product-desc/overview', '/api/product-desc/batch-status', '/api/product-desc/list', '/api/product-desc/generate', '/api/product-desc/filters', '/api/product-desc/saved/{internal_ref_code}', '/api/product-desc/saved/{internal_ref_code}/update', '/api/product-desc/saved/{internal_ref_code}/update-text', '/api/product-desc/saved/{internal_ref_code}/rewrite-field', '/api/product-desc/approve', '/api/product-desc/sync-material', '/api/product-desc/approve-all', '/api/product-desc/clean-missing-summary', '/api/product-desc/backfill-clean-description', '/api/product-desc/batch-generate', '/api/product-desc/batch-generate-all', '/api/product-desc/batch-status/{job_id}', '/api/product-desc/fields', '/api/product-desc/fields/{field_key}', '/api/product-desc/batch-generate-tags', '/api/product-desc/tags-batch-status', '/api/product-desc/n8n-generate', '/api/bulk/search', '/api/bulk/ai-search', '/api/bulk/fields', '/api/bulk/ai-edit', '/api/bulk/ai-edit-preview', '/api/bulk/ai-edit-status', '/api/bulk/update', '/api/bulk/batch-generate-tags', '/api/bulk/tags-batch-status', '/api/bulk/generate-tags-single', '/api/bulk/enrich-size-single', '/api/bulk/enrich-size-batch', '/api/bulk/size-stats', '/api/tags-direct/run', '/api/tags-direct/status', '/api/tags-direct/stop', '/api/tags-direct/single', '/api/tags-direct/count-untagged', '/api/fashion-matches/{code}', '/api/fashion-matches/{code}/update', '/api/fashion-matches/{code}/regen', '/api/fashion-matches/batch-regen', '/api/fashion-matches/batch', '/api/fashion-matches/batch/status', '/api/fashion-matches/rules/config', '/api/fashion-matches/rules/meta', '/api/fashion-matches/color-logic', '/api/fashion-matches/outfit-suggest', '/api/fashion-matches/score-test', '/api/fashion-matches/audit/tag-coverage', '/api/fashion-matches/rules/view', '/api/fashion-matches/simulator/search', '/api/fashion-matches/simulator/stream', '/api/outfit-matches/stats', '/api/outfit-matches/products', '/api/outfit-matches/{code}', '/api/store-search/chat', '/api/image-search/chat', '/api/v2/chat', '/api/sql-chat', '/api/sql-chat/status', '/api/sql-dashboard', '/api/ai-sql/trace', '/api/ai-sql/approve/{session_id}', '/api/ai-sql/sessions', '/api/ai-sql/sessions/{session_id}', '/api/ai-sql/conversations', '/api/ai-sql/conversations/{conv_id}', '/api/user-insights/all', '/api/user-insights/{identity_key}', '/api/dashboard/info', '/api/dashboard/links', '/api/dashboard/links/{link_id}', '/api/dashboard/links/{link_id}/pin', '/api/faqs', '/api/faqs/{faq_id}', '/api/faqs/stats', '/api/faqs/generate-variants', '/api/faqs/batch-generate', '/api/faqs/simulate-match', '/api/cache/stats', '/api/cache/keys', '/api/cache/clear', '/api/cache/clear-all', '/api/cache/get', '/api/live-monitor/bootstrap', '/api/live-monitor/stream', '/api/report-html', '/api/report-html/stream/{task_id}', '/reports', '/api/reports/conversations', '/api/reports/conversations/{conv_id}', '/api/reports/{report_id}', '/api/report-inline-edit', '/api/report-save-edit', '/api/report-followup', '/api/dashboard/experiment-links', '/api/dashboard/experiment-links/{link_id}', '/api/dashboard/experiment-links/{link_id}/versions', '/api/dashboard/experiment-links/{link_id}/versions/{idx}', '/api/dashboard/experiments', '/api/dashboard/experiments/{exp_id}', '/api/dashboard/experiments/{exp_id}/pin', '/api/dashboard/experiments/compare', '/api/dashboard/roadmap', '/api/dashboard/roadmap/{item_id}', '/api/dashboard/flow', '/api/dashboard/flow/{item_id}', '/api/social/webhook/facebook', '/api/social/webhook/tiktok', '/api/social/messages', '/api/social/messages/stats', '/api/social/messages/analyze', '/api/social/messages/clear', '/api/notifications', '/api/notifications/unread-count', '/api/notifications/{notification_id}/read', '/api/notifications/read-all', '/api/notifications/test', '/api/notifications/check-spike', '/api/content', '/api/content/stats', '/api/content/{content_id}', '/api/content/{content_id}/submit', '/api/content/{content_id}/approve', '/api/content/{content_id}/reject', '/api/content/{content_id}/changes', '/api/content/{content_id}/schedule', '/api/content/{content_id}/publish-now', '/api/templates', '/api/templates/categories', '/api/templates/search', '/api/templates/{template_id}', '/api/templates/feeds/categories', '/api/templates/feeds/{category}', '/api/queue', '/api/queue/{content_id}', '/api/queue/reorder', '/api/queue/calendar', '/api/queue/slots', '/api/queue/slots/reset', '/api/queue/retry-state', '/api/queue/publish-tick', '/api/media/upload', '/api/media', '/api/media/platforms', '/api/media/{asset_id}/info', '/api/media/{asset_id}', '/api/media/{asset_id}/thumb', '/api/media/{asset_id}/resize', '/api/mock/agent/chat', '/api/mock/db/search', '/api/mock/retriverdb', '/api/mock/retrieverdb', '/api/prompt-optimizer/run-test', '/api/prompt-optimizer/judge', '/api/prompt-optimizer/optimize', '/api/user-simulator/archetypes', '/api/user-simulator/generate-personas', '/api/user-simulator/generate-message', '/api/user-simulator/chat-with-bot', '/api/user-simulator/evaluate', '/api/user-simulator/synthesize', '/api/user-simulator/run-simulation', '/api/regression-test/run', '/api/stress-test/run', '/api/reaction-simulator/segments', '/api/reaction-simulator/campaign-types', '/api/reaction-simulator/simulate', '/api/reaction-simulator/simulate-mock', '/api/agent/chat-lead-flow', '/api/agent/lead-stage', '/api/lead/history', '/api/lead/conversations', '/api/lead/dashboard', '/api/lead-flow/simulate', '/api/limit/info', '/api/limit/reset']
\ No newline at end of file
{
"status": "success",
"ai_response": "Rất hợp đi làm cho nữ nè—mình gợi ý ngay 1 set “lịch sự - gọn dáng - dễ mặc mỗi ngày” từ 3 lựa chọn quần/váy cơ bản:\n\nOUTFIT A: Quần khaki nữ basic dáng suông (6BK25S002) + (bạn có thể phối thêm) áo sơ mi/blouse nữ cùng tông trung tính để lên form chuẩn công sở.\nOUTFIT B: Chân váy ngắn màu xanh nhạt (6KS25W002) + (bạn có thể phối thêm) áo sơ mi nữ dáng suông hoặc croptop che phủ gọn gàng (hợp đi làm).\nOUTFIT C: Quần jeans đen nữ (6BJ25C001) + (bạn có thể phối thêm) áo blouse/áo sơ mi nữ màu trơn để tổng thể tinh gọn.\n\nĐể mình chốt đúng set hợp nhất cho bạn, bạn cho mình biết chiều cao và cân nặng (mặc size S/M/L hay số quần bao nhiêu) nhé?",
"products": [],
"trace_id": "83c12c18a7cf6ec4d736e2df7d559278",
"lead_stage": {
"stage": 1,
"stage_name": "BROWSE",
"tone_directive": "Friendly",
"behavioral_hints": [
"Gợi ý combo set đi làm với nhiều lựa chọn (quần khaki/chân váy/jeans)."
]
},
"pipeline": [
{
"step": "user",
"label": "👤 User",
"content": "combo đi làm"
},
{
"step": "classifier",
"label": "🔧 Classifier → Early Exit",
"content": "Khách chỉ nói 'combo đi làm' và không có yêu cầu đính chính loại sản phẩm; đã có 3 SP được giới thiệu trong ngữ cảnh. Theo quy tắc recall, không search mới mà trả về lựa chọn từ SP đã show.",
"elapsed_ms": 1419
},
{
"step": "stylist",
"label": "💬 Stylist (Response + Insight)",
"content": "Rất hợp đi làm cho nữ nè—mình gợi ý ngay 1 set “lịch sự - gọn dáng - dễ mặc mỗi ngày” từ 3 lựa chọn quần/váy cơ bản:\n\nOUTFIT A: Quần khaki nữ basic dáng suông (6BK25S002) + (bạn có thể phối thêm) áo sơ mi/blouse nữ cùng tông trung tính để lên form chuẩn công sở.\nOUTFIT B: Chân váy ngắn màu xanh nhạt (6KS25W002) + (bạn có thể phối thêm) áo sơ mi nữ dáng suông hoặc croptop che phủ gọn gàng (hợp đi làm).\nOUTFIT C: Quần jeans đen nữ (6BJ25C001) + (bạn có thể phối thêm) áo blouse/áo sơ mi nữ màu trơn",
"elapsed_ms": 3633,
"raw_json": "{\n \"ai_response\": \"Rất hợp đi làm cho nữ nè—mình gợi ý ngay 1 set “lịch sự - gọn dáng - dễ mặc mỗi ngày” từ 3 lựa chọn quần/váy cơ bản:\\n\\nOUTFIT A: Quần khaki nữ basic dáng suông (6BK25S002) + (bạn có thể phối thêm) áo sơ mi/blouse nữ cùng tông trung tính để lên form chuẩn công sở.\\nOUTFIT B: Chân váy ngắn màu xanh nhạt (6KS25W002) + (bạn có thể phối thêm) áo sơ mi nữ dáng suông hoặc croptop che phủ gọn gàng (hợp đi làm).\\nOUTFIT C: Quần jeans đen nữ (6BJ25C001) + (bạn có thể phối thêm) áo blouse/áo sơ mi nữ màu trơn để tổng thể tinh gọn.\\n\\nĐể mình chốt đúng set hợp nhất cho bạn, bạn cho mình biết chiều cao và cân nặng (mặc size S/M/L hay số quần bao nhiêu) nhé?\",\n \"product_ids\": [\n \"6BK25S002\",\n \"6KS25W002\",\n \"6BJ25C001\"\n ],\n \"user_insight\": {\n \"USER\": \"Chưa rõ\",\n \"TARGET\": \"Nữ\",\n \"GOAL\": \"Đồ đi làm\",\n \"CONSTRAINS\": \"\",\n \"STAGE\": \"BROWSE\",\n \"STAGE_NUM\": 1,\n \"TONE\": \"Friendly\",\n \"BEHAVIORAL_HINTS\": [\n \"Gợi ý combo set đi làm với nhiều lựa chọn (quần khaki/chân váy/jeans).\"\n ],\n \"LATEST_PRODUCT_INTEREST\": \"Quần khaki nữ basic dáng suông (6BK25S002), Chân váy ngắn màu xanh nhạt (6KS25W002), Quần jeans đen nữ (6BJ25C001)\",\n \"LAST_ACTION\": \"Khách hỏi combo đi làm cho nữ\",\n \"SUMMARY_HISTORY\": \"Khách hỏi đồ đi làm cho nữ -> đã gợi ý 3 món: quần khaki (6BK25S002), chân váy xanh nhạt (6KS25W002), quần jeans đen (6BJ25C001).\"\n }\n}"
}
],
"timing": {
"classifier_ms": 1419,
"stylist_ms": 3633,
"tool_ms": 0,
"total_ms": 5062
}
}
\ No newline at end of file
{
"status": "success",
"ai_response": "Rất hợp để đi làm cho nữ nè—3 lựa chọn cơ bản, đứng dáng và dễ phối áo công sở:\n\n1) Quần khaki nữ basic dáng suông (6BK25S002) — màu trung tính, mặc gọn gàng; size đang có S/M/L/XL.\n2) Chân váy ngắn màu xanh nhạt (6KS25W002) — tông nhẹ nhàng, nhìn chuyên nghiệp; size có S/M/L/XL.\n3) Quần jeans đen nữ (6BJ25C001) — denim đen lịch sự, dễ mặc hằng ngày; có size 26/27/28/29/30.\n\nBạn đang định theo hướng quần hay chân váy để mình gợi ý set áo phù hợp nhất? (Bạn cho mình chiều cao + cân nặng để chốt size chuẩn nhé.)",
"products": [
{
"sku": "6BK25S002",
"name": "Quần khaki nữ basic dáng suông",
"price": 349500,
"original_price": 699000,
"discount": "-50%",
"color": "Xám/ Gray",
"gender": "women",
"product_line": "Quần Khaki",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bk25s002-sa164-m-1-u.jpg",
"url": "https://canifa.com/quan-khaki-nu-6ks25s002?color=SA164&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "L|M|S|XL",
"sizes_available": [
"L",
"M",
"S",
"XL"
],
"desc": "Form dáng suông ôm nhẹ từ hông đến gối, ôm vừa vặn bắp chân để hack tỷ lệ cơ thể. Thiết kế túi chéo …",
"material": "Polyester mềm mại",
"tagline": "Tối giản nhưng không hề nhàm chán",
"tags": "quần khaki nữ, dáng suông, công sở, casual, tối giản",
"style": "Casual, Minimalist",
"outfit_recommendations": [
{
"match_product_code": "6TP26S001-SK010",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Đen/ Black của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "6OT25C007-SE060",
"match_product_name": "Áo Khoác Blazer Nữ",
"role": "outerwear",
"reason": "Màu Be/ Beige của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "6TH25W002",
"match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn",
"role": "top",
"reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
},
{
"match_product_code": "6TP26S001-SB001",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Xanh da trời/ Blue của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "6TP26S001-SW001",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Trắng/ White của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "5TH25W003-SA952",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
},
{
"match_product_code": "6OT25S001",
"match_product_name": "Áo khoác blazer nữ ngắn tay",
"role": "outerwear",
"reason": "Màu Trắng/ White của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "6OT25W025",
"match_product_name": "Áo khoác blazer nữ",
"role": "outerwear",
"reason": "Màu Đen/ Black của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "5TH25W003-SK010",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
}
]
},
{
"sku": "6KS25W002",
"name": "Chân váy ngắn màu xanh nhạt",
"price": 249500,
"original_price": 499000,
"discount": "-50%",
"color": "Xanh da trời/ Blue",
"gender": "women",
"product_line": "Chân váy",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/k/6ks25w002-sb479-m-1-u.jpg",
"url": "https://canifa.com/chan-vay-nu-6ks25w002?color=SB479&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "L|M|S|XL",
"sizes_available": [
"L",
"M",
"S",
"XL"
],
"size_message": "Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).",
"desc": "Chân váy ngắn màu xanh nhạt, thiết kế đơn giản, form thẳng.",
"material": "Không xác định",
"tagline": "Thời trang công sở, phong cách trẻ trung",
"tags": "Chân váy, váy công sở, váy ngắn",
"style": "Công sở, trẻ trung",
"outfit_recommendations": [
{
"match_product_code": "5TH25W003-SA952",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
},
{
"match_product_code": "6TH25W002",
"match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn",
"role": "top",
"reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
},
{
"match_product_code": "6TO25C001-SK010",
"match_product_name": "Áo Cổ Cao Tay Dài",
"role": "top",
"reason": "Màu Đen/ Black của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."
},
{
"match_product_code": "6TE26S006-SE187",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Be/ Beige của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TE26S001-SW001",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Trắng/ White của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TS26A002-SN011",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Nâu/ Brown của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."
},
{
"match_product_code": "6TS26A002-SW001",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Trắng/ White của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."
},
{
"match_product_code": "5TH25W003-SK010",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
},
{
"match_product_code": "6TE26S001-SY089",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Vàng/ Yellow + Gold của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TO24S007",
"match_product_name": "Áo kiểu nữ bamboo sát nách cổ tròn",
"role": "top",
"reason": "Màu Hồng/ Pink- Magenta của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."
}
]
},
{
"sku": "6BJ25C001",
"name": "Quần jeans đen nữ",
"price": 559300,
"original_price": 799000,
"discount": "-30%",
"color": "Màu xanh Jeans",
"gender": "women",
"product_line": "Quần jean",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bj25c001-sj851-28-1-u.jpg",
"url": "https://canifa.com/quan-jeans-nu-6bj25c001?color=SJ851&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "27|28|26|30|29",
"sizes_available": [
"27",
"28",
"26",
"30",
"29"
],
"size_message": "Quần Nữ (Size số). Các size ĐANG CÒN HÀNG: Size 27 (Vòng eo 67.5cm, Vòng mông 81-89cm); Size 28 (Vòng eo 70cm, Vòng mông 84-92cm); Size 26 (Vòng eo 65cm, Vòng mông 79-87cm); Size 30 (Vòng eo 75cm, Vòng mông 89-97cm); Size 29 (Vòng eo 72.5cm, Vòng mông 86-94cm).",
"desc": "Quần jeans đen thẳng ống, viền xước",
"material": "Không xác định",
"tagline": "Thời trang cơ bản",
"tags": "quần jeans, quần thẳng ống",
"style": "Casual",
"outfit_recommendations": [
{
"match_product_code": "5TH25W003-SK010",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual."
},
{
"match_product_code": "6OT25W026",
"match_product_name": "Áo khoác nữ khaki có cổ",
"role": "outerwear",
"reason": "Màu Xanh da trời/ Blue của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy."
},
{
"match_product_code": "6TS26A002-SW001",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Trắng/ White của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân."
},
{
"match_product_code": "5OT25C001",
"match_product_name": "Áo khoác gió unisex người lớn in hình Demon Slayer",
"role": "outerwear",
"reason": "Màu Đen/ Black của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual."
},
{
"match_product_code": "6TH25W002",
"match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn",
"role": "top",
"reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual."
},
{
"match_product_code": "6TS26A002-SN011",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Nâu/ Brown của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân."
},
{
"match_product_code": "5TH25W003-SA952",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual."
},
{
"match_product_code": "5TW25C001",
"match_product_name": "Áo nỉ unisex người lớn có hình in",
"role": "top",
"reason": "Màu Trắng/ White của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp."
},
{
"match_product_code": "6OT25W012",
"match_product_name": "Áo khoác lông nữ",
"role": "outerwear",
"reason": "Màu Be/ Beige của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy."
},
{
"match_product_code": "6OT25W023",
"match_product_name": "Áo khoác gió active nữ",
"role": "outerwear",
"reason": "Màu Trắng/ White của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual."
}
]
}
],
"trace_id": "af8cb0166b9ba4071ff1da0f35f06381",
"lead_stage": {
"stage": 1,
"stage_name": "BROWSE",
"tone_directive": "Friendly",
"behavioral_hints": []
},
"pipeline": [
{
"step": "user",
"label": "👤 User",
"content": "tìm đồ đi làm"
},
{
"step": "classifier",
"label": "🔧 Classifier → lead_search_tool",
"content": "Khách nói nhu cầu tìm đồ đi làm cho nữ => cần search sản phẩm theo bộ lọc công sở/đi làm. Không chỉ định thêm món cụ thể ngoài đi làm nên suy luận theo nhóm quần khaki/chân váy/jeans và áo sơ mi/blouse.",
"elapsed_ms": 3388,
"tool_calls": [
{
"name": "lead_search_tool",
"args": {
"literal": {
"raw_text": "đồ đi làm"
},
"inferred": {
"product_line_vn": [
"Quần Khaki",
"Chân váy",
"Quần jean",
"Áo sơ mi",
"Áo kiểu",
"Blazer"
],
"gender_by_product": "women",
"age_by_product": "adult",
"master_color": null,
"tags": [
"Đi làm / Công sở",
"Thanh lịch"
],
"keywords": [
"công sở",
"lịch sự"
],
"price_min": null,
"price_max": null,
"discount_min": null,
"discount_max": null,
"discovery_mode": null,
"size": null
},
"magento_ref_code": null,
"reasoning": null
}
}
]
},
{
"step": "tool_result",
"label": "📦 lead_search_tool",
"content": "✅ 10 sản phẩm\n• Quần khaki nữ basic dáng suông\n• Chân váy ngắn màu xanh nhạt\n• Quần jeans đen nữ",
"raw_json": "{\"status\": \"success\", \"count\": 10, \"tier\": 1, \"search_mode\": \"literal_like\", \"elapsed_ms\": 93.55, \"reasoning\": \"\", \"literal_text\": \"đồ đi làm\", \"keywords_used\": [\"công sở\", \"lịch sự\"], \"tags_used\": [\"Đi làm / Công sở\", \"Thanh lịch\"], \"products\": [{\"sku\": \"6BK25S002\", \"name\": \"Quần khaki nữ basic dáng suông\", \"price\": 349500, \"original_price\": 699000, \"discount\": \"-50%\", \"color\": \"Xám/ Gray\", \"gender\": \"women\", \"product_line\": \"Quần Khaki\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bk25s002-sa164-m-1-u.jpg\", \"url\": \"https://canifa.com/quan-khaki-nu-6ks25s002?color=SA164&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"L|M|S|XL\", \"sizes_available\": [\"L\", \"M\", \"S\", \"XL\"], \"desc\": \"Form dáng suông ôm nhẹ từ hông đến gối, ôm vừa vặn bắp chân để hack tỷ lệ cơ thể. Thiết kế túi chéo …\", \"material\": \"Polyester mềm mại\", \"tagline\": \"Tối giản nhưng không hề nhàm chán\", \"tags\": \"quần khaki nữ, dáng suông, công sở, casual, tối giản\", \"style\": \"Casual, Minimalist\", \"outfit_recommendations\": [{\"match_product_code\": \"6TP26S001-SK010\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"6OT25C007-SE060\", \"match_product_name\": \"Áo Khoác Blazer Nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Be/ Beige của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"6TH25W002\", \"match_product_name\": \"Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn\", \"role\": \"top\", \"reason\": \"Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}, {\"match_product_code\": \"6TP26S001-SB001\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Xanh da trời/ Blue của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"6TP26S001-SW001\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"5TH25W003-SA952\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}, {\"match_product_code\": \"6OT25S001\", \"match_product_name\": \"Áo khoác blazer nữ ngắn tay\", \"role\": \"outerwear\", \"reason\": \"Màu Trắng/ White của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"6OT25W025\", \"match_product_name\": \"Áo khoác blazer nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Đen/ Black của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"5TH25W003-SK010\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}]}, {\"sku\": \"6KS25W002\", \"name\": \"Chân váy ngắn màu xanh nhạt\", \"price\": 249500, \"original_price\": 499000, \"discount\": \"-50%\", \"color\": \"Xanh da trời/ Blue\", \"gender\": \"women\", \"product_line\": \"Chân váy\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/k/6ks25w002-sb479-m-1-u.jpg\", \"url\": \"https://canifa.com/chan-vay-nu-6ks25w002?color=SB479&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"L|M|S|XL\", \"sizes_available\": [\"L\", \"M\", \"S\", \"XL\"], \"size_message\": \"Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).\", \"desc\": \"Chân váy ngắn màu xanh nhạt, thiết kế đơn giản, form thẳng.\", \"material\": \"Không xác định\", \"tagline\": \"Thời trang công sở, phong cách trẻ trung\", \"tags\": \"Chân váy, váy công sở, váy ngắn\", \"style\": \"Công sở, trẻ trung\", \"outfit_recommendations\": [{\"match_product_code\": \"5TH25W003-SA952\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}, {\"match_product_code\": \"6TH25W002\", \"match_product_name\": \"Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn\", \"role\": \"top\", \"reason\": \"Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}, {\"match_product_code\": \"6TO25C001-SK010\", \"match_product_name\": \"Áo Cổ Cao Tay Dài\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố.\"}, {\"match_product_code\": \"6TE26S006-SE187\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Be/ Beige của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TE26S001-SW001\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TS26A002-SN011\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Nâu/ Brown của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính.\"}, {\"match_product_code\": \"6TS26A002-SW001\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính.\"}, {\"match_product_code\": \"5TH25W003-SK010\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}, {\"match_product_code\": \"6TE26S001-SY089\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Vàng/ Yellow + Gold của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TO24S007\", \"match_product_name\": \"Áo kiểu nữ bamboo sát nách cổ tròn\", \"role\": \"top\", \"reason\": \"Màu Hồng/ Pink- Magenta của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố.\"}]}, {\"sku\": \"6BJ25C001\", \"name\": \"Quần jeans đen nữ\", \"price\": 559300, \"original_price\": 799000, \"discount\": \"-30%\", \"color\": \"Màu xanh Jeans\", \"gender\": \"women\", \"product_line\": \"Quần jean\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bj25c001-sj851-28-1-u.jpg\", \"url\": \"https://canifa.com/quan-jeans-nu-6bj25c001?color=SJ851&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"27|28|26|30|29\", \"sizes_available\": [\"27\", \"28\", \"26\", \"30\", \"29\"], \"size_message\": \"Quần Nữ (Size số). Các size ĐANG CÒN HÀNG: Size 27 (Vòng eo 67.5cm, Vòng mông 81-89cm); Size 28 (Vòng eo 70cm, Vòng mông 84-92cm); Size 26 (Vòng eo 65cm, Vòng mông 79-87cm); Size 30 (Vòng eo 75cm, Vòng mông 89-97cm); Size 29 (Vòng eo 72.5cm, Vòng mông 86-94cm).\", \"desc\": \"Quần jeans đen thẳng ống, viền xước\", \"material\": \"Không xác định\", \"tagline\": \"Thời trang cơ bản\", \"tags\": \"quần jeans, quần thẳng ống\", \"style\": \"Casual\", \"outfit_recommendations\": [{\"match_product_code\": \"5TH25W003-SK010\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual.\"}, {\"match_product_code\": \"6OT25W026\", \"match_product_name\": \"Áo khoác nữ khaki có cổ\", \"role\": \"outerwear\", \"reason\": \"Màu Xanh da trời/ Blue của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy.\"}, {\"match_product_code\": \"6TS26A002-SW001\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân.\"}, {\"match_product_code\": \"5OT25C001\", \"match_product_name\": \"Áo khoác gió unisex người lớn in hình Demon Slayer\", \"role\": \"outerwear\", \"reason\": \"Màu Đen/ Black của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual.\"}, {\"match_product_code\": \"6TH25W002\", \"match_product_name\": \"Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn\", \"role\": \"top\", \"reason\": \"Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual.\"}, {\"match_product_code\": \"6TS26A002-SN011\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Nâu/ Brown của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân.\"}, {\"match_product_code\": \"5TH25W003-SA952\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo Sơ mi phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo Sơ mi — smart casual.\"}, {\"match_product_code\": \"5TW25C001\", \"match_product_name\": \"Áo nỉ unisex người lớn có hình in\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp.\"}, {\"match_product_code\": \"6OT25W012\", \"match_product_name\": \"Áo khoác lông nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Be/ Beige của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy.\"}, {\"match_product_code\": \"6OT25W023\", \"match_product_name\": \"Áo khoác gió active nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Trắng/ White của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual.\"}]}], \"size_tools_message\": \"CHÚ Ý QUAN TRỌNG TỪ HỆ THỐNG: Mỗi sản phẩm trả về đã đính kèm `size_message` chứa các size CÒN HÀNG và Bảng đo tương ứng. BẠN PHẢI ƯU TIÊN lấy đúng `size_message` của sản phẩm đó để tư vấn cho khách dựa trên chiều cao cân nặng của họ, và chú ý áp dụng các Mẹo nâng/hạ size (nếu có). KHÔNG tự bịa size.\", \"outfit_tools_message\": \"GỢI Ý PHỐI ĐỒ: Một số sản phẩm có kèm `outfit_recommendations` — danh sách các items phối cùng (vai trò: top/bottom/layer/accessory) kèm lý do. Hãy gợi ý khách mua combo nếu phù hợp, ưu tiên các sản phẩm match_role bổ trợ cho item chính. Luôn nêu mã SKU khi gợi ý.\", \"description_tools_message\": \"MÔ TẢ SẢN PHẨM: Mỗi sản phẩm có kèm thông tin mô tả ngắn gọn (desc, material, why_buy, hook, tagline...). Hãy dùng các thông tin này để tư vấn khách hàng một cách tự nhiên, KHÔNG copy nguyên văn, mà hãy diễn đạt lại phù hợp với ngữ cảnh hội thoại.\"}",
"elapsed_ms": 97
},
{
"step": "stylist",
"label": "💬 Stylist (Response + Insight)",
"content": "Rất hợp để đi làm cho nữ nè—3 lựa chọn cơ bản, đứng dáng và dễ phối áo công sở:\n\n1) Quần khaki nữ basic dáng suông (6BK25S002) — màu trung tính, mặc gọn gàng; size đang có S/M/L/XL.\n2) Chân váy ngắn màu xanh nhạt (6KS25W002) — tông nhẹ nhàng, nhìn chuyên nghiệp; size có S/M/L/XL.\n3) Quần jeans đen nữ (6BJ25C001) — denim đen lịch sự, dễ mặc hằng ngày; có size 26/27/28/29/30.\n\nBạn đang định theo hướng quần hay chân váy để mình gợi ý set áo phù hợp nhất? (Bạn cho mình chiều cao + cân nặng để chốt s",
"elapsed_ms": 3203,
"raw_json": "{\n \"ai_response\": \"Rất hợp để đi làm cho nữ nè—3 lựa chọn cơ bản, đứng dáng và dễ phối áo công sở:\\n\\n1) Quần khaki nữ basic dáng suông (6BK25S002) — màu trung tính, mặc gọn gàng; size đang có S/M/L/XL.\\n2) Chân váy ngắn màu xanh nhạt (6KS25W002) — tông nhẹ nhàng, nhìn chuyên nghiệp; size có S/M/L/XL.\\n3) Quần jeans đen nữ (6BJ25C001) — denim đen lịch sự, dễ mặc hằng ngày; có size 26/27/28/29/30.\\n\\nBạn đang định theo hướng quần hay chân váy để mình gợi ý set áo phù hợp nhất? (Bạn cho mình chiều cao + cân nặng để chốt size chuẩn nhé.)\",\n \"product_ids\": [\n \"6BK25S002\",\n \"6KS25W002\",\n \"6BJ25C001\"\n ],\n \"user_insight\": {\n \"USER\": \"Chưa rõ\",\n \"TARGET\": \"Nữ\",\n \"GOAL\": \"Đồ đi làm\",\n \"CONSTRAINS\": \"\",\n \"STAGE\": \"BROWSE\",\n \"STAGE_NUM\": 1,\n \"TONE\": \"Friendly\",\n \"BEHAVIORAL_HINTS\": [],\n \"LATEST_PRODUCT_INTEREST\": \"Quần khaki nữ basic dáng suông (6BK25S002), Chân váy ngắn màu xanh nhạt (6KS25W002), Quần jeans đen nữ (6BJ25C001)\",\n \"LAST_ACTION\": \"\",\n \"SUMMARY_HISTORY\": \"Khách hỏi đồ đi làm/combo đi làm cho nữ -> đã gợi ý 3 lựa chọn: quần khaki (6BK25S002), chân váy xanh nhạt (6KS25W002), quần jeans đen (6BJ25C001).\"\n }\n}"
}
],
"timing": {
"classifier_ms": 3388,
"stylist_ms": 3203,
"tool_ms": 97,
"total_ms": 6701
}
}
\ No newline at end of file
{
"status": "success",
"ai_response": "Rất hợp để đi làm cho nữ nè—mình gợi ý combo “gọn - lịch sự - dễ phối” từ 3 item cơ bản này:\n\nOUTFIT 1: Quần khaki nữ basic dáng suông (6BK25S002) — lên form thẳng, mặc cả ngày thoải mái.\n• Phối hợp: chọn áo sơ mi/blouse nữ dáng suông hoặc ôm nhẹ màu trung tính (trắng/kem/xanh nhạt) để ra dáng công sở.\n\nOUTFIT 2: Chân váy ngắn màu xanh nhạt (6KS25W002) — tươi nhưng vẫn trang nhã, dễ tạo cảm giác gọn người.\n• Phối hợp: áo sơ mi nữ tay dài/overshirt mỏng màu trơn để tổng thể tinh tế.\n\nOUTFIT 3: Quần jeans đen nữ (6BJ25C001) — đứng dáng, ít kén kiểu áo, phù hợp cả ngày đi lại.\n• Phối hợp: áo sơ mi/bodysuit trơn hoặc áo thun form gọn (nhét/đai lưng) cho lịch sự.\n\nBạn đang mặc size S/M/L hay số quần 26-30 để mình chốt đúng size giúp bạn nhé?",
"products": [
{
"sku": "6BK25S002",
"name": "Quần khaki nữ basic dáng suông",
"price": 349500,
"original_price": 699000,
"discount": "-50%",
"color": "Xám/ Gray",
"gender": "women",
"product_line": "Quần Khaki",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bk25s002-sa164-m-1-u.jpg",
"url": "https://canifa.com/quan-khaki-nu-6ks25s002?color=SA164&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "L|M|S|XL",
"sizes_available": [
"L",
"M",
"S",
"XL"
],
"desc": "Form dáng suông ôm nhẹ từ hông đến gối, ôm vừa vặn bắp chân để hack tỷ lệ cơ thể. Thiết kế túi chéo …",
"material": "Polyester mềm mại",
"tagline": "Tối giản nhưng không hề nhàm chán",
"tags": "quần khaki nữ, dáng suông, công sở, casual, tối giản",
"style": "Casual, Minimalist",
"outfit_recommendations": [
{
"match_product_code": "5TH25W003-SA952",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
},
{
"match_product_code": "6OT25C007-SE060",
"match_product_name": "Áo Khoác Blazer Nữ",
"role": "outerwear",
"reason": "Màu Be/ Beige của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "6TP26S001-SW001",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Trắng/ White của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "6TP26S001-SK010",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Đen/ Black của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "6OT25S001",
"match_product_name": "Áo khoác blazer nữ ngắn tay",
"role": "outerwear",
"reason": "Màu Trắng/ White của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "6OT25W025",
"match_product_name": "Áo khoác blazer nữ",
"role": "outerwear",
"reason": "Màu Đen/ Black của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự."
},
{
"match_product_code": "6TP26S001-SB001",
"match_product_name": "Áo polo nữ",
"role": "top",
"reason": "Màu Xanh da trời/ Blue của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày."
},
{
"match_product_code": "6TH25W002",
"match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn",
"role": "top",
"reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
},
{
"match_product_code": "5TH25W003-SK010",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực."
}
]
},
{
"sku": "6KS25W002",
"name": "Chân váy ngắn màu xanh nhạt",
"price": 249500,
"original_price": 499000,
"discount": "-50%",
"color": "Xanh da trời/ Blue",
"gender": "women",
"product_line": "Chân váy",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/k/6ks25w002-sb479-m-1-u.jpg",
"url": "https://canifa.com/chan-vay-nu-6ks25w002?color=SB479&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "L|M|S|XL",
"sizes_available": [
"L",
"M",
"S",
"XL"
],
"size_message": "Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).",
"desc": "Chân váy ngắn màu xanh nhạt, thiết kế đơn giản, form thẳng.",
"material": "Không xác định",
"tagline": "Thời trang công sở, phong cách trẻ trung",
"tags": "Chân váy, váy công sở, váy ngắn",
"style": "Công sở, trẻ trung",
"outfit_recommendations": [
{
"match_product_code": "6TS26A002-SN011",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Nâu/ Brown của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."
},
{
"match_product_code": "6TE26S001-SY089",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Vàng/ Yellow + Gold của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TE26S006-SE187",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Be/ Beige của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TO25C001-SK010",
"match_product_name": "Áo Cổ Cao Tay Dài",
"role": "top",
"reason": "Màu Đen/ Black của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."
},
{
"match_product_code": "5TH25W003-SK010",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Đen/ Black của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
},
{
"match_product_code": "6TE26S001-SW001",
"match_product_name": "Áo len nữ",
"role": "top",
"reason": "Màu Trắng/ White của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông."
},
{
"match_product_code": "6TO24S007",
"match_product_name": "Áo kiểu nữ bamboo sát nách cổ tròn",
"role": "top",
"reason": "Màu Hồng/ Pink- Magenta của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố."
},
{
"match_product_code": "5TA26S001-SK010",
"match_product_name": "Áo Tank Top Unisex Người Lớn Vietnam 75",
"role": "top",
"reason": "Màu Đen/ Black của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính."
},
{
"match_product_code": "6TH25W002",
"match_product_name": "Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn",
"role": "top",
"reason": "Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
},
{
"match_product_code": "5TH25W003-SA952",
"match_product_name": "Áo sơ mi unisex người lớn in hình Demon Slayer",
"role": "top",
"reason": "Màu Xám/ Gray của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở."
}
]
},
{
"sku": "6BJ25C001",
"name": "Quần jeans đen nữ",
"price": 559300,
"original_price": 799000,
"discount": "-30%",
"color": "Màu xanh Jeans",
"gender": "women",
"product_line": "Quần jean",
"image": "https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bj25c001-sj851-28-1-u.jpg",
"url": "https://canifa.com/quan-jeans-nu-6bj25c001?color=SJ851&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1",
"sizes": "27|28|26|30|29",
"sizes_available": [
"27",
"28",
"26",
"30",
"29"
],
"size_message": "Quần Nữ (Size số). Các size ĐANG CÒN HÀNG: Size 27 (Vòng eo 67.5cm, Vòng mông 81-89cm); Size 28 (Vòng eo 70cm, Vòng mông 84-92cm); Size 26 (Vòng eo 65cm, Vòng mông 79-87cm); Size 30 (Vòng eo 75cm, Vòng mông 89-97cm); Size 29 (Vòng eo 72.5cm, Vòng mông 86-94cm).",
"desc": "Quần jeans đen thẳng ống, viền xước",
"material": "Không xác định",
"tagline": "Thời trang cơ bản",
"tags": "quần jeans, quần thẳng ống",
"style": "Casual",
"outfit_recommendations": [
{
"match_product_code": "6TS26A002-SW001",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Trắng/ White của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân."
},
{
"match_product_code": "6OT25W023",
"match_product_name": "Áo khoác gió active nữ",
"role": "outerwear",
"reason": "Màu Trắng/ White của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual."
},
{
"match_product_code": "5TA26S001-SK010",
"match_product_name": "Áo Tank Top Unisex Người Lớn Vietnam 75",
"role": "top",
"reason": "Màu Đen/ Black của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân."
},
{
"match_product_code": "6OT25W012",
"match_product_name": "Áo khoác lông nữ",
"role": "outerwear",
"reason": "Màu Be/ Beige của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy."
},
{
"match_product_code": "6OT25W014",
"match_product_name": "Áo khoác nữ",
"role": "outerwear",
"reason": "Màu Xám/ Gray của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy."
},
{
"match_product_code": "6OT25W026",
"match_product_name": "Áo khoác nữ khaki có cổ",
"role": "outerwear",
"reason": "Màu Xanh da trời/ Blue của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy."
},
{
"match_product_code": "6TS26A002-SN011",
"match_product_name": "Áo phông nữ basic cổ tim",
"role": "top",
"reason": "Màu Nâu/ Brown của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân."
},
{
"match_product_code": "5OT25C001",
"match_product_name": "Áo khoác gió unisex người lớn in hình Demon Slayer",
"role": "outerwear",
"reason": "Màu Đen/ Black của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual."
},
{
"match_product_code": "6TW25W003",
"match_product_name": "Áo nỉ nữ có khóa cổ, interlock",
"role": "top",
"reason": "Màu Đen/ Black của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp."
},
{
"match_product_code": "5TW25W010-SA563",
"match_product_name": "Áo thun dài tay unisex người lớn Vietnam Youngster",
"role": "top",
"reason": "Màu Xám/ Gray của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp."
}
]
}
],
"trace_id": "9822d614d1aeb06f41534b7e6beb1998",
"lead_stage": {
"stage": 1,
"stage_name": "BROWSE",
"tone_directive": "Friendly",
"behavioral_hints": []
},
"pipeline": [
{
"step": "user",
"label": "👤 User",
"content": "combo đi làm"
},
{
"step": "classifier",
"label": "🔧 Classifier → lead_search_tool",
"content": "Khách nói 'combo đi làm' → có nhu cầu tìm sản phẩm theo danh mục đồ đi làm. Insight có TARGET Nữ và đã giới thiệu 3 món; nhưng khách chưa hỏi về SP cụ thể hay đính chính. Vẫn cần tìm/refresh gợi ý combo đi làm phù hợp.",
"elapsed_ms": 1847,
"tool_calls": [
{
"name": "lead_search_tool",
"args": {
"literal": {
"raw_text": "combo đi làm"
},
"inferred": {
"product_line_vn": [
"Quần Khaki",
"Chân váy",
"Quần jean"
],
"gender_by_product": "women",
"age_by_product": "adult",
"master_color": null,
"tags": [
"Đi làm / Công sở",
"Thanh lịch"
],
"keywords": [
"công sở",
"lịch sự"
],
"price_min": null,
"price_max": null,
"discount_min": null,
"discount_max": null,
"discovery_mode": null,
"size": null
},
"magento_ref_code": null,
"reasoning": null
}
}
]
},
{
"step": "tool_result",
"label": "📦 lead_search_tool",
"content": "✅ 10 sản phẩm\n• Quần khaki nữ basic dáng suông\n• Chân váy ngắn màu xanh nhạt\n• Quần jeans đen nữ",
"raw_json": "{\"status\": \"success\", \"count\": 10, \"tier\": 1, \"search_mode\": \"literal_like\", \"elapsed_ms\": 86.33, \"reasoning\": \"\", \"literal_text\": \"đi làm\", \"keywords_used\": [\"công sở\", \"lịch sự\"], \"tags_used\": [\"Đi làm / Công sở\", \"Thanh lịch\"], \"products\": [{\"sku\": \"6BK25S002\", \"name\": \"Quần khaki nữ basic dáng suông\", \"price\": 349500, \"original_price\": 699000, \"discount\": \"-50%\", \"color\": \"Xám/ Gray\", \"gender\": \"women\", \"product_line\": \"Quần Khaki\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bk25s002-sa164-m-1-u.jpg\", \"url\": \"https://canifa.com/quan-khaki-nu-6ks25s002?color=SA164&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"L|M|S|XL\", \"sizes_available\": [\"L\", \"M\", \"S\", \"XL\"], \"desc\": \"Form dáng suông ôm nhẹ từ hông đến gối, ôm vừa vặn bắp chân để hack tỷ lệ cơ thể. Thiết kế túi chéo …\", \"material\": \"Polyester mềm mại\", \"tagline\": \"Tối giản nhưng không hề nhàm chán\", \"tags\": \"quần khaki nữ, dáng suông, công sở, casual, tối giản\", \"style\": \"Casual, Minimalist\", \"outfit_recommendations\": [{\"match_product_code\": \"5TH25W003-SA952\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}, {\"match_product_code\": \"6OT25C007-SE060\", \"match_product_name\": \"Áo Khoác Blazer Nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Be/ Beige của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"6TP26S001-SW001\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"6TP26S001-SK010\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"6OT25S001\", \"match_product_name\": \"Áo khoác blazer nữ ngắn tay\", \"role\": \"outerwear\", \"reason\": \"Màu Trắng/ White của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"6OT25W025\", \"match_product_name\": \"Áo khoác blazer nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Đen/ Black của Blazer phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối Blazer — smart casual lịch sự.\"}, {\"match_product_code\": \"6TP26S001-SB001\", \"match_product_name\": \"Áo polo nữ\", \"role\": \"top\", \"reason\": \"Màu Xanh da trời/ Blue của Áo Polo phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Polo — lịch sự hàng ngày.\"}, {\"match_product_code\": \"6TH25W002\", \"match_product_name\": \"Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn\", \"role\": \"top\", \"reason\": \"Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}, {\"match_product_code\": \"5TH25W003-SK010\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Sơ mi phối cùng màu Trắng/ White tạo điểm nhấn hoàn hảo. Quần Khaki phối áo Sơ mi — công sở chuẩn mực.\"}]}, {\"sku\": \"6KS25W002\", \"name\": \"Chân váy ngắn màu xanh nhạt\", \"price\": 249500, \"original_price\": 499000, \"discount\": \"-50%\", \"color\": \"Xanh da trời/ Blue\", \"gender\": \"women\", \"product_line\": \"Chân váy\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/k/6ks25w002-sb479-m-1-u.jpg\", \"url\": \"https://canifa.com/chan-vay-nu-6ks25w002?color=SB479&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"L|M|S|XL\", \"sizes_available\": [\"L\", \"M\", \"S\", \"XL\"], \"size_message\": \"Sản phẩm Nữ. Các size ĐANG CÒN HÀNG: Size L (Cao 1m60-1m65, Nặng 53-58kg); Size M (Cao 1m55-1m63, Nặng 47-52kg); Size S (Cao 1m50-1m55, Nặng 41-46kg); Size XL (Cao 1m62-1m66, Nặng 59-64kg).\", \"desc\": \"Chân váy ngắn màu xanh nhạt, thiết kế đơn giản, form thẳng.\", \"material\": \"Không xác định\", \"tagline\": \"Thời trang công sở, phong cách trẻ trung\", \"tags\": \"Chân váy, váy công sở, váy ngắn\", \"style\": \"Công sở, trẻ trung\", \"outfit_recommendations\": [{\"match_product_code\": \"6TS26A002-SN011\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Nâu/ Brown của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính.\"}, {\"match_product_code\": \"6TE26S001-SY089\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Vàng/ Yellow + Gold của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TE26S006-SE187\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Be/ Beige của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TO25C001-SK010\", \"match_product_name\": \"Áo Cổ Cao Tay Dài\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố.\"}, {\"match_product_code\": \"5TH25W003-SK010\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}, {\"match_product_code\": \"6TE26S001-SW001\", \"match_product_name\": \"Áo len nữ\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo len phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo len — ấm áp mùa đông.\"}, {\"match_product_code\": \"6TO24S007\", \"match_product_name\": \"Áo kiểu nữ bamboo sát nách cổ tròn\", \"role\": \"top\", \"reason\": \"Màu Hồng/ Pink- Magenta của Áo kiểu phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo kiểu — thanh lịch cho dạo phố.\"}, {\"match_product_code\": \"5TA26S001-SK010\", \"match_product_name\": \"Áo Tank Top Unisex Người Lớn Vietnam 75\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo phông phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo phông — năng động nữ tính.\"}, {\"match_product_code\": \"6TH25W002\", \"match_product_name\": \"Áo sơ mi dài tay nữ cotton USA dài tay dáng ngắn\", \"role\": \"top\", \"reason\": \"Màu Xanh than/ Aqua của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}, {\"match_product_code\": \"5TH25W003-SA952\", \"match_product_name\": \"Áo sơ mi unisex người lớn in hình Demon Slayer\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo Sơ mi phối cùng màu Be/ Beige tạo điểm nhấn hoàn hảo. Chân váy phối áo Sơ mi — nữ tính công sở.\"}]}, {\"sku\": \"6BJ25C001\", \"name\": \"Quần jeans đen nữ\", \"price\": 559300, \"original_price\": 799000, \"discount\": \"-30%\", \"color\": \"Màu xanh Jeans\", \"gender\": \"women\", \"product_line\": \"Quần jean\", \"image\": \"https://2885371169.e.cdneverest.net/pub/media/catalog/product/6/b/6bj25c001-sj851-28-1-u.jpg\", \"url\": \"https://canifa.com/quan-jeans-nu-6bj25c001?color=SJ851&utm_source=chatbot&utm_medium=rsa&utm_campaign=ver1\", \"sizes\": \"27|28|26|30|29\", \"sizes_available\": [\"27\", \"28\", \"26\", \"30\", \"29\"], \"size_message\": \"Quần Nữ (Size số). Các size ĐANG CÒN HÀNG: Size 27 (Vòng eo 67.5cm, Vòng mông 81-89cm); Size 28 (Vòng eo 70cm, Vòng mông 84-92cm); Size 26 (Vòng eo 65cm, Vòng mông 79-87cm); Size 30 (Vòng eo 75cm, Vòng mông 89-97cm); Size 29 (Vòng eo 72.5cm, Vòng mông 86-94cm).\", \"desc\": \"Quần jeans đen thẳng ống, viền xước\", \"material\": \"Không xác định\", \"tagline\": \"Thời trang cơ bản\", \"tags\": \"quần jeans, quần thẳng ống\", \"style\": \"Casual\", \"outfit_recommendations\": [{\"match_product_code\": \"6TS26A002-SW001\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Trắng/ White của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân.\"}, {\"match_product_code\": \"6OT25W023\", \"match_product_name\": \"Áo khoác gió active nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Trắng/ White của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual.\"}, {\"match_product_code\": \"5TA26S001-SK010\", \"match_product_name\": \"Áo Tank Top Unisex Người Lớn Vietnam 75\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân.\"}, {\"match_product_code\": \"6OT25W012\", \"match_product_name\": \"Áo khoác lông nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Be/ Beige của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy.\"}, {\"match_product_code\": \"6OT25W014\", \"match_product_name\": \"Áo khoác nữ\", \"role\": \"outerwear\", \"reason\": \"Màu Xám/ Gray của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy.\"}, {\"match_product_code\": \"6OT25W026\", \"match_product_name\": \"Áo khoác nữ khaki có cổ\", \"role\": \"outerwear\", \"reason\": \"Màu Xanh da trời/ Blue của Áo khoác dáng ngắn phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác dáng ngắn — trendy.\"}, {\"match_product_code\": \"6TS26A002-SN011\", \"match_product_name\": \"Áo phông nữ basic cổ tim\", \"role\": \"top\", \"reason\": \"Màu Nâu/ Brown của Áo phông phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo phông — combo quốc dân.\"}, {\"match_product_code\": \"5OT25C001\", \"match_product_name\": \"Áo khoác gió unisex người lớn in hình Demon Slayer\", \"role\": \"outerwear\", \"reason\": \"Màu Đen/ Black của Áo khoác gió phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo khoác gió — phong cách casual.\"}, {\"match_product_code\": \"6TW25W003\", \"match_product_name\": \"Áo nỉ nữ có khóa cổ, interlock\", \"role\": \"top\", \"reason\": \"Màu Đen/ Black của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp.\"}, {\"match_product_code\": \"5TW25W010-SA563\", \"match_product_name\": \"Áo thun dài tay unisex người lớn Vietnam Youngster\", \"role\": \"top\", \"reason\": \"Màu Xám/ Gray của Áo nỉ phối cùng màu Màu xanh Jeans tạo điểm nhấn hoàn hảo. Quần Jeans phối áo nỉ — casual ấm áp.\"}]}], \"size_tools_message\": \"CHÚ Ý QUAN TRỌNG TỪ HỆ THỐNG: Mỗi sản phẩm trả về đã đính kèm `size_message` chứa các size CÒN HÀNG và Bảng đo tương ứng. BẠN PHẢI ƯU TIÊN lấy đúng `size_message` của sản phẩm đó để tư vấn cho khách dựa trên chiều cao cân nặng của họ, và chú ý áp dụng các Mẹo nâng/hạ size (nếu có). KHÔNG tự bịa size.\", \"outfit_tools_message\": \"GỢI Ý PHỐI ĐỒ: Một số sản phẩm có kèm `outfit_recommendations` — danh sách các items phối cùng (vai trò: top/bottom/layer/accessory) kèm lý do. Hãy gợi ý khách mua combo nếu phù hợp, ưu tiên các sản phẩm match_role bổ trợ cho item chính. Luôn nêu mã SKU khi gợi ý.\", \"description_tools_message\": \"MÔ TẢ SẢN PHẨM: Mỗi sản phẩm có kèm thông tin mô tả ngắn gọn (desc, material, why_buy, hook, tagline...). Hãy dùng các thông tin này để tư vấn khách hàng một cách tự nhiên, KHÔNG copy nguyên văn, mà hãy diễn đạt lại phù hợp với ngữ cảnh hội thoại.\"}",
"elapsed_ms": 90
},
{
"step": "stylist",
"label": "💬 Stylist (Response + Insight)",
"content": "Rất hợp để đi làm cho nữ nè—mình gợi ý combo “gọn - lịch sự - dễ phối” từ 3 item cơ bản này:\n\nOUTFIT 1: Quần khaki nữ basic dáng suông (6BK25S002) — lên form thẳng, mặc cả ngày thoải mái.\n• Phối hợp: chọn áo sơ mi/blouse nữ dáng suông hoặc ôm nhẹ màu trung tính (trắng/kem/xanh nhạt) để ra dáng công sở.\n\nOUTFIT 2: Chân váy ngắn màu xanh nhạt (6KS25W002) — tươi nhưng vẫn trang nhã, dễ tạo cảm giác gọn người.\n• Phối hợp: áo sơ mi nữ tay dài/overshirt mỏng màu trơn để tổng thể tinh tế.\n\nOUTFIT 3: Qu",
"elapsed_ms": 3821,
"raw_json": "{\n \"ai_response\": \"Rất hợp để đi làm cho nữ nè—mình gợi ý combo “gọn - lịch sự - dễ phối” từ 3 item cơ bản này:\\n\\nOUTFIT 1: Quần khaki nữ basic dáng suông (6BK25S002) — lên form thẳng, mặc cả ngày thoải mái.\\n• Phối hợp: chọn áo sơ mi/blouse nữ dáng suông hoặc ôm nhẹ màu trung tính (trắng/kem/xanh nhạt) để ra dáng công sở.\\n\\nOUTFIT 2: Chân váy ngắn màu xanh nhạt (6KS25W002) — tươi nhưng vẫn trang nhã, dễ tạo cảm giác gọn người.\\n• Phối hợp: áo sơ mi nữ tay dài/overshirt mỏng màu trơn để tổng thể tinh tế.\\n\\nOUTFIT 3: Quần jeans đen nữ (6BJ25C001) — đứng dáng, ít kén kiểu áo, phù hợp cả ngày đi lại.\\n• Phối hợp: áo sơ mi/bodysuit trơn hoặc áo thun form gọn (nhét/đai lưng) cho lịch sự.\\n\\nBạn đang mặc size S/M/L hay số quần 26-30 để mình chốt đúng size giúp bạn nhé?\",\n \"product_ids\": [\n \"6BK25S002\",\n \"6KS25W002\",\n \"6BJ25C001\"\n ],\n \"user_insight\": {\n \"USER\": \"Chưa rõ\",\n \"TARGET\": \"Nữ\",\n \"GOAL\": \"Combo đi làm\",\n \"CONSTRAINS\": \"\",\n \"STAGE\": \"BROWSE\",\n \"STAGE_NUM\": 1,\n \"TONE\": \"Friendly\",\n \"BEHAVIORAL_HINTS\": [],\n \"LATEST_PRODUCT_INTEREST\": \"Quần khaki nữ basic dáng suông (6BK25S002), Chân váy ngắn màu xanh nhạt (6KS25W002), Quần jeans đen nữ (6BJ25C001)\",\n \"LAST_ACTION\": \"Khách hỏi combo đi làm.\",\n \"SUMMARY_HISTORY\": \"Khách cần đồ đi làm cho nữ → đã gợi ý 3 lựa chọn cơ bản quần khaki (6BK25S002), chân váy xanh nhạt (6KS25W002), jeans đen (6BJ25C001) và nay chuyển sang combo đi làm từ 3 món này.\"\n }\n}"
}
],
"timing": {
"classifier_ms": 1847,
"stylist_ms": 3821,
"tool_ms": 90,
"total_ms": 5774
}
}
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3"/>
<text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="20" font-weight="700">Canifa AI Platform Architecture</text>
<!-- Client Layer -->
<text x="50" y="125" fill="#6a6a6a" font-size="14" font-weight="600">Client</text>
<rect x="380" y="80" width="200" height="80" rx="12" ry="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="120" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Browser (MFE)</text>
<text x="480" y="140" text-anchor="middle" fill="#6a6a6a" font-size="12">Static HTML/JS/CSS</text>
<!-- FastAPI Layer -->
<text x="50" y="285" fill="#6a6a6a" font-size="14" font-weight="600">Gateway</text>
<rect x="380" y="240" width="200" height="90" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="275" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">FastAPI (Lifespan)</text>
<text x="480" y="295" text-anchor="middle" fill="#6a6a6a" font-size="12">Orchestrator &amp; Routing</text>
<text x="480" y="315" text-anchor="middle" fill="#6a6a6a" font-size="12">Graceful Startup/Shutdown</text>
<!-- Data/Infra Layer -->
<text x="50" y="485" fill="#6a6a6a" font-size="14" font-weight="600">Storage / Bus</text>
<!-- EventBus -->
<rect x="100" y="440" width="200" height="80" rx="12" ry="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="200" y="480" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">EventBus</text>
<text x="200" y="500" text-anchor="middle" fill="#6a6a6a" font-size="12">FastStream (Pub/Sub)</text>
<!-- Redis -->
<rect x="380" y="440" width="200" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="480" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Redis Cache</text>
<text x="480" y="500" text-anchor="middle" fill="#6a6a6a" font-size="12">State &amp; TTL Management</text>
<!-- Postgres -->
<rect x="660" y="440" width="200" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="760" y="480" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Postgres / StarRocks</text>
<text x="760" y="500" text-anchor="middle" fill="#6a6a6a" font-size="12">Relational &amp; OLAP Data</text>
<!-- Arrows -->
<!-- Client -> FastAPI -->
<line x1="480" y1="160" x2="480" y2="240" stroke="#5a5a5a" stroke-width="2.5" marker-end="url(#arrow-claude)"/>
<rect x="445" y="190" width="70" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="480" y="205" text-anchor="middle" fill="#5a5a5a" font-size="13">HTTP / WS</text>
<!-- FastAPI -> EventBus (ortho) -->
<path d="M 380,285 L 200,285 L 200,440" fill="none" stroke="#5a5a5a" stroke-width="2.5" marker-end="url(#arrow-claude)"/>
<rect x="165" y="340" width="70" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="200" y="355" text-anchor="middle" fill="#5a5a5a" font-size="13">Pub/Sub</text>
<!-- FastAPI -> Redis -->
<line x1="480" y1="330" x2="480" y2="440" stroke="#5a5a5a" stroke-width="2.5" marker-end="url(#arrow-claude)"/>
<rect x="445" y="375" width="70" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="480" y="390" text-anchor="middle" fill="#5a5a5a" font-size="13">Init / Cache</text>
<!-- FastAPI -> Postgres (ortho) -->
<path d="M 580,285 L 760,285 L 760,440" fill="none" stroke="#5a5a5a" stroke-width="2.5" marker-end="url(#arrow-claude)"/>
<rect x="725" y="340" width="70" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="760" y="355" text-anchor="middle" fill="#5a5a5a" font-size="13">db_pool</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 700">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<rect width="960" height="700" fill="#f8f6f3"/>
<text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Canifa AI Platform Architecture</text>
<text x="40" y="120" fill="#6a6a6a" font-size="14" font-weight="600">Client</text>
<text x="40" y="240" fill="#6a6a6a" font-size="14" font-weight="600">Interface</text>
<text x="40" y="360" fill="#6a6a6a" font-size="14" font-weight="600">Gateway</text>
<text x="40" y="480" fill="#6a6a6a" font-size="14" font-weight="600">Module</text>
<text x="40" y="600" fill="#6a6a6a" font-size="14" font-weight="600">Data</text>
<rect x="380" y="80" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="115" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Web Browser</text>
<text x="480" y="135" text-anchor="middle" fill="#6a6a6a" font-size="13">Static Entry Points</text>
<rect x="380" y="200" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="235" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Micro-Frontends</text>
<text x="480" y="255" text-anchor="middle" fill="#6a6a6a" font-size="13">Independent Namespaces</text>
<rect x="380" y="320" width="200" height="80" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="355" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">FastAPI Gateway</text>
<text x="480" y="375" text-anchor="middle" fill="#6a6a6a" font-size="13">api_router &amp; Middleware</text>
<rect x="380" y="440" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="475" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Business Modules</text>
<text x="480" y="495" text-anchor="middle" fill="#6a6a6a" font-size="13">36+ Agents &amp; Event Bus</text>
<rect x="180" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="270" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Postgres DB</text>
<text x="270" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">db_pool management</text>
<rect x="390" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Redis Cache</text>
<text x="480" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">redis_cache module</text>
<rect x="600" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="690" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">OLAP (StarRocks)</text>
<text x="690" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">Analytic Engine</text>
<line x1="480" y1="160" x2="480" y2="200" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="480" y1="280" x2="480" y2="320" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="480" y1="400" x2="480" y2="440" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<path d="M 480 520 L 480 540 L 270 540 L 270 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<path d="M 480 520 L 480 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<path d="M 480 520 L 480 540 L 690 540 L 690 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "01-architecture", "id": "01-architecture",
"title": "Platform Architecture Overview", "title": "Platform Architecture Overview",
"description": "Cái nhìn tổng quan về kiến trúc 5 lớp của Canifa AI Platform, từ giao diện người dùng đến các module API và hạ tầng dữ liệu.", "description": "Cái nhìn tổng quan về kiến trúc 5 lớp của Canifa AI Platform, từ giao diện người dùng đến các module API và hạ tầng dữ liệu.",
"diagram": "diagrams/platform-architecture.svg", "diagram": "data/01-architecture/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 650" width="960" height="650">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
.title { font-size: 22px; font-weight: 700; fill: #1a1a1a; }
.node-label { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-detail { font-size: 13px; fill: #6a6a6a; }
.arrow-label { font-size: 13px; font-weight: 600; fill: #5a5a5a; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<rect width="960" height="650" fill="#f8f6f3"/>
<text x="480" y="45" text-anchor="middle" class="title">Database Layer &amp; SQLite Mock Architecture</text>
<!-- Layer Labels -->
<text x="50" y="140" class="layer-label">Application</text>
<text x="50" y="270" class="layer-label">Routing</text>
<text x="50" y="420" class="layer-label">Translation/Pool</text>
<text x="50" y="570" class="layer-label">Data Store</text>
<!-- --- Nodes --- -->
<!-- App Node -->
<rect x="380" y="100" width="200" height="80" rx="12" ry="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="135" text-anchor="middle" class="node-label">App (FastAPI / Helper)</text>
<text x="480" y="155" text-anchor="middle" class="node-detail">Sync Helpers &amp; Async API</text>
<!-- Config Check Node -->
<rect x="380" y="230" width="200" height="80" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="265" text-anchor="middle" class="node-label">USE_LOCAL_SQLITE?</text>
<text x="480" y="285" text-anchor="middle" class="node-detail">Environment Routing</text>
<!-- SQLite Path: Translator -->
<rect x="180" y="380" width="200" height="90" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="280" y="415" text-anchor="middle" class="node-label">SQLite Mock Translator</text>
<text x="280" y="435" text-anchor="middle" class="node-detail">Regex-based SQL Rewrite</text>
<text x="280" y="452" text-anchor="middle" class="node-detail">(sqlite_mock.py)</text>
<!-- SQLite Path: DB -->
<rect x="180" y="530" width="200" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="280" y="565" text-anchor="middle" class="node-label">Local SQLite</text>
<text x="280" y="585" text-anchor="middle" class="node-detail">Development Mock DB</text>
<!-- Postgres Path: Pool -->
<rect x="580" y="380" width="200" height="90" rx="12" ry="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="680" y="415" text-anchor="middle" class="node-label">Postgres Pool</text>
<text x="680" y="435" text-anchor="middle" class="node-detail">psycopg_pool / asyncpg</text>
<text x="680" y="452" text-anchor="middle" class="node-detail">(CanifaDbPool)</text>
<!-- Postgres Path: DB -->
<rect x="580" y="530" width="200" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="680" y="565" text-anchor="middle" class="node-label">PostgreSQL DB</text>
<text x="680" y="585" text-anchor="middle" class="node-detail">Production Database</text>
<!-- --- Arrows --- -->
<!-- App -> Config Check -->
<line x1="480" y1="180" x2="480" y2="230" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="440" y="195" width="80" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="480" y="210" text-anchor="middle" class="arrow-label">Query Request</text>
<!-- Config Check -> True Path (SQLite) -->
<path d="M 380 270 Q 280 270 280 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="250" y="280" width="60" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="280" y="295" text-anchor="middle" class="arrow-label" fill="#059669">True</text>
<!-- Config Check -> False Path (Postgres) -->
<path d="M 580 270 Q 680 270 680 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="650" y="280" width="60" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="680" y="295" text-anchor="middle" class="arrow-label" fill="#dc2626">False</text>
<!-- SQLite Translator -> SQLite DB -->
<line x1="280" y1="470" x2="280" y2="530" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="210" y="490" width="140" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="280" y="505" text-anchor="middle" class="arrow-label">Translated SQL</text>
<!-- Postgres Pool -> Postgres DB -->
<line x1="680" y1="470" x2="680" y2="530" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<rect x="620" y="490" width="120" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="680" y="505" text-anchor="middle" class="arrow-label">Raw Postgres SQL</text>
<!-- Legend -->
<rect x="700" y="30" width="220" height="85" rx="8" ry="8" fill="#ffffff" stroke="#4a4a4a" stroke-width="1.5" opacity="0.9"/>
<text x="715" y="55" class="node-label" font-size="14">Legend</text>
<rect x="715" y="65" width="12" height="12" fill="#a8c5e6" stroke="#4a4a4a"/>
<text x="735" y="75" class="node-detail" font-size="12">Source / Client</text>
<rect x="715" y="82" width="12" height="12" fill="#9dd4c7" stroke="#4a4a4a"/>
<text x="735" y="92" class="node-detail" font-size="12">Process / Logic</text>
<rect x="715" y="99" width="12" height="12" fill="#f4e4c1" stroke="#4a4a4a"/>
<text x="735" y="109" class="node-detail" font-size="12">Infrastructure / Pool</text>
</svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3"/>
<text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Database Connection &amp; Routing Logic</text>
<text x="40" y="100" fill="#6a6a6a" font-size="14" font-weight="600">Application</text>
<text x="40" y="240" fill="#6a6a6a" font-size="14" font-weight="600">Router</text>
<text x="40" y="420" fill="#6a6a6a" font-size="14" font-weight="600">Storage Layer</text>
<rect x="380" y="70" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="105" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">App / Helper</text>
<text x="480" y="125" text-anchor="middle" fill="#6a6a6a" font-size="13">FastAPI or Class Helpers</text>
<rect x="380" y="200" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="235" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Connection Switch</text>
<text x="480" y="255" text-anchor="middle" fill="#6a6a6a" font-size="13">Check USE_LOCAL_SQLITE</text>
<rect x="180" y="380" width="200" height="100" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="280" y="415" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">SQLite Mock</text>
<text x="280" y="435" text-anchor="middle" fill="#6a6a6a" font-size="13">SQL Translation</text>
<text x="280" y="455" text-anchor="middle" fill="#6a6a6a" font-size="13">sqlite_mock.py</text>
<rect x="580" y="380" width="200" height="100" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="680" y="415" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Postgres Pool</text>
<text x="680" y="435" text-anchor="middle" fill="#6a6a6a" font-size="13">psycopg_pool / asyncpg</text>
<text x="680" y="455" text-anchor="middle" fill="#6a6a6a" font-size="13">CanifaDbPool</text>
<line x1="480" y1="150" x2="480" y2="200" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="490" y="180" fill="#5a5a5a" font-size="13">Connect/Query</text>
<path d="M 380 240 L 280 240 L 280 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="200" y="310" fill="#5a5a5a" font-size="13">True (Local)</text>
<path d="M 580 240 L 680 240 L 680 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="700" y="310" fill="#5a5a5a" font-size="13">False (Prod)</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "01b-database", "id": "01b-database",
"title": "Database Layer & SQLite Mock", "title": "Database Layer & SQLite Mock",
"description": "Chi tiết về cách hệ thống xử lý dữ liệu đồng bộ và bất đồng bộ, cùng cơ chế SQLite Mock để giả lập Postgres trong môi trường phát triển.", "description": "Chi tiết về cách hệ thống xử lý dữ liệu đồng bộ và bất đồng bộ, cùng cơ chế SQLite Mock để giả lập Postgres trong môi trường phát triển.",
"diagram": "diagrams/database-layer.svg", "diagram": "data/01b-database/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3"/>
<text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Authentication &amp; RBAC Flow</text>
<rect x="100" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>
<text x="200" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">Browser / Frontend</text>
<rect x="380" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>
<text x="480" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">FastAPI Backend</text>
<rect x="660" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>
<text x="760" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">PostgreSQL DB</text>
<rect x="120" y="160" width="160" height="60" rx="10" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="200" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">auth.js Guard</text>
<rect x="400" y="160" width="160" height="60" rx="10" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">POST /api/auth/login</text>
<rect x="680" y="160" width="160" height="60" rx="10" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="760" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">admin_users table</text>
<rect x="400" y="300" width="160" height="60" rx="10" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="335" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">get_current_user</text>
<rect x="120" y="300" width="160" height="60" rx="10" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="200" y="335" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">localStorage</text>
<line x1="280" y1="180" x2="400" y2="180" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="340" y="175" text-anchor="middle" fill="#5a5a5a" font-size="11">Credentials</text>
<line x1="560" y1="190" x2="680" y2="190" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="620" y="185" text-anchor="middle" fill="#5a5a5a" font-size="11">Verify</text>
<line x1="400" y1="210" x2="280" y2="210" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="340" y="205" text-anchor="middle" fill="#5a5a5a" font-size="11">JWT Token</text>
<line x1="200" y1="220" x2="200" y2="300" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="140" y="260" text-anchor="middle" fill="#5a5a5a" font-size="11">Store Token</text>
<line x1="280" y1="330" x2="400" y2="330" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="340" y="325" text-anchor="middle" fill="#5a5a5a" font-size="11">Auth Header</text>
<line x1="560" y1="330" x2="680" y2="190" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>
<text x="630" y="270" text-anchor="middle" fill="#5a5a5a" font-size="11" transform="rotate(-30 630 270)">Check Role</text>
</svg>
\ No newline at end of file
...@@ -2,30 +2,6 @@ ...@@ -2,30 +2,6 @@
Kiến trúc cốt lõi sử dụng `StateGraph` từ LangGraph với 2 node chính. Không sử dụng ReAct loop truyền thống để giảm độ trễ và tránh ảo giác vòng lặp. Kiến trúc cốt lõi sử dụng `StateGraph` từ LangGraph với 2 node chính. Không sử dụng ReAct loop truyền thống để giảm độ trễ và tránh ảo giác vòng lặp.
```mermaid
sequenceDiagram
participant U as User
participant C as Classifier Node
participant T as Tool Registry (RAG/DB)
participant S as Stylist Node
participant DB as StarRocks / Postgres
U->>C: Nhập câu hỏi (Query)
Note over C: Tiêm History (10 turns) + Insight cũ
C->>C: Sinh Structured Output (Tool + Args)
alt Không cần gọi Tool (Early Exit)
C-->>U: AI Response (Chào hỏi / Tâm sự)
else Cần gọi Tool
C->>T: Execute Tool (ví dụ: lead_search_tool)
T->>DB: Query DB / Vector Search
DB-->>T: JSON Kết quả
T-->>S: Tool Result + Context
Note over S: Tiêm STYLIST_SYSTEM_PROMPT
S->>S: Sinh AI Response + InsightJSON mới
S-->>U: Trả lời kèm SP
end
```
**Các bước cụ thể:** **Các bước cụ thể:**
1. **Tiếp nhận:** State `LeadStageState` lưu trữ `messages`, `user_insight`, `tool_result`, và `diagnostics`. 1. **Tiếp nhận:** State `LeadStageState` lưu trữ `messages`, `user_insight`, `tool_result`, và `diagnostics`.
2. **Định tuyến (Classifier):** AI quyết định gọi công cụ như `lead_search_tool`, `canifa_knowledge_search` (truy xuất RAG), hoặc `check_is_stock`. 2. **Định tuyến (Classifier):** AI quyết định gọi công cụ như `lead_search_tool`, `canifa_knowledge_search` (truy xuất RAG), hoặc `check_is_stock`.
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #1a1a1a; }
.node-title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-sub { font-size: 14px; font-weight: 400; fill: #1a1a1a; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#00000010"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3"/>
<text x="480" y="50" text-anchor="middle" class="title">Chatbot Core (LangGraph) Architecture</text>
<!-- Layers -->
<rect x="50" y="100" width="860" height="120" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2" stroke-dasharray="8,4"/>
<text x="70" y="130" class="layer-label">Orchestration Layer</text>
<rect x="50" y="240" width="860" height="200" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2" stroke-dasharray="8,4"/>
<text x="70" y="270" class="layer-label">Agent Reasoning (DAG)</text>
<rect x="50" y="460" width="860" height="100" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2" stroke-dasharray="8,4"/>
<text x="70" y="490" class="layer-label">External Tools &amp; Data</text>
<!-- Nodes -->
<rect x="390" y="130" width="180" height="60" rx="12" fill="#ffffff" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="480" y="165" text-anchor="middle" class="node-title">FastAPI Controller</text>
<rect x="150" y="290" width="200" height="100" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="250" y="325" text-anchor="middle" class="node-title">Classifier Node</text>
<text x="250" y="345" text-anchor="middle" class="node-sub">• Structured Output</text>
<text x="250" y="365" text-anchor="middle" class="node-sub">• Intent Routing</text>
<rect x="610" y="290" width="200" height="100" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="710" y="325" text-anchor="middle" class="node-title">Stylist Node</text>
<text x="710" y="345" text-anchor="middle" class="node-sub">• Response Gen</text>
<text x="710" y="365" text-anchor="middle" class="node-sub">• Insight Update</text>
<rect x="380" y="480" width="200" height="60" rx="12" fill="#ffffff" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow)"/>
<text x="480" y="515" text-anchor="middle" class="node-title">StarRocks RAG / SQL</text>
<!-- Arrows -->
<line x1="480" y1="190" x2="250" y2="290" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="350" y1="340" x2="610" y2="340" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="710" y1="390" x2="480" y2="480" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="480" y1="480" x2="250" y2="390" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
</svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 700" width="960" height="700">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #1a1a1a; }
.label { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.detail { font-size: 13px; fill: #6a6a6a; }
.arrow-text { font-size: 13px; fill: #5a5a5a; font-weight: 500; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; opacity: 0.8; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<!-- Warm cream background -->
<rect width="960" height="700" fill="#f8f6f3"/>
<!-- Title -->
<text x="480" y="45" text-anchor="middle" class="title">Chatbot Core &amp; n8n Pipeline Architecture</text>
<!-- Layer Labels -->
<text x="50" y="110" class="layer-label">INPUT</text>
<text x="50" y="270" class="layer-label">ORCHESTRATION</text>
<text x="50" y="430" class="layer-label">TOOLS &amp; REASONING</text>
<text x="50" y="590" class="layer-label">DATA</text>
<!-- User Node -->
<rect x="380" y="80" width="200" height="60" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="105" text-anchor="middle" class="label">User</text>
<text x="480" y="125" text-anchor="middle" class="detail">Customer Query (Text)</text>
<!-- Classifier Node -->
<rect x="380" y="240" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="265" text-anchor="middle" class="label">Classifier (AI 1)</text>
<text x="480" y="285" text-anchor="middle" class="detail">LangGraph State Node</text>
<text x="480" y="305" text-anchor="middle" class="detail">Structured Tool Selection</text>
<!-- Tool Execution Node -->
<rect x="230" y="400" width="220" height="80" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="340" y="425" text-anchor="middle" class="label">Tool Execution</text>
<text x="340" y="445" text-anchor="middle" class="detail">RAG Search / Stock Check</text>
<text x="340" y="465" text-anchor="middle" class="detail">Python / n8n Integrations</text>
<!-- Stylist Node -->
<rect x="530" y="400" width="220" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="640" y="425" text-anchor="middle" class="label">Stylist (AI 2)</text>
<text x="640" y="445" text-anchor="middle" class="detail">Response Generation</text>
<text x="640" y="465" text-anchor="middle" class="detail">Insight Extraction</text>
<!-- Database Node -->
<rect x="230" y="560" width="220" height="60" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="340" y="585" text-anchor="middle" class="label">StarRocks / Postgres</text>
<text x="340" y="605" text-anchor="middle" class="detail">Knowledge Base &amp; Logs</text>
<!-- Flow Arrows -->
<!-- User -> Classifier -->
<path d="M480,140 L480,230" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="485" y="195" class="arrow-text">Query</text>
<!-- Classifier -> Tool (curved) -->
<path d="M380,280 C280,280 280,350 340,390" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="240" y="340" class="arrow-text">Execute Tool</text>
<!-- Tool -> Stylist -->
<path d="M450,440 L520,440" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="485" y="430" text-anchor="middle" class="arrow-text">Context</text>
<!-- Stylist -> User (long return) -->
<path d="M640,400 C640,110 590,110 590,110" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="645" y="240" class="arrow-text">Response + SP</text>
<!-- Early Exit (Dashed) -->
<path d="M580,280 C680,280 680,150 590,120" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none" stroke-dasharray="5,3"/>
<text x="685" y="200" class="arrow-text">Early Exit</text>
<!-- Tool -> DB -->
<path d="M320,480 L320,550" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="250" y="525" class="arrow-text">DB Query</text>
<!-- DB -> Tool -->
<path d="M360,560 L360,490" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)" fill="none"/>
<text x="365" y="525" class="arrow-text">Results</text>
<!-- Legend -->
<rect x="720" y="580" width="220" height="100" rx="8" fill="#ffffff" stroke="#4a4a4a" stroke-width="1.5"/>
<text x="735" y="605" class="label" style="font-size:14px">Legend</text>
<line x1="735" y1="630" x2="765" y2="630" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="775" y="635" class="detail">Primary Data Flow</text>
<line x1="735" y1="655" x2="765" y2="655" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<text x="775" y="660" class="detail">Direct AI Response</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "02-chatbot-core", "id": "02-chatbot-core",
"title": "Chatbot Core & n8n Pipeline", "title": "Chatbot Core & n8n Pipeline",
"description": "Khám phá luồng xử lý tin nhắn của Fashion Q&A Agent, từ lúc nhận query đến khi gọi các tool n8n và trả về kết quả cho người dùng.", "description": "Khám phá luồng xử lý tin nhắn của Fashion Q&A Agent, từ lúc nhận query đến khi gọi các tool n8n và trả về kết quả cho người dùng.",
"diagram": "diagrams/chatbot-pipeline.svg", "diagram": "chatbot_graph_arch.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
...@@ -4,35 +4,3 @@ Quy trình update và sử dụng prompt diễn ra như sau: ...@@ -4,35 +4,3 @@ Quy trình update và sử dụng prompt diễn ra như sau:
2. **File Override & Cache Invalidation:** Hệ thống ghi đè file `.txt` tương ứng và gọi hàm `force_refresh_prompts()` để xóa in-memory cache trong `prompt_utils.py`. 2. **File Override & Cache Invalidation:** Hệ thống ghi đè file `.txt` tương ứng và gọi hàm `force_refresh_prompts()` để xóa in-memory cache trong `prompt_utils.py`.
3. **MD5 Hash Check:** Ở request tiếp theo, `graph.py` lấy template mới. Nó tính mã băm MD5 của template. Nếu khác với `_cached_prompt_hash` trước đó, chuỗi (chain) LangChain sẽ được compile lại. 3. **MD5 Hash Check:** Ở request tiếp theo, `graph.py` lấy template mới. Nó tính mã băm MD5 của template. Nếu khác với `_cached_prompt_hash` trước đó, chuỗi (chain) LangChain sẽ được compile lại.
4. **LLM Factory Execution:** Chain sử dụng instance LLM được lấy từ `LLMFactory` (đã được cache để không khởi tạo lại network client liên tục) để thực thi. 4. **LLM Factory Execution:** Chain sử dụng instance LLM được lấy từ `LLMFactory` (đã được cache để không khởi tạo lại network client liên tục) để thực thi.
### Sequence Diagram
```mermaid
sequenceDiagram
participant Admin
participant API (prompt_route.py)
participant utils (prompt_utils.py)
participant FileSystem
participant graph.py
participant LLMFactory
Admin->>API (prompt_route.py): POST /api/agent/system-prompt
API (prompt_route.py)->>FileSystem: Write to system_prompt.txt
API (prompt_route.py)->>utils (prompt_utils.py): force_refresh_prompts()
utils (prompt_utils.py)-->>API (prompt_route.py): Clear in-memory caches
API (prompt_route.py)-->>Admin: Success Response
Note over graph.py: On next user chat message
graph.py->>utils (prompt_utils.py): get_system_prompt_template()
utils (prompt_utils.py)->>FileSystem: Read system_prompt.txt (cache miss)
utils (prompt_utils.py)-->>graph.py: Return injected template
graph.py->>graph.py: Compute MD5 of prompt
alt MD5 Hash Changed
graph.py->>LLMFactory: get_model()
LLMFactory-->>graph.py: Cached LLM Instance
graph.py->>graph.py: Rebuild ChatPromptTemplate & Chain
end
graph.py->>LLMFactory (LLM): invoke()
```
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "03-prompt-system", "id": "03-prompt-system",
"title": "Prompt System & LLM Factory", "title": "Prompt System & LLM Factory",
"description": "Hướng dẫn chi tiết quản lý System Prompt và Tool Prompts, cơ chế MD5 Caching, xử lý LangChain variables và kiến trúc LLM Factory.", "description": "Hướng dẫn chi tiết quản lý System Prompt và Tool Prompts, cơ chế MD5 Caching, xử lý LangChain variables và kiến trúc LLM Factory.",
"diagram": "diagrams/prompt-management.svg", "diagram": "prompt_cache_arch.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
.title { font-size: 22px; font-weight: 700; fill: #1a1a1a; }
.node-label { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-detail { font-size: 13px; fill: #5a5a5a; }
.arrow-label { font-size: 13px; fill: #5a5a5a; font-weight: 500; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="10" markerHeight="10" refX="9" refY="5" orient="auto">
<path d="M0,0 L10,5 L0,10 Z" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<!-- Warm cream background -->
<rect width="960" height="600" fill="#f8f6f3"/>
<!-- Title -->
<text x="480" y="50" text-anchor="middle" class="title">Prompt System Caching Architecture</text>
<!-- Layer Labels -->
<text x="40" y="155" class="layer-label">Interaction</text>
<text x="40" y="275" class="layer-label">Cache Logic</text>
<text x="40" y="395" class="layer-label">Compilation</text>
<text x="40" y="515" class="layer-label">Execution</text>
<!-- Nodes -->
<!-- UI Edit -->
<rect x="360" y="115" width="240" height="80" rx="12" ry="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="145" text-anchor="middle" class="node-label">UI Edit (Admin)</text>
<text x="480" y="165" text-anchor="middle" class="node-detail">POST /api/agent/system-prompt</text>
<text x="480" y="182" text-anchor="middle" class="node-detail">• Updates system_prompt.txt</text>
<!-- Cache Invalidation -->
<rect x="360" y="235" width="240" height="80" rx="12" ry="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="265" text-anchor="middle" class="node-label">Cache Invalidation</text>
<text x="480" y="285" text-anchor="middle" class="node-detail">force_refresh_prompts()</text>
<text x="480" y="302" text-anchor="middle" class="node-detail">• Clears in-memory cache</text>
<!-- prompt_utils (MD5 Hash Check) -->
<rect x="360" y="355" width="240" height="80" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="385" text-anchor="middle" class="node-label">prompt_utils (MD5 Check)</text>
<text x="480" y="405" text-anchor="middle" class="node-detail">_cached_prompt_hash validation</text>
<text x="480" y="422" text-anchor="middle" class="node-detail">• Recompiles chain if changed</text>
<!-- LLM Factory -->
<rect x="360" y="475" width="240" height="80" rx="12" ry="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>
<text x="480" y="505" text-anchor="middle" class="node-label">LLM Factory</text>
<text x="480" y="525" text-anchor="middle" class="node-detail">Cached Model Instance</text>
<text x="480" y="542" text-anchor="middle" class="node-detail">• Single client initialization</text>
<!-- Arrows -->
<!-- UI Edit to Invalidation -->
<line x1="480" y1="195" x2="480" y2="225" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="490" y="215" class="arrow-label">Refresh Trigger</text>
<!-- Invalidation to MD5 Check -->
<line x1="480" y1="315" x2="480" y2="345" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<text x="490" y="335" class="arrow-label">On Next Read</text>
<!-- MD5 Check to LLM Factory -->
<line x1="480" y1="435" x2="480" y2="465" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="490" y="455" class="arrow-label">Invoke</text>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 720" width="960" height="720">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #1a1a1a; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
.node-title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-detail { font-size: 13px; fill: #5a5a5a; }
.arrow-label { font-size: 12px; fill: #5a5a5a; font-weight: 500; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<!-- Warm cream background -->
<rect width="960" height="720" fill="#f8f6f3"/>
<!-- Title -->
<text x="480" y="50" text-anchor="middle" class="title">Feedback &amp; Learning Loop Architecture</text>
<!-- Layer Labels -->
<text x="40" y="140" class="layer-label">Collection</text>
<text x="40" y="300" class="layer-label">Storage</text>
<text x="40" y="480" class="layer-label">Learning</text>
<text x="40" y="640" class="layer-label">Optimization</text>
<!-- Nodes -->
<!-- User Feedback UI -->
<g transform="translate(380, 100)" filter="url(#shadow-soft)">
<rect width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2"/>
<text x="100" y="35" text-anchor="middle" class="node-title">User Interaction</text>
<text x="100" y="55" text-anchor="middle" class="node-detail">Rating (Like/Dislike)</text>
<text x="100" y="70" text-anchor="middle" class="node-detail">&amp; User Comments</text>
</g>
<!-- Feedback Pipeline -->
<g transform="translate(380, 260)" filter="url(#shadow-soft)">
<rect width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2"/>
<text x="100" y="35" text-anchor="middle" class="node-title">Feedback Pipeline</text>
<text x="100" y="55" text-anchor="middle" class="node-detail">FastAPI BackgroundTasks</text>
<text x="100" y="70" text-anchor="middle" class="node-detail">Async Data Processing</text>
</g>
<!-- Langfuse Store -->
<g transform="translate(180, 420)" filter="url(#shadow-soft)">
<rect width="200" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2"/>
<text x="100" y="35" text-anchor="middle" class="node-title">Langfuse Store</text>
<text x="100" y="55" text-anchor="middle" class="node-detail">Trace Data &amp; Scores</text>
<text x="100" y="70" text-anchor="middle" class="node-detail">Production Observability</text>
</g>
<!-- Local DB -->
<g transform="translate(580, 420)" filter="url(#shadow-soft)">
<rect width="200" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2"/>
<text x="100" y="35" text-anchor="middle" class="node-title">Local Database</text>
<text x="100" y="55" text-anchor="middle" class="node-detail">Persistent Feedback Log</text>
<text x="100" y="70" text-anchor="middle" class="node-detail">SQLAlchemy / PostgreSQL</text>
</g>
<!-- Feedback Agent -->
<g transform="translate(380, 560)" filter="url(#shadow-soft)">
<rect width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5"/>
<text x="100" y="35" text-anchor="middle" class="node-title">Feedback Agent</text>
<text x="100" y="55" text-anchor="middle" class="node-detail">LLM Learning Loop</text>
<text x="100" y="70" text-anchor="middle" class="node-detail">Rules Optimization</text>
</g>
<!-- System Rules -->
<g transform="translate(380, 660)" filter="url(#shadow-soft)">
<rect width="200" height="40" rx="8" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2"/>
<text x="100" y="25" text-anchor="middle" class="node-title" style="font-size: 14px;">fashion_rules.json</text>
</g>
<!-- Arrows -->
<!-- User -> Pipeline -->
<path d="M 480 180 L 480 250" stroke="#5a5a5a" stroke-width="2.5" fill="none" marker-end="url(#arrow-claude)"/>
<rect x="425" y="205" width="110" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="480" y="220" text-anchor="middle" class="arrow-label">POST /api/feedback</text>
<!-- Pipeline -> Langfuse -->
<path d="M 380 300 L 280 300 L 280 410" stroke="#5a5a5a" stroke-width="2" fill="none" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<rect x="230" y="345" width="100" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="280" y="360" text-anchor="middle" class="arrow-label">push_scores()</text>
<!-- Pipeline -> Local DB -->
<path d="M 580 300 L 680 300 L 680 410" stroke="#5a5a5a" stroke-width="2" fill="none" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<rect x="630" y="345" width="100" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="680" y="360" text-anchor="middle" class="arrow-label">save_feedback()</text>
<!-- Langfuse -> Feedback Agent -->
<path d="M 280 500 L 280 600 L 370 600" stroke="#5a5a5a" stroke-width="2" fill="none" marker-end="url(#arrow-claude)"/>
<rect x="235" y="540" width="120" height="20" fill="#f8f6f3" opacity="0.9"/>
<text x="295" y="555" text-anchor="middle" class="arrow-label">fetch_bad_feedbacks()</text>
<!-- Feedback Agent -> Rules -->
<path d="M 480 640 L 480 655" stroke="#5a5a5a" stroke-width="2.5" fill="none" marker-end="url(#arrow-claude)"/>
<!-- Rules -> User (Loop) -->
<path d="M 580 680 L 880 680 L 880 140 L 580 140" stroke="#5a5a5a" stroke-width="2" fill="none" stroke-dasharray="8,4" marker-end="url(#arrow-claude)"/>
<text x="870" y="410" text-anchor="middle" class="arrow-label" transform="rotate(-90, 870, 410)">Continuous Improvement Loop</text>
<!-- Legend -->
<g transform="translate(680, 580)">
<rect width="240" height="80" rx="8" fill="#ffffff" stroke="#4a4a4a" stroke-width="1.5"/>
<text x="15" y="25" class="node-title" style="font-size: 13px;">Legend</text>
<line x1="15" y1="40" x2="45" y2="40" stroke="#5a5a5a" stroke-width="2"/>
<text x="55" y="45" class="node-detail">Primary Flow / Read</text>
<line x1="15" y1="60" x2="45" y2="60" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3"/>
<text x="55" y="65" class="node-detail">Async Write / Store</text>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 700" width="960" height="700">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }
.title { font-size: 20px; font-weight: 700; fill: #1a1a1a; }
.node-label { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-desc { font-size: 13px; fill: #6a6a6a; }
.arrow-label { font-size: 12px; fill: #5a5a5a; }
.layer-label { font-size: 14px; font-weight: 600; fill: #6a6a6a; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow-soft">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>
</filter>
</defs>
<!-- Background -->
<rect width="960" height="700" fill="#f8f6f3"/>
<text x="480" y="40" text-anchor="middle" class="title">Product Performance &amp; Stock Sync Architecture</text>
<!-- Layers Labels -->
<text x="50" y="120" class="layer-label">Client</text>
<text x="50" y="240" class="layer-label">API</text>
<text x="50" y="380" class="layer-label">Logic</text>
<text x="50" y="520" class="layer-label">Data</text>
<!-- Nodes -->
<!-- Client UI -->
<rect x="380" y="80" width="200" height="80" rx="12" ry="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="115" text-anchor="middle" class="node-label">Web UI / Agent</text>
<text x="480" y="135" text-anchor="middle" class="node-desc">Stock Status Inquiry</text>
<!-- Product Route -->
<rect x="380" y="200" width="200" height="80" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="235" text-anchor="middle" class="node-label">Product Route</text>
<text x="480" y="255" text-anchor="middle" class="node-desc">FastAPI Orchestrator</text>
<!-- StarRocks -->
<rect x="130" y="200" width="180" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="220" y="235" text-anchor="middle" class="node-label">StarRocks</text>
<text x="220" y="255" text-anchor="middle" class="node-desc">SKU Expansion Engine</text>
<!-- Parallel Fetching -->
<rect x="380" y="340" width="200" height="80" rx="12" ry="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="375" text-anchor="middle" class="node-label">Parallel Fetching</text>
<text x="480" y="395" text-anchor="middle" class="node-desc">asyncio.gather (Chunks)</text>
<!-- Stock Cache -->
<rect x="650" y="340" width="180" height="80" rx="12" ry="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="740" y="375" text-anchor="middle" class="node-label">Stock Cache</text>
<text x="740" y="395" text-anchor="middle" class="node-desc">Redis / State Store</text>
<!-- Canifa ERP API -->
<rect x="380" y="480" width="200" height="80" rx="12" ry="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>
<text x="480" y="515" text-anchor="middle" class="node-label">Canifa Stock API</text>
<text x="480" y="535" text-anchor="middle" class="node-desc">External ERP System</text>
<!-- Edges -->
<!-- UI -> Route -->
<line x1="480" y1="160" x2="480" y2="200" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="485" y="185" class="arrow-label">POST /api/stock/check</text>
<!-- Route -> StarRocks -->
<line x1="380" y1="230" x2="310" y2="230" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="325" y="225" text-anchor="middle" class="arrow-label">Expand SKU</text>
<!-- StarRocks -> Route -->
<line x1="310" y1="250" x2="380" y2="250" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="345" y="265" text-anchor="middle" class="arrow-label">Variants</text>
<!-- Route -> Parallel -->
<line x1="480" y1="280" x2="480" y2="340" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="485" y="315" class="arrow-label">Chunked SKUs</text>
<!-- Parallel -> ERP -->
<line x1="480" y1="420" x2="480" y2="480" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="485" y="455" class="arrow-label">Parallel HTTP Get</text>
<!-- ERP -> Parallel -->
<path d="M 500,480 Q 530,450 500,420" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="525" y="455" class="arrow-label">Responses</text>
<!-- Parallel -> Cache -->
<line x1="580" y1="380" x2="650" y2="380" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3" marker-end="url(#arrow-claude)"/>
<text x="615" y="375" text-anchor="middle" class="arrow-label">Store</text>
<!-- Cache -> UI -->
<path d="M 740,340 C 740,120 580,120 580,120" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<text x="680" y="115" class="arrow-label">Final Stock Data</text>
<!-- Legend -->
<rect x="700" y="600" width="220" height="80" rx="12" ry="12" fill="#ffffff" stroke="#4a4a4a" stroke-width="1.5"/>
<text x="715" y="625" class="node-label" style="font-size: 14px;">Legend</text>
<line x1="715" y1="645" x2="745" y2="645" stroke="#5a5a5a" stroke-width="2"/>
<text x="755" y="650" class="node-desc">Read / Sync operation</text>
<line x1="715" y1="665" x2="745" y2="665" stroke="#5a5a5a" stroke-width="2" stroke-dasharray="5,3"/>
<text x="755" y="670" class="node-desc">Write / Async store</text>
</svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600" width="960" height="600">
<style>
text { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Arial, sans-serif; }
.title { font-size: 24px; font-weight: 700; fill: #1a1a1a; }
.node-title { font-size: 16px; font-weight: 600; fill: #1a1a1a; }
.node-sub { font-size: 14px; font-weight: 400; fill: #1a1a1a; }
</style>
<defs>
<marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">
<polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>
</marker>
<filter id="shadow">
<feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#00000010"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3"/>
<text x="480" y="50" text-anchor="middle" class="title">Text-to-SQL Pipeline (V1 &amp; V2)</text>
<!-- V1 Flow -->
<rect x="50" y="100" width="410" height="420" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1.5" stroke-dasharray="5,3"/>
<text x="255" y="130" text-anchor="middle" class="node-title">V1: AI Data Analyst</text>
<rect x="150" y="160" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="250" y="195" text-anchor="middle" class="node-title">Codex Engine</text>
<text x="250" y="215" text-anchor="middle" class="node-sub">• Schema Injection</text>
<rect x="150" y="300" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="250" y="335" text-anchor="middle" class="node-title">SQL Validator</text>
<text x="250" y="355" text-anchor="middle" class="node-sub">• Regex Guardrails</text>
<!-- V2 Flow -->
<rect x="500" y="100" width="410" height="420" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1.5" stroke-dasharray="5,3"/>
<text x="705" y="130" text-anchor="middle" class="node-title">V2: Vector RAG</text>
<rect x="600" y="160" width="200" height="80" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="700" y="195" text-anchor="middle" class="node-title">Embedding</text>
<text x="700" y="215" text-anchor="middle" class="node-sub">• text-embedding-3</text>
<rect x="600" y="300" width="200" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow)"/>
<text x="700" y="335" text-anchor="middle" class="node-title">Vector Search</text>
<text x="700" y="355" text-anchor="middle" class="node-sub">• approx_cosine_sim</text>
<!-- Common DB -->
<rect x="380" y="460" width="200" height="80" rx="12" fill="#ffffff" stroke="#4a4a4a" stroke-width="3" filter="url(#shadow)"/>
<text x="480" y="505" text-anchor="middle" class="node-title">StarRocks / DB</text>
<line x1="250" y1="380" x2="480" y2="460" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
<line x1="700" y1="380" x2="480" y2="460" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>
</svg>
\ No newline at end of file
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<marker id="grayarrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Regression Testing Loop Architecture</text>
<rect x="60" y="80" width="840" height="460" rx="8" fill="none" stroke="#e2e8f0" stroke-dasharray="5,5" />
<text x="70" y="100" class="container-label">System Flow</text>
<!-- Nodes -->
<rect x="120" y="140" width="180" height="100" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="210" y="180" text-anchor="middle" class="node-label">Frontend UI</text>
<text x="210" y="200" text-anchor="middle" class="sub-label">regression-test.js</text>
<text x="210" y="215" text-anchor="middle" class="sub-label">(localStorage Cases)</text>
<rect x="420" y="140" width="180" height="100" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="510" y="180" text-anchor="middle" class="node-label">Backend API</text>
<text x="510" y="200" text-anchor="middle" class="sub-label">regression_test_route.py</text>
<rect x="720" y="140" width="180" height="100" rx="8" fill="#ffffff" stroke="#ea580c" stroke-width="2" filter="url(#shadow)" />
<text x="810" y="180" text-anchor="middle" class="node-label">Target Agent</text>
<text x="810" y="200" text-anchor="middle" class="sub-label">Prod / Dev URL</text>
<!-- Arrows -->
<path d="M 300,175 L 410,175" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="355" y="165" text-anchor="middle" class="arrow-label">POST test_case</text>
<path d="M 600,175 L 710,175" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="655" y="165" text-anchor="middle" class="arrow-label">POST /chat</text>
<path d="M 720,205 L 610,205" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#grayarrowhead)" />
<text x="665" y="225" text-anchor="middle" class="sub-label">AI Response</text>
<path d="M 420,205 L 310,205" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#grayarrowhead)" />
<text x="365" y="225" text-anchor="middle" class="sub-label">Result + Latency</text>
<!-- Info Box -->
<rect x="140" y="320" width="680" height="180" rx="8" fill="#f1f5f9" stroke="#94a3b8" />
<text x="480" y="350" text-anchor="middle" class="node-label">Client-side Logic (Loop)</text>
<text x="160" y="385" class="sub-label">1. Read cases from localStorage</text>
<text x="160" y="410" class="sub-label">2. Loop: Send 1 request per iteration to keep UI responsive</text>
<text x="160" y="435" class="sub-label">3. Update Progress Bar &amp; Log real-time results</text>
<text x="160" y="460" class="sub-label">4. Final synthesis: Pass/Fail report + Average Latency</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "17-regression-test", "id": "17-regression-test",
"title": "Regression Testing Suite", "title": "Regression Testing Suite",
"description": "Hướng dẫn chi tiết về kiến trúc bộ kiểm thử hồi quy (Regression Test), cách luồng dữ liệu tương tác giữa UI và Backend nhằm đảm bảo chất lượng phản hồi từ chatbot sau mỗi lần cập nhật prompt hoặc mô hình AI.", "description": "Hướng dẫn chi tiết về kiến trúc bộ kiểm thử hồi quy (Regression Test), cách luồng dữ liệu tương tác giữa UI và Backend nhằm đảm bảo chất lượng phản hồi từ chatbot sau mỗi lần cập nhật prompt hoặc mô hình AI.",
"diagram": "diagrams/testing-suite.svg", "diagram": "data/17-regression-test/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Stress &amp; Load Testing Architecture</text>
<!-- Layers -->
<rect x="120" y="100" width="180" height="80" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="210" y="135" text-anchor="middle" class="node-label">Locust / User</text>
<text x="210" y="155" text-anchor="middle" class="sub-label">Simulated Traffic</text>
<rect x="390" y="100" width="180" height="80" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="480" y="135" text-anchor="middle" class="node-label">StressTestRouter</text>
<text x="480" y="155" text-anchor="middle" class="sub-label">API Handler</text>
<rect x="390" y="240" width="180" height="80" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-width="2" filter="url(#shadow)" />
<text x="480" y="275" text-anchor="middle" class="node-label">Async Semaphore</text>
<text x="480" y="295" text-anchor="middle" class="sub-label">Concurrency Control</text>
<rect x="660" y="240" width="180" height="80" rx="8" fill="#ffffff" stroke="#ea580c" stroke-width="2" filter="url(#shadow)" />
<text x="750" y="275" text-anchor="middle" class="node-label">Chatbot API</text>
<text x="750" y="295" text-anchor="middle" class="sub-label">Target Under Test</text>
<!-- Connections -->
<path d="M 300,140 L 380,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="340" y="130" text-anchor="middle" class="arrow-label">Request</text>
<path d="M 480,180 L 480,230" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="520" y="210" text-anchor="middle" class="arrow-label">Tasks</text>
<path d="M 570,280 L 650,280" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="610" y="270" text-anchor="middle" class="arrow-label">POST /chat</text>
<!-- Results Loop -->
<path d="M 750,320 L 750,450 L 480,450 L 480,320" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrowhead)" />
<text x="615" y="440" text-anchor="middle" class="sub-label">Latency Metrics (P50, P95, P99)</text>
<!-- Detailed Box -->
<rect x="120" y="480" width="720" height="100" rx="8" fill="#f1f5f9" stroke="#94a3b8" />
<text x="480" y="510" text-anchor="middle" class="node-label">Key Features</text>
<text x="140" y="540" class="sub-label">• locustfile.py: Supports ChatUser (Realistic) and QuickBurst (Stress) scenarios.</text>
<text x="140" y="565" class="sub-label">• Built-in API: Uses asyncio.Semaphore to limit concurrency without external tools.</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "18-stress-test", "id": "18-stress-test",
"title": "Stress & Load Testing", "title": "Stress & Load Testing",
"description": "Hướng dẫn thực hiện kiểm thử tải để xác định giới hạn chịu tải của hệ thống và latency ở mức concurrency cao.", "description": "Hướng dẫn thực hiện kiểm thử tải để xác định giới hạn chịu tải của hệ thống và latency ở mức concurrency cao.",
"diagram": "diagrams/testing-suite.svg", "diagram": "data/18-stress-test/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">User Persona Simulation Workflow</text>
<!-- Layers -->
<rect x="390" y="100" width="180" height="100" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="480" y="140" text-anchor="middle" class="node-label">User Simulator API</text>
<text x="480" y="160" text-anchor="middle" class="sub-label">Simulation Controller</text>
<rect x="120" y="100" width="180" height="100" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="210" y="140" text-anchor="middle" class="node-label">Persona Generator</text>
<text x="210" y="160" text-anchor="middle" class="sub-label">LLM Archetypes</text>
<rect x="660" y="100" width="180" height="100" rx="8" fill="#ffffff" stroke="#ea580c" stroke-width="2" filter="url(#shadow)" />
<text x="750" y="140" text-anchor="middle" class="node-label">Chatbot Agent</text>
<text x="750" y="160" text-anchor="middle" class="sub-label">System Under Test</text>
<rect x="390" y="300" width="180" height="100" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-width="2" filter="url(#shadow)" />
<text x="480" y="340" text-anchor="middle" class="node-label">Evaluation Engine</text>
<text x="480" y="360" text-anchor="middle" class="sub-label">Success/Drop Analysis</text>
<!-- Connections -->
<path d="M 300,150 L 380,150" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="340" y="140" text-anchor="middle" class="sub-label">Personas</text>
<path d="M 570,140 L 650,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="610" y="130" text-anchor="middle" class="arrow-label">User Msg</text>
<path d="M 650,160 L 580,160" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrowhead)" />
<text x="615" y="180" text-anchor="middle" class="sub-label">Bot Resp</text>
<path d="M 480,200 L 480,290" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="530" y="250" text-anchor="middle" class="arrow-label">History</text>
<!-- Info Box -->
<rect x="120" y="440" width="720" height="120" rx="8" fill="#f1f5f9" stroke="#94a3b8" />
<text x="480" y="470" text-anchor="middle" class="node-label">Simulation Logic</text>
<text x="140" y="500" class="sub-label">1. Generate Persona: Identity, Shopping Context, Chat Behavior (MBTI, Style).</text>
<text x="140" y="525" class="sub-label">2. Interaction: AI-to-AI dialogue to test Conversion vs. Drop triggers.</text>
<text x="140" y="550" class="sub-label">3. Evaluation: Cross-persona report on helpfulness and sales effectiveness.</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "19-user-simulator", "id": "19-user-simulator",
"title": "User Persona Simulation", "title": "User Persona Simulation",
"description": "Sử dụng AI để sinh ra các 'Persona' khách hàng (MiroFish-inspired) nhằm kiểm thử tự động khả năng tư vấn và chốt sale của chatbot.", "description": "Sử dụng AI để sinh ra các 'Persona' khách hàng (MiroFish-inspired) nhằm kiểm thử tự động khả năng tư vấn và chốt sale của chatbot.",
"diagram": "diagrams/testing-suite.svg", "diagram": "data/19-user-simulator/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Community Reaction Engine Architecture</text>
<!-- Input -->
<rect x="60" y="100" width="160" height="80" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="140" y="135" text-anchor="middle" class="node-label">Marketing Content</text>
<text x="140" y="155" text-anchor="middle" class="sub-label">Campaign / News</text>
<!-- Agent -->
<rect x="300" y="100" width="180" height="80" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="390" y="135" text-anchor="middle" class="node-label">Reaction Agent</text>
<text x="390" y="155" text-anchor="middle" class="sub-label">Simulation Orchestrator</text>
<!-- Persona Segments -->
<g transform="translate(560, 80)">
<rect width="340" height="240" rx="8" fill="none" stroke="#e2e8f0" stroke-dasharray="5,5" />
<text x="10" y="20" class="container-label">Persona Segments</text>
<rect x="20" y="40" width="140" height="40" rx="4" fill="#ffffff" stroke="#94a3b8" />
<text x="90" y="65" text-anchor="middle" class="sub-label">Mom Shopper (20%)</text>
<rect x="180" y="40" width="140" height="40" rx="4" fill="#ffffff" stroke="#94a3b8" />
<text x="250" y="65" text-anchor="middle" class="sub-label">Young Prof (20%)</text>
<rect x="20" y="100" width="140" height="40" rx="4" fill="#ffffff" stroke="#94a3b8" />
<text x="90" y="125" text-anchor="middle" class="sub-label">Gen Z Trendy (15%)</text>
<rect x="180" y="100" width="140" height="40" rx="4" fill="#ffffff" stroke="#94a3b8" />
<text x="250" y="125" text-anchor="middle" class="sub-label">Fashionista (15%)</text>
<rect x="100" y="160" width="140" height="40" rx="4" fill="#ffffff" stroke="#94a3b8" />
<text x="170" y="185" text-anchor="middle" class="sub-label">Social Troll (5%)</text>
</g>
<!-- LLM -->
<rect x="300" y="240" width="180" height="80" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-width="2" filter="url(#shadow)" />
<text x="390" y="275" text-anchor="middle" class="node-label">LLM Factory</text>
<text x="390" y="295" text-anchor="middle" class="sub-label">JSON Generation</text>
<!-- Output -->
<rect x="300" y="420" width="360" height="120" rx="8" fill="#f1f5f9" stroke="#94a3b8" />
<text x="480" y="450" text-anchor="middle" class="node-label">Synthesis &amp; Recommendations</text>
<text x="320" y="480" class="sub-label">• Sentiment Analysis (Pos / Neu / Neg)</text>
<text x="320" y="500" class="sub-label">• Action Advice (Launch / Delay / Response Template)</text>
<text x="320" y="520" class="sub-label">• Key Concerns extraction</text>
<!-- Connections -->
<path d="M 220,140 L 290,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 390,180 L 390,230" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 480,140 L 550,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 480,280 L 550,280" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 390,320 L 390,410" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "20-reaction-simulator", "id": "20-reaction-simulator",
"title": "Community Reaction Simulator", "title": "Community Reaction Simulator",
"description": "Giả lập phản ứng của cộng đồng mạng (theo Persona) đối với các chiến dịch marketing bằng cách sử dụng LLM.", "description": "Giả lập phản ứng của cộng đồng mạng (theo Persona) đối với các chiến dịch marketing bằng cách sử dụng LLM.",
"diagram": "diagrams/testing-suite.svg", "diagram": "data/20-reaction-simulator/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Hybrid Search FAQ Match Algorithm</text>
<!-- Flow -->
<rect x="100" y="100" width="160" height="80" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="180" y="135" text-anchor="middle" class="node-label">User Query</text>
<text x="180" y="155" text-anchor="middle" class="sub-label">Natural Language</text>
<rect x="340" y="100" width="160" height="80" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="420" y="135" text-anchor="middle" class="node-label">Normalizer</text>
<text x="420" y="155" text-anchor="middle" class="sub-label">Lower, No Accents</text>
<rect x="580" y="100" width="280" height="120" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-width="2" filter="url(#shadow)" />
<text x="720" y="135" text-anchor="middle" class="node-label">Similarity Scoring</text>
<text x="600" y="165" class="sub-label">• Word Overlap (70% weight)</text>
<text x="600" y="185" class="sub-label">• Fuzzy Ratio (30% weight)</text>
<text x="600" y="205" class="sub-label">• Threshold > 5.0 points</text>
<rect x="340" y="300" width="160" height="80" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-dasharray="4,2" />
<text x="420" y="335" text-anchor="middle" class="node-label">FAQ Database</text>
<text x="420" y="355" text-anchor="middle" class="sub-label">SQLite (faqs table)</text>
<rect x="580" y="300" width="280" height="100" rx="8" fill="#f1f5f9" stroke="#2563eb" stroke-width="2" />
<text x="720" y="335" text-anchor="middle" class="node-label">Best Match Results</text>
<text x="720" y="355" text-anchor="middle" class="sub-label">Top 3 Sorted by Score</text>
<text x="720" y="375" text-anchor="middle" class="sub-label">Includes Variants</text>
<!-- Connections -->
<path d="M 260,140 L 330,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 500,140 L 570,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<path d="M 420,290 L 420,190" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrowhead)" />
<text x="380" y="240" text-anchor="middle" class="sub-label" transform="rotate(-90, 380, 240)">Fetch all FAQs</text>
<path d="M 720,220 L 720,290" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "21-faq-manager", "id": "21-faq-manager",
"title": "FAQ Manager & BM25 Search", "title": "FAQ Manager & BM25 Search",
"description": "Phân tích chuyên sâu (Deep Dive) về hệ thống FAQ Manager, cơ chế sinh biến thể AI (Variant Generation) và thuật toán Hybrid Search (kết hợp Word Overlap và Fuzzy Matching) dựa trên source code thực tế.", "description": "Phân tích chuyên sâu (Deep Dive) về hệ thống FAQ Manager, cơ chế sinh biến thể AI (Variant Generation) và thuật toán Hybrid Search (kết hợp Word Overlap và Fuzzy Matching) dựa trên source code thực tế.",
"diagram": "diagrams/faq-bm25-search.svg", "diagram": "data/21-faq-manager/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Test-Driven Prompt Optimization (TDPE)</text>
<!-- Step 1 -->
<rect x="60" y="80" width="240" height="460" rx="8" fill="none" stroke="#2563eb" stroke-dasharray="5,5" />
<text x="70" y="100" class="container-label">Step 1: Run Test</text>
<rect x="90" y="120" width="180" height="60" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="180" y="155" text-anchor="middle" class="node-label">Chatbot Agent</text>
<text x="180" y="220" text-anchor="middle" class="sub-label">Extract: Response &amp; Product IDs</text>
<!-- Step 2 -->
<rect x="360" y="80" width="240" height="460" rx="8" fill="none" stroke="#059669" stroke-dasharray="5,5" />
<text x="370" y="100" class="container-label">Step 2: AI Judge</text>
<rect x="390" y="120" width="180" height="80" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="480" y="155" text-anchor="middle" class="node-label">AI Judge LLM</text>
<text x="480" y="175" text-anchor="middle" class="sub-label">Factual Verify</text>
<rect x="390" y="240" width="180" height="60" rx="8" fill="#ffffff" stroke="#94a3b8" stroke-dasharray="4,2" />
<text x="480" y="275" text-anchor="middle" class="node-label">Internal APIs</text>
<text x="480" y="320" text-anchor="middle" class="sub-label">Product Specs &amp; Stock</text>
<!-- Step 3 -->
<rect x="660" y="80" width="240" height="460" rx="8" fill="none" stroke="#ea580c" stroke-dasharray="5,5" />
<text x="670" y="100" class="container-label">Step 3: Optimizer</text>
<rect x="690" y="120" width="180" height="80" rx="8" fill="#ffffff" stroke="#ea580c" stroke-width="2" filter="url(#shadow)" />
<text x="780" y="155" text-anchor="middle" class="node-label">AI Optimizer</text>
<text x="780" y="175" text-anchor="middle" class="sub-label">Prompt Rewriting</text>
<!-- Global Connections -->
<path d="M 270,150 L 380,150" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="325" y="140" text-anchor="middle" class="arrow-label">Output</text>
<path d="M 480,235 L 480,205" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrowhead)" />
<text x="530" y="225" text-anchor="middle" class="sub-label">Ground Truth</text>
<path d="M 570,150 L 680,150" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="625" y="140" text-anchor="middle" class="arrow-label">Metrics</text>
<!-- Info -->
<rect x="90" y="380" width="780" height="140" rx="8" fill="#f1f5f9" stroke="#94a3b8" />
<text x="480" y="410" text-anchor="middle" class="node-label">Optimization Loop</text>
<text x="110" y="440" class="sub-label">1. Judge assesses 5 criteria: factual accuracy, relevance, tone, helpfulness, and hallucinations.</text>
<text x="110" y="465" class="sub-label">2. Fails are analyzed to find root cause of incorrect product info or wrong tone.</text>
<text x="110" y="490" class="sub-label">3. AI Optimizer generates a NEW system prompt to fix issues while maintaining PASS status for good cases.</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "22-prompt-optimizer", "id": "22-prompt-optimizer",
"title": "Prompt Optimizer", "title": "Prompt Optimizer",
"description": "Hệ thống Test-Driven Prompt Engineering (TDPE) sử dụng AI Judge để chấm điểm phản hồi và tự động tối ưu hóa System Prompt dựa trên các test case passing/failing.", "description": "Hệ thống Test-Driven Prompt Engineering (TDPE) sử dụng AI Judge để chấm điểm phản hồi và tự động tối ưu hóa System Prompt dựa trên các test case passing/failing.",
"diagram": "diagrams/prompt-management.svg", "diagram": "data/22-prompt-optimizer/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">
<style>
.title { font-size: 20px; font-weight: bold; fill: #1a1a1a; font-family: sans-serif; }
.node-label { font-size: 14px; font-weight: 500; fill: #333; font-family: sans-serif; }
.sub-label { font-size: 11px; fill: #666; font-family: sans-serif; }
.arrow-label { font-size: 12px; fill: #2563eb; font-weight: 500; font-family: sans-serif; }
.container-label { font-size: 12px; font-weight: bold; fill: #94a3b8; font-family: sans-serif; text-transform: uppercase; }
</style>
<defs>
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
<polygon points="0 0, 10 3.5, 0 7" fill="#2563eb" />
</marker>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="2" stdDeviation="3" flood-opacity="0.1"/>
</filter>
</defs>
<rect width="960" height="600" fill="#f8f6f3" />
<text x="480" y="40" text-anchor="middle" class="title">Multi-Layer Protection Architecture</text>
<!-- Layers -->
<rect x="100" y="100" width="160" height="80" rx="8" fill="#ffffff" stroke="#2563eb" stroke-width="2" filter="url(#shadow)" />
<text x="180" y="135" text-anchor="middle" class="node-label">Client</text>
<text x="180" y="155" text-anchor="middle" class="sub-label">Guest / User</text>
<rect x="340" y="100" width="180" height="100" rx="8" fill="#ffffff" stroke="#059669" stroke-width="2" filter="url(#shadow)" />
<text x="430" y="140" text-anchor="middle" class="node-label">RateLimitService</text>
<text x="430" y="160" text-anchor="middle" class="sub-label">SlowAPI (30/min)</text>
<text x="430" y="175" text-anchor="middle" class="sub-label">Memory Backend</text>
<rect x="600" y="100" width="200" height="100" rx="8" fill="#ffffff" stroke="#ea580c" stroke-width="2" filter="url(#shadow)" />
<text x="700" y="140" text-anchor="middle" class="node-label">MessageLimitService</text>
<text x="700" y="160" text-anchor="middle" class="sub-label">Redis Quota (Daily)</text>
<text x="700" y="175" text-anchor="middle" class="sub-label">Guest: 10, User: 100</text>
<rect x="340" y="300" width="460" height="100" rx="8" fill="#ffffff" stroke="#7c3aed" stroke-width="2" filter="url(#shadow)" />
<text x="570" y="340" text-anchor="middle" class="node-label">Agent Controller (LLM Execution)</text>
<text x="570" y="360" text-anchor="middle" class="sub-label">Calls Semantic Cache &amp; External LLM</text>
<!-- Connections -->
<path d="M 260,140 L 330,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="295" y="130" text-anchor="middle" class="arrow-label">Check</text>
<path d="M 520,140 L 590,140" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="555" y="130" text-anchor="middle" class="arrow-label">Pass</text>
<path d="M 700,200 L 700,290" fill="none" stroke="#2563eb" stroke-width="2" marker-end="url(#arrowhead)" />
<text x="750" y="245" text-anchor="middle" class="arrow-label">Execute</text>
<path d="M 340,350 L 260,350 L 260,180" fill="none" stroke="#6b7280" stroke-width="1.5" stroke-dasharray="4,2" marker-end="url(#arrowhead)" />
<text x="180" y="245" text-anchor="middle" class="sub-label">Increment Quota &amp; Response</text>
<!-- Error paths -->
<path d="M 430,200 L 430,240 L 180,240 L 180,190" fill="none" stroke="#ef4444" stroke-width="1.5" stroke-dasharray="2,2" marker-end="url(#arrowhead)" />
<text x="300" y="235" text-anchor="middle" class="sub-label" fill="#ef4444">429 Rate Exceeded</text>
</svg>
\ No newline at end of file
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
"id": "24-cache-rate-limit", "id": "24-cache-rate-limit",
"title": "Cache & Rate Limiting", "title": "Cache & Rate Limiting",
"description": "Chi tiết về cơ chế bảo vệ hệ thống khỏi spam và tối ưu chi phí LLM thông qua lớp Cache/Rate Limit.", "description": "Chi tiết về cơ chế bảo vệ hệ thống khỏi spam và tối ưu chi phí LLM thông qua lớp Cache/Rate Limit.",
"diagram": "diagrams/auth-routing.svg", "diagram": "data/24-cache-rate-limit/diagram.svg",
"sections": [ "sections": [
{ {
"id": "sec0", "id": "sec0",
......
import sys
import io
import json
import urllib.request
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
data = json.dumps({"user_query": "d? di an cu?i", "user_insight": None, "chat_history_summary": ""}).encode('utf-8')
req = urllib.request.Request("http://localhost:5000/api/agent/lead-stage", data=data, headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req) as f:
print(f.read().decode('utf-8'))
except Exception as e:
print(e)
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE foo (a INT, b INT)')
# try to reproduce the exact error
try:
conn.execute('SELECT * FROM foo WHERE ("LOWER(A) NOT LIKE ?", "LOWER(A) NOT LIKE ?")')
except Exception as e:
print('TEST 1 ERROR:', repr(e))
try:
conn.execute('SELECT * FROM foo WHERE LOWER(A) NOT LIKE ? AND LOWER(A) NOT LIKE ?', ('%a%', '%b%'))
except Exception as e:
print('TEST 2 ERROR:', repr(e))
try:
conn.execute('SELECT * FROM foo WHERE "LOWER(A) NOT LIKE ?, LOWER(A) NOT LIKE ?"')
except Exception as e:
print('TEST 3 ERROR:', repr(e))
try:
conn.execute('SELECT * FROM foo WHERE "LOWER(A) NOT LIKE ?", "LOWER(A) NOT LIKE ?"')
except Exception as e:
print('TEST 4 ERROR:', repr(e))
try:
conn.execute('SELECT * FROM foo WHERE \'LOWER(A) NOT LIKE ?\', \'LOWER(A) NOT LIKE ?\'')
except Exception as e:
print('TEST 5 ERROR:', repr(e))
import sqlite3
conn = sqlite3.connect(':memory:')
conn.execute('CREATE TABLE foo (a INT, b INT)')
try:
conn.execute('SELECT * FROM foo WHERE "LOWER(description_text) NOT LIKE ?, LOWER(description_text) NOT LIKE ?"')
except Exception as e:
print('TEST ERROR:', repr(e))
import sys
sys.stdout.reconfigure(encoding='utf-8')
import sqlite3
import json
import os
db_path = os.path.join("database", "canifa_ai_dump.sqlite")
print(f"Checking DB: {db_path}")
try:
conn = sqlite3.connect(db_path)
cur = conn.cursor()
cur.execute("PRAGMA table_info(pg__dashboard_canifa__ultra_descriptions);")
columns = cur.fetchall()
print("Columns:", [c[1] for c in columns])
cur.execute("SELECT base_ref_code, tags, description_data FROM pg__dashboard_canifa__ultra_descriptions WHERE tags IS NOT NULL AND tags != '' LIMIT 1;")
row = cur.fetchone()
if row:
product_code = row[0]
tags = row[1]
desc_data = row[2]
print("--- BEFORE UPDATE ---")
print("Product Code:", product_code)
print("Tags (Standalone):", tags)
print("Desc Data JSON:", desc_data)
# Parse it
try:
parsed_desc = json.loads(desc_data)
parsed_tags = json.loads(tags)
# Simulate update
parsed_desc["tags"] = parsed_tags
print("\n--- AFTER UPDATE (PREVIEW) ---")
print("New Desc Data JSON:", json.dumps(parsed_desc, ensure_ascii=False, indent=2))
except Exception as json_err:
print("JSON Parse Error:", json_err)
else:
print("No rows found with tags.")
cur.close()
conn.close()
except Exception as e:
print("DB Error:", e)
lines = []
lines.append('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 700">')
lines.append(' <style>')
lines.append(' text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }')
lines.append(' </style>')
lines.append(' <defs>')
lines.append(' <marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">')
lines.append(' <polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>')
lines.append(' </marker>')
lines.append(' <filter id="shadow-soft">')
lines.append(' <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>')
lines.append(' </filter>')
lines.append(' </defs>')
lines.append(' <rect width="960" height="700" fill="#f8f6f3"/>')
lines.append(' <text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Canifa AI Platform Architecture</text>')
# Layer labels
lines.append(' <text x="40" y="120" fill="#6a6a6a" font-size="14" font-weight="600">Client</text>')
lines.append(' <text x="40" y="240" fill="#6a6a6a" font-size="14" font-weight="600">Interface</text>')
lines.append(' <text x="40" y="360" fill="#6a6a6a" font-size="14" font-weight="600">Gateway</text>')
lines.append(' <text x="40" y="480" fill="#6a6a6a" font-size="14" font-weight="600">Module</text>')
lines.append(' <text x="40" y="600" fill="#6a6a6a" font-size="14" font-weight="600">Data</text>')
# Nodes
# Client Layer
lines.append(' <rect x="380" y="80" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="115" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Web Browser</text>')
lines.append(' <text x="480" y="135" text-anchor="middle" fill="#6a6a6a" font-size="13">Static Entry Points</text>')
# Interface Layer
lines.append(' <rect x="380" y="200" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="235" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Micro-Frontends</text>')
lines.append(' <text x="480" y="255" text-anchor="middle" fill="#6a6a6a" font-size="13">Independent Namespaces</text>')
# Gateway Layer
lines.append(' <rect x="380" y="320" width="200" height="80" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="355" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">FastAPI Gateway</text>')
lines.append(' <text x="480" y="375" text-anchor="middle" fill="#6a6a6a" font-size="13">api_router &amp; Middleware</text>')
# Module Layer
lines.append(' <rect x="380" y="440" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="475" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Business Modules</text>')
lines.append(' <text x="480" y="495" text-anchor="middle" fill="#6a6a6a" font-size="13">36+ Agents &amp; Event Bus</text>')
# Data Layer
lines.append(' <rect x="180" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="270" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Postgres DB</text>')
lines.append(' <text x="270" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">db_pool management</text>')
lines.append(' <rect x="390" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Redis Cache</text>')
lines.append(' <text x="480" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">redis_cache module</text>')
lines.append(' <rect x="600" y="560" width="180" height="80" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="690" y="595" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">OLAP (StarRocks)</text>')
lines.append(' <text x="690" y="615" text-anchor="middle" fill="#6a6a6a" font-size="13">Analytic Engine</text>')
# Arrows
lines.append(' <line x1="480" y1="160" x2="480" y2="200" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <line x1="480" y1="280" x2="480" y2="320" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <line x1="480" y1="400" x2="480" y2="440" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <path d="M 480 520 L 480 540 L 270 540 L 270 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <path d="M 480 520 L 480 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <path d="M 480 520 L 480 540 L 690 540 L 690 560" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append('</svg>')
output_path = "backend/static/cookbook/data/01-architecture/diagram.svg"
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"SVG generated at {output_path}")
lines = []
lines.append('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">')
lines.append(' <style>')
lines.append(' text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }')
lines.append(' </style>')
lines.append(' <defs>')
lines.append(' <marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">')
lines.append(' <polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>')
lines.append(' </marker>')
lines.append(' <filter id="shadow-soft">')
lines.append(' <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>')
lines.append(' </filter>')
lines.append(' </defs>')
lines.append(' <rect width="960" height="600" fill="#f8f6f3"/>')
lines.append(' <text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Database Connection &amp; Routing Logic</text>')
# Layer labels
lines.append(' <text x="40" y="100" fill="#6a6a6a" font-size="14" font-weight="600">Application</text>')
lines.append(' <text x="40" y="240" fill="#6a6a6a" font-size="14" font-weight="600">Router</text>')
lines.append(' <text x="40" y="420" fill="#6a6a6a" font-size="14" font-weight="600">Storage Layer</text>')
# App Layer
lines.append(' <rect x="380" y="70" width="200" height="80" rx="12" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="105" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">App / Helper</text>')
lines.append(' <text x="480" y="125" text-anchor="middle" fill="#6a6a6a" font-size="13">FastAPI or Class Helpers</text>')
# Logic/Router Layer
lines.append(' <rect x="380" y="200" width="200" height="80" rx="12" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="235" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Connection Switch</text>')
lines.append(' <text x="480" y="255" text-anchor="middle" fill="#6a6a6a" font-size="13">Check USE_LOCAL_SQLITE</text>')
# Local Mode
lines.append(' <rect x="180" y="380" width="200" height="100" rx="12" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="280" y="415" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">SQLite Mock</text>')
lines.append(' <text x="280" y="435" text-anchor="middle" fill="#6a6a6a" font-size="13">SQL Translation</text>')
lines.append(' <text x="280" y="455" text-anchor="middle" fill="#6a6a6a" font-size="13">sqlite_mock.py</text>')
# Production Mode
lines.append(' <rect x="580" y="380" width="200" height="100" rx="12" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2.5" filter="url(#shadow-soft)"/>')
lines.append(' <text x="680" y="415" text-anchor="middle" fill="#1a1a1a" font-size="16" font-weight="600">Postgres Pool</text>')
lines.append(' <text x="680" y="435" text-anchor="middle" fill="#6a6a6a" font-size="13">psycopg_pool / asyncpg</text>')
lines.append(' <text x="680" y="455" text-anchor="middle" fill="#6a6a6a" font-size="13">CanifaDbPool</text>')
# Arrows
lines.append(' <line x1="480" y1="150" x2="480" y2="200" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="490" y="180" fill="#5a5a5a" font-size="13">Connect/Query</text>')
lines.append(' <path d="M 380 240 L 280 240 L 280 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="200" y="310" fill="#5a5a5a" font-size="13">True (Local)</text>')
lines.append(' <path d="M 580 240 L 680 240 L 680 380" fill="none" stroke="#5a5a5a" stroke-width="2" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="700" y="310" fill="#5a5a5a" font-size="13">False (Prod)</text>')
lines.append('</svg>')
output_path = "backend/static/cookbook/data/01b-database/diagram.svg"
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"SVG generated at {output_path}")
lines = []
lines.append('<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 600">')
lines.append(' <style>')
lines.append(' text { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Arial, sans-serif; }')
lines.append(' </style>')
lines.append(' <defs>')
lines.append(' <marker id="arrow-claude" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto">')
lines.append(' <polygon points="0 0, 8 4, 0 8" fill="#5a5a5a"/>')
lines.append(' </marker>')
lines.append(' <filter id="shadow-soft">')
lines.append(' <feDropShadow dx="0" dy="2" stdDeviation="4" flood-color="#000000" flood-opacity="0.05"/>')
lines.append(' </filter>')
lines.append(' </defs>')
lines.append(' <rect width="960" height="600" fill="#f8f6f3"/>')
lines.append(' <text x="480" y="40" text-anchor="middle" fill="#1a1a1a" font-size="22" font-weight="700">Authentication &amp; RBAC Flow</text>')
# Participants
# Browser
lines.append(' <rect x="100" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>')
lines.append(' <text x="200" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">Browser / Frontend</text>')
# Backend
lines.append(' <rect x="380" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>')
lines.append(' <text x="480" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">FastAPI Backend</text>')
# Database
lines.append(' <rect x="660" y="100" width="200" height="400" rx="12" fill="none" stroke="#4a4a4a" stroke-width="1" stroke-dasharray="5,5"/>')
lines.append(' <text x="760" y="125" text-anchor="middle" fill="#6a6a6a" font-size="14" font-weight="600">PostgreSQL DB</text>')
# Nodes
# Auth.js Guard
lines.append(' <rect x="120" y="160" width="160" height="60" rx="10" fill="#a8c5e6" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>')
lines.append(' <text x="200" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">auth.js Guard</text>')
# API Login
lines.append(' <rect x="400" y="160" width="160" height="60" rx="10" fill="#9dd4c7" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">POST /api/auth/login</text>')
# Admin Users Table
lines.append(' <rect x="680" y="160" width="160" height="60" rx="10" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>')
lines.append(' <text x="760" y="195" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">admin_users table</text>')
# Middleware
lines.append(' <rect x="400" y="300" width="160" height="60" rx="10" fill="#f4e4c1" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>')
lines.append(' <text x="480" y="335" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">get_current_user</text>')
# LocalStorage
lines.append(' <rect x="120" y="300" width="160" height="60" rx="10" fill="#e8e6e3" stroke="#4a4a4a" stroke-width="2" filter="url(#shadow-soft)"/>')
lines.append(' <text x="200" y="335" text-anchor="middle" fill="#1a1a1a" font-size="14" font-weight="600">localStorage</text>')
# Arrows
lines.append(' <line x1="280" y1="180" x2="400" y2="180" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="340" y="175" text-anchor="middle" fill="#5a5a5a" font-size="11">Credentials</text>')
lines.append(' <line x1="560" y1="190" x2="680" y2="190" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="620" y="185" text-anchor="middle" fill="#5a5a5a" font-size="11">Verify</text>')
lines.append(' <line x1="400" y1="210" x2="280" y2="210" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="340" y="205" text-anchor="middle" fill="#5a5a5a" font-size="11">JWT Token</text>')
lines.append(' <line x1="200" y1="220" x2="200" y2="300" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="140" y="260" text-anchor="middle" fill="#5a5a5a" font-size="11">Store Token</text>')
lines.append(' <line x1="280" y1="330" x2="400" y2="330" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="340" y="325" text-anchor="middle" fill="#5a5a5a" font-size="11">Auth Header</text>')
lines.append(' <line x1="560" y1="330" x2="680" y2="190" stroke="#5a5a5a" stroke-width="1.5" marker-end="url(#arrow-claude)"/>')
lines.append(' <text x="630" y="270" text-anchor="middle" fill="#5a5a5a" font-size="11" transform="rotate(-30 630 270)">Check Role</text>')
lines.append('</svg>')
output_path = "backend/static/cookbook/data/01c-auth-routing/diagram.svg"
with open(output_path, "w", encoding="utf-8") as f:
f.write("\n".join(lines))
print(f"SVG generated at {output_path}")
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