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

feat: tier-aware responder disclaimer

parent 40577a90
......@@ -309,6 +309,20 @@ class EventStageGraph:
try:
parsed = json.loads(tool_result)
if parsed.get("status") == "success":
tier = parsed.get("tier", 1)
stock_timed_out = parsed.get("stock_timed_out", False)
tier_context = ""
if tier >= 3:
tier_context = "⚠️ TIER 3/4: Không tìm thấy kết quả khớp chính xác (keywords và tags không match). Đây là gợi ý trong cùng danh mục."
elif tier == 2:
tier_context = "✅ TIER 2: Kết quả khớp theo tags (occasion/style), có thể không match 100% từ khoá user."
else:
tier_context = "✅ TIER 1: Kết quả chính xác theo từ khoá user."
if stock_timed_out:
tier_context += "\n⚠️ STOCK TIMEOUT: Hệ thống tồn kho tạm thời không phản hồi, thông tin có thể không realtime."
products = parsed.get("products", [])
if products:
# Deduplicate by SKU to prevent duplicate context
......@@ -319,7 +333,7 @@ class EventStageGraph:
dedup[sku] = p
unique_products = list(dedup.values())
lines = [f"✅ Tìm thấy {len(unique_products)} sản phẩm:"]
lines = [f"{tier_context}\n✅ Tìm thấy {len(unique_products)} sản phẩm:"]
for p in unique_products[:8]:
price = p.get("price", 0)
discount = p.get("discount", "")
......
......@@ -835,7 +835,24 @@ async def _enrich_with_outfit(
if not _os.path.exists(SQLITE_DB_PATH):
return products
top_products = products[:3]
# Phase 1 & 2: Resolve occasion from tags & expand to top 5 products
TAGS_TO_OCCASION = {
"occ:di_lam": "di_lam",
"occ:di_choi": "di_choi",
"occ:di_tiec": "di_tiec",
"occ:mac_nha": "mac_nha",
"occ:the_thao": "the_thao",
"occ:hang_ngay": "hang_ngay",
}
occasion = "hang_ngay"
for tag in (tags or []):
occ = TAGS_TO_OCCASION.get(tag.lower())
if occ:
occasion = occ
break
top_products = products[:5]
anchor_base_codes = [
(p.get("internal_ref_code") or p.get("magento_ref_code", "").split("-")[0]).strip()
for p in top_products
......@@ -851,20 +868,10 @@ async def _enrich_with_outfit(
placeholders = ",".join(["?"] * len(anchor_base_codes))
# Lấy outfit matches từ bảng mới
outfit_rows = cursor.execute(
f"""
SELECT anchor_product_code, match_product_code, match_product_name, match_role, ai_reason
FROM pg__dashboard_canifa__ai_outfit_product_matches
WHERE anchor_product_code IN ({placeholders})
""",
anchor_base_codes,
).fetchall()
# Lấy clean_description + description_data_cut từ bảng ultra_descriptions
# Đọc ai_matches trực tiếp từ ultra_descriptions
desc_rows = cursor.execute(
f"""
SELECT base_ref_code, clean_description, description_data_cut
SELECT base_ref_code, clean_description, description_data_cut, ai_matches
FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders})
""",
......@@ -876,53 +883,78 @@ async def _enrich_with_outfit(
logger.error("❌ SQLite outfit read error: %s", e)
return products
import random
# Nhóm theo anchor -> role -> unique matches
temp_outfit_map: dict[str, dict[str, dict[str, dict]]] = {}
for row in outfit_rows:
anchor = row["anchor_product_code"]
role = row["match_role"]
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"],
"role": role,
"reason": row["ai_reason"]
}
import json
desc_map: dict[str, dict] = {}
all_match_codes = set()
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:
ai_matches_raw = row["ai_matches"]
parsed_matches = {}
if ai_matches_raw:
try:
parsed_matches = json.loads(ai_matches_raw) if isinstance(ai_matches_raw, str) else ai_matches_raw
except Exception:
pass
# Deduplicate and filter by score for the resolved occasion
filtered_occasion_matches = []
occ_matches = parsed_matches.get(occasion, {})
# Fallback to hang_ngay if occasion yields nothing
if not occ_matches and occasion != "hang_ngay":
occ_matches = parsed_matches.get("hang_ngay", {})
for role, items in occ_matches.items():
best_item = None
best_score = -1
fallback_item = None
fallback_score = -1
for item in items:
score = int(item.get("score") or 0)
if score > fallback_score:
fallback_item = item
fallback_score = score
if score >= 70 and score > best_score:
best_item = item
best_score = score
chosen_item = best_item or fallback_item
if chosen_item:
chosen_item["role"] = role
filtered_occasion_matches.append(chosen_item)
if chosen_item.get("code"):
all_match_codes.add(chosen_item["code"])
desc_map[row["base_ref_code"]] = {
"clean_description": row["clean_description"],
"description_data_cut": row["description_data_cut"],
"filtered_matches": filtered_occasion_matches
}
# Fetch missing metadata (price, discount, url, etc) from StarRocks
star_metadata = {}
if all_match_codes and db:
try:
match_placeholders = ",".join([f"'{c}'" for c in all_match_codes])
sql = f"""
SELECT magento_ref_code, sale_price, original_price, discount_percent,
is_new_product, quantity_sold, product_web_url
FROM {TABLE_NAME}
WHERE magento_ref_code IN ({match_placeholders})
"""
star_rows = await db.execute_query_async(sql)
if star_rows:
for r in star_rows:
star_metadata[r["magento_ref_code"]] = r
except Exception as e:
logger.error("❌ StarRocks match metadata fetch error: %s", e)
# Check stock for match items
match_base_codes = list(set(code.split("-")[0] for code in all_match_codes))
stock_map, _, _ = await _fetch_stock_batch(match_base_codes)
for p in top_products:
anchor_magento = p.get("magento_ref_code", "")
base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip()
......@@ -935,18 +967,52 @@ async def _enrich_with_outfit(
if desc_info.get("description_data_cut"):
p["description_data_cut"] = desc_info["description_data_cut"]
# Gán outfit matches
matches = outfit_map.get(base)
if matches:
p["ai_matches"] = matches # Dùng luôn key ai_matches để AI #2 không bị ngợp và tương thích prompt cũ
p["outfit_recommendations"] = matches # Tên rõ nghĩa hơn
matches = desc_info.get("filtered_matches", [])
enriched_matches = []
for m in matches:
m_code = m.get("code")
m_base = m_code.split("-")[0] if m_code else ""
# Check stock
colors_stock = stock_map.get(m_base, [])
in_stock = len(colors_stock) > 0
# We skip injecting if out of stock
if not in_stock:
continue
meta = star_metadata.get(m_code, {})
disc_pct = int(meta.get("discount_percent") or 0)
enriched_m = {
"sku": m_code,
"name": m.get("name", ""),
"color": m.get("color", ""),
"image": m.get("image", ""),
"price": int(meta.get("sale_price") or 0),
"original_price": int(meta.get("original_price") or 0),
"discount": f"-{disc_pct}%" if disc_pct > 0 else None,
"is_new": bool(meta.get("is_new_product")),
"popularity": int(meta.get("quantity_sold") or 0),
"url": meta.get("product_web_url", ""),
"role": m.get("role", "other"),
"ai_reason": m.get("reason", ""),
"score": m.get("score", 0),
"occasion": occasion,
"in_stock": in_stock
}
enriched_matches.append(enriched_m)
if enriched_matches:
p["ai_matches"] = enriched_matches
p["outfit_recommendations"] = enriched_matches
return products
async def _format_products(products: list, db) -> list[dict]:
formatted = []
for p in products[:3]:
for p in products[:5]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
......@@ -1007,7 +1073,7 @@ async def _format_products(products: list, db) -> list[dict]:
# ---------------------------
# Add compact description from description_data_cut
product_count = len(products[:3])
product_count = len(products[:5])
if desc_data_cut:
compact_desc = _extract_compact_description(
desc_data_cut,
......
......@@ -298,6 +298,12 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t
→ product_ids: ["6BS25S011", "6TS25S001", "6SS25S002"]
```
- 🚨 **TIER HANDLING (XỬ LÝ ĐỘ CHÍNH XÁC CỦA KẾT QUẢ TÌM KIẾM):**
- Nếu kết quả trả về có ghi "TIER 3/4": Bạn BẮT BUỘC phải mở đầu câu trả lời bằng một câu Disclaimer rõ ràng: "Mình chưa tìm thấy đúng món bạn hỏi, nhưng đây là các gợi ý tương tự trong cùng danh mục...". Kết thúc câu trả lời bằng việc hỏi khách muốn tìm cụ thể loại nào hơn để hệ thống tìm lại.
- Nếu kết quả trả về có ghi "TIER 2": Tự tin tư vấn, nhưng note nhẹ là kết quả này "phù hợp với dịp/phong cách bạn muốn".
- Nếu kết quả trả về có ghi "TIER 1": Hoàn toàn tự tin 100%.
- Nếu kết quả có ghi "STOCK TIMEOUT": Thêm lưu ý nhỏ "(lưu ý: số lượng tồn kho có thể chưa cập nhật realtime)".
- **KIỂM TRA SỰ PHÙ HỢP (CRITICAL):**
- Nếu khách tìm đồ ĐÔNG/LẠNH mà tool ra QUẦN SOÓC (Shorts) hoặc ÁO BA LỖ: Tuyệt đối không được gợi ý. Hãy lọc bỏ.
- Nếu khách tìm đồ ĐI LÀM/CÔNG SỞ: Tuyệt đối không gợi ý áo in hình hoạt hình (Cartoon), Manga.
......
......@@ -308,6 +308,20 @@ class LeadStageGraph:
try:
parsed = json.loads(tool_result)
if parsed.get("status") == "success":
tier = parsed.get("tier", 1)
stock_timed_out = parsed.get("stock_timed_out", False)
tier_context = ""
if tier >= 3:
tier_context = "⚠️ TIER 3/4: Không tìm thấy kết quả khớp chính xác (keywords và tags không match). Đây là gợi ý trong cùng danh mục."
elif tier == 2:
tier_context = "✅ TIER 2: Kết quả khớp theo tags (occasion/style), có thể không match 100% từ khoá user."
else:
tier_context = "✅ TIER 1: Kết quả chính xác theo từ khoá user."
if stock_timed_out:
tier_context += "\n⚠️ STOCK TIMEOUT: Hệ thống tồn kho tạm thời không phản hồi, thông tin có thể không realtime."
products = parsed.get("products", [])
if products:
# Deduplicate by SKU to prevent duplicate context
......@@ -318,7 +332,7 @@ class LeadStageGraph:
dedup[sku] = p
unique_products = list(dedup.values())
lines = [f"✅ Tìm thấy {len(unique_products)} sản phẩm:"]
lines = [f"{tier_context}\n✅ Tìm thấy {len(unique_products)} sản phẩm:"]
for p in unique_products[:8]:
price = p.get("price", 0)
discount = p.get("discount", "")
......
......@@ -834,7 +834,24 @@ async def _enrich_with_outfit(
if not _os.path.exists(SQLITE_DB_PATH):
return products
top_products = products[:3]
# Phase 1 & 2: Resolve occasion from tags & expand to top 5 products
TAGS_TO_OCCASION = {
"occ:di_lam": "di_lam",
"occ:di_choi": "di_choi",
"occ:di_tiec": "di_tiec",
"occ:mac_nha": "mac_nha",
"occ:the_thao": "the_thao",
"occ:hang_ngay": "hang_ngay",
}
occasion = "hang_ngay"
for tag in (tags or []):
occ = TAGS_TO_OCCASION.get(tag.lower())
if occ:
occasion = occ
break
top_products = products[:5]
anchor_base_codes = [
(p.get("internal_ref_code") or p.get("magento_ref_code", "").split("-")[0]).strip()
for p in top_products
......@@ -850,20 +867,10 @@ async def _enrich_with_outfit(
placeholders = ",".join(["?"] * len(anchor_base_codes))
# Lấy outfit matches từ bảng mới
outfit_rows = cursor.execute(
f"""
SELECT anchor_product_code, match_product_code, match_product_name, match_role, ai_reason
FROM pg__dashboard_canifa__ai_outfit_product_matches
WHERE anchor_product_code IN ({placeholders})
""",
anchor_base_codes,
).fetchall()
# Lấy clean_description + description_data_cut từ bảng ultra_descriptions
# Đọc ai_matches trực tiếp từ ultra_descriptions
desc_rows = cursor.execute(
f"""
SELECT base_ref_code, clean_description, description_data_cut
SELECT base_ref_code, clean_description, description_data_cut, ai_matches
FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders})
""",
......@@ -875,53 +882,78 @@ async def _enrich_with_outfit(
logger.error("❌ SQLite outfit read error: %s", e)
return products
import random
# Nhóm theo anchor -> role -> unique matches
temp_outfit_map: dict[str, dict[str, dict[str, dict]]] = {}
for row in outfit_rows:
anchor = row["anchor_product_code"]
role = row["match_role"]
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"],
"role": role,
"reason": row["ai_reason"]
}
import json
desc_map: dict[str, dict] = {}
all_match_codes = set()
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:
ai_matches_raw = row["ai_matches"]
parsed_matches = {}
if ai_matches_raw:
try:
parsed_matches = json.loads(ai_matches_raw) if isinstance(ai_matches_raw, str) else ai_matches_raw
except Exception:
pass
# Deduplicate and filter by score for the resolved occasion
filtered_occasion_matches = []
occ_matches = parsed_matches.get(occasion, {})
# Fallback to hang_ngay if occasion yields nothing
if not occ_matches and occasion != "hang_ngay":
occ_matches = parsed_matches.get("hang_ngay", {})
for role, items in occ_matches.items():
best_item = None
best_score = -1
fallback_item = None
fallback_score = -1
for item in items:
score = int(item.get("score") or 0)
if score > fallback_score:
fallback_item = item
fallback_score = score
if score >= 70 and score > best_score:
best_item = item
best_score = score
chosen_item = best_item or fallback_item
if chosen_item:
chosen_item["role"] = role
filtered_occasion_matches.append(chosen_item)
if chosen_item.get("code"):
all_match_codes.add(chosen_item["code"])
desc_map[row["base_ref_code"]] = {
"clean_description": row["clean_description"],
"description_data_cut": row["description_data_cut"],
"filtered_matches": filtered_occasion_matches
}
# Fetch missing metadata (price, discount, url, etc) from StarRocks
star_metadata = {}
if all_match_codes and db:
try:
match_placeholders = ",".join([f"'{c}'" for c in all_match_codes])
sql = f"""
SELECT magento_ref_code, sale_price, original_price, discount_percent,
is_new_product, quantity_sold, product_web_url
FROM {TABLE_NAME}
WHERE magento_ref_code IN ({match_placeholders})
"""
star_rows = await db.execute_query_async(sql)
if star_rows:
for r in star_rows:
star_metadata[r["magento_ref_code"]] = r
except Exception as e:
logger.error("❌ StarRocks match metadata fetch error: %s", e)
# Check stock for match items
match_base_codes = list(set(code.split("-")[0] for code in all_match_codes))
stock_map, _, _ = await _fetch_stock_batch(match_base_codes)
for p in top_products:
anchor_magento = p.get("magento_ref_code", "")
base = (p.get("internal_ref_code") or anchor_magento.split("-")[0]).strip()
......@@ -934,18 +966,52 @@ async def _enrich_with_outfit(
if desc_info.get("description_data_cut"):
p["description_data_cut"] = desc_info["description_data_cut"]
# Gán outfit matches
matches = outfit_map.get(base)
if matches:
p["ai_matches"] = matches # Dùng luôn key ai_matches để AI #2 không bị ngợp và tương thích prompt cũ
p["outfit_recommendations"] = matches # Tên rõ nghĩa hơn
matches = desc_info.get("filtered_matches", [])
enriched_matches = []
for m in matches:
m_code = m.get("code")
m_base = m_code.split("-")[0] if m_code else ""
# Check stock
colors_stock = stock_map.get(m_base, [])
in_stock = len(colors_stock) > 0
# We skip injecting if out of stock
if not in_stock:
continue
meta = star_metadata.get(m_code, {})
disc_pct = int(meta.get("discount_percent") or 0)
enriched_m = {
"sku": m_code,
"name": m.get("name", ""),
"color": m.get("color", ""),
"image": m.get("image", ""),
"price": int(meta.get("sale_price") or 0),
"original_price": int(meta.get("original_price") or 0),
"discount": f"-{disc_pct}%" if disc_pct > 0 else None,
"is_new": bool(meta.get("is_new_product")),
"popularity": int(meta.get("quantity_sold") or 0),
"url": meta.get("product_web_url", ""),
"role": m.get("role", "other"),
"ai_reason": m.get("reason", ""),
"score": m.get("score", 0),
"occasion": occasion,
"in_stock": in_stock
}
enriched_matches.append(enriched_m)
if enriched_matches:
p["ai_matches"] = enriched_matches
p["outfit_recommendations"] = enriched_matches
return products
async def _format_products(products: list, db) -> list[dict]:
formatted = []
for p in products[:3]:
for p in products[:5]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
......@@ -1006,7 +1072,7 @@ async def _format_products(products: list, db) -> list[dict]:
# ---------------------------
# Add compact description from description_data_cut
product_count = len(products[:3])
product_count = len(products[:5])
if desc_data_cut:
compact_desc = _extract_compact_description(
desc_data_cut,
......
......@@ -298,6 +298,12 @@ Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm t
→ product_ids: ["6BS25S011", "6TS25S001", "6SS25S002"]
```
- 🚨 **TIER HANDLING (XỬ LÝ ĐỘ CHÍNH XÁC CỦA KẾT QUẢ TÌM KIẾM):**
- Nếu kết quả trả về có ghi "TIER 3/4": Bạn BẮT BUỘC phải mở đầu câu trả lời bằng một câu Disclaimer rõ ràng: "Mình chưa tìm thấy đúng món bạn hỏi, nhưng đây là các gợi ý tương tự trong cùng danh mục...". Kết thúc câu trả lời bằng việc hỏi khách muốn tìm cụ thể loại nào hơn để hệ thống tìm lại.
- Nếu kết quả trả về có ghi "TIER 2": Tự tin tư vấn, nhưng note nhẹ là kết quả này "phù hợp với dịp/phong cách bạn muốn".
- Nếu kết quả trả về có ghi "TIER 1": Hoàn toàn tự tin 100%.
- Nếu kết quả có ghi "STOCK TIMEOUT": Thêm lưu ý nhỏ "(lưu ý: số lượng tồn kho có thể chưa cập nhật realtime)".
- **KIỂM TRA SỰ PHÙ HỢP (CRITICAL):**
- Nếu khách tìm đồ ĐÔNG/LẠNH mà tool ra QUẦN SOÓC (Shorts) hoặc ÁO BA LỖ: Tuyệt đối không được gợi ý. Hãy lọc bỏ.
- Nếu khách tìm đồ ĐI LÀM/CÔNG SỞ: Tuyệt đối không gợi ý áo in hình hoạt hình (Cartoon), Manga.
......
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