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

feat(product-desc): extract compare tab into standalone page and fix starrocks db connection error

parent 5fe080db
"""Lead Stage Agent — AI #1 phân tích giai đoạn mua hàng của khách."""
"""
Lead Stage Agent Controller — Entry point with Langfuse tracing.
Wraps LeadStageGraph (Classifier ⇄ Tools → Stylist) with:
- Langfuse trace + span context
- Conversation history loading
- User insight injection
- Background task for history persistence
Every call creates a named Langfuse trace "lead-stage-chat" so the full
Classifier → Tool → Stylist pipeline is visible in the Langfuse dashboard.
"""
import logging
import time
import uuid
from fastapi import BackgroundTasks
from langchain_core.messages import AIMessage, HumanMessage
from langchain_core.runnables import RunnableConfig
from langfuse import Langfuse, get_client as get_langfuse
from agent.controller_helpers import load_user_insight_from_redis, save_user_insight_to_redis
from agent.helper import handle_post_chat_async
from common.conversation_manager import get_conversation_manager
from common.langfuse_client import get_callback_handler
from .graph import get_lead_stage_agent
logger = logging.getLogger(__name__)
async def lead_stage_chat_controller(
*,
query: str,
identity_key: str,
background_tasks: BackgroundTasks,
model_name: str | None = None,
conversation_id: str | None = None,
is_authenticated: bool = False,
device_id: str | None = None,
) -> dict:
"""
Controller cho Lead Stage Agent — full Langfuse tracing.
Flow:
1. Load user_insight từ Redis
2. Load chat history từ ConversationManager
3. Build Langfuse trace context
4. Run LeadStageGraph (Classifier ⇄ Tools → Stylist)
5. Save conversation background
6. Return response + pipeline + lead_stage metadata
Returns:
dict with: status, ai_response, products, lead_stage, pipeline, timing, trace_id
"""
start_time = time.time()
run_id = str(uuid.uuid4())
session_id = conversation_id or f"{identity_key}-lead-{run_id[:8]}"
logger.info(f"📥 [Lead Controller] identity={identity_key} | query={query[:80]}")
# ═══ 1. LOAD USER INSIGHT ═══
user_insight = None
if identity_key:
user_insight = await load_user_insight_from_redis(str(identity_key))
# ═══ 2. LOAD CHAT HISTORY ═══
memory = await get_conversation_manager()
history_dicts = await memory.get_chat_history(
str(identity_key),
limit=10,
include_product_ids=False,
conversation_id=conversation_id,
)
history_msgs = [
HumanMessage(content=m["message"]) if m["is_human"] else AIMessage(content=m["message"])
for m in history_dicts
][::-1]
# ═══ 3. BUILD LANGFUSE TRACE ═══
trace_id = Langfuse.create_trace_id()
tags = ["lead_stage", "experiment"]
if is_authenticated:
tags.append("user:authenticated")
else:
tags.append("user:anonymous")
langfuse = get_langfuse()
observation_ctx = None
if langfuse:
try:
observation_ctx = langfuse.start_as_current_observation(
as_type="span",
name="lead-stage-chat",
trace_context={"trace_id": trace_id},
)
except Exception as e:
logger.warning(f"⚠️ Langfuse span init failed: {e}")
# Enter observation context
span = None
if observation_ctx:
try:
span = observation_ctx.__enter__()
span.update_trace(
name="lead-stage-chat",
user_id=str(identity_key),
session_id=session_id,
tags=tags,
input={"query": query, "identity_key": identity_key},
metadata={
"device_id": device_id,
"customer_id": identity_key if is_authenticated else None,
"model": model_name,
"conversation_id": conversation_id,
"history_turns": len(history_msgs),
"has_user_insight": user_insight is not None,
},
)
except Exception as e:
logger.warning(f"⚠️ Langfuse trace update failed: {e}")
# Create CallbackHandler INSIDE observation context for proper nesting
langfuse_handler = get_callback_handler()
exec_config = RunnableConfig(
callbacks=[langfuse_handler] if langfuse_handler else [],
run_name="lead-stage-graph",
run_id=run_id,
metadata={
"langfuse_session_id": session_id,
"langfuse_user_id": str(identity_key),
"langfuse_tags": tags,
"trace_id": trace_id,
"conversation_id": session_id,
"customer_id": identity_key if is_authenticated else None,
"device_id": device_id,
},
)
# ═══ 4. RUN LEAD STAGE GRAPH ═══
try:
agent = get_lead_stage_agent(model_name)
chat_result = await agent.chat(
user_message=query,
user_insight=user_insight,
history=history_msgs,
config=exec_config,
)
ai_response = chat_result.get("response", "")
lead_stage = chat_result.get("lead_stage") or {}
pipeline = chat_result.get("pipeline", [])
products = chat_result.get("products", [])
total_elapsed = chat_result.get("elapsed_ms", 0)
# Timing breakdown from pipeline
classifier_ms = 0
stylist_ms = 0
tool_ms = 0
for p in pipeline:
step = p.get("step", "")
ms = p.get("elapsed_ms", 0)
if "classifier" in step:
classifier_ms += ms
elif step in ("stylist", "responder"):
stylist_ms += ms
elif step == "tool_result":
tool_ms += ms
# Update Langfuse trace output
if span:
try:
span.update_trace(
output={
"ai_response": (ai_response or "")[:500],
"stage": lead_stage.get("stage_name", "UNKNOWN"),
"stage_confidence": lead_stage.get("confidence"),
"product_count": len(products),
"elapsed_ms": round(total_elapsed),
"classifier_ms": classifier_ms,
"stylist_ms": stylist_ms,
},
)
except Exception:
pass
logger.info(
f"✅ [Lead Controller] Stage: {lead_stage.get('stage_name', 'N/A')} | "
f"Products: {len(products)} | "
f"Time: {total_elapsed:.0f}ms (C:{classifier_ms}ms S:{stylist_ms}ms)"
)
# ═══ 5. SAVE CONVERSATION (background) ═══
# Chỉ lưu text của Stylist + compact product list (sku + name)
compact_products = [
{"sku": p.get("sku_code") or p.get("sku") or p.get("internal_ref_code", ""),
"name": p.get("name") or p.get("product_name", "")}
for p in products[:6]
]
response_payload = {
"ai_response": ai_response, # Chỉ text của Stylist
"product_ids": compact_products, # Compact: SKU + tên (không full object)
"lead_stage": lead_stage,
}
background_tasks.add_task(
handle_post_chat_async,
memory=memory,
identity_key=str(identity_key),
human_query=query,
ai_response=response_payload,
conversation_id=conversation_id,
)
# ── Persist Updated Insight về Redis ──
updated_insight = chat_result.get("updated_insight")
if updated_insight and identity_key:
import json as _json
insight_str = _json.dumps(updated_insight, ensure_ascii=False)
background_tasks.add_task(save_user_insight_to_redis, str(identity_key), insight_str)
logger.info(f"💾 [Lead Controller] Queued insight persist | stage={updated_insight.get('STAGE', '?')}")
# Lưu Postgres (persistent, không bị TTL như Redis)
from common.lead_flow_postgres import save_lead_turn
background_tasks.add_task(
save_lead_turn,
device_id=str(device_id or identity_key),
conv_id=str(conversation_id or session_id),
human_message=query,
ai_response_text=ai_response,
products=products,
lead_stage=lead_stage,
)
# ═══ 6. RETURN ═══
return {
"status": "success",
"ai_response": ai_response,
"products": products,
"trace_id": trace_id,
"lead_stage": lead_stage,
"pipeline": pipeline,
"timing": {
"classifier_ms": classifier_ms,
"stylist_ms": stylist_ms,
"tool_ms": tool_ms,
"total_ms": round(total_elapsed),
},
}
except Exception as e:
elapsed = round((time.time() - start_time) * 1000)
logger.error(f"❌ [Lead Controller] Error after {elapsed}ms: {e}", exc_info=True)
# Log error to Langfuse
if span:
try:
span.update_trace(
output={"error": str(e)[:500], "elapsed_ms": elapsed},
level="ERROR",
)
except Exception:
pass
return {
"status": "error",
"error_code": "LEAD_STAGE_ERROR",
"message": f"Lead Stage Error: {str(e)[:200]}",
"trace_id": trace_id,
}
finally:
# Close Langfuse observation context
if observation_ctx:
try:
observation_ctx.__exit__(None, None, None)
except Exception:
pass
This diff is collapsed.
This diff is collapsed.
"""
Lead Search Tool - LangChain @tool cho AI goi.
Lớp này giờ chỉ đóng vai trò Wrapper mỏng (Thin Wrapper).
Toàn bộ logic phức tạp đã được đưa sang ProductSearchEngine.
"""
import json
import logging
from langchain_core.tools import tool
from .product_search_engine import InferredSearch, LeadSearchInput, LiteralSearch, ProductSearchEngine
logger = logging.getLogger(__name__)
engine = ProductSearchEngine()
@tool(args_schema=LeadSearchInput)
async def lead_search_tool(
literal: LiteralSearch,
inferred: InferredSearch,
magento_ref_code: str | None = None,
reasoning: str | None = None,
user_insight: dict | None = None, # THÊM: từ graph state
) -> str:
"""
Tim kiem san pham CANIFA theo Dual-Lane Architecture.
"""
req = LeadSearchInput(
literal=literal,
inferred=inferred,
magento_ref_code=magento_ref_code,
reasoning=reasoning,
)
try:
# Gọi engine để xử lý logic dual-query / cascade
result_dict = await engine.search(req, reasoning=reasoning, user_insight=user_insight)
return json.dumps(result_dict, ensure_ascii=False, default=str)
except Exception as e:
logger.error("Lead search tool error: %s", e, exc_info=True)
return json.dumps({"status": "error", "message": str(e)})
"""
Product Line Mapping
Key = DB product_line_vn (chính xác)
Value = list các từ khách hàng hay dùng (synonym)
"""
# DB value → [các từ khách hàng hay gọi]
PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo Sơ mi": ["áo sơ mi", "áo công sở", "áo đi làm", "sơ mi", "sơmi", "áo sơmi"],
"Áo Polo": ["áo polo", "áo cổ bẻ", "polo"],
"Áo phông": ["áo phông", "áo thun", "áo thun ngắn tay", "áo cổ v", "áo cổ tym", "áo cộc tay"],
"Áo nỉ có mũ": ["áo nỉ có mũ", "áo hoodie", "hoodie"],
"Áo nỉ": ["áo nỉ", "áo sweater", "sweater"],
"Áo mặc nhà": ["áo mặc nhà", "áo ngủ", "áo ở nhà"],
"Áo lót": ["áo lót", "áo ngực", "áo quây", "áo lót nữ", "áo lót nam", "áo lót trẻ em"],
"Áo len gilet": ["áo len gilet", "áo gile len"],
"Áo len": ["áo len", "áo len dài tay"],
"Áo kiểu": ["áo kiểu", "áo điệu", "áo nữ tính"],
"Áo khoác sợi": ["áo khoác sợi"],
"Áo khoác nỉ không mũ": ["áo khoác nỉ không mũ", "áo khoác sweater"],
"Áo khoác nỉ có mũ": ["áo khoác nỉ có mũ", "áo khoác hoodie", "áo khoác nỉ"],
"Áo khoác lông vũ": ["áo khoác lông vũ", "áo phao lông vũ", "áo lông vũ"],
"Áo khoác gió": ["áo khoác gió", "áo gió", "áo khoác mỏng"],
"Áo khoác gilet chần bông": ["áo khoác gilet chần bông", "áo khoác gilet trần bông", "áo gilet chần bông", "áo gilet trần bông"],
"Áo khoác gilet": ["áo khoác gilet", "áo gile", "gile"],
"Áo khoác dạ": ["áo khoác dạ", "áo dạ"],
"Áo khoác dáng ngắn": ["áo khoác dáng ngắn", "áo khoác croptop"],
"Áo khoác chống nắng": ["áo khoác chống nắng", "áo chống nắng"],
"Áo khoác chần bông": ["áo khoác chần bông", "áo khoác trần bông", "áo chần bông", "áo trần bông", "áo phao"],
"Áo khoác": ["áo khoác", "áo ấm", "áo rét"],
"Áo giữ nhiệt": ["áo giữ nhiệt", "áo tản nhiệt", "áo heattech"],
"Áo bra active": ["áo bra active", "áo bra", "bra", "áo tập", "áo thể thao"],
"Áo Body": ["áo body", "áo croptop", "croptop", "baby tee", "áo lửng", "áo dáng ngắn", "áo ôm"],
"Áo ba lỗ": ["áo ba lỗ", "áo sát nách", "tanktop", "tank top", "áo dây", "áo 2 dây", "áo hai dây"],
"Váy liền": ["váy liền", "đầm", "váy công sở", "đầm công sở", "váy liền thân", "đầm suông", "váy dài", "váy body"],
"Chân váy": ["chân váy", "váy maxi", "váy midi", "chân váy dài", "chân váy chữ a", "chân váy công sở", "váy ngắn"],
"Quần giả váy": ["quần giả váy", "quần váy", "skort"],
"Quần soóc": ["quần soóc", "quần đùi", "quần short", "quần lửng", "quần ngố", "short", "quần đùi nam", "quần đùi nữ"],
"Quần nỉ": ["quần nỉ", "quần jogger", "quần ống bo chun", "jogger", "quần thể thao"],
"Quần mặc nhà": ["quần mặc nhà", "quần ngủ", "quần đùi mặc nhà"],
"Quần lót đùi": ["quần lót đùi", "quần sịp đùi", "quần boxer", "boxer", "sịp đùi", "quần xì đùi"],
"Quần lót tam giác": ["quần lót tam giác", "quần sịp tam giác", "quần brief", "brief", "sịp tam giác", "quần xì tam giác"],
"Quần lót": ["quần lót", "quần chip", "quần sịp", "quần trong", "quần nhỏ", "quần xơ lít", "quần xì", "sịp", "chip", "đồ lót"],
"Quần leggings mặc nhà": ["quần leggings mặc nhà", "quần legging mặc nhà"],
"Quần leggings": ["quần leggings", "leggings", "quần legging", "legging", "quần thun ôm"],
"Quần Khaki": ["quần khaki", "quần âu", "quần vải", "quần tây", "quần công sở", "quần đi làm", "quần âu nam", "quần âu nữ", "quần kaki"],
"Quần jean": ["quần jean", "quần bò", "quần jeans", "denim", "jeans", "bò", "jean", "quần dzin"],
"Quần giữ nhiệt": ["quần giữ nhiệt", "quần heattech"],
"Quần dài": ["quần dài", "quần suông", "quần ống rộng", "quần ống suông", "quần lưng thun"],
"Quần culottes": ["quần culottes", "culottes", "quần lửng ống rộng"],
"Quần Body": ["quần body", "quần ôm"],
"Pyjama": ["pyjama", "pajama", "đồ pijama"],
"Mũ": ["mũ", "nón", "phụ kiện Canifa", "phụ kiện"],
"Khăn tắm": ["khăn tắm", "khăn to", "phụ kiện"],
"Khăn mặt": ["khăn mặt", "khăn nhỏ", "phụ kiện"],
"Khăn lau đầu": ["khăn lau đầu", "phụ kiện"],
"Khăn": ["khăn", "khăn len", "khăn quàng cổ", "phụ kiện"],
"Găng tay chống nắng": ["găng tay chống nắng", "găng tay", "bao tay"],
"Chăn cá nhân": ["chăn cá nhân", "chăn", "mền"],
"Cardigan": ["cardigan", "áo khoác len", "áo cardigan"],
"Bộ thể thao": ["bộ thể thao", "đồ tập", "đồ thể thao"],
"Bộ quần áo": ["bộ quần áo", "đồ bộ", "set đồ"],
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà", "đồ ở nhà", "bộ lanh"],
"Blazer": ["blazer", "áo vest", "vest"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"],
"Quần tất": ["quần tất", "quần vớ", "tất quần"],
"Mũ thể thao": ["mũ thể thao", "mũ snapback", "mũ lưỡi trai", "cap"],
"Khẩu trang": ["khẩu trang", "mask", "mặt nạ vải"],
"Túi xách": ["túi xách", "túi"],
}
# ==============================================================================
# AUTO-GENERATE reverse lookup: synonym → DB value
# "áo thun" → "Áo phông", "quần bò" → "Quần jean", ...
# ==============================================================================
SYNONYM_TO_DB: dict[str, str] = {}
for db_value, synonyms in PRODUCT_LINE_MAP.items():
for syn in synonyms:
SYNONYM_TO_DB[syn.lower()] = db_value
# ==============================================================================
# RELATED LINES: hỏi "áo bra" → tìm cả "Áo bra active" + "Áo lót" và ngược lại
# ==============================================================================
RELATED_LINES: dict[str, list[str]] = {
"Áo bra active": ["Áo lót"],
"Áo lót": ["Áo bra active"],
# Quần lót (chung) → mở rộng tìm cả Quần lót đùi (Trunk) + Quần lót tam giác (Brief)
"Quần lót": ["Quần lót đùi", "Quần lót tam giác"],
"Quần lót đùi": ["Quần lót", "Quần lót tam giác"],
"Quần lót tam giác": ["Quần lót", "Quần lót đùi"],
}
def get_related_lines(product_line: str) -> list[str]:
"""VD: get_related_lines("Áo bra active") → ["Áo bra active", "Áo lót"]"""
return [product_line] + RELATED_LINES.get(product_line, [])
# Pre-sort synonyms by length DESC for longest-match-first
_SORTED_SYNONYMS = sorted(SYNONYM_TO_DB.keys(), key=len, reverse=True)
def resolve_product_name(raw_name: str) -> str:
"""
Resolve synonym trong product_name → tên DB thật.
Dùng longest-match-first để tránh match sai. Cập nhật thay thế ở bất kỳ vị trí nào trong chuỗi.
VD:
"tìm áo cổ bẻ khaki" → "tìm áo polo khaki"
"áo thun disney" → "áo phông disney"
"quần bò ống rộng" → "quần jean ống rộng"
"""
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:
if synonym in result:
db_value = SYNONYM_TO_DB[synonym]
# Thay thế tất cả các lần xuất hiện của synonym bằng db_value (để thường)
result = result.replace(synonym, db_value.lower())
return result
def resolve_product_line(raw_value: str) -> list[str]:
"""
Lookup keyword → DB product_line_vn.
Hỗ trợ '/' separator (VD: "Quần/ Váy").
Không tìm thấy → giữ nguyên (prefix match ở SQL).
"""
parts = [p.strip() for p in raw_value.split("/") if p.strip()]
resolved = []
for part in parts:
mapped = SYNONYM_TO_DB.get(part.lower())
if mapped:
resolved.append(mapped)
else:
resolved.append(part)
return resolved
This diff is collapsed.
This diff is collapsed.
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"
if 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"
if 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
......@@ -59,7 +59,6 @@ from api.product.stock_route import router as stock_router
from api.product_desc.bulk_ops_route import router as bulk_ops_router
from api.product_desc.n8n_desc import router as n8n_desc_router
from api.product_desc.product_desc_route import router as product_desc_router
from api.product_desc.tags_direct_route import router as tags_direct_router
from api.prompt_optimizer.prompt_optimizer_route import router as prompt_optimizer_router
from api.reaction_simulator.reaction_simulator_route import router as reaction_simulator_router
from api.regression_test.regression_test_route import router as regression_test_router
......@@ -101,7 +100,6 @@ api_router.include_router(canifa_product_router)
api_router.include_router(product_desc_router)
api_router.include_router(n8n_desc_router)
api_router.include_router(bulk_ops_router)
api_router.include_router(tags_direct_router)
api_router.include_router(fashion_matches_router)
api_router.include_router(fashion_matches_simulator_router)
api_router.include_router(outfit_matches_router)
......
This diff is collapsed.
"""
Shared constants, helpers, and configs for Product Description modules.
Eliminates duplication between product_desc_route.py and bulk_ops_route.py.
"""
import json
import logging
import os
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════════════
# Groq API Keys — Multi-Key Round-Robin
# ═══════════════════════════════════════════════════════
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_key_index = 0
def next_groq_key() -> str:
"""Get next Groq API key using round-robin rotation."""
global _groq_key_index
key = GROQ_API_KEYS[_groq_key_index % len(GROQ_API_KEYS)]
_groq_key_index += 1
return key
GROQ_API_KEY = GROQ_API_KEYS[0] # Backwards compat
# ═══════════════════════════════════════════════════════
# Models
# ═══════════════════════════════════════════════════════
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
ENRICH_MODEL = "openai/gpt-oss-120b"
# ═══════════════════════════════════════════════════════
# Field Labels — JSON key → Vietnamese label
# ═══════════════════════════════════════════════════════
FIELD_LABELS = {
"ten_san_pham": "Tên sản phẩm",
"tagline": "Tagline",
"mo_ta_chinh": "Mô tả chính",
"chat_lieu": "Chất liệu",
"tinh_nang_vai": "Tính năng vải",
"huong_dan_bao_quan": "Hướng dẫn bảo quản",
"phong_cach": "Phong cách",
"tags": "Tags",
"do_tuoi": "Độ tuổi",
"gioi_tinh": "Giới tính",
"loi_song": "Lối sống",
"tinh_cach": "Tính cách",
"dip_mac": "Dịp mặc",
"phoi_do": "Gợi ý phối đồ",
"nguyen_tac_phoi_do": "Nguyên tắc phối đồ",
"tranh_phoi_cung": "Tránh phối cùng",
"layer": "Gợi ý layering",
"mua": "Mùa phù hợp",
"faq_1_q": "FAQ 1 - Câu hỏi",
"faq_1_a": "FAQ 1 - Trả lời",
"faq_2_q": "FAQ 2 - Câu hỏi",
"faq_2_a": "FAQ 2 - Trả lời",
"faq_3_q": "FAQ 3 - Câu hỏi",
"faq_3_a": "FAQ 3 - Trả lời",
"hook_quang_cao": "Hook quảng cáo",
"cross_sell": "Cross-sell",
"ly_do_mua": "Lý do nên mua",
"luu_y_size": "Lưu ý chọn size",
"huong_dan_size": "Hướng dẫn chọn size",
}
# ═══════════════════════════════════════════════════════
# Section Grouping — for rendered clean_description output
# ═══════════════════════════════════════════════════════
SECTIONS = [
("📦 THÔNG TIN CƠ BẢN", ["ten_san_pham", "tagline", "mo_ta_chinh", "phong_cach", "tags"]),
("🧵 CHẤT LIỆU & BẢO QUẢN", ["chat_lieu", "tinh_nang_vai", "huong_dan_bao_quan"]),
("🎯 ĐỐI TƯỢNG", ["do_tuoi", "gioi_tinh", "loi_song", "tinh_cach"]),
("📅 DỊP MẶC & STYLING", ["dip_mac", "phoi_do", "nguyen_tac_phoi_do", "tranh_phoi_cung", "layer", "mua"]),
("❓ FAQ", ["faq_1_q", "faq_1_a", "faq_2_q", "faq_2_a", "faq_3_q", "faq_3_a"]),
("🛒 HỖ TRỢ BÁN HÀNG", ["hook_quang_cao", "cross_sell", "ly_do_mua", "huong_dan_size", "luu_y_size"]),
]
# ═══════════════════════════════════════════════════════
# Tags System
# ═══════════════════════════════════════════════════════
TAGS_ALLOWED = [
# Color tones
"trung tính", "sáng", "đậm",
# Specific colors
"đen", "trắng", "xám", "nâu", "vàng", "hồng", "xanh da trời", "xanh than", "tím", "đỏ", "cam", "xanh lá",
# Seasons
"mùa hè", "mùa đông", "mùa xuân", "mùa thu",
# Occasions
"đi học", "đi chơi", "đi tiệc", "dạo phố", "mặc nhà", "đi ngủ",
"thể thao", "đi dã ngoại", "đi biển", "đi làm", "giữ ấm", "thoáng mát",
# Styles
"thanh lịch", "năng động", "basic", "cá tính", "dễ thương", "trẻ trung", "tối giản", "smart casual",
# Fit
"oversize", "slim", "regular", "wide leg", "cropped", "relaxed"
]
TAGS_PROMPT = f"""Bạn là chuyên gia phân loại sản phẩm. Hãy đọc phần mô tả sản phẩm và trích xuất đúng các tags phù hợp nhất.
HỆ THỐNG TAGS HỢP LỆ (Chỉ được chọn từ danh sách này):
{json.dumps(TAGS_ALLOWED, ensure_ascii=False)}
QUY TẮC:
- Trả về 1 mảng JSON chứa tối đa 6 tags.
- KHÔNG sáng tạo thêm tags mới ngoài danh sách.
- Ví dụ trả về: ["đi làm", "thoáng mát", "mùa hè"]
"""
# ═══════════════════════════════════════════════════════
# Canifa Product Lines
# ═══════════════════════════════════════════════════════
CANIFA_PRODUCT_LINES = "Áo phông, Quần soóc, Áo nỉ, Váy liền, Quần nỉ, Tất, Áo len, Bộ mặc nhà, Bộ quần áo, Áo Polo, Chân váy, Áo nỉ có mũ, Áo Sơ mi, Quần jean, Quần dài, Áo khoác chần bông, Quần mặc nhà, Áo Body, Quần Khaki, Áo khoác gió, Áo khoác dáng ngắn, Áo kiểu, Áo len gilet, Áo khoác gilet chần bông, Áo giữ nhiệt, Áo mặc nhà, Quần lót, Pyjama, Áo ba lỗ, Áo khoác nỉ không mũ, Blazer, Cardigan, Khăn, Bộ thể thao, Áo khoác chống nắng, Quần váy, Quần lót đùi, Áo khoác, Khăn mặt, Áo khoác nỉ có mũ, Mũ, Áo khoác lông vũ, Khăn tắm, Áo lót, Quần lót tam giác, Túi xách, Quần leggings mặc nhà, Áo khoác sợi, Quần leggings, Chăn cá nhân, Mũ thể thao, Áo khoác dạ, Quần giữ nhiệt, Găng tay chống nắng, Khăn lau đầu, Áo khoác gilet, Áo hai dây, Khẩu trang, Quần culottes, Quần Body, Áo bra active"
# ═══════════════════════════════════════════════════════
# Render Function — JSON → formatted text
# ═══════════════════════════════════════════════════════
def render_description_text(data: dict, product_name: str = "") -> str:
"""Render JSON description_data into a single formatted text block."""
if not data:
data = {}
lines = []
title = data.get("ten_san_pham", product_name or "Sản phẩm")
lines.append(f"{'=' * 50}")
lines.append(f" {title.upper()}")
lines.append(f"{'=' * 50}")
lines.append("")
for section_title, fields in SECTIONS:
section_lines = []
for key in fields:
val = data.get(key, "")
if not val:
continue
label = FIELD_LABELS.get(key, key)
# FAQ: group Q+A together
if key.endswith("_q"):
section_lines.append(f" 💬 {val}")
continue
if key.endswith("_a"):
section_lines.append(f" → {val}")
continue
# Phối đồ: format combos nicely
if key == "phoi_do" and "|" in str(val):
section_lines.append(f" {label}:")
for combo in str(val).split("|"):
section_lines.append(f" • {combo.strip()}")
continue
# Lý do mua: format with bullets
if key == "ly_do_mua" and "|" in str(val):
section_lines.append(f" {label}:")
for reason in str(val).split("|"):
section_lines.append(f" • {reason.strip()}")
continue
# Default: label: value
section_lines.append(f" {label}: {val}")
if section_lines:
lines.append(f"── {section_title} ──")
lines.extend(section_lines)
lines.append("")
else:
lines.append(f"── {section_title} ──")
lines.append(" Chưa có dữ liệu.")
lines.append("")
return "\n".join(lines)
This diff is collapsed.
This diff is collapsed.
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Đối Chiếu Data — Canifa AI</title>
<link rel="stylesheet" href="/static/common/theme.css">
<link rel="stylesheet" href="/static/common/components.css">
<script src="/static/common/frame-detect.js"></script>
<link rel="stylesheet" href="/static/product-desc/product-desc.css">
</head>
<body>
<div class="page">
<!-- Header -->
<div class="page-hdr" style="margin-bottom:20px;">
<div>
<div style="display:flex; align-items:center; gap:10px;">
<button class="btn btn-outline btn-sm" onclick="let a=document.createElement('a'); a.setAttribute('data-page','product-desc/product-desc.html'); window.parent.navigateTo(a)">← Quay Lại</button>
<h1 style="margin:0;">✦ Kiểm Tra & Đối Chiếu Data (Auto-Pilot)</h1>
</div>
<p style="margin-top:4px;">Danh sách các sản phẩm đang thiếu Data hoặc bị lệch đồng bộ giữa StarRocks và AI.</p>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm"
onclick="loadCompareProducts()">↻ Refresh</button>
</div>
</div>
<!-- Actions -->
<div style="display:flex; justify-content:flex-end; margin-bottom:12px; gap:10px;">
<button class="btn btn-sm" style="background:#e11d48;color:#fff;border:none;font-weight:700;box-shadow:0 0 10px rgba(225,29,72,0.4);" onclick="runAutoPilotCleanAndTags()">🚀 Sinh Clean & Tags (Hàng loạt)</button>
<button class="btn btn-sm" style="background:#10b981;color:#fff;border:none;font-weight:700;box-shadow:0 0 10px rgba(16,185,129,0.4);" onclick="runAutoPilotDataCut()">✂️ Sinh Data Cut (Hàng loạt)</button>
</div>
<!-- Filters -->
<div class="filter-bar" style="margin-bottom: 20px;">
<select id="compareStatusFilter" onchange="loadCompareProducts()">
<option value="missing">✕ Trống trơn (Thiếu toàn bộ)</option>
<option value="missing_cut">✕ Có Clean nhưng Thiếu Data Cut</option>
<option value="all">Tất cả sản phẩm (Để đối chiếu)</option>
</select>
<button class="btn btn-outline-dark btn-sm" onclick="loadCompareProducts()">Lọc</button>
</div>
<div style="border-radius:10px; overflow:hidden; border:1px solid var(--border); background:var(--card);">
<table class="product-table" id="compareTable">
<thead style="background:var(--muted);">
<tr>
<th style="width:20%">Mã Sản Phẩm</th>
<th style="width:30%">Tên Sản Phẩm</th>
<th style="width:15%">Trạng thái Data</th>
<th style="width:20%">Chi tiết Thiếu</th>
<th style="width:15%">Thao tác</th>
</tr>
</thead>
<tbody id="compareTableBody">
<tr>
<td colspan="5" class="table-loading">Đang tải...</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination" id="comparePagination"></div>
</div>
<!-- COMPARE MODAL -->
<div class="detail-overlay" id="compareModal" style="display:none;" onclick="if(event.target===this) this.style.display='none'">
<div class="detail-panel" style="width:95%; max-width:1400px; padding: 0; display:flex; flex-direction:column; height: 90vh;">
<div style="padding: 20px; border-bottom: 1px solid var(--border); display:flex; justify-content:space-between; align-items:center; background:#f8fafc;">
<h2 style="margin:0; font-size:18px; color:var(--primary);">⚖️ Đối Chiếu Mô Tả: <span id="compareModalCode" style="color:#0284c7;"></span></h2>
<button class="btn btn-outline btn-sm" onclick="document.getElementById('compareModal').style.display='none'">✕ Đóng</button>
</div>
<div style="display:flex; flex:1; overflow:hidden;">
<!-- Left Panel: StarRocks -->
<div style="flex:1; border-right:2px solid var(--border); display:flex; flex-direction:column; background:#fff;">
<div style="padding:12px; background:#f1f5f9; font-weight:bold; border-bottom:1px solid var(--border); color:#334155;">
Kho Gốc (StarRocks - description_text_full)
</div>
<div id="compareStarRocks" style="flex:1; padding:20px; overflow-y:auto; font-size:14px; line-height:1.6; white-space:pre-wrap; font-family:var(--font-mono); color:#475569;">
Đang tải...
</div>
</div>
<!-- Right Panel: SQLite -->
<div style="flex:1; display:flex; flex-direction:column; background:#fff;">
<div style="padding:12px; background:#ecfdf5; font-weight:bold; border-bottom:1px solid var(--border); color:#065f46; display:flex; justify-content:space-between;">
<span>AI Đã Xử Lý (SQLite / Postgres)</span>
<div style="display:flex; gap:10px;">
<span style="font-size:12px; font-weight:normal; display:flex; align-items:center;"><span style="display:inline-block; width:12px; height:12px; background:#dcfce7; margin-right:4px;"></span> Thêm</span>
<span style="font-size:12px; font-weight:normal; display:flex; align-items:center;"><span style="display:inline-block; width:12px; height:12px; background:#fee2e2; margin-right:4px; text-decoration:line-through;"></span> Xóa</span>
</div>
</div>
<div id="compareSQLite" style="flex:1; padding:20px; overflow-y:auto; font-size:14px; line-height:1.6; white-space:pre-wrap;">
Đang tải...
</div>
</div>
</div>
</div>
</div>
<script src="/static/product-desc/js/state.js?v=202604231530"></script>
<script src="/static/product-desc/js/core.js?v=202604231530"></script>
<script src="/static/product-desc/js/compare-tab.js?v=2"></script>
<script>
document.addEventListener("DOMContentLoaded", function() {
loadCompareProducts(1);
});
</script>
</body>
</html>
// auto-pilot.js
async function runAutoPilot() {
if (!confirm(`Hệ thống sẽ quét toàn bộ DB để tìm SP thiếu Tags hoặc thiếu Mô tả chính, sau đó tự động đẩy vào Queue xử lý.\n\nBạn có muốn bắt đầu 1-Click Auto-Pilot không?`)) return;
const btn = document.getElementById('btnAutoPilot');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm" style="border-top-color:#fff"></div> Đang quét...';
btn.disabled = true;
try {
const res = await fetch(`/api/bulk/auto-pilot/run`, { method: 'POST' });
const data = await res.json();
if (res.status === 409) {
alert(data.message);
// Fallback to start polling anyway in case they are running
if (typeof startBatchPolling === 'function') startBatchPolling();
if (typeof startTagsBatchPolling === 'function') startTagsBatchPolling();
return;
}
if (data.status === 'error') throw new Error(data.message);
alert(`${data.message}\n- Tổng SP có Clean Desc: ${data.stats.total}\n- Chờ bơm Tags: ${data.stats.queued_for_tags}\n- Chờ bơm Mô tả: ${data.stats.queued_for_desc}\n- Đã đầy đủ: ${data.stats.full_info}`);
// Start tracking both tags and descriptions
if (typeof startBatchPolling === 'function') startBatchPolling();
if (typeof startTagsBatchPolling === 'function') startTagsBatchPolling();
// Refresh stats
if (typeof loadOverview === 'function') loadOverview();
if (typeof loadProducts === 'function') loadProducts();
} catch (e) {
alert('Lỗi Auto-Pilot: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
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