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
"""
Lead Stage Graph — 2-Agent Architecture (Classifier NHẸ + Stylist NẶNG).
Flow: User → Classifier (route tool) → [Tool Exec] → Stylist (response + insight) → END
2 con AI:
- Classifier (NHẸ): with_structured_output(ClassifierOutput) → chỉ quyết định tool
- Stylist (NẶNG): with_structured_output(StylistOutput) → sinh response + InsightJSON
"""
import json
import logging
import re
import time
from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages
from pydantic import BaseModel, Field
from agent.tools.add_to_cart_tool import add_to_cart
from agent.tools.brand_knowledge_tool import canifa_knowledge_search
from agent.tools.check_is_stock import check_is_stock
from agent.tools.customer_info_tool import collect_customer_info
from agent.tools.promotion_canifa_tool import canifa_get_promotions
from agent.tools.store_search_tool import canifa_store_search
from common.llm_factory import create_llm
from config import DEFAULT_MODEL
# Tools
from .lead_search_tool import lead_search_tool
from .prompts import LEAD_STAGE_CLASSIFIER_PROMPT, STYLIST_SYSTEM_PROMPT, format_insight_injection
logger = logging.getLogger(__name__)
# ═══════════════════════════════════════════════
# Pydantic Models
# ═══════════════════════════════════════════════
class LiteralSearchArgs(BaseModel):
model_config = {"extra": "ignore"}
raw_text: str = Field(
default="",
description=(
"Câu search NGUYÊN VĂN từ user. Copy y hệt phần liên quan đến sản phẩm. "
"VD: user nói 'tìm áo 30/4' -> raw_text='áo 30/4'. "
"VD: user nói 'có quần jean ống rộng ko' -> raw_text='quần jean ống rộng'. "
"KHÔNG suy luận, KHÔNG thêm bớt. Giữ nguyên ý user."
),
)
class InferredSearchArgs(BaseModel):
model_config = {"extra": "ignore"}
product_line_vn: list[str] = Field(
default=[],
description=(
"BẮT BUỘC ĐIỀN nêú xác định được loại SP. Đây là bộ lọc SQL QUAN TRỌNG NHẤT. "
"Sử dụng các từ khóa chuẩn: 'Áo phông', 'Áo Polo', 'Áo sơ mi', 'Quần jean', 'Váy liền', 'Chân váy', 'Áo khoác', 'Áo len', 'Áo nỉ', 'Áo giữ nhiệt', 'Bộ mặc nhà', 'Quần Khaki', 'Quần dài', 'Bộ thể thao', 'Áo kiểu'..."
),
)
gender_by_product: str | None = Field(
default=None,
description="Suy luan tu ngu canh: women/men/boy/girl/unisex. Null neu khong co manh moi.",
)
age_by_product: str | None = Field(default=None, description="adult | kid | others")
master_color: str | None = Field(
default=None,
description="Màu sắc AI SUY LUẬN từ ngữ cảnh. VD: '30/4' -> color='đỏ'. 'áo trắng' -> color='trắng'.",
)
tags: list[str] = Field(
default=[],
description=(
"AI suy luận ý định khách -> chọn từ DANH SÁCH TAGS CỐ ĐỊNH: "
"Trục 1 (Màu & Tông): Màu đen, Màu trắng, Màu xám, Màu xanh navy, Màu xanh da trời, Màu be, Màu nâu, Màu hồng, Màu đỏ, Màu vàng, Tông trung tính (Neutral), Tông màu pastel, Tông sáng (Light), Tông nổi bật (Dark). "
"Trục 2 (Dịp): Đi làm / Công sở, Đi chơi / dạo phố, Mặc nhà, Tập thể thao, Đi tiệc, Đi biển, Du lịch, Đi học. "
"Trục 3 (Mùa): Mùa hè, Mùa đông, Cả 2 mùa, Giao mùa. "
"Trục 4 (Phong cách): Cơ bản (Basic), Năng động, Thanh lịch, Dễ thương, Cá tính, Tối giản, Smart Casual, Thoáng mát, Giữ ấm, Thấm hút, Nhanh khô, Chống UV, Cản gió. "
"Trục 5 (Dáng): Oversize, Slim, Regular, Wide leg, Cropped, Relaxed. "
"VD: 'đi tiệc' -> tags=['Đi tiệc']. 'mùa đông' -> tags=['Mùa đông', 'Giữ ấm']. "
"KHÔNG tự nghĩ tag mới! Tối đa 3."
),
)
keywords: list[str] = Field(
default=[],
description=(
"Từ khóa BỔ TRỢ AI suy luận thêm (chất liệu, tính năng...). "
"VD: '30/4' -> keywords=['cờ đỏ sao vàng']. 'đi biển' -> keywords=['thoáng mát']. "
"Tối đa 2."
),
)
price_min: int | None = Field(default=None)
price_max: int | None = Field(default=None)
discount_min: int | None = Field(default=None, description="% giam gia toi thieu. VD: hang sale -> 1")
discount_max: int | None = Field(default=None)
discovery_mode: str | None = Field(default=None, description="new | best_seller. Chi dung khi khach noi ro.")
size: str | None = Field(default=None, description="XS/S/M/L/XL/XXL/3XL... Chi dien khi khach noi ro size.")
class ClassifierLeadSearchArgs(BaseModel):
"""Dual-Lane Search Args — buộc AI tách bạch rõ Literal vs Inferred."""
model_config = {"extra": "ignore"}
literal: LiteralSearchArgs = Field(
default_factory=LiteralSearchArgs,
description="Lane 1: Câu search nguyên văn từ user. KHÔNG filter, KHÔNG suy luận.",
)
inferred: InferredSearchArgs = Field(
default_factory=InferredSearchArgs,
description="Lane 2: AI suy luận filter structured.",
)
magento_ref_code: str | None = Field(default=None, description="Ma SKU cu the neu khach cung cap.")
reasoning: str | None = Field(default=None, description="Giai thich ngan gon tai sao chon params nay.")
class ClassifierOutput(BaseModel):
"""Classifier output — Định tuyến hoặc Trả lời Sớm."""
model_config = {"extra": "ignore"}
reasoning: str = Field(description="Ly luan goi tool hay tra loi truc tiep")
# ── TH 1: GOI TOOL ──
tool_name: str | None = Field(
default=None,
description="Ten tool (lead_search_tool). Bat buoc de null neu khach chi dang tam su/chao hoi/ko can DB.",
)
lead_search_args: ClassifierLeadSearchArgs | None = Field(
default=None,
description=(
"Args cho lead_search_tool. PHAI dien khi tool_name='lead_search_tool'. Null neu tool_name khac hoac null."
),
)
tool_args: dict | None = Field(
default=None,
description="Args cho cac tool khac (knowledge_search, check_is_stock, v.v.). Null neu dung lead_search_tool.",
)
# ── TH 2: EARLY EXIT ──
ai_response: str | None = Field(
default=None,
description="Cau tra loi cho khach. CHI nha ra khi tool_name=null. Neu goi tool, bat buoc de null.",
)
product_ids: list[str] | None = Field(default_factory=list, description="Ma SKU (neu khach hoi trung ma).")
class InsightJSON(BaseModel):
"""Bộ nhớ khách hàng — 12 trường."""
model_config = {"extra": "ignore"}
USER: str = Field(default="Chưa rõ")
TARGET: str = Field(default="Chưa rõ")
GOAL: str = Field(default="Chưa rõ")
CONSTRAINS: str = Field(
default="", description="CHỈ điền khi khách NÓI RÕ. VD: khách nói 'dưới 500k' → 'Budget: <500k'. KHÔNG TỰ BỊA!"
)
STAGE: str = Field(default="BROWSE")
STAGE_NUM: int = Field(default=1)
TONE: str = Field(default="Friendly")
BEHAVIORAL_HINTS: list[str] = Field(default_factory=list)
LATEST_PRODUCT_INTEREST: str = Field(default="")
LAST_ACTION: str = Field(default="")
SUMMARY_HISTORY: str = Field(default="")
class StylistOutput(BaseModel):
"""Stylist output — NẶNG, sinh cả response + insight."""
model_config = {"extra": "ignore"}
ai_response: str = Field(description="Câu trả lời cho khách (max 200 từ)")
product_ids: list[str] = Field(default_factory=list)
user_insight: InsightJSON = Field(description="Updated user insight sau turn này")
# ═══════════════════════════════════════════════
# State
# ═══════════════════════════════════════════════
class LeadStageState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages]
# Input context
user_insight: str | None
# Internal pipeline
tool_result: str | None
tool_name_used: str | None
# Output
updated_insight: dict | None
lead_stage: dict | None
stage_injection: str | None
product_ids: list[str]
early_exit: bool | None
diagnostics: Annotated[list[dict[str, Any]], lambda x, y: x + y]
# ═══════════════════════════════════════════════
# Helpers
# ═══════════════════════════════════════════════
def _extract_text(content) -> str:
"""Extract plain text từ msg.content."""
if isinstance(content, str):
return content
if isinstance(content, list):
parts = []
for item in content:
if isinstance(item, dict) and item.get("type") == "text":
parts.append(item.get("text", ""))
elif isinstance(item, str):
parts.append(item)
return "\n".join(parts) if parts else str(content)
return str(content or "")
_SKU_REGEX = re.compile(r"\b\d[A-Z0-9]{5,}(?:-[A-Z0-9]{2,}){0,2}\b", re.IGNORECASE)
def _dedupe_preserve_order(items: list[str]) -> list[str]:
seen: set[str] = set()
out: list[str] = []
for raw in items:
val = str(raw or "").upper().strip()
if not val or val in seen:
continue
seen.add(val)
out.append(val)
return out
def _extract_skus_from_text(text: str | None) -> list[str]:
if not text:
return []
matches = _SKU_REGEX.findall(text)
return _dedupe_preserve_order([m for m in matches if m])
# ═══════════════════════════════════════════════
# Graph Builder
# ═══════════════════════════════════════════════
class LeadStageGraph:
"""2-Agent: Classifier (NHẸ, route tool) → Stylist (NẶNG, response + insight)."""
def __init__(self, model_name: str | None = None):
self.model_name = model_name or DEFAULT_MODEL
# Tool registry
self._tool_registry = {
"lead_search_tool": lead_search_tool,
"canifa_knowledge_search": canifa_knowledge_search,
"canifa_get_promotions": canifa_get_promotions,
"canifa_store_search": canifa_store_search,
"check_is_stock": check_is_stock,
"collect_customer_info": collect_customer_info,
"add_to_cart": add_to_cart,
}
# AI 1: Classifier — structured output bypass for Claude API (zunef gateway)
_classifier_base = create_llm(model_name=self.model_name, streaming=False)
self.classifier_llm = _classifier_base
# Note: Claude models bypass structured output, parse JSON manually
# AI 2: Stylist — structured output bypass for Claude API (zunef gateway)
_stylist_base = create_llm(model_name=self.model_name, streaming=False)
self.stylist_llm = _stylist_base
# Note: Claude models bypass structured output, parse JSON manually
self._compiled = None
logger.info(f"✅ LeadStageGraph initialized | model: {self.model_name}")
# ─────────────────────────────────────
# Helper: Format history
# ─────────────────────────────────────
def _format_history(self, messages: list[BaseMessage], max_turns: int = 10) -> str:
"""Format recent messages cho context — giữ 10 turns, 500 chars/msg."""
lines = []
human_count = 0
for msg in reversed(messages):
if isinstance(msg, HumanMessage):
lines.insert(0, f"Khách: {_extract_text(msg.content)[:500]}")
human_count += 1
elif isinstance(msg, AIMessage) and not (hasattr(msg, "tool_calls") and msg.tool_calls):
lines.insert(0, f"Bot: {_extract_text(msg.content)[:500]}")
if human_count >= max_turns:
break
return "\n".join(lines) if lines else "Chưa có lịch sử."
# ─────────────────────────────────────
# Helper: Format tool result cho Stylist
# ─────────────────────────────────────
def _format_tool_result(self, tool_result: str | None) -> str:
if not tool_result:
return ""
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
dedup = {}
for p in products:
sku = str(p.get("sku_code") or p.get("sku") or p.get("internal_ref_code") or "").upper().strip()
if sku and sku not in dedup:
dedup[sku] = p
unique_products = list(dedup.values())
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", "")
line = f"- [{p.get('sku', '')}] {p.get('name', '')} | Giá: {price:,}đ"
if discount:
line += f" ({discount})"
if p.get("sizes"):
line += f" | Sizes: {p['sizes']}"
desc = (p.get("description") or "").strip()
if desc:
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)
return "\n".join(lines)
return parsed.get("message", "Không tìm thấy sản phẩm phù hợp.")
content = parsed.get("content") or parsed.get("message") or str(parsed)
return str(content)[:800]
except Exception:
return (tool_result or "")[:500]
# ─────────────────────────────────────
# Node 1: CLASSIFIER — TOOL ROUTER (NHẸ)
# ─────────────────────────────────────
async def _classifier_node(self, state: LeadStageState, config: RunnableConfig | None = None) -> dict:
"""Classifier: Đọc query + insight → quyết định gọi tool nào. KHÔNG sinh insight."""
messages = state["messages"]
user_query = _extract_text(messages[-1].content) if messages else ""
user_insight = state.get("user_insight") or "Chưa có thông tin."
history_text = self._format_history(messages[:-1])
# Extract previously shown products from insight
products_context = ""
try:
if isinstance(user_insight, str) and user_insight.startswith("{"):
_insight = json.loads(user_insight)
latest = _insight.get("LATEST_PRODUCT_INTEREST", "")
summary = _insight.get("SUMMARY_HISTORY", "")
if latest:
products_context += f"\n=== SP ĐÃ GIỚI THIỆU (QUAN TRỌNG) ===\n{latest}\n"
if summary:
products_context += f"\n=== TÓM TẮT HỘI THOẠI ===\n{summary}\n"
except Exception:
pass
context_block = (
f"=== TIN NHẮN HIỆN TẠI ===\n{user_query}\n\n"
f"=== USER INSIGHT ===\n{user_insight}\n\n"
f"{products_context}"
f"=== LỊCH SỬ (10 cặp gần nhất) ===\n{history_text}"
)
classifier_messages = [
SystemMessage(content=LEAD_STAGE_CLASSIFIER_PROMPT),
HumanMessage(content=context_block),
]
start = time.time()
try:
response = await self.classifier_llm.ainvoke(
classifier_messages, config=config
)
# Parse JSON from text response (Claude models return text, not structured output)
import json as json_lib
text = response.content if hasattr(response, "content") else str(response)
logger.info(f"[CLASSIFIER] Raw response repr: {text[:500]!r}")
# Extract JSON from text (may have extra text)
text = text.strip()
text = text.removeprefix('```json')
text = text.removeprefix('```')
text = text.removesuffix('```')
text = text.strip()
try:
output = json_lib.loads(text)
except json_lib.JSONDecodeError as je:
logger.error(f"[CLASSIFIER] JSON parse error: {je}. Text was: {text[:500]!r}")
raise
# Convert dict to object-like access
class DictToObj:
def __init__(self, data):
self.__dict__.update(data)
output = DictToObj(output)
except Exception as e:
logger.error(f"❌ Classifier error: {e}")
return {
"tool_result": None,
"tool_name_used": None,
"diagnostics": [
{"step": "classifier_error", "label": "❌ Classifier", "content": str(e)[:300], "elapsed_ms": 0}
],
}
elapsed_ms = (time.time() - start) * 1000
tool_name = output.tool_name
ai_response = output.ai_response
# ── Resolve tool_args: typed lead_search_args hoac raw dict ──
if tool_name == "lead_search_tool" and output.lead_search_args:
# Handle both dict and Pydantic model cases
if isinstance(output.lead_search_args, dict):
tool_args = output.lead_search_args
elif hasattr(output.lead_search_args, 'model_dump'):
tool_args = output.lead_search_args.model_dump(exclude_none=False)
else:
tool_args = dict(output.lead_search_args) if output.lead_search_args else {}
elif tool_name == "lead_search_tool" and output.tool_args:
# fallback: model dung tool_args thay vi lead_search_args
tool_args = output.tool_args
elif tool_name and tool_name != "lead_search_tool":
tool_args = output.tool_args or {}
else:
tool_args = {}
# ── PASS USER_INSIGHT TO LEAD_SEARCH_TOOL ──
if tool_name == "lead_search_tool" and user_insight:
tool_args["user_insight"] = user_insight
# ── Log Classifier parsed output ──
logger.info(
"[CLASSIFIER] tool=%s | product_line=%s | keywords=%s | gender=%s | color=%s | reasoning=%s",
tool_name,
tool_args.get("product_line_vn") if tool_args else None,
tool_args.get("keywords") if tool_args else None,
tool_args.get("gender_by_product") if tool_args else None,
tool_args.get("master_color") if tool_args else None,
(output.reasoning or "")[:120],
)
# product_line_vn guard removed — keywords LIKE description_text handles category matching
# Tool call proceeds even with empty product_line_vn
# Build diagnostic
diag = {
"step": "classifier",
"label": f"🔧 Classifier → {tool_name or 'Early Exit'}",
"content": output.reasoning,
"elapsed_ms": round(elapsed_ms),
}
# ── EARLY EXIT (KHÔNG GỌI TOOL) ──
if not tool_name and ai_response:
logger.info(f"⚡ Early Exit: {ai_response[:50]}...")
diag["label"] = "⚡ Classifier (Early Exit)"
diag["content"] = output.reasoning + f"\n[AI Response: {ai_response[:100]}]"
return {
"messages": [AIMessage(content=ai_response)],
"tool_result": None,
"tool_name_used": None,
"product_ids": output.product_ids or [],
"early_exit": True,
"diagnostics": [diag],
}
# ── CALL TOOL ──
if tool_name:
diag["tool_calls"] = [{"name": tool_name, "args": tool_args}]
diagnostics = [diag]
# ── Execute Tool Inline ──
tool_result = None
if tool_name:
tool_fn = self._tool_registry.get(tool_name)
if tool_fn:
try:
t_start = time.time()
tool_result = await tool_fn.ainvoke(tool_args)
tool_elapsed = round((time.time() - t_start) * 1000)
logger.info(f"📦 Tool '{tool_name}' OK | {tool_elapsed}ms")
# Tool result diagnostic
try:
parsed = json.loads(tool_result)
count = parsed.get("count", len(parsed.get("products", [])))
summary = f"✅ {count} sản phẩm"
products = parsed.get("products", [])
if products:
names = [f"• {p.get('name', '')}" for p in products[:4]]
summary += "\n" + "\n".join(names)
except Exception:
summary = (tool_result or "")[:200]
diagnostics.append(
{
"step": "tool_result",
"label": f"📦 {tool_name}",
"content": summary,
"raw_json": tool_result,
"elapsed_ms": tool_elapsed,
}
)
except Exception as te:
logger.error(f"❌ Tool '{tool_name}' error: {te}")
tool_result = json.dumps({"status": "error", "message": str(te)})
else:
logger.warning(f"⚠️ Unknown tool: '{tool_name}'")
return {
"tool_result": tool_result,
"tool_name_used": tool_name,
"diagnostics": diagnostics,
}
# ─────────────────────────────────────
# Node 2: STYLIST — Response + Insight (NẶNG, LUÔN CHẠY)
# ─────────────────────────────────────
async def _stylist_node(self, state: LeadStageState, config: RunnableConfig | None = None) -> dict:
"""Stylist: Nhận tool result + history + insight cũ → sinh response + InsightJSON mới."""
messages = state["messages"]
tool_result = state.get("tool_result")
user_insight = state.get("user_insight") or "Chưa có."
# Format insight cũ → inject vào system prompt
try:
old_insight = (
json.loads(user_insight) if isinstance(user_insight, str) and user_insight.startswith("{") else {}
)
except Exception:
old_insight = {}
injection = format_insight_injection(old_insight) if old_insight else "Chưa có insight — đây là lần đầu."
# Escape curly braces in prompt, then substitute only stage_injection
stylist_sys = STYLIST_SYSTEM_PROMPT.replace("{", "{{").replace("}", "}}").replace("{{stage_injection}}", "{stage_injection}").format(stage_injection=injection)
# Build messages cho Stylist
stylist_messages: list[BaseMessage] = [SystemMessage(content=stylist_sys)]
# Recent history (3 turns)
human_count = 0
recent: list[BaseMessage] = []
for msg in reversed(messages[:-1]):
if isinstance(msg, HumanMessage):
recent.insert(0, msg)
human_count += 1
elif isinstance(msg, AIMessage) and not (hasattr(msg, "tool_calls") and msg.tool_calls):
recent.insert(0, msg)
if human_count >= 3:
break
stylist_messages.extend(recent)
# Current query (LAST HumanMessage — tin nhắn hiện tại)
current_query = None
for msg in reversed(messages):
if isinstance(msg, HumanMessage):
current_query = msg
break
if current_query:
stylist_messages.append(current_query)
# Tool results
tool_context = self._format_tool_result(tool_result)
if tool_context:
stylist_messages.append(HumanMessage(content=f"=== KẾT QUẢ TRA CỨU ===\n{tool_context}"))
else:
stylist_messages.append(
HumanMessage(content="=== KHÔNG CÓ TOOL RESULT === (Khách chào hỏi hoặc câu hỏi chung)")
)
# Strict reminder before LLM call
stylist_messages.append(HumanMessage(content="""REMINDER - MUST FOLLOW:
1. List ALL SKUs mentioned in your response in `product_ids` array.
2. For combo/set queries: MUST return 5+ products (TOP + BOTTOM).
3. If tool returns <5 items, PICK MORE from `outfit_recommendations`/`ai_matches`.
4. Final check: Scan your response for (XXXXX) patterns, ensure ALL are in `product_ids`.
Output ONLY JSON, no extra text!"""))
# ── LLM Call: Parse JSON from text (Claude returns text, not structured) ──
# ── LLM Call: Parse JSON from text (Claude returns text, not structured) ──
start = time.time()
try:
response = await self.stylist_llm.ainvoke(
stylist_messages, config=config
)
# Parse JSON from text response
text = response.content if hasattr(response, "content") else str(response)
logger.info(f"[STYLIST] Raw response text (first 500 chars): {text[:500]!r}")
text = text.strip()
text = text.removeprefix('```json')
text = text.removeprefix('```')
text = text.removesuffix('```')
text = text.strip()
output = json.loads(text)
# Convert dict to object-like access
class DictToObj:
def __init__(self, data):
self.__dict__.update(data)
def __getattr__(self, name):
return self.__dict__.get(name)
output = DictToObj(output)
# Handle nested user_insight dict
if hasattr(output, "user_insight") and isinstance(output.user_insight, dict):
output.user_insight = DictToObj(output.user_insight)
except Exception as e:
logger.error(f"❌ Stylist JSON parse error: {e}")
fallback = "Dạ bạn cho mình hỏi thêm để tư vấn chính xác hơn nhé!"
return {
"messages": [AIMessage(content=fallback)],
"updated_insight": None,
"lead_stage": {"stage": 1, "stage_name": "BROWSE", "tone_directive": "Friendly"},
"stage_injection": "",
"product_ids": [],
"diagnostics": [
{"step": "stylist_error", "label": "❌ Stylist Error", "content": str(e)[:300], "elapsed_ms": 0}
],
}
elapsed_ms = (time.time() - start) * 1000
# Ensure product_ids covers every SKU mentioned in ai_response (deterministic safeguard)
from_text = _extract_skus_from_text(output.ai_response)
merged_product_ids = _dedupe_preserve_order((getattr(output, "product_ids", []) or []) + from_text)
# Extract insight - handle both dict and object cases
insight_raw = getattr(output, "user_insight", None)
if isinstance(insight_raw, dict):
insight_dict = insight_raw
elif hasattr(insight_raw, "__dict__"):
insight_dict = insight_raw.__dict__
else:
insight_dict = {}
lead_stage = {
"stage": insight_dict.get("STAGE_NUM", 1),
"stage_name": insight_dict.get("STAGE", "BROWSE"),
"tone_directive": insight_dict.get("TONE", "Friendly"),
"behavioral_hints": insight_dict.get("BEHAVIORAL_HINTS", []),
}
stage_injection = format_insight_injection(insight_dict)
ai_response = getattr(output, "ai_response", "Dạ mình chưa hiểu, bạn nói lại nhé!")
product_ids = getattr(output, "product_ids", []) or []
logger.info(
f"💬 Stylist: stage={insight_dict.get('STAGE')} | goal={insight_dict.get('GOAL')} | "
f"response_len={len(ai_response)} | products={merged_product_ids} | {elapsed_ms:.0f}ms"
)
diag = {
"step": "stylist",
"label": "💬 Stylist (Response + Insight)",
"content": ai_response[:500],
"elapsed_ms": round(elapsed_ms),
"raw_json": json.dumps(insight_dict, ensure_ascii=False, indent=2),
}
return {
"messages": [AIMessage(content=ai_response)],
"updated_insight": insight_dict,
"lead_stage": lead_stage,
"stage_injection": stage_injection,
"product_ids": merged_product_ids,
"diagnostics": [diag],
}
# ─────────────────────────────────────
# Router: Classifier → Stylist or END
# ─────────────────────────────────────
def _after_classifier(self, state: LeadStageState) -> str:
if state.get("early_exit"):
return END
return "stylist"
# ─────────────────────────────────────
# Build Graph: Classifier → Stylist → END (always)
# ─────────────────────────────────────
def build(self):
if self._compiled:
return self._compiled
workflow = StateGraph(LeadStageState)
workflow.add_node("classifier", self._classifier_node)
workflow.add_node("stylist", self._stylist_node)
workflow.set_entry_point("classifier")
workflow.add_conditional_edges("classifier", self._after_classifier, {"stylist": "stylist", END: END})
workflow.add_edge("stylist", END)
self._compiled = workflow.compile()
logger.info("✅ LeadStageGraph compiled: Classifier → Stylist → END")
return self._compiled
# ─────────────────────────────────────
# Main Entry
# ─────────────────────────────────────
async def chat(
self,
user_message: str,
user_insight: str | None = None,
history: list[BaseMessage] | None = None,
config: RunnableConfig | None = None,
) -> dict:
start = time.time()
graph = self.build()
messages: list[BaseMessage] = []
if history:
messages.extend(history)
messages.append(HumanMessage(content=user_message))
initial_state = {
"messages": messages,
"user_insight": user_insight,
"tool_result": None,
"tool_name_used": None,
"updated_insight": None,
"lead_stage": None,
"stage_injection": None,
"product_ids": [],
"diagnostics": [],
}
result = await graph.ainvoke(initial_state, config=config)
elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"]
# Extract AI response
ai_response = ""
for msg in reversed(all_messages):
if isinstance(msg, AIMessage):
ai_response = _extract_text(msg.content)
break
# Build pipeline trace
pipeline = [{"step": "user", "label": "👤 User", "content": user_message}]
for diag in result.get("diagnostics", []):
pipeline.append(diag)
# Extract products from tool_result
all_products = []
tool_result_raw = result.get("tool_result")
if tool_result_raw:
try:
parsed = json.loads(tool_result_raw)
if parsed.get("status") == "success":
all_products = parsed.get("products", [])
except Exception:
pass
# ════════════════════════════════════════════════════════════════
# LỌC & FLATTEN SẢN PHẨM:
# Lấy đúng các sản phẩm AI đã chọn trả ra trong product_ids,
# bao gồm cả sản phẩm chính VÀ sản phẩm phối (nằm trong outfit_recommendations).
# ════════════════════════════════════════════════════════════════
final_products = []
ai_product_ids = result.get("product_ids", [])
if ai_product_ids and all_products:
# Tạo dictionary lưu trữ mọi sản phẩm (chính + outfit) theo dạng in hoa, không khoảng trắng
product_dict = {}
for p in all_products:
# Tìm đúng key của mã SKU cho SP chính
p_sku = (
str(
p.get("magento_ref_code")
or p.get("sku_code")
or p.get("sku")
or p.get("internal_ref_code")
or ""
)
.upper()
.strip()
)
if p_sku:
# Đảm bảo frontend có trường 'sku' để render
if "sku" not in p:
p["sku"] = p_sku
product_dict[p_sku] = p
# Duyệt luôn outfit_recommendations để gom data
outfit_recs = p.get("outfit_recommendations", [])
if isinstance(outfit_recs, list):
for rec in outfit_recs:
# Món phối thường nằm ở match_product_code hoặc magento_ref_code
rec_sku = (
str(rec.get("match_product_code") or rec.get("magento_ref_code") or rec.get("sku") or "")
.upper()
.strip()
)
if not rec_sku:
continue
# Tạo object tối thiểu để FE render được nếu chưa enrich
if "sku" not in rec:
rec["sku"] = rec_sku
if "name" not in rec:
rec["name"] = rec.get("match_product_name") or rec.get("product_name") or ""
product_dict[rec_sku] = rec
# Enrich: nếu product_ids có món phối chưa có đủ data (price/image), lookup DB theo magento_ref_code
wanted_ids = [str(x).upper().strip() for x in (ai_product_ids or []) if str(x).strip()]
missing_ids = [
pid
for pid in wanted_ids
if pid not in product_dict
or not isinstance(product_dict.get(pid), dict)
or not product_dict.get(pid, {}).get("name")
or not product_dict.get(pid, {}).get("image")
]
if missing_ids:
try:
from agent.lead_stage_agent.product_search_engine import SELECT_COLUMNS, TABLE_NAME
from common.starrocks_connection import get_db_connection
db = get_db_connection()
# Query cả magento_ref_code (full) + internal_ref_code (base)
skus = list(dict.fromkeys([pid for pid in missing_ids if pid]))
base_skus = list(dict.fromkeys([pid.split("-")[0] for pid in skus if pid]))
keys = list(dict.fromkeys([*skus, *base_skus]))
placeholders = ",".join(["%s"] * len(keys))
sql = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE UPPER(magento_ref_code) IN ({placeholders})
OR UPPER(internal_ref_code) IN ({placeholders})
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 200
"""
rows = await db.execute_query_async(sql, params=tuple(keys + keys))
# Map kết quả
by_magento: dict[str, dict] = {}
by_internal: dict[str, dict] = {}
for r in rows or []:
mag = str(r.get("magento_ref_code") or "").upper().strip()
internal = str(r.get("internal_ref_code") or "").upper().strip()
if mag and mag not in by_magento:
by_magento[mag] = r
if internal and internal not in by_internal:
by_internal[internal] = r
def _row_to_card(row: dict) -> dict:
sale = float(row.get("sale_price") or 0)
orig = float(row.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(row.get("discount_percent") or 0)
sku = (row.get("magento_ref_code") or row.get("internal_ref_code") or "").strip()
return {
"sku": sku,
"name": row.get("product_name", ""),
"price": int(sale),
"original_price": int(orig),
"discount": f"-{disc_pct}%" if has_discount else None,
"color": row.get("master_color", ""),
"gender": row.get("gender_by_product", ""),
"product_line": row.get("product_line_vn", ""),
"image": row.get("product_image_url_thumbnail", ""),
"url": row.get("product_web_url", ""),
"sizes": row.get("size_scale", ""),
}
for pid in skus:
row = by_magento.get(pid) or by_internal.get(pid.split("-")[0])
if row:
product_dict[pid] = _row_to_card(row)
except Exception as e:
logger.warning(f"⚠️ Product enrich by SKU failed: {str(e)[:200]}")
# Gom đúng thứ tự AI trả ra, tránh duplicate
seen_skus = set()
for raw_pid in ai_product_ids:
pid = str(raw_pid).upper().strip()
if pid in product_dict and pid not in seen_skus:
final_products.append(product_dict[pid])
seen_skus.add(pid)
else:
# Nếu AI không trả product_ids (hoặc không có tool_result), trả về mặc định
final_products = all_products
logger.info(
"🏷️ [LEAD] query='%s' | stage=%s | products=%d | time=%.0fms",
user_message[:50],
result.get("lead_stage", {}).get("stage_name", "?") if result.get("lead_stage") else "?",
len(final_products),
elapsed_ms,
)
return {
"response": ai_response,
"elapsed_ms": elapsed_ms,
"lead_stage": result.get("lead_stage"),
"updated_insight": result.get("updated_insight"),
"pipeline": pipeline,
"product_ids": result.get("product_ids", []),
"products": final_products,
}
# ═══════════════════════════════════════════════
# Singleton & Public API
# ═══════════════════════════════════════════════
_instance: LeadStageGraph | None = None
def get_lead_stage_agent(model_name: str | None = None) -> LeadStageGraph:
global _instance
if _instance is None:
_instance = LeadStageGraph(model_name)
return _instance
async def run_lead_stage_classifier(
*,
user_query: str,
user_insight: str | None = None,
chat_history_summary: str | None = None,
) -> dict:
"""Backward compat API."""
agent = get_lead_stage_agent()
state = {
"messages": [HumanMessage(content=user_query)],
"user_insight": user_insight,
"tool_result": None,
"tool_name_used": None,
"updated_insight": None,
"lead_stage": None,
"stage_injection": None,
"diagnostics": [],
}
res = await agent._classifier_node(state)
return {
"lead_stage": res.get("lead_stage"),
"classifier_latency_ms": res.get("diagnostics", [{}])[0].get("elapsed_ms", 0),
"tool_result": res.get("tool_result"),
}
"""
Hard Pattern Detection — Bắt các mẫu hỏi CỨNG đòi hỏi tham chiếu.
Các pattern phổ biến:
• "rẻ hơn", "giá rẻ hơn" → so sánh giá
• "đắt hơn", "cao hơn" → so sánh giá
• "tương tự", "giống như" → lấy đặc điểm SP tham chiếu
• "như cái trước", "cái kia" → refer đến SP đã nói
• "còn size...", "size còn không" → check size variant
• "còn màu...", "màu gì còn" → check color variant
• "còn hàng không" → check stock
"""
import re
from dataclasses import dataclass
from typing import Any
@dataclass
class PatternMatch:
"""Kết quả pattern detection."""
pattern_type: str # "cheaper_than", "similar_to", "check_size", ...
reference_sku: str | None = None
reference_product_line: str | None = None
reference_price: float | None = None
reference_color: str | None = None
reference_size: str | None = None
# Target criteria (nếu có)
target_price_max: float | None = None
target_price_min: float | None = None
target_size: str | None = None
target_color: str | None = None
confidence: float = 0.0 # 0.0 - 1.0
raw_match: str = "" # Văn bản khớp
class HardPatternDetector:
"""Detect và trích xuất reference từ query user."""
# ═══════════════════════════════════════════════════════════════
# PATTERN REGEXES
# ═══════════════════════════════════════════════════════════════
# 1. GIÁ RẺ HƠN
CHEAPER_PATTERNS = [
r"(?:rẻ|giá)\s+(?:hơn|thấp hơn|xuống hơn)",
r"có\s+(?:cái)?\s+(?:nào)?\s+(?:rẻ|giá)\s+(?:hơn|thấp hơn)",
r"tìm\s+(?:cái)?\s+(?:rẻ|giá)\s+(?:hơn|thấp hơn)",
r"(?:rẻ|giá)\s+(?:hơn|thấp hơn)\s+(?:cái|chiếc)?",
r"có.*rẻ.*hơn",
]
# 2. GIÁ ĐẮT HƠN
EXPENSIVE_PATTERNS = [
r"(?:đắt|giá)\s+(?:hơn|cao hơn)",
r"có\s+(?:cái)?\s+(?:nào)?\s+(?:đắt|giá)\s+(?:hơn|cao hơn)",
r"(?:đắt|giá)\s+(?:hơn|cao hơn)\s+(?:cái|chiếc)?",
r"có.*đắt.*hơn",
]
# 3. TƯƠNG TỰ / GIỐNG NHƯ
SIMILAR_PATTERNS = [
r"(?:giống|tương tự|như|cùng)\s+(?:như|với)?\s+(?:cái|chiếc)?\s*(?:đó|trước|ban\s*nãy|cạnh|là)?",
r"còn\s+(?:cái)?\s+(?:nào|còn)?\s+(?:tương tự|giống)\s+(?:như|với)?",
r"(?:có|tìm)\s+(?:cái)?\s+(?:nào|còn)?\s+(?:giống|tương tự)",
r"giống\s+(?:như)?\s+(?:cái)?(?:đó|trước|kia)",
]
# 4. CHECK SIZE (còn size?, size còn không, có size X không)
SIZE_CHECK_PATTERNS = [
r"(?:còn|có)\s+size\s+([A-Z0-9]+)",
r"size\s+([A-Z0-9]+)\s+(?:còn|có)",
r"(?:còn|có)\s+(?:size\s+)?([A-Z0-9]+)\s+(?:không|ko|kh)",
]
# 5. CHECK MÀU (còn màu?, màu gì còn, có màu X không)
COLOR_CHECK_PATTERNS = [
r"(?:còn|có)\s+(?:màu\s+)?(\w+)\s+(?:còn|không|ko)",
r"màu\s+(\w+)\s+(?:còn|có)",
r"(?:màu\s+)?(\w+)\s+(?:còn|có)\s+(?:không|ko)",
]
# 6. CHECK TỒN KHO (còn hàng không, còn ko, có hàng ko)
STOCK_CHECK_PATTERNS = [
r"(?:còn|có)\s+(?:hàng|kho)\s+(?:không|ko|kh)",
r"(?:hàng|kho)\s+(?:còn|có)\s+(?:không|ko|kh)",
]
# 7. REFERENCE SKU (nhắc trực tiếp mã: 8TP25A002, 5TS25S021-SR079)
SKU_PATTERN = r"\b([A-Z0-9]{4,}-[A-Z0-9]{3,}(?:-[A-Z0-9]{3,})?)\b"
# 8. REFERENCE BY POSITION (cái trước, cái kia, cái đầu tiên)
POSITION_REFERENCE = {
"trước": 0, # SP trước đó (index -1)
"đầu": 0, # SP đầu tiên trong list
"đầu tiên": 0,
"kia": -1, # SP cuối/cuối cùng (gần nhất)
"cuối": -1,
"cuối cùng": -1,
"sau": -1,
}
def detect(self, query: str, context_products: list[dict[str, Any]] = None) -> PatternMatch:
"""
Detect pattern trong query user.
Args:
query: Câu hỏi của user (raw text)
context_products: List sản phẩm đã được giới thiệu trong conversation
[{"sku": "...", "product_line_vn": "...", "sale_price": ...}, ...]
Returns:
PatternMatch với đầy đủ thông tin pattern.
"""
query_lower = query.lower().strip()
match = PatternMatch(pattern_type="unknown")
# 1. Detect CHEAPER THAN
if self._match_any_pattern(query_lower, self.CHEAPER_PATTERNS):
match.pattern_type = "cheaper_than"
match.confidence = 0.9
match = self._extract_reference_info(query, context_products, match)
if match.reference_price:
match.target_price_max = match.reference_price * 0.95 # Rẻ hơn 5%
# 2. Detect EXPENSIVE THAN
elif self._match_any_pattern(query_lower, self.EXPENSIVE_PATTERNS):
match.pattern_type = "more_expensive_than"
match.confidence = 0.9
match = self._extract_reference_info(query, context_products, match)
if match.reference_price:
match.target_price_min = match.reference_price * 1.05 # Đắt hơn 5%
# 3. Detect SIMILAR TO
elif self._match_any_pattern(query_lower, self.SIMILAR_PATTERNS):
match.pattern_type = "similar_to"
match.confidence = 0.8
match = self._extract_reference_info(query, context_products, match)
# 4. Detect SIZE CHECK
elif size_match := re.search(r"(?:còn|có)\s+size\s+([A-Z0-9]+)", query_lower):
match.pattern_type = "check_size"
match.confidence = 0.95
match.target_size = size_match.group(1).upper()
match = self._extract_reference_info(query, context_products, match)
# 5. Detect COLOR CHECK
elif color_match := re.search(r"(?:màu\s+)?(\w+)\s+(?:còn|có)", query_lower):
# Filter out common stop words
color = color_match.group(1)
if color not in ["có", "còn", "ko", "không", "màu"]:
match.pattern_type = "check_color"
match.confidence = 0.9
match.target_color = color
match = self._extract_reference_info(query, context_products, match)
# 6. Detect STOCK CHECK
elif self._match_any_pattern(query_lower, self.STOCK_CHECK_PATTERNS):
match.pattern_type = "check_stock"
match.confidence = 0.9
match = self._extract_reference_info(query, context_products, match)
# 7. Detect direct SKU reference
elif sku_match := re.search(self.SKU_PATTERN, query):
match.pattern_type = "direct_sku"
match.confidence = 1.0
match.reference_sku = sku_match.group(1)
return match
def _match_any_pattern(self, text: str, patterns: list[str]) -> bool:
"""Check nếu text match bất kỳ pattern nào."""
return any(re.search(p, text) for p in patterns)
def _extract_reference_info(self, query: str, context_products: list[dict[str, Any]],
current_match: PatternMatch) -> PatternMatch:
"""
Trích xuất thông tin reference SP từ context.
Logic:
1. Nếu query có SKU cụ thể → tìm trực tiếp
2. Nếu query có tên product_line ("áo polo kia") → filter context
3. Nếu query có từ vị trí ("cái trước", "cái kia") → lấy theo index
4. Default: lấy SP gần nhất (cuối context)
"""
if not context_products:
return current_match
query_lower = query.lower()
# --- Case 1: Direct SKU trong query ---
sku_match = re.search(self.SKU_PATTERN, query)
if sku_match:
sku = sku_match.group(1)
for p in context_products:
if p.get("sku") == sku or p.get("magento_ref_code") == sku:
self._populate_match_from_product(p, current_match)
return current_match
# --- Case 2: Reference by position ---
for pos_word, idx_offset in self.POSITION_REFERENCE.items():
if pos_word in query_lower:
idx = idx_offset if idx_offset >= 0 else len(context_products) + idx_offset
if 0 <= idx < len(context_products):
self._populate_match_from_product(context_products[idx], current_match)
return current_match
# --- Case 3: Reference by product_line ---
# Từ khóa product_line phổ biến
product_keywords = [
"áo phông", "áo polo", "áo sơ mi", "áo khoác", "áo len",
"quần jean", "quần khaki", "quần âu", "quần soóc", "váy", "chân váy",
"bộ", "đồ lót", "quần lót", "áo dài", "blazer", "cardigan"
]
for kw in product_keywords:
if kw in query_lower:
# Tìm SP trong context có product_line chứa kw
for p in reversed(context_products): # Ưu tiên SP gần nhất
if kw in p.get("product_line_vn", "").lower():
self._populate_match_from_product(p, current_match)
return current_match
# --- Case 4: Default → SP gần nhất (last in context) ---
if context_products:
self._populate_match_from_product(context_products[-1], current_match)
return current_match
def _populate_match_from_product(self, product: dict[str, Any], match: PatternMatch) -> None:
"""Fill match info từ product dict."""
match.reference_sku = product.get("sku") or product.get("magento_ref_code") or product.get("internal_ref_code")
match.reference_product_line = product.get("product_line_vn") or product.get("product_line")
match.reference_price = float(product.get("sale_price") or product.get("price") or 0)
match.reference_color = product.get("master_color") or product.get("color")
match.reference_size = None # Size thường không có trong product summary
"""
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
"""
Lead Search Tool - LangChain @tool cho AI goi.
AI sinh keywords + tags -> Tool search -> cascade -> Tra ket qua.
+ Tu dong goi Canifa Stock API -> chi tra SP CON HANG.
+ Doc outfit tu SQLite canifa_ai_dump.sqlite
Architecture:
CO DINH (luon giu): product_type + color + gender + price + size
BIEN DOI (fallback):
Tang 1: CO DINH + keywords (NGRAMBF tren product_name hoac LIKE tren description)
Tang 2: CO DINH + tags (BITMAP hoac LIKE full phrase)
Tang 3: CHI CO DINH (product_type + color + gender + size + price)
Tang 4: Drop gender
Tang 5-6: Price relaxation (1.5x, 2x)
Tang 7: Bo price hoan toan
"""
import json
import logging
import os as _os
import re
import sqlite3
import time
import httpx
from pydantic import BaseModel, Field
from common.starrocks_connection import get_db_connection
from .hard_patterns import HardPatternDetector
from .product_mapping import get_related_lines, resolve_product_line
logger = logging.getLogger(__name__)
# Global hard pattern detector
_hard_pattern_detector = HardPatternDetector()
try:
from langfuse import get_client as _get_langfuse
_LANGFUSE_AVAILABLE = True
except ImportError:
_get_langfuse = None
_LANGFUSE_AVAILABLE = False
# ═══════════════════════════════════════════════
# Stock API — Kiểm tra tồn kho Canifa
# ═══════════════════════════════════════════════
CANIFA_STOCK_API = "https://canifa.com/v1/middleware/stock_get_stock_list_parent"
_STOCK_TIMEOUT = 1.5 # seconds
async def _fetch_stock_batch(base_codes: list[str]) -> dict[str, list[dict]]:
if not base_codes:
return {}, 0.0, False
sku_string = ",".join(base_codes)
url = f"{CANIFA_STOCK_API}?skus={sku_string}"
t0 = time.time()
try:
async with httpx.AsyncClient(timeout=_STOCK_TIMEOUT) as client:
resp = await client.get(url)
resp.raise_for_status()
data = resp.json()
elapsed = round((time.time() - t0) * 1000, 1)
stock_map: dict[str, list[dict]] = {}
results = data.get("result", [])
if isinstance(results, list):
for item in results:
sku_full = item.get("sku", "")
qty = item.get("qty", 0) or 0
if not sku_full:
continue
parts = sku_full.rsplit("-", 1)
if len(parts) == 2:
color_code = parts[0]
size = parts[1]
status = "còn hàng" if qty > 0 else "hết hàng"
stock_map.setdefault(color_code, []).append({
"size": size, "qty": qty, "status": status
})
logger.info("📦 Stock API: %d base_codes -> %d color_codes | %.0fms", len(base_codes), len(stock_map), elapsed)
return stock_map, elapsed, False
except httpx.TimeoutException:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⏰ Stock API timeout (%.1fs=%.0fms)", _STOCK_TIMEOUT, elapsed)
return {}, elapsed, True
except Exception as e:
elapsed = round((time.time() - t0) * 1000, 1)
logger.warning("⚠️ Stock API error (%.0fms): %s", elapsed, e)
return {}, elapsed, False
async def _enrich_with_stock(products: list[dict]) -> tuple[list[dict], bool, float, bool]:
if not products:
return [], False, 0.0, False
base_codes = list({p.get("internal_ref_code", "") for p in products if p.get("internal_ref_code")})
if not base_codes:
return products, False, 0.0, False
stock_map, stock_api_ms, timed_out = await _fetch_stock_batch(base_codes)
if not stock_map:
return products, False, stock_api_ms, timed_out
in_stock = []
dropped = 0
for p in products:
color_code = p.get("product_color_code", "")
if color_code in stock_map:
sizes_detail = stock_map[color_code]
total_qty = sum(s["qty"] for s in sizes_detail)
if total_qty > 0:
p["_stock_detail"] = sizes_detail
p["_total_qty"] = total_qty
in_stock.append(p)
else:
dropped += 1
logger.info("📦 Stock filter: %d -> %d in-stock, %d dropped | api=%.0fms", len(products), len(in_stock), dropped, stock_api_ms)
return in_stock if in_stock else products[:5], True, stock_api_ms, timed_out
# ═══════════════════════════════════════════════
# SQLite local DB path
# ═══════════════════════════════════════════════
from common.constants import SQLITE_DB_PATH
TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding"
SELECT_COLUMNS = """
internal_ref_code,
magento_ref_code,
product_color_code,
product_name,
master_color,
product_color_name,
product_image_url_thumbnail,
product_web_url,
sale_price,
original_price,
COALESCE(discount_amount, 0) AS discount_amount,
ROUND(CASE WHEN original_price > 0
THEN ((original_price - sale_price) / original_price * 100)
ELSE 0 END, 0) AS discount_percent,
age_by_product,
gender_by_product,
product_line_vn,
COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product,
size_scale,
description_text,
tags,
similar_items,
outfit_recommendations
"""
TAGS_TO_OCCASION: dict[str, str] = {
"occ:di_lam": "di_lam",
"occ:di_choi": "di_choi",
"occ:di_tiec": "di_tiec",
"occ:di_hoc": "di_choi",
"occ:mac_nha": "mac_nha",
"occ:the_thao": "the_thao",
"occ:di_bien": "di_choi",
"occ:du_lich": "di_choi",
"occ:da_ngoai": "di_choi",
"occ:di_ngu": "mac_nha",
"occ:hang_ngay": "hang_ngay",
}
def _resolve_occasion(tags: list[str]) -> str:
for tag in (tags or []):
occ = TAGS_TO_OCCASION.get(tag.lower())
if occ:
return occ
return "hang_ngay"
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):
"""Lane 1: Tìm NGUYÊN VĂN — không filter, chỉ LIKE raw text."""
model_config = {"extra": "forbid"}
raw_text: str = Field(
default="",
description=(
"Câu search NGUYÊN VĂN từ user. Copy y hệt phần liên quan đến sản phẩm. "
"VD: user nói 'tìm áo 30/4' -> raw_text='áo 30/4'. "
"VD: user nói 'có quần jean ống rộng ko' -> raw_text='quần jean ống rộng'. "
"KHÔNG suy luận, KHÔNG thêm bớt. Giữ nguyên ý user."
),
)
class InferredSearch(BaseModel):
"""Lane 2: AI SUY LUẬN — đầy đủ filter structured."""
model_config = {"extra": "ignore"}
product_line_vn: list[str] = Field(
default=[],
description=(
"Dòng sản phẩm. CHÍNH XÁC lời user nói: 'áo phông', 'váy liền', 'đồ lót', 'quần jean'... "
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' -> 'Áo phông'). "
"Nếu khách nói chung chung ('đồ mùa đông', 'đồ tập') hoặc không nhắc, để []. "
),
)
gender_by_product: str | None = Field(
default=None,
description="Giới tính. GIÁ TRỊ HỢP LỆ: women, men, boy, girl, unisex, newborn",
)
age_by_product: str | None = Field(
default=None,
description="Độ tuổi. GIÁ TRỊ HỢP LỆ: adult, kid, others",
)
master_color: str | None = Field(
default=None,
description=(
"Màu sắc AI SUY LUẬN từ ngữ cảnh. "
"VD: '30/4' -> color='đỏ' (cờ đỏ sao vàng). 'áo trắng' -> color='trắng'."
),
)
tags: list[str] = Field(
default=[],
description=(
"AI suy luận ý định khách -> chọn từ 4 TRỤC CỐ ĐỊNH (BẮT BUỘC có prefix!): "
"Trục 1 (occ:): occ:di_lam, occ:di_choi, occ:di_tiec, occ:di_hoc, occ:mac_nha, occ:the_thao, occ:di_bien, occ:du_lich, occ:da_ngoai, occ:di_ngu. "
"Trục 2a (wthr:): wthr:mua_he, wthr:mua_dong, wthr:giao_mua, wthr:troi_mua, wthr:troi_nang. "
"Trục 2b (func:): func:thoang_mat, func:giu_am, func:tham_hut, func:nhanh_kho, func:chong_uv, func:can_gio. "
"Trục 3 (style:): style:thanh_lich, style:nang_dong, style:basic, style:ca_tinh, style:de_thuong, style:tre_trung, style:toi_gian, style:smart_casual. "
"Trục 4 (fit:): fit:oversize, fit:slim, fit:regular, fit:wide_leg, fit:cropped, fit:relaxed. "
"KHÔNG tự nghĩ tag mới! PHẢI giữ prefix! Tối đa 3."
)
)
keywords: list[str] = Field(
default=[],
description=(
"Từ khóa BỔ TRỢ AI suy luận thêm (chất liệu, tính năng...). "
"VD: '30/4' -> keywords=['cờ đỏ sao vàng']. 'đi biển' -> keywords=['thoáng mát']. "
"Tối đa 2."
)
)
price_min: int | None = Field(default=None, description="Giá thấp nhất (VND)")
price_max: int | None = Field(default=None, description="Giá cao nhất (VND)")
discount_min: int | None = Field(default=None, description="% giảm giá TỐI THIỂU.")
discount_max: int | None = Field(default=None, description="% giảm giá TỐI ĐA.")
discovery_mode: str | None = Field(default=None, description="'new' = hàng mới, 'best_seller' = bán chạy.")
size: str | None = Field(
default=None,
description="Size: XS/S/M/L/XL/XXL/3XL/4XL hoặc trẻ em 80-164cm.",
)
class LeadSearchInput(BaseModel):
"""
Dual-Lane Search Input.
- literal: Lane 1 — raw text NGUYÊN VĂN từ user (LIKE thuần, không filter)
- inferred: Lane 2 — AI suy luận structured filters
"""
model_config = {"extra": "ignore"}
literal: LiteralSearch = Field(
default_factory=LiteralSearch,
description="Lane 1: Câu search nguyên văn từ user. Chỉ LIKE, KHÔNG filter.",
)
inferred: InferredSearch = Field(
default_factory=InferredSearch,
description="Lane 2: AI suy luận — đầy đủ product_line, color, tags, gender, price...",
)
magento_ref_code: str | None = Field(default=None, description="Ma SKU cu the.")
reasoning: str | None = Field(default=None, description="SUY LUẬN TẠI SAO bạn chọn params này.")
# ======================================================
# UNDERWEAR / ĐỒ LÓT — Hard Override (Tool-level)
# Khi detect product_line_vn chứa đồ lót → drop gender + age
# để tìm cả nam/nữ, cả người lớn/trẻ em.
# Logic này BẮT BUỘC nằm ở tool, KHÔNG phụ thuộc prompt.
# ======================================================
UNDERWEAR_PRODUCT_LINES: set[str] = {
"Quần lót", "Quần lót đùi", "Quần lót tam giác",
"Áo lót", "Áo bra active",
}
def _is_underwear_request(product_lines: list[str]) -> bool:
"""Check nếu TẤT CẢ product_line_vn đều là đồ lót."""
if not product_lines:
return False
resolved_lines: set[str] = set()
for line in product_lines:
if not line:
continue
resolved = resolve_product_line(line)
for r in resolved:
expanded = get_related_lines(r)
resolved_lines.update(expanded)
if not resolved_lines:
return False
return resolved_lines.issubset(UNDERWEAR_PRODUCT_LINES)
def _apply_underwear_override(req: LeadSearchInput) -> str | None:
"""
Hard override cho đồ lót: drop gender + age để tìm ALL.
Returns system_message nếu đã override, None nếu không.
"""
inf = req.inferred
if not _is_underwear_request(inf.product_line_vn):
return None
overrides = []
if inf.gender_by_product:
overrides.append(f"gender={inf.gender_by_product}")
inf.gender_by_product = None
if inf.age_by_product:
overrides.append(f"age={inf.age_by_product}")
inf.age_by_product = None
if overrides:
logger.info(
"🩲 UNDERWEAR OVERRIDE: Dropped filters [%s] → search ALL genders/ages",
", ".join(overrides),
)
return (
"[SYSTEM] Đây là yêu cầu tìm ĐỒ LÓT/SỊP. Tool đã tự động tìm TẤT CẢ giới tính "
"(nam + nữ) và TẤT CẢ độ tuổi (người lớn + trẻ em). "
"Hãy gợi ý sản phẩm phù hợp nhất dựa trên ngữ cảnh hội thoại."
)
def _dedup(products: list[dict], limit: int = 10) -> list[dict]:
seen: set[str] = set()
res: list[dict] = []
for p in products:
bc = p.get('internal_ref_code', '')
if bc and bc not in seen:
seen.add(bc)
res.append(p)
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]:
"""Build fixed WHERE clauses from InferredSearch (Lane 2 only)."""
clauses = []
if inf.product_line_vn:
lines = []
for line in inf.product_line_vn:
if not line: continue
resolved = resolve_product_line(line)
for r in resolved:
expanded = get_related_lines(r)
lines.extend(expanded)
if lines:
placeholders = ", ".join(["%s"] * len(lines))
params.extend(lines)
clauses.append(f"product_line_vn IN ({placeholders})")
if inf.gender_by_product:
gender_lower = inf.gender_by_product.lower().strip()
genders_to_search = []
if gender_lower in ("women", "nu", "female", "nữ"):
genders_to_search = ["female", "women", "nu", "nữ", "unisex"]
elif gender_lower in ("men", "nam", "male"):
genders_to_search = ["male", "men", "nam", "unisex"]
elif gender_lower in ("boy", "bé trai", "be trai"):
genders_to_search = ["boy", "bé trai", "be trai", "unisex"]
elif gender_lower in ("girl", "bé gái", "be gai"):
genders_to_search = ["girl", "bé gái", "be gai", "unisex"]
else:
genders_to_search = [gender_lower, "unisex"]
placeholders = ", ".join(["%s"] * len(genders_to_search))
params.extend(genders_to_search)
clauses.append(f"gender_by_product IN ({placeholders})")
if inf.age_by_product:
age_lower = inf.age_by_product.lower().strip()
if age_lower in ("baby", "newborn"):
params.append("kid")
else:
params.append(age_lower)
clauses.append("age_by_product = %s")
if inf.master_color:
c_raw = inf.master_color.strip()
COLOR_EN_MAP = {
"do": "red", "trang": "white", "den": "black", "xanh": "blue",
"hong": "pink", "tim": "purple", "cam": "orange", "vang": "yellow",
"nau": "brown", "xam": "gray", "be": "beige", "kem": "cream",
"xanh la": "green", "bac": "silver", "vang dong": "gold",
"đo": "red", "đỏ": "red", "trắng": "white", "đen": "black",
"xanh lá": "green", "hồng": "pink", "tím": "purple", "nâu": "brown",
"xám": "gray", "vàng": "yellow", "bạc": "silver"
}
c_key = c_raw.lower().strip()
en_color = COLOR_EN_MAP.get(c_key)
if not en_color:
import unicodedata
c_no_acc = "".join(
ch for ch in unicodedata.normalize("NFD", c_key)
if unicodedata.category(ch) != "Mn"
).strip()
en_color = COLOR_EN_MAP.get(c_no_acc)
c_no_accent = c_key
color_parts = []
color_parts.append("(master_color LIKE %s OR product_color_name LIKE %s)")
params.append(f"%{c_raw}%")
params.append(f"%{c_raw}%")
if c_no_accent != c_raw.lower():
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{c_no_accent}%")
params.append(f"%{c_no_accent}%")
if en_color:
color_parts.append("(LOWER(master_color) LIKE %s OR LOWER(product_color_name) LIKE %s)")
params.append(f"%{en_color}%")
params.append(f"%{en_color}%")
clauses.append("(" + " OR ".join(color_parts) + ")")
if inf.price_min is not None:
params.append(inf.price_min)
clauses.append("sale_price >= %s")
if inf.price_max is not None:
params.append(inf.price_max)
clauses.append("sale_price <= %s")
if inf.discount_min is not None:
params.append(inf.discount_min)
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) >= %s)")
if inf.discount_max is not None:
params.append(inf.discount_max)
clauses.append("(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
if inf.size:
s = inf.size.strip().upper()
params.append(s)
clauses.append("FIND_IN_SET(%s, REPLACE(size_scale, '|', ',')) > 0")
if inf.discovery_mode:
mode = inf.discovery_mode.lower().strip()
if mode == "new":
clauses.append("is_new_product = 1")
elif mode == "best_seller":
clauses.append("quantity_sold > 0")
return clauses
TAG_TO_BITMAP_COL: dict[str, tuple[str, str]] = {
"thanh lịch": ("style", "Feminine"),
"năng động": ("style", "Dynamic"),
"cơ bản (basic)": ("style", "Basic"),
"cá tính": ("style", "Street"),
"dễ thương": ("style", "Cute"),
"trẻ trung": ("style", "Trend"),
"tối giản": ("style", "Essential"),
"smart casual":("style", "Smart Casual"),
"oversize": ("fitting", "Oversize"),
"slim": ("fitting", "Slim"),
"regular": ("fitting", "Regular"),
"wide leg": ("fitting", "Relax"),
"cropped": ("fitting", "Boxy"),
"relaxed": ("fitting", "Relax"),
"mùa hè": ("season_sale", "Summer"),
"mùa đông": ("season_sale", "Winter"),
"cả 2 mùa": ("season_sale", "Basic"),
"đi biển": ("season_sale", "Summer"),
"du lịch": ("season_sale", "Summer"),
"tập thể thao": ("style", "Athleisure"),
}
TAG_TO_SEARCH_TEXT: dict[str, str] = {
"đi làm / công sở": "đi làm",
"đi chơi / dạo phố": "đi chơi",
"đi tiệc": "đi tiệc",
"đi học": "đi học",
"mặc nhà": "mặc nhà",
"dã ngoại": "dã ngoại",
"đi ngủ": "ngủ",
"giao mùa": "giao mùa",
"thoáng mát": "thoáng mát",
"giữ ấm": "giữ ấm",
"thấm hút": "thấm hút",
"nhanh khô": "nhanh khô",
"chống uv": "chống UV",
"cản gió": "cản gió",
}
def _build_tag_clauses(tags: list[str], params: list) -> str:
if not tags: return ""
bitmap_by_col: dict[str, list[str]] = {}
text_terms: list[str] = []
for tag in tags:
tag_lower = tag.strip().lower()
if tag_lower in TAG_TO_BITMAP_COL:
col, val = TAG_TO_BITMAP_COL[tag_lower]
bitmap_by_col.setdefault(col, []).append(val)
elif tag_lower in TAG_TO_SEARCH_TEXT:
text_terms.append(TAG_TO_SEARCH_TEXT[tag_lower])
else:
text_terms.append(tag_lower.replace(":", " ").replace("_", " "))
all_clauses = []
for col, values in bitmap_by_col.items():
if len(values) == 1:
params.append(values[0])
all_clauses.append(f"{col} = %s")
else:
placeholders = ", ".join(["%s"] * len(values))
params.extend(values)
all_clauses.append(f"{col} IN ({placeholders})")
if text_terms:
term_clauses = []
for term in text_terms:
term = term.strip()
if not term: continue
params.append(f"%{term}%")
params.append(f"%{term}%")
term_clauses.append("(LOWER(description_text) LIKE %s OR product_name LIKE %s)")
if term_clauses:
all_clauses.append(f"({' OR '.join(term_clauses)})")
if all_clauses:
return f"({' OR '.join(all_clauses)})"
return ""
def _build_search_clause(search_terms: list[str], params: list) -> str:
term_clauses = []
for term in search_terms:
t = term.strip()
if not t: continue
words = t.split()
if not words: continue
word_clauses = []
for word in words:
if len(word) >= 4:
params.append(f"%{word}%")
word_clauses.append("product_name LIKE %s")
else:
params.append(f"%{word}%")
params.append(f"%{word}%")
word_clauses.append("(product_name LIKE %s OR LOWER(description_text) LIKE %s)")
if word_clauses:
term_clauses.append(f"({' AND '.join(word_clauses)})")
if term_clauses:
return f"({' OR '.join(term_clauses)})"
return ""
def _build_exclusion_clauses(keywords: list[str], params: list) -> list[str]:
clauses = []
kws_str = " ".join(keywords).lower()
if any(k in kws_str for k in ["đông", "lạnh", "winter", "tuyết", "giữ ấm"]):
clauses.append("product_line_vn NOT IN (%s, %s)")
params.extend(["Quần soóc", "Áo ba lỗ"])
if any(k in kws_str for k in ["đi làm", "công sở", "văn phòng", "office"]):
forbidden = ["cartoon", "hoạt hình", "manga", "anime", "demon slayer", "naruto", "disney", "marvel"]
for f in forbidden:
clauses.append("LOWER(description_text) NOT LIKE %s")
params.append(f"%{f}%")
return clauses
def _build_full_query(fixed_clauses: list[str], search_clause: str | None, exclusion_clauses: list[str] = None) -> str:
all_clauses = list(fixed_clauses)
if search_clause:
all_clauses.append(search_clause)
if exclusion_clauses:
all_clauses.extend(exclusion_clauses)
where = " AND ".join(all_clauses) if all_clauses else "1=1"
return f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE {where}
ORDER BY quantity_sold DESC NULLS LAST, sale_price ASC
LIMIT 150
"""
async def _price_relaxed_search(inf: InferredSearch, db, multiplier: float) -> list:
if inf.price_max is None:
return []
saved_max = inf.price_max
saved_min = inf.price_min
inf.price_max = int(inf.price_max * multiplier)
inf.price_min = None
params: list = []
fixed = _build_fixed_clauses(inf, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
inf.price_max = saved_max
inf.price_min = saved_min
return products
async def _cascading_search(req: LeadSearchInput, db) -> tuple[list, int, str | None]:
ex_params = []
exclusions = _build_exclusion_clauses(req.keywords, ex_params)
# Tang 1: Fixed + Keywords (NGRAMBF)
if req.keywords:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_search_clause(req.keywords, params)
if search:
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
return products, 1, None
# Tang 2: Fixed + Tags (BITMAP)
if req.tags:
params = []
fixed = _build_fixed_clauses(req, params)
search = _build_tag_clauses(req.tags, params)
if search:
sql = _build_full_query(fixed, search, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
return products, 2, None
# Tang 3: Chỉ cố định
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
return products, 3, None
# Tang 4: Drop gender
if req.product_line_vn and req.gender_by_product:
saved_gender = req.gender_by_product
req.gender_by_product = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.gender_by_product = saved_gender
if products:
return _dedup(products), 4, None
# Tang 5-7: Price relaxation
if req.price_max is not None and req.product_line_vn:
original_max = req.price_max
product_label = "/".join(req.product_line_vn)
for multiplier, tier, label in [(1.5, 5, "1.5x"), (2.0, 6, "2x")]:
new_max = int(original_max * multiplier)
products = await _price_relaxed_search(req, db, multiplier)
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
diff = int(cheapest - original_max)
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f"Mau re nhat: \"{cheapest_name}\" gia {cheapest:,.0f}d "
f"(chi them {diff:,}d so voi budget). "
f"Tong co {len(products)} mau trong tam {new_max:,.0f}d. "
f"-> HAY goi y SP nay cho khach va noi kheo: them chut xiu la co mau rat dep!"
)
return products, tier, msg
# Tang 7: bo price hoan toan
saved_max = req.price_max
saved_min = req.price_min
req.price_max = None
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
req.price_max = saved_max
req.price_min = saved_min
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f"Mau re nhat hien co: \"{cheapest_name}\" gia {cheapest:,.0f}d. "
f"Tong co {len(products)} mau. "
f"-> HAY goi y SP nay va noi: budget them chut la co mau rat dang!"
)
return _dedup(products), 7, msg
return [], 3, None
async def _enrich_with_outfit(
products: list[dict],
db,
tags: list[str] | None = None,
) -> list[dict]:
if not products:
return products
if not _os.path.exists(SQLITE_DB_PATH):
return products
# 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
]
anchor_base_codes = [c for c in anchor_base_codes if c]
if not anchor_base_codes:
return products
try:
conn = sqlite3.connect(SQLITE_DB_PATH)
conn.row_factory = sqlite3.Row
cursor = conn.cursor()
placeholders = ",".join(["?"] * len(anchor_base_codes))
# Đọc ai_matches trực tiếp từ ultra_descriptions
desc_rows = cursor.execute(
f"""
SELECT base_ref_code, clean_description, description_data_cut, ai_matches
FROM pg__dashboard_canifa__ultra_descriptions
WHERE base_ref_code IN ({placeholders})
""",
anchor_base_codes,
).fetchall()
conn.close()
except Exception as e:
logger.error("❌ SQLite outfit read error: %s", e)
return products
import json
desc_map: dict[str, dict] = {}
all_match_codes = set()
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()
# Gán description + description_data_cut
desc_info = desc_map.get(base)
if desc_info:
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"]
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[:5]:
sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0)
main_sku = p.get("magento_ref_code", "")
# Parse description_data_cut (pre-compressed from SQLite)
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 = {
"sku": main_sku,
"name": p.get("product_name", ""),
"price": int(sale),
"original_price": int(orig),
"discount": f"-{disc_pct}%" if has_discount else None,
"color": p.get("master_color", ""),
"gender": p.get("gender_by_product", ""),
"product_line": p.get("product_line_vn", ""),
"image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""),
}
# --- 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[:5])
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"]
# Inject similar_items (from OBT column)
raw_similar = p.get("similar_items")
if raw_similar:
try:
if isinstance(raw_similar, str):
try:
parsed = json.loads(raw_similar)
except Exception:
import ast
parsed = ast.literal_eval(raw_similar)
else:
parsed = raw_similar
if isinstance(parsed, list):
extracted = []
for entry in parsed:
if isinstance(entry, str) and entry.startswith('{'):
try:
obj = json.loads(entry)
extracted.append(obj)
except:
try:
import ast
obj = ast.literal_eval(entry)
extracted.append(obj)
except:
extracted.append(entry)
elif isinstance(entry, dict):
extracted.append(entry)
else:
extracted.append(entry)
item["similar_items"] = extracted
else:
item["similar_items"] = parsed
except Exception:
item["similar_items"] = raw_similar
if "in_stock" in p:
item["in_stock"] = p["in_stock"]
item["total_qty"] = p["total_qty"]
item["stock"] = p["stock"]
formatted.append(item)
return formatted
def _build_sku_query(code: str) -> tuple[str, list]:
code = code.strip().upper()
sql = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE (UPPER(magento_ref_code) = %s
OR UPPER(internal_ref_code) = %s
OR UPPER(magento_ref_code) LIKE %s)
ORDER BY quantity_sold DESC NULLS LAST
LIMIT 20
"""
return sql, [code, code, f"{code}%"]
class ProductSearchEngine:
"""
Standalone Product Search Service.
Dual-Lane Architecture:
Lane 1 (LITERAL): raw_text LIKE trực tiếp — KHÔNG filter, giữ nguyên ý user
Lane 2 (INFERRED): AI suy luận → fixed filters + tags/keywords
→ Merge + Dedup theo SKU → Ưu tiên LITERAL trước
→ Nếu cả 2 rỗng → fallback cascade (tier 3-7)
"""
# ── Dual-Lane: chạy LITERAL + INFERRED song song, merge kết quả ──
async def _dual_query_search(
self, req: LeadSearchInput, db
) -> tuple[list, int, str | None]:
"""
Chạy 2 lane song song:
Lane 1 (LITERAL): raw_text → LIKE thuần trên product_name + description (KHÔNG filter)
Lane 2 (INFERRED): fixed clauses + tags/keywords → structured filters
Merge + dedup, ưu tiên LITERAL trước.
Nếu cả 2 rỗng → delegate cho _fallback_cascade (tier 3+).
"""
import asyncio
inf = req.inferred
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 ──
literal_coro = None
if lit.raw_text and lit.raw_text.strip():
from agent.lead_stage_agent.product_mapping import resolve_product_name
raw_text = resolve_product_name(lit.raw_text.strip())
# Lọc bỏ stop words phổ biến khi khách chat để tránh tịt Literal Search
stop_words = {
"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",
"đó", "thử", "nhỉ", "vậy", "mà", "chứ", "ạ", "ơi", "đâu", "đây", "rồi",
"nữa", "sao", "đi", "được", "ra", "để", "thì", "tầm", "khoảng", "loại",
"combo", "bộ", "set"
}
# Ép lower-case toàn bộ words
words = [w.lower() for w in raw_text.split() if w.lower() not in stop_words]
if words:
params_lit = []
word_clauses = []
for word in words:
params_lit.extend([f"%{word}%", f"%{word}%", f"%{word}%"])
word_clauses.append(
"(LOWER(product_name) LIKE %s OR LOWER(description_text) LIKE %s OR LOWER(description_data_cut) LIKE %s)"
)
literal_where = " AND ".join(word_clauses)
# ── GLOBAL FILTERS (TẦNG 1): Áp dụng cứng Gender và Age vào Literal Search ──
if inf.gender_by_product:
literal_where = f"({literal_where}) AND gender_by_product = %s"
params_lit.append(inf.gender_by_product)
if inf.age_by_product:
literal_where = f"({literal_where}) AND age_by_product = %s"
params_lit.append(inf.age_by_product)
sql_lit = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE {literal_where}
ORDER BY quantity_sold DESC NULLS LAST, sale_price ASC
LIMIT 150
"""
literal_coro = db.execute_query_async(sql_lit, params=tuple(params_lit))
# ── Build Lane 2: INFERRED — full structured filters ──
inferred_coro = None
has_inferred = (
inf.product_line_vn or inf.gender_by_product or inf.master_color
or inf.tags or inf.keywords or inf.price_min is not None
or inf.price_max is not None or inf.size or inf.discovery_mode
)
if has_inferred:
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
search_parts = []
if filtered_tags:
tag_clause = _build_tag_clauses(filtered_tags, params_inf)
if tag_clause:
search_parts.append(tag_clause)
if filtered_keywords:
kw_clause = _build_search_clause(filtered_keywords, params_inf)
if kw_clause:
search_parts.append(kw_clause)
search_combined = f"({' OR '.join(search_parts)})" if search_parts else None
ex_params = []
exclusions = _build_exclusion_clauses(filtered_keywords, ex_params)
sql_inf = _build_full_query(fixed, search_combined, exclusions)
inferred_coro = db.execute_query_async(
sql_inf, params=tuple(params_inf + ex_params)
)
# ── Chạy song song ──
literal_products = []
inferred_products = []
if literal_coro and inferred_coro:
literal_products, inferred_products = await asyncio.gather(
literal_coro, inferred_coro
)
elif literal_coro:
literal_products = await literal_coro
elif inferred_coro:
inferred_products = await inferred_coro
logger.info(
"[DUAL LANE] LITERAL=%d results | INFERRED=%d results",
len(literal_products), len(inferred_products),
)
# ── Merge + Dedup (ưu tiên LITERAL trước) ──
if literal_products or inferred_products:
seen_base_codes: set[str] = set()
merged: list[dict] = []
# INFERRED match = ưu tiên cao hơn → thêm trước (chuẩn giới tính & phân loại)
for p in inferred_products:
base_code = p.get("internal_ref_code", "")
if base_code and base_code not in seen_base_codes:
p["_search_source"] = "inferred"
seen_base_codes.add(base_code)
merged.append(p)
# LITERAL match = bổ sung thêm (dành cho bộ sưu tập, tên riêng)
for p in literal_products:
base_code = p.get("internal_ref_code", "")
if base_code and base_code not in seen_base_codes:
p["_search_source"] = "literal"
seen_base_codes.add(base_code)
merged.append(p)
if merged:
if literal_products and inferred_products:
tier = 1 # dual hit
elif literal_products:
tier = 1 # literal only
else:
tier = 2 # inferred only
return merged[:10], tier, None
# ── Cả 2 rỗng → fallback cascade tier 3+ ──
return await self._fallback_cascade(req, db)
async def _fallback_cascade(
self, req: LeadSearchInput, db
) -> tuple[list, int, str | None]:
"""Cascade fallback khi dual-lane rỗng (tier 3-7). Dùng inferred filters."""
inf = req.inferred
ex_params = []
exclusions = _build_exclusion_clauses(inf.keywords, ex_params)
# Tier 3: Semantic Vector Search (Fuzzy Match / BM25 Alternative)
lit = req.literal
if lit and lit.raw_text and lit.raw_text.strip():
from common.embedding_service import create_embedding_async
try:
query_vector = await create_embedding_async(lit.raw_text.strip())
if query_vector:
v_str = "[" + ",".join(str(v) for v in query_vector) + "]"
params_vec = []
fixed_vec = _build_fixed_clauses(inf, params_vec)
where_str = " AND ".join(fixed_vec) if fixed_vec else "1=1"
if exclusions:
where_str += f" AND {' AND '.join(exclusions)}"
sql_vec = f"""
WITH vector_matches AS (
SELECT /*+ SET_VAR(ann_params='{{"ef_search":256}}') */
{SELECT_COLUMNS},
approx_cosine_similarity(vector, {v_str}) as similarity_score
FROM {TABLE_NAME}
ORDER BY similarity_score DESC
LIMIT 200
)
SELECT * FROM vector_matches
WHERE {where_str}
ORDER BY similarity_score DESC, quantity_sold DESC
LIMIT 150
"""
vec_products = await db.execute_query_async(sql_vec, params=tuple(params_vec + ex_params))
if vec_products:
seen_base_codes: set[str] = set()
good_vecs = []
for p in vec_products:
if p.get("similarity_score", 0) > 0.4:
base_code = p.get("internal_ref_code", "")
if base_code and base_code not in seen_base_codes:
seen_base_codes.add(base_code)
p["_search_source"] = "semantic_fuzzy"
good_vecs.append(p)
if good_vecs:
return good_vecs[:10], 3, "FALLBACK_SEMANTIC"
except Exception as e:
logger.error(f"Semantic Search Fallback Error: {e}")
# Tier 4: Chỉ fixed clauses
params = []
fixed = _build_fixed_clauses(inf, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
if products:
seen_base_codes: set[str] = set()
dedup = []
for p in products:
base_code = p.get("internal_ref_code", "")
if base_code and base_code not in seen_base_codes:
seen_base_codes.add(base_code)
dedup.append(p)
return dedup[:10], 4, None
# Tier 4: Drop gender
if inf.product_line_vn and inf.gender_by_product:
saved = inf.gender_by_product
inf.gender_by_product = None
params = []
fixed = _build_fixed_clauses(inf, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
inf.gender_by_product = saved
if products:
return _dedup(products), 4, None
# Tier 5-7: Price relaxation
if inf.price_max is not None and inf.product_line_vn:
original_max = inf.price_max
product_label = "/".join(inf.product_line_vn)
for multiplier, tier_num in [(1.5, 5), (2.0, 6)]:
new_max = int(original_max * multiplier)
products = await _price_relaxed_search(inf, db, multiplier)
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
diff = int(cheapest - original_max)
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f'Mau re nhat: "{cheapest_name}" gia {cheapest:,.0f}d '
f"(chi them {diff:,}d so voi budget). "
f"Tong co {len(products)} mau trong tam {new_max:,.0f}d. "
f"-> HAY goi y SP nay cho khach va noi kheo: them chut xiu la co mau rat dep!"
)
return _dedup(products), tier_num, msg
# Tier 7: drop price
saved_max, saved_min = inf.price_max, inf.price_min
inf.price_max = inf.price_min = None
params = []
fixed = _build_fixed_clauses(inf, params)
sql = _build_full_query(fixed, None, exclusions)
products = await db.execute_query_async(sql, params=tuple(params + ex_params))
inf.price_max, inf.price_min = saved_max, saved_min
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999_999_999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
msg = (
f"FALLBACK_PRICE: Khong co {product_label} duoi {original_max:,.0f}d. "
f'Mau re nhat hien co: "{cheapest_name}" gia {cheapest:,.0f}d. '
f"Tong co {len(products)} mau. "
f"-> HAY goi y SP nay va noi: budget them chut la co mau rat dang!"
)
return _dedup(products), 7, msg
return [], 3, None
async def search(self, req: LeadSearchInput, reasoning: str | None = None,
user_insight: dict = None) -> dict:
"""
Entry point: Dual-Lane search + enrichment pipeline.
Args:
user_insight: User insight dict (chứa LATEST_PRODUCT_INTEREST, TARGET, ...)
"""
start = time.time()
db = get_db_connection()
try:
fallback_msg = None
# ★★★ PRE-SEARCH: HARD PATTERN DETECTION & ADAPTATION ★★★
# Detect nếu query có pattern "rẻ hơn", "tương tự", ...
context_products = []
if user_insight:
# Lấy LATEST_PRODUCT_INTEREST và lookup thành product dict
latest = user_insight.get("LATEST_PRODUCT_INTEREST")
if latest:
# Parse SKU từ format "Áo Polo Nam Basic (8TP25A002)" hoặc "8TP25A002"
sku = self._parse_sku_from_insight(latest)
if sku:
# Lookup product từ DB để có giá
product = await self._lookup_product_by_sku(sku, db)
if product:
context_products = [product]
pattern = _hard_pattern_detector.detect(req.literal.raw_text, context_products)
if pattern.pattern_type == "cheaper_than" and pattern.target_price_max:
# Override price_max từ pattern
orig_price_max = req.inferred.price_max
req.inferred.price_max = int(pattern.target_price_max)
logger.info(
f"[HARD PATTERN] 'Rẻ hơn' detected: ref_price={pattern.reference_price:,.0f}đ "
f"→ set price_max={req.inferred.price_max:,.0f}đ (was {orig_price_max})"
)
elif pattern.pattern_type == "more_expensive_than" and pattern.target_price_min:
orig_price_min = req.inferred.price_min
req.inferred.price_min = int(pattern.target_price_min)
logger.info(
f"[HARD PATTERN] 'Đắt hơn' detected: ref_price={pattern.reference_price:,.0f}đ "
f"→ set price_min={req.inferred.price_min:,.0f}đ (was {orig_price_min})"
)
# Các pattern khác có thể xử lý sau...
# ★ UNDERWEAR HARD OVERRIDE — drop gender/age trước khi search ★
underwear_msg = _apply_underwear_override(req)
if req.magento_ref_code:
sql, params = _build_sku_query(req.magento_ref_code)
products = await db.execute_query_async(sql, params=tuple(params))
tier = 0
else:
# ★ DUAL-LANE: literal + inferred song song ★
products, tier, fallback_msg = await self._dual_query_search(req, db)
# Enrichment pipeline (giữ nguyên)
# 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)
formatted = await _format_products(products, db)
elapsed_ms = round((time.time() - start) * 1000, 2)
_search_modes = [
"sku_lookup",
"literal_like",
"inferred_structured",
"hard_filters_only",
"drop_gender",
"price_1.5x",
"price_2x",
"price_unlimited",
]
inf = req.inferred
logger.info(
"[LEAD SEARCH] REQUEST | literal='%s' | tags=%s | kw=%s | line=%s | price=%s~%s",
req.literal.raw_text, inf.tags, inf.keywords, inf.product_line_vn, inf.price_min, inf.price_max,
)
logger.info(
"[LEAD SEARCH] RESULT | tier=%d (%s) | results=%d | time=%.0fms | stock=%.0fms",
tier, _search_modes[min(tier, 7)], len(products), elapsed_ms, stock_api_ms,
)
result = {
"status": "success",
"count": len(products),
"tier": tier,
"search_mode": _search_modes[min(tier, 7)],
"elapsed_ms": elapsed_ms,
"reasoning": reasoning 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 [],
"tags_used": inf.tags or [],
"products": formatted,
}
if fallback_msg:
result["fallback_message"] = fallback_msg
if 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
except Exception as e:
logger.error("Lead search tool error: %s", e, exc_info=True)
return {"status": "error", "message": str(e)}
# ═══════════════════════════════════════════════════════════════
# HELPER: Hard Pattern Support
# ═══════════════════════════════════════════════════════════════
def _parse_sku_from_insight(self, insight_value: str) -> str | None:
"""Parse SKU từ LATEST_PRODUCT_INTEREST."""
if not insight_value:
return None
# Format: "Áo Polo Nam Basic (8TP25A002)" hoặc "8TP25A002"
match = re.search(r"\(?([A-Z0-9]{4,}-[A-Z0-9]{3,})\)?", str(insight_value))
return match.group(1) if match else None
async def _lookup_product_by_sku(self, sku: str, db) -> dict | None:
"""Lookup product từ DB bằng SKU."""
sql = f"""
SELECT {SELECT_COLUMNS}
FROM {TABLE_NAME}
WHERE magento_ref_code = %s OR internal_ref_code = %s
LIMIT 1
"""
result = await db.execute_query_async(sql, params=(sku, sku))
return result[0] if result else None
"""
Prompts cho Lead Stage Agent — 2-Agent Architecture.
AI #1 — Classifier (NHẸ): Chỉ route tool, không sinh insight
AI #2 — Stylist (NẶNG): Viết response + sinh/update InsightJSON
"""
# ═══════════════════════════════════════════════
# AI #1: Classifier — TOOL ROUTER (NHẸ, chỉ quyết định gọi tool nào)
# ═══════════════════════════════════════════════
LEAD_STAGE_CLASSIFIER_PROMPT = """\
Bạn là AI Tool Router cho chatbot CANIFA — thương hiệu thời trang gia đình Việt Nam.
## NHIỆM VỤ DUY NHẤT:
Đọc tin nhắn khách + user insight hiện tại → Quyết định **có cần gọi tool không** và **gọi tool nào**.
KHÔNG viết response, KHÔNG tư vấn, KHÔNG sinh insight — chỉ route tool.
---
### Tools có sẵn:
**`lead_search_tool`** — Tìm sản phẩm CANIFA (Dual-Lane Search: RAW Literal + Inferred Filters)
⚡ GỌI NGAY KHI: khách nói bất kỳ từ nào liên quan đến tìm/mua sản phẩm hoặc có nhu cầu/hoàn cảnh cụ thể.
⚠️ **Bạn BẮT BUỘC phải phân tách 2 Lane tìm kiếm:**
- **Lane 1 (`literal.raw_text`)**: AI tự phân tích và trích xuất **TỪ KHÓA GỐC CỐT LÕI (Core Raw Keywords)** về sản phẩm.
+ BẮT BUỘC BỎ HẾT các từ giao tiếp thừa (tìm, mua, cái, chiếc, nhé, nha, ấy, kia, thế, đồ, cho, mình...).
+ TỰ ĐỘNG SỬA LỖI CHÍNH TẢ/TYPO nếu nhận diện được (VD: "nghọ nghiêng" -> "ngọ nghiêng", "khoát" -> "khoác").
+ VD: "mình muốn tìm áo hình con ngựa ấy" -> `raw_text="áo hình con ngựa"`.
+ VD: "áo nghọ nghiêng" -> `raw_text="áo ngọ nghiêng"`.
- **Lane 2 (`inferred.*`)**: Suy luận có cấu trúc từ câu khách nói.
Args cho Lane 2 (`inferred`):
product_line_vn: [] // BẮT BUỘC ĐIỀN để filter SQL chính xác (VD: "Áo phông", "Váy liền"...).
// Nếu khách nói chung chung "đồ", "quần áo" nhưng có ngữ cảnh mùa/dịp -> HÃY suy luận loại đồ phù hợp.
gender_by_product: null // women/men/boy/girl/unisex. Suy luận từ ngữ cảnh.
age_by_product: null // adult | kid
master_color: null // Suy luận màu sắc từ nghĩa (VD: "30/4" -> "đỏ") HOẶC màu khách nói thẳng ("trắng").
tags: [] // Suy luận ý định khách -> chọn từ DANH SÁCH TAGS CỐ ĐỊNH:
// Trục 1 (Màu & Tông): Màu đen, Màu trắng, Màu xám, Màu xanh navy, Màu xanh da trời, Màu be, Màu nâu, Màu hồng, Màu đỏ, Màu vàng, Tông trung tính (Neutral), Tông màu pastel, Tông sáng (Light), Tông nổi bật (Dark).
// Trục 2 (Dịp): Đi làm / Công sở, Đi chơi / dạo phố, Mặc nhà, Tập thể thao, Đi tiệc, Đi biển, Du lịch, Đi học.
// Trục 3 (Mùa): Mùa hè, Mùa đông, Cả 2 mùa, Giao mùa.
// Trục 4 (Phong cách): Cơ bản (Basic), Năng động, Thanh lịch, Dễ thương, Cá tính, Tối giản, Smart Casual, Thoáng mát, Giữ ấm, Thấm hút, Nhanh khô, Chống UV, Cản gió.
// Trục 5 (Dáng): Oversize, Slim, Regular, Wide leg, Cropped, Relaxed.
keywords: [] // Từ khóa BỔ TRỢ AI suy luận thêm (chất liệu, tính năng, sự kiện...). VD: "30/4" -> "cờ đỏ sao vàng". Tối đa 2 cụm.
price_min: null
price_max: null
size: null // XS/S/M/L/XL/XXL...
magento_ref_code: null // Mã SKU cụ thể nếu khách cung cấp
reasoning: ""
---
### QUY TẮC MAPPING (BẮT BUỘC):
1. **Thời tiết/Mùa:**
- "trời mưa" -> product_line_vn=["Áo khoác", "Áo khoác gió"], keywords=["chống thấm", "trượt nước", "cản gió"]
- "trời lạnh/mùa đông" -> product_line_vn=["Áo khoác", "Áo len", "Áo nỉ", "Áo giữ nhiệt", "Quần dài", "Quần nỉ"], keywords=["giữ ấm", "dày dặn"]
- "mùa hè/nắng nóng" -> product_line_vn=["Áo phông", "Áo ba lỗ", "Quần soóc", "Váy liền", "Áo khoác chống nắng"], keywords=["thoáng mát", "thấm hút", "chống nắng"]
- "mùa thu" -> product_line_vn=["Áo khoác", "Áo len", "Áo nỉ", "Quần dài", "Cardigan"], keywords=["nhẹ nhàng", "thoải mái"]
- "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):**
- "đ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"], 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 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 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:**
- "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"]
- "đồ lót nói chung" -> product_line_vn=["Quần lót", "Áo lót", "Bộ quần áo"]
- ⚠️ **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:**
- 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.
- **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.
### QUY TẮC KẾ THỪA INSIGHT (CỰC KỲ QUAN TRỌNG):
**TRƯỜNG CỨNG (BẮT BUỘC kế thừa từ Insight nếu có):**
- `gender_by_product`: Kế thừa từ Insight.TARGET hoặc Insight.USER.
+ Insight ghi `Target: Bé trai` -> `age_by_product="kid"`, `gender_by_product="boy"`.
+ Insight ghi `Target: Bé gái` -> `age_by_product="kid"`, `gender_by_product="girl"`.
+ Insight ghi `Target: Nam` -> `gender_by_product="men"`.
+ Insight ghi `Target: Nữ` -> `gender_by_product="women"`.
- `age_by_product`: Kế thừa adult/kid từ Insight.
- "váy", "đầm", "chân váy" -> BẮT BUỘC gender_by_product="women" (nếu không nói cho bé).
- Nếu khách ĐỔI đối tượng (VD: đang tìm đồ bé trai, bỗng nói "tìm cho mẹ") thì mới cập nhật lại.
**TRƯỜNG MỀM (TUYỆT ĐỐI KHÔNG kế thừa — PHẢI suy luận tươi từ câu hỏi hiện tại):**
- `product_line_vn`: LUÔN suy luận từ câu hỏi hiện tại. KHÔNG BAO GIỜ copy từ Insight.TARGET.
+ VD: Insight ghi `TARGET: Sịp nữ` nhưng khách hỏi "combo đi biển" -> product_line_vn PHẢI là ["Áo phông", "Quần soóc", "Váy liền"...], KHÔNG được là ["Quần lót"].
- `tags`, `keywords`: LUÔN suy luận tươi từ câu hỏi hiện tại.
- `literal.raw_text`: LUÔN trích từ câu hỏi hiện tại.
### 🔁 HARD PATTERNS (BẮT BUỘC GỌI TOOL — KHÔNG ĐƯỢC EARLY EXIT):
- "combo", "set đồ", "đồ đi biển", "đồ đi làm", "đồ mặc nhà", "phối đồ", "mặc gì" + context
→ BẮT BUỘC `tool_name="lead_search_tool"`, điền `product_line_vn` theo context.
→ Ví dụ: "combo đi biển" → product_line_vn=["Áo phông", "Quần soóc", "Váy liền"]
- "sale", "giảm giá", "giảm sâu", "hàng sale", "outlet", "clearance"
→ BẮT BUỘC `tool_name="lead_search_tool"`, đặt `discount_min=1`
- "mới nhất", "hàng mới về", "collection mới", "new arrivals"
→ BẮT BUỘC `tool_name="lead_search_tool"`, đặt `discovery_mode="new"`
- "bán chạy nhất", "best seller", "được mua nhiều"
→ BẮT BUỘC `tool_name="lead_search_tool"`, đặt `discovery_mode="best_seller"`
### 🔁 RECALL & ĐÍNH CHÍNH (QUAN TRỌNG):
- NẾU khách CHỈ tham chiếu SP đã có mặt trong list: "cái đầu tiên", "cái đó", "cái áo ban nãy", "lấy cái kia":
→ **ĐỌC NGAY phần "SP ĐÃ GIỚI THIỆU" trong context** để biết SP nào đã show.
→ **KHÔNG search mới**. Đặt `tool_name = null` và điền `ai_response` tham chiếu đúng SP đã giới thiệu + mã SKU.
- ⚠️ CỰC KỲ QUAN TRỌNG: NẾU khách ĐỔI THÔNG TIN / ĐÍNH CHÍNH (đổi giới tính, đổi loại SP) kiểu như "cho nữ cơ mà", "tìm áo chứ không phải quần", "sịp nữ cơ mà":
→ ĐÂY LÀ YÊU CẦU TÌM MỚI! **BẮT BUỘC GỌI TOOL `lead_search_tool`** với thông tin đã đính chính (update gender/product_line). KHÔNG được đặt `tool_name = null`.
- NẾU khách hỏi thêm về SP ĐÃ show (VD: "size bao nhiêu", "còn màu khác không", "giá bao nhiêu"):
→ Dùng `check_is_stock` hoặc `lead_search_tool` với `magento_ref_code` của SP đó.
### 🚀 EARLY EXIT (Trả lời nhanh không cần Tool):
- NẾU khách chỉ nói "hi", "xin chào", "tôi muốn hỏi", "oke", "dạ", hoặc các câu giao tiếp xã giao:
→ để `tool_name` = null và điền câu chào/đáp trả vào `ai_response`.
- ⚠️ CHỈ để `tool_name` = null khi query thực sự KHÔNG có mục tiêu tìm kiếm (VD: "alo", "tư vấn đi"). Nếu có "mặc Tết", "đi chơi" -> PHẢI gọi tool.
---
### 📝 VÍ DỤ MINH HỌA (Few-Shot Examples):
**VD1 — Khách hỏi "combo đi biển" (QUAN TRỌNG NHẤT — BẮT BUỘC GỌI TOOL):**
```
Insight: {"TARGET": "Sịp nữ", "GOAL": "Tìm quần lót"}
Query: "tìm combo đi biển sầm sơn"
→ tool_name: "lead_search_tool"
→ literal.raw_text: "combo đi biển"
→ inferred.product_line_vn: ["Áo phông", "Quần soóc", "Váy liền"] ← RESET, KHÔNG copy "Quần lót" từ Insight!
→ inferred.gender_by_product: "women" ← Kế thừa giới tính từ Insight
→ inferred.tags: ["Đi biển", "Mùa hè"]
→ inferred.keywords: ["thoáng mát", "đi biển"]
```
**VD2 — Khách đính chính giới tính ("cho nữ cơ mà"):**
```
Insight: {"TARGET": "Sịp nữ", "GOAL": "Tìm quần lót"}
Query: "tìm combo đi biển sầm sơn"
→ tool_name: "lead_search_tool"
→ literal.raw_text: "combo đi biển"
→ inferred.product_line_vn: ["Áo phông", "Quần soóc", "Váy liền", "Áo ba lỗ"] ← RESET, KHÔNG copy "Quần lót" từ Insight!
→ inferred.gender_by_product: "women" ← Kế thừa giới tính từ Insight
→ inferred.tags: ["occ:di_bien", "wthr:mua_he"]
→ inferred.keywords: ["thoáng mát", "đi biển"]
```
**VD2 — Khách đính chính giới tính ("cho nữ cơ mà"):**
```
Insight: {"TARGET": "Đồ lót cho nam"}
Query: "sịp cho nữ cơ mà"
→ tool_name: "lead_search_tool" ← BẮT BUỘC gọi tool mới, KHÔNG early exit!
→ literal.raw_text: "sịp nữ"
→ inferred.product_line_vn: ["Quần lót", "Quần lót đùi", "Quần lót tam giác"]
→ inferred.gender_by_product: "women" ← Đính chính từ nam → nữ
```
**VD3 — Khách hỏi chung "đồ mùa hè" (Insight cũ: TARGET=Áo khoác):**
```
Insight: {"TARGET": "Áo khoác gió"}
Query: "có đồ mùa hè gì đẹp cho nữ không"
→ tool_name: "lead_search_tool"
→ literal.raw_text: "đồ mùa hè"
→ inferred.product_line_vn: ["Áo phông", "Áo ba lỗ", "Quần soóc", "Váy liền"] ← RESET theo ngữ cảnh mùa hè, KHÔNG copy "Áo khoác"!
→ inferred.gender_by_product: "women"
→ inferred.tags: ["wthr:mua_he", "func:thoang_mat"]
```
**VD4 — Khách chào hỏi (Early Exit):**
```
Query: "xin chào"
→ tool_name: null
→ ai_response: "Chào bạn! Mình là stylist Canifa. Bạn đang tìm đồ cho ai và dịp gì để mình tư vấn nhé?"
```
**VD5 — Khách hỏi cực kỳ mơ hồ, không có insight giới tính:**
```
Insight: {"TARGET": "Chưa rõ"}
Query: "có gì đẹp không"
→ tool_name: null ← Quá mơ hồ, KHÔNG gọi tool
→ ai_response: "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 ạ?"
```
**VD6 — Khách tham chiếu SP cũ (Recall):**
```
Insight: {"LATEST_PRODUCT_INTEREST": "Quần lót Hipster Nữ (6US25A004)"}
Query: "lấy cái đầu tiên đi"
→ tool_name: null ← Không search mới, tham chiếu SP đã show
→ 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"]
```
**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"]
```
**QUAN TRỌNG: BẮT BUỘC OUTPUT FORMAT**
Bạn PHẢI trả về DUY NHẤT một JSON object hợp lệ, KHÔNG text thêm thắt.
JSON schema:
{
"reasoning": "lý luận ngắn gọn",
"tool_name": null hoặc "lead_search_tool",
"lead_search_args": {"literal": {"raw_text": "..."}, "inferred": {"product_line_vn": [], "gender_by_product": null, ...}, "magento_ref_code": null, "reasoning": "..."},
"tool_args": null,
"ai_response": null hoặc "câu chào hỏi",
"product_ids": []
}
"""
# ═══════════════════════════════════════════════
# AI #2: Stylist — Response + Insight Generation (NẶNG)
# ═══════════════════════════════════════════════
STYLIST_SYSTEM_PROMPT = """\
Bạn là AI Stylist độc quyền của CANIFA — thương hiệu thời trang hàng đầu Việt Nam.
## 3 NHIỆM VỤ (output JSON đúng schema):
### NHIỆM VỤ 1 — Viết `ai_response` (câu trả lời cho khách):
Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm thực.
- 🔥 **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?".
- 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.
- 🎯 **COMBO / SET ĐỒ (CỰC KỲ QUAN TRỌNG):**
- Khi khách hỏi "combo", "set đồ", "đồ đi biển", "đồ đi làm", "mặc gì đi chơi" hoặc "phối cùng cái nào" (những từ chỉ chung chung như "đồ" hay context mặc đi đâu) → Khách MUỐN MỘT OUTFIT HOÀN CHỈNH (CÓ ÁO CHỊU TRÁCH NHIỆM TOP VÀ QUẦN/VÁY CHỊU TRÁCH NHIỆM BOTTOM), TỰ ĐỘNG PHỐI CHO KHÁCH.
- ⚠️ **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...".
- **HÀNH ĐỘNG BẮT BUỘC:** Bạn phải đọc `outfit_recommendations` hoặc `ai_matches` trong data tool trả về để **TỰ GHÉP THÀNH OUTFIT HOÀN CHỈNH GỒM TOP VÀ BOTTOM**.
+ TUYỆT ĐỐI KHÔNG RECOMEND "Toàn Váy" HAY "Toàn Quần" NẾU ĐƯỢC HỎI "ĐỒ". LUÔN PHẢI BỐC 1 CÁI ÁO TRONG `outfit_recommendations` RA ĐỂ PHỐI.
+ ❌ TUYỆT ĐỐI CẤM kiểu chào hàng: "Bạn muốn tìm thêm áo hay quần không để mình gợi ý?". BẠN PHẢI TỰ GỢI Ý MÓN CÒN THIẾU LUÔN TRONG CÂU TRẢ LỜI ĐÓ!
+ Nếu có `outfit_recommendations` chứa "role": "top", hãy bốc nó ra phối ngay với cái quần.
+ 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).
- 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 short nam (6BS25S011) + ai_matches:**
```
❌ 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: "Đi biển thì cứ combo 'Mát - Dễ phối' này mà triển thôi bạn ơi:
• 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 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é!
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?"
→ 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.
- Nếu khách tìm VÁY mà tool ra PHỤ KIỆN (mũ, găng tay): Tuyệt đối không được bảo mũ là váy.
- Nếu khách tìm SỊP/ĐỒ LÓT mà tool ra QUẦN DÀI/QUẦN KAKI/QUẦN SHORT: BẮT BUỘC bỏ qua, không bao giờ được chào bán.
- **KHÔNG TRẦN TÌNH THUẬT TOÁN (CRITICAL):**
- Tuyệt đối KHÔNG sử dụng các mẫu câu giải thích hệ thống như: "Vì kết quả mới chỉ thấy combo 2 chứ chưa thấy 7 màu", "Hệ thống trả về...", "Theo dữ liệu của mình...".
- 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.
- **🎯 PHÁT HIỆN INTENT: SINGLE-ITEM vs COMBO/OUTFIT (CRITICAL):**
- **SINGLE-ITEM INTENT:** Khách HỎI ĐÍCH DANH MỘT TÊN SẢN PHẨM CỤ THỂ (VD: "mua áo gì", "nên chọn váy nào", "tìm quần lót dài") → CHỈ GỢI Ý NHỮNG SP CHÍNH, KHÔNG CẦN TỰ ĐỘNG 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 dùng TỪ CHUNG CHUNG ("tìm đồ đi làm", "quần áo mùa hè", "đồ đi biển", "đi chợ", "đi chơi cho cả gia đình", "set đồ", "combo", "phối", "mặc gì đi") → BẮT BUỘC CHUYỂN SANG CHẾ ĐỘ TƯ VẤN OUTFIT (TỰ ĐỘNG PHỐI TOP + BOTTOM TRỌN BỘ).
+ ⚠️ Khách hỏi "đồ", "quần áo" là muốn tìm CẢ BỘ (Set). Tuyệt đối KHÔNG ĐƯỢC chỉ trả về toàn Quần hoặc toàn Áo, toàn Váy.
+ ⚠️ NẾU TOOL CHỈ TRẢ VỀ TOÀN QUẦN, BẠN PHẢI MỞ `outfit_recommendations` CỦA CÁI QUẦN ĐÓ RA VÀ BỐC LẤY 1 CÁI ÁO ("role": "top") ĐỂ GỢI Ý NGAY LẬP TỨC VỚI QUẦN ĐÓ! KHÔNG ĐƯỢC BẢO "BẠN MUỐN ÁO GÌ ĐỂ MÌNH TÌM".
+ ⚠️ KHI TOOL TRẢ VỀ NHIỀU SẢN PHẨM (2+ items) — ĐẶC BIỆT là "đi chơi/đi chợ cho cả gia đình": BẮT BUỘC đưa TẤT CẢ sản phẩm vào `product_ids` và đề cập đến tất cả trong response. KHÔNG ĐƯỢC bỏ sót bất kỳ món nào.
+ `product_ids`: Chứa SKU của các SP chính + SP phối từ `outfit_recommendations` (Bắt buộc phải có Áo + Quần/Váy phối hợp).
+ Response: Viết theo dạng OUTFIT với phối hợp logic giữa các món (TOP + BOTTOM / OUTERWEAR).
- **ĐỊ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ừ).
2. **[TƯ VẤN PHỐI ĐỒ (OUTFIT)]:**
- ⚠️ **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).
- **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 đó. BẮT BUỘC LẤY ĐÚNG MÃ SKU VÀ TÊN SẢN PHẨM Ở TRONG ĐÓ RA MÀ DÙNG.
2. MUÔN ĐỜI CẤM kiểu lười biếng chỉ điền tên chung chung (VD: "Quần âu") rồi bảo khách tự tìm.
+ NẾU MÓN CHÍNH LÀ ÁO/BLAZER, PHẢI bốc 1 CÁI QUẦN/CHÂN VÁY từ `outfit_recommendations` ghép vào.
+ NẾU MÓN CHÍNH LÀ QUẦN/VÁY, PHẢI bốc 1 CÁI ÁO từ `outfit_recommendations` ghép vào.
3. Chỉ khi nào `outfit_recommendations` THỰC SỰ TRỐNG TẤT CẢ (rất hiếm) thì mới được nhắc tự gợi ý chung chung.
- 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ách rủ chung "đồ đi làm":**
Query: "đồ đi làm cho nữ"
Tool trả: Quần Khaki Nữ (SP1) + `outfit_recommendations: [Áo polo nữ (SKU-POLO)]`, Chân váy (SP2)
- ✅ ĐÚNG: "Đi làm thì một set đồ thanh lịch luôn là lựa chọn số 1!
• Bạn có thể chọn Quần Khaki Nữ (SP1) cực tôn dáng.
• Phối cùng Áo polo nữ (SKU-POLO) — lịch sự và thoải mái cả ngày.
Ngoài ra, mình cũng có mã Chân váy (SP2) điệu đà hơn. Bạn thích style quần hay váy ạ?"
- `product_ids: ["SP1", "SKU-POLO", "SP2"]` ← Có đủ Top + Bottom.
**VD4 — COMBO INTENT đi ăn cưới (Áo khoác/Blazer + Quần):**
Query: "tìm combo đi ăn cưới"
Tool trả: Áo khoác blazer nam (8OT25W001) + `outfit_recommendations: [Quần âu nam (8BP25S001), Áo sơ mi nam (8SM25S002)]`
- ❌ SAI: "Mình gợi ý Áo khoác blazer (8OT25W001), bạn có thể tự tìm thêm quần âu/quần tây trên Canifa nhé." (LỖI NGHIÊM TRỌNG: Lười biếng, xui khách tự đi tìm quần, không trả SKU quần vào product_ids nên màn hình sẽ không hiển thị quần!).
- ✅ ĐÚNG: "Đi ăn cưới thì combo lịch lãm này là đỉnh nhất bạn ơi:
• Áo khoác blazer nam (8OT25W001) — form đứng dáng, sang trọng.
• Phối cùng Quần âu nam (8BP25S001) tôn dáng và mặc kèm Áo sơ mi nam (8SM25S002) bên trong là chuẩn bài luôn!
Bạn cho mình chiều cao cân nặng để mình check size nhé."
- `product_ids: ["8OT25W001", "8BP25S001", "8SM25S002"]` ← Bắt buộc phải nhét SKU của TẤT CẢ các món để hiện lên màn hình.
**VD5 — FAMILY/GROUP INTENT (đi chơi/đi chợ cho cả gia đình):**
Query: "tìm đồ đi chơi cho cả gia đình"
Tool trả: [Áo sơ mi bé trai (2TH25A001), Áo phông nam (8TS26S018-SK010), Váy bé gái (1DS26S003-SM090)]
- ❌ SAI: Chỉ nhắc Áo sơ mi + Váy, bỏ Áo phông nam → `product_ids: ["2TH25A001", "1DS26S003-SM090"]`
- ⚠️ Lý do: Bỏ sản phẩm quan trọng (áo phông) → UI không hiển thị → khách không thấy.
- ✅ ĐÚNG: Phải liệt kê TẤT CẢ sản phẩm tool trả về, phân nhóm rõ ràng:
"Bạn ơi, set đồ cả gia đình thì mình gợi ý theo 3 nhóm này nè:
• Bé trai: Áo sơ mi nam ngắn tay (2TH25A001) — phối Quần khaki bé trai (2BK25S002).
• Người lớn nam: Áo phông nam có hình in (8TS26S018-SK010) — phối Quần jeans nam.
• Bé gái: Váy trẻ em in hình động vật (1DS26S003-SM090).
Bạn cho mình biết bé nhà mình bao nhiêu tuổi và người lớn mặc size gì nhé?"
- `product_ids: ["2TH25A001", "8TS26S018-SK010", "1DS26S003-SM090"]` ← BAO GỒM TẤT CẢ.
### NHIỆM VỤ 2 — Chốt `product_ids` (HIỂN THỊ LÊN GIAO DIỆN):
**💥 LUẬT TỐI CAO BẮT BUỘC (SỐNG CÒN):** BẤT KỲ MÃ SKU NÀO XUẤT HIỆN TRONG TEXT `ai_response` TRONG CẶP NGOẶC TRÒN (VD: `(8TH26A001-SW001)`) ĐỀU PHẢI NẰM TRONG MẢNG `product_ids`!
- TUYỆT ĐỐI KHÔNG BỎ SÓT BẤT CỨ MÃ NÀO! Kể cả khi đó là SP chính hay SP mà bạn gợi ý phối thêm. Nếu bạn nhắc tên nó, bạn PHẢI nhét mã nó vào `product_ids`.
- Nếu AI response nhắc đến 3 mã, `product_ids` PHẢI CÓ 3 phần tử. Nhắc 5 mã, `product_ids` PHẢI CÓ 5 phần tử.
**Cho KHÔNG PHÂN BIỆT SINGLE-ITEM HAY COMBO:**
- `product_ids` LÀ DANH SÁCH DUY NHẤT CHỨA TẤT CẢ CÁC MÃ SẢN PHẨM ĐƯỢC NHẮC TỚI TRONG CÂU TRẢ LỜI. MỌI MÃ XUẤT HIỆN ĐỀU PHẢI NẰM TRONG NÀY.
- VD: Khách hỏi áo, bạn giới thiệu 2 áo: `(8TS25C002-SE260)`, `(8TS26S018-SM200)`. Xong bạn tiện miệng bảo "có thể phối quần `(8TH26A001-SW001)`" → `product_ids` BẮT BUỘC phải là: `["8TS25C002-SE260", "8TS26S018-SM200", "8TH26A001-SW001"]`.
**LƯU Ý:**
- Luôn cẩn thận double-check vòng cuối: Quét qua toàn bộ nội dung `ai_response` bạn chuẩn bị nhả ra. Cứ thấy mã `(6xxxxxx)` hoặc `(8xxxxxx)`... là vứt thẳng vào array `product_ids`!
### 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.
⚠️ **QUAN TRỌNG:**
- `LATEST_PRODUCT_INTEREST`: BẮT BUỘC ghi rõ Tên Sản Phẩm + Mã SKU của các SP bạn vừa giới thiệu (VD: "Áo polo Ngọ Nghiêng (5TP25W003-SR037)"). Khách tham chiếu "cái đó" thì đây chính là "cái đó".
- `SUMMARY_HISTORY`: Tóm tắt ngắn gọn các bước của hội thoại (VD: "Khách hỏi áo ngọ nghiêng -> Đã show 5TS25W014. Khách hỏi phối quần -> Đã show 8BP25W005").
---
## [RESPONSE RULES] — Cách viết câu trả lời:
{stage_injection}
### 🔍 DISCOVERY (có tool_result):
- Chọn **5-8 sản phẩm phù hợp nhất** (ưu tiên đúng loại, đúng mùa, đúng dịp).
- Với "combo/set đồ": BẮT BUỘC chọn ít nhất 5 SP gồm TOP + BOTTOM (áo + quần/váy) để tạo outfit hoàn chỉnh.
- Nếu tool chỉ trả về 3-4 SP, hãy tự bốc thêm từ `outfit_recommendations` hoặc `ai_matches` để đủ 5 SP.
- Trích dẫn chất liệu/tính năng từ description để tăng tính thuyết phục.
### 📏 TƯ VẤN SIZE:
- Chỉ tư vấn khi có chiều cao/cân nặng. Sử dụng bảng size XS-XXL.
- Kiểm tra trường `Sizes` trong tool_result để báo còn hàng hay không.
---
## ⚠️ GIỚI HẠN CỨNG:
- Tối đa **250 từ** cho response
- KHÔNG dán link URL
- KHÔNG tự bịa tính năng không có trong tool results
- Mở đầu bằng 1 câu đồng cảm dựa trên GOAL
**QUAN TRỌNG: BẮT BUỘC OUTPUT FORMAT**
Bạn PHẢI trả về DUY NHẤT một JSON object hợp lệ, KHÔNG text thêm thắt.
**LUẬT CỰC KỲ QUAN TRỌNG VỀ product_ids:**
- BẮT BUỘC: MỌI mã SKU xuất hiện trong `ai_response` PHẢI nằm trong mảng `product_ids`.
- BẮT BUỘC: Nếu có outfit (Top + Bottom), PHẢI nhét SKU của CẢ 2 món vào `product_ids`.
- BẮT BUỘC: Nếu tool trả về 3-4 SP, hãy tự bốc thêm từ `outfit_recommendations`/`ai_matches` để đủ 5 SP, rồi nhét HẾT vào `product_ids`.
- KIỂM TRA LẠI: Trước khi trả về, đọc lại `ai_response`, quét hết SKU `(xxxxx)` nào có, nhét HẾT vào `product_ids`.
JSON schema:
{
"ai_response": "câu trả lời cho khách (max 250 từ)",
"product_ids": ["SKU1", "SKU2", "SKU3", "SKU4", "SKU5"], ← BẮT BUỘC 5+ SKU cho combo/set đồ
"user_insight": {"USER": "...", "TARGET": "...", "GOAL": "...", ...}
}
"""
# ═══════════════════════════════════════════════
# Template inject user insight cũ vào prompt
# ═══════════════════════════════════════════════
INSIGHT_INJECTION_TEMPLATE = """\
══════ CUSTOMER INSIGHT (từ turn trước) ══════
👤 User: {user}
🎁 Target: {target}
🛍️ Goal: {goal}
🔒 Constraints: {constrains}
🎯 Stage: {stage_num} — {stage} | Tone: {tone}
📦 Latest Interest: {latest_product}
📝 History: {summary}
══════════════════════════════"""
def format_insight_injection(insight: dict) -> str:
"""Format InsightJSON dict thành đoạn text inject vào prompt."""
return INSIGHT_INJECTION_TEMPLATE.format(
stage_num=insight.get("STAGE_NUM", "?"),
stage=insight.get("STAGE", "BROWSE"),
tone=insight.get("TONE", "Consultant"),
user=insight.get("USER", "Chưa rõ"),
target=insight.get("TARGET", "Chưa rõ"),
goal=insight.get("GOAL", "Chưa rõ"),
constrains=insight.get("CONSTRAINS") or "—",
latest_product=insight.get("LATEST_PRODUCT_INTEREST") or "—",
summary=insight.get("SUMMARY_HISTORY") or "—",
)
# Backward compat
def format_stage_injection(stage_result: dict) -> str:
return format_insight_injection(stage_result)
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 ...@@ -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.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.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.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.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.reaction_simulator.reaction_simulator_route import router as reaction_simulator_router
from api.regression_test.regression_test_route import router as regression_test_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) ...@@ -101,7 +100,6 @@ api_router.include_router(canifa_product_router)
api_router.include_router(product_desc_router) api_router.include_router(product_desc_router)
api_router.include_router(n8n_desc_router) api_router.include_router(n8n_desc_router)
api_router.include_router(bulk_ops_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_router)
api_router.include_router(fashion_matches_simulator_router) api_router.include_router(fashion_matches_simulator_router)
api_router.include_router(outfit_matches_router) api_router.include_router(outfit_matches_router)
......
...@@ -15,6 +15,12 @@ from pydantic import BaseModel, Field ...@@ -15,6 +15,12 @@ from pydantic import BaseModel, Field
from common.llm_factory import create_llm from common.llm_factory import create_llm
from api.product_desc.product_desc_shared import (
GROQ_API_KEYS, next_groq_key,
FIELD_LABELS as _FIELD_LABELS, SECTIONS as _SECTIONS,
render_description_text as _render_description_text,
)
class AiSearchFilter(BaseModel): class AiSearchFilter(BaseModel):
keywords: list[str] = Field(default=[], description="Từ khóa tìm kiếm trong mô tả (VD: 'cotton', 'co giãn')") keywords: list[str] = Field(default=[], description="Từ khóa tìm kiếm trong mô tả (VD: 'cotton', 'co giãn')")
...@@ -38,16 +44,10 @@ router = APIRouter(prefix="/api/bulk", tags=["Bulk Operations"]) ...@@ -38,16 +44,10 @@ router = APIRouter(prefix="/api/bulk", tags=["Bulk Operations"])
PG_TABLE = "dashboard_canifa.ultra_descriptions" PG_TABLE = "dashboard_canifa.ultra_descriptions"
# ═══ Groq Config — Reuse from product_desc_route ═══ # ═══ Groq Config — Imported from product_desc_shared ═══
GROQ_API_KEYS = [ # GROQ_API_KEYS, next_groq_key → see import above
"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", _next_groq_key = next_groq_key # Alias for backwards compat
"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", _groq_key_index = 0 # Local counter (unused, kept for compat)
"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",
"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",
"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",
"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",
]
_groq_key_index = 0
AI_MODEL = "openai/gpt-oss-120b" AI_MODEL = "openai/gpt-oss-120b"
...@@ -79,30 +79,7 @@ FIELD_OPTIONS = { ...@@ -79,30 +79,7 @@ FIELD_OPTIONS = {
"faq_3_a": "FAQ 3 - Trả lời", "faq_3_a": "FAQ 3 - Trả lời",
} }
# ═══ Section grouping for re-rendering clean_description ═══ # _FIELD_LABELS, _SECTIONS, _render_description_text imported from product_desc_shared
_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",
}
_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"]),
]
# ═══ Batch AI Edit State ═══ # ═══ Batch AI Edit State ═══
...@@ -113,7 +90,7 @@ BULK_AI_STATE = { ...@@ -113,7 +90,7 @@ BULK_AI_STATE = {
"errors": 0, "errors": 0,
"current_code": None, "current_code": None,
"current_name": None, "current_name": None,
"results": [], # last results for display "results": [],
} }
...@@ -121,12 +98,6 @@ BULK_AI_STATE = { ...@@ -121,12 +98,6 @@ BULK_AI_STATE = {
# HELPERS # HELPERS
# ═══════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════
def _next_groq_key() -> str:
global _groq_key_index
key = GROQ_API_KEYS[_groq_key_index % len(GROQ_API_KEYS)]
_groq_key_index += 1
return key
async def _call_codex(messages: list, max_tokens: int = 1000, temperature: float = 0.7) -> str: async def _call_codex(messages: list, max_tokens: int = 1000, temperature: float = 0.7) -> str:
"""Call Groq Codex with multi-key round-robin and model fallback.""" """Call Groq Codex with multi-key round-robin and model fallback."""
...@@ -168,52 +139,7 @@ async def _call_codex(messages: list, max_tokens: int = 1000, temperature: float ...@@ -168,52 +139,7 @@ async def _call_codex(messages: list, max_tokens: int = 1000, temperature: float
raise Exception("Codex: tất cả model và key đều thất bại") raise Exception("Codex: tất cả model và key đều thất bại")
def _render_description_text(data: dict, product_name: str = "") -> str: # _render_description_text imported from product_desc_shared (see top imports)
"""Re-render clean_description from description_data JSON."""
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)
if key.endswith("_q"):
section_lines.append(f" 💬 {val}")
continue
if key.endswith("_a"):
section_lines.append(f" → {val}")
continue
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
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
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)
# ═══════════════════════════════════════════════════════ # ═══════════════════════════════════════════════════════
...@@ -1295,3 +1221,110 @@ async def size_stats(): ...@@ -1295,3 +1221,110 @@ async def size_stats():
finally: finally:
if conn: conn.close() if conn: conn.close()
# ═══════════════════════════════════════════════════════
# 9. AUTO-PILOT — 1-Click Sync Check & Generate
# ═══════════════════════════════════════════════════════
@router.post("/auto-pilot/run", summary="Scan DB and automatically queue missing tags/descriptions")
async def auto_pilot_run():
"""
1-Click Auto-Pilot:
1. Scans DB for products missing tags or 'mo_ta_chinh'.
2. Pushes jobs to 'tags:queue' and 'ai_edit:queue'.
3. Returns immediate statistics.
"""
redis = redis_cache.get_client()
if not redis:
return JSONResponse(status_code=500, content={"status": "error", "message": "Redis is not available"})
# Check if there's already an active Auto-Pilot or related task running to prevent overlap
latest_tags_job = await redis.get("tags:progress:latest_job")
latest_edit_job = await redis.get("ai_edit:progress:latest_job")
if latest_tags_job:
st = await redis.hgetall(f"tags:progress:{latest_tags_job.decode('utf-8') if isinstance(latest_tags_job, bytes) else latest_tags_job}")
if st and st.get(b"is_running") == b"true":
return JSONResponse(status_code=409, content={"status": "error", "message": "Tiến trình Tags đang chạy."})
if latest_edit_job:
st = await redis.hgetall(f"ai_edit:progress:{latest_edit_job.decode('utf-8') if isinstance(latest_edit_job, bytes) else latest_edit_job}")
if st and st.get(b"is_running") == b"true":
return JSONResponse(status_code=409, content={"status": "error", "message": "Tiến trình AI Edit đang chạy."})
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
# Tổng số sản phẩm có clean_description
cur.execute(f"SELECT COUNT(*) FROM {PG_TABLE} WHERE clean_description IS NOT NULL AND BTRIM(clean_description) != ''")
total_products = cur.fetchone()[0]
# 1. Tìm sản phẩm thiếu tags
cur.execute(f"""
SELECT internal_ref_code
FROM {PG_TABLE}
WHERE clean_description IS NOT NULL AND BTRIM(clean_description) != ''
AND (tags IS NULL OR tags::text = 'null' OR tags::text = '[]')
LIMIT 500
""")
missing_tags_codes = [r[0] for r in cur.fetchall()]
# 2. Tìm sản phẩm thiếu mô tả chính (mo_ta_chinh)
cur.execute(f"""
SELECT internal_ref_code
FROM {PG_TABLE}
WHERE clean_description IS NOT NULL AND BTRIM(clean_description) != ''
AND (description_data->>'mo_ta_chinh' IS NULL OR description_data->>'mo_ta_chinh' = '')
LIMIT 500
""")
missing_desc_codes = [r[0] for r in cur.fetchall()]
cur.close()
import uuid
queued_tags = len(missing_tags_codes)
queued_desc = len(missing_desc_codes)
# Đẩy vào Tags Queue
if queued_tags > 0:
job_id = f"batch_tags_{uuid.uuid4().hex[:8]}"
await redis.lpush("tags:queue", json.dumps({"job_id": job_id, "codes": missing_tags_codes}))
await redis.set("tags:progress:latest_job", job_id)
await redis.hset(f"tags:progress:{job_id}", mapping={
"is_running": "true", "total": queued_tags, "done": 0, "errors": 0, "current_code": missing_tags_codes[0]
})
# Đẩy vào Edit Queue
if queued_desc > 0:
job_id = f"batch_desc_{uuid.uuid4().hex[:8]}"
job_data = {
"job_id": job_id,
"codes": missing_desc_codes,
"target_field": "mo_ta_chinh",
"instruction": "Viết mô tả hấp dẫn, đầy đủ thông tin cho sản phẩm.",
"mode": "overwrite"
}
await redis.lpush("ai_edit:queue", json.dumps(job_data))
await redis.set("ai_edit:progress:latest_job", job_id)
await redis.hset(f"ai_edit:progress:{job_id}", mapping={
"is_running": "true", "total": queued_desc, "done": 0, "errors": 0, "current_code": missing_desc_codes[0], "results": "[]"
})
return {
"status": "success",
"message": "Auto-Pilot đã được kích hoạt!",
"stats": {
"total": total_products,
"full_info": total_products - queued_tags - queued_desc, # Thống kê ước chừng
"queued_for_tags": queued_tags,
"queued_for_desc": queued_desc
}
}
except Exception as e:
logger.error(f"Auto-Pilot error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
finally:
if conn:
conn.close()
...@@ -27,49 +27,11 @@ router = APIRouter(prefix="/api/product-desc", tags=["Ultra Description"]) ...@@ -27,49 +27,11 @@ router = APIRouter(prefix="/api/product-desc", tags=["Ultra Description"])
TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding" TABLE_NAME = "test_db.magento_product_dimension_with_text_embedding"
# ═══ Groq Config — Multi-Key Round-Robin ═══ from .product_desc_shared import (
GROQ_API_KEYS = [ GROQ_API_KEYS, next_groq_key, VISION_MODEL, ENRICH_MODEL,
"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", FIELD_LABELS, SECTIONS, TAGS_ALLOWED, TAGS_PROMPT,
"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", CANIFA_PRODUCT_LINES, render_description_text
"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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 for imports
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
ENRICH_MODEL = "openai/gpt-oss-120b"
# ═══ Groq Config — Multi-Key Round-Robin ═══
GROQ_API_KEYS = [
"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", # Key 1
"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", # Key 2
"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", # Key 3
"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",
"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",
"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",
]
_groq_key_index = 0 # Round-robin counter
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 for imports
VISION_MODEL = "meta-llama/llama-4-scout-17b-16e-instruct"
ENRICH_MODEL = "openai/gpt-oss-120b"
# ═══ Batch Tracking ═══ # ═══ Batch Tracking ═══
BATCH_STATE = { BATCH_STATE = {
...@@ -118,7 +80,7 @@ QUAN TRỌNG: KHÔNG đoán chất liệu nếu không chắc. KHÔNG bỏ trố ...@@ -118,7 +80,7 @@ QUAN TRỌNG: KHÔNG đoán chất liệu nếu không chắc. KHÔNG bỏ trố
Trả về ĐÚNG JSON, không text thừa.""" Trả về ĐÚNG JSON, không text thừa."""
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"
ENRICHMENT_SYSTEM = """Bạn là Stylist cao cấp của Canifa — chuyên viết mô tả sản phẩm thời trang bằng tiếng Việt. ENRICHMENT_SYSTEM = """Bạn là Stylist cao cấp của Canifa — chuyên viết mô tả sản phẩm thời trang bằng tiếng Việt.
Phong cách: tự tin, chuyên gia, dùng từ ngữ gợi hình (hack dáng, tôn dáng, thoát dáng, che khuyết điểm). Phong cách: tự tin, chuyên gia, dùng từ ngữ gợi hình (hack dáng, tôn dáng, thoát dáng, che khuyết điểm).
...@@ -160,105 +122,6 @@ ENRICHMENT_EXAMPLE = """{ ...@@ -160,105 +122,6 @@ ENRICHMENT_EXAMPLE = """{
# ═══ Helpers ═══ # ═══ Helpers ═══
# Label mapping for rendering JSON → readable text
_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",
}
# Section grouping for rendered 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", "luu_y_size"]),
]
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
# Dịp mặc: keep · separator
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)
def _extract_json(raw_text: str | list | dict) -> dict | None: def _extract_json(raw_text: str | list | dict) -> dict | None:
"""Parse JSON safely from LLM output.""" """Parse JSON safely from LLM output."""
if not raw_text: if not raw_text:
...@@ -328,7 +191,7 @@ async def _call_ai_with_fallback(messages: list, model: str, max_tokens: int = 3 ...@@ -328,7 +191,7 @@ async def _call_ai_with_fallback(messages: list, model: str, max_tokens: int = 3
for groq_model in groq_chain: for groq_model in groq_chain:
# Try each key (round-robin) — if one key is rate-limited, try next key instantly # Try each key (round-robin) — if one key is rate-limited, try next key instantly
for key_attempt in range(len(GROQ_API_KEYS)): for key_attempt in range(len(GROQ_API_KEYS)):
api_key = _next_groq_key() api_key = next_groq_key()
key_tag = f"K{(_groq_key_index - 1) % len(GROQ_API_KEYS) + 1}" key_tag = f"K{(_groq_key_index - 1) % len(GROQ_API_KEYS) + 1}"
try: try:
async with httpx.AsyncClient(timeout=30) as client: async with httpx.AsyncClient(timeout=30) as client:
...@@ -384,6 +247,31 @@ async def _fetch_image_base64(image_url: str) -> str: ...@@ -384,6 +247,31 @@ async def _fetch_image_base64(image_url: str) -> str:
return base64.b64encode(resp.content).decode("utf-8") return base64.b64encode(resp.content).decode("utf-8")
# ═══ 0. SYNC CHECK — Validate Data Sync between StarRocks and Postgres ═══
@router.get("/sync-check", summary="Check missing metadata between StarRocks and PostgreSQL")
async def sync_check():
"""Compare metadata between StarRocks catalog and PostgreSQL."""
db = get_db_connection()
try:
sr_rows = await db.execute_query_async(f"SELECT DISTINCT magento_ref_code FROM {TABLE_NAME}")
sr_codes = {row["magento_ref_code"] for row in sr_rows} if sr_rows else set()
pg_stats = UltraDescriptionDB.get_all_codes_with_status()
pg_codes = set(pg_stats.keys())
missing_in_pg = sr_codes - pg_codes
orphan_in_pg = pg_codes - sr_codes
return {
"status": "success",
"starrocks_total": len(sr_codes),
"postgres_total": len(pg_codes),
"missing_in_postgres": len(missing_in_pg),
"orphan_in_postgres": len(orphan_in_pg),
"missing_sample": list(missing_in_pg)[:10],
"orphan_sample": list(orphan_in_pg)[:10],
}
except Exception as e:
logger.error(f"Sync check error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══ 1. OVERVIEW — Stats on descriptions ═══ # ═══ 1. OVERVIEW — Stats on descriptions ═══
@router.get("/overview", summary="Overview stats: total products, how many have descriptions") @router.get("/overview", summary="Overview stats: total products, how many have descriptions")
async def overview(): async def overview():
...@@ -528,6 +416,24 @@ async def product_desc_list( ...@@ -528,6 +416,24 @@ async def product_desc_list(
# Ensure all products are mutable dicts (convert from MockRow if needed) # Ensure all products are mutable dicts (convert from MockRow if needed)
products = [dict(p) if not isinstance(p, dict) else p for p in products] products = [dict(p) if not isinstance(p, dict) else p for p in products]
# Batch query: check which base_codes have AI outfit matches (1 query instead of N)
ai_match_codes = set()
try:
from common.constants import TABLE_OUTFIT_MATCHES
all_base_codes = list({
(p.get("magento_ref_code") or p.get("internal_ref_code", "")).split("-")[0]
for p in products
})
if all_base_codes:
placeholders = ", ".join(["?" for _ in all_base_codes])
match_rows = await db.execute_query_async(
f"SELECT DISTINCT anchor_product_code FROM {TABLE_OUTFIT_MATCHES} WHERE anchor_product_code IN ({placeholders})",
params=tuple(all_base_codes),
)
ai_match_codes = {r["anchor_product_code"] for r in match_rows} if match_rows else set()
except Exception:
pass # Table may not exist in SQLite mock — silently skip
# Enrich with desc status from Postgres # Enrich with desc status from Postgres
for p in products: for p in products:
magento_code = p.get("magento_ref_code") or p.get("internal_ref_code") magento_code = p.get("magento_ref_code") or p.get("internal_ref_code")
...@@ -554,13 +460,9 @@ async def product_desc_list( ...@@ -554,13 +460,9 @@ async def product_desc_list(
p["has_desc"] = 0 p["has_desc"] = 0
p["desc_status"] = -1 p["desc_status"] = -1
p["tags"] = []; p["has_size_guide"] = 0; p["updated_at"] = None p["tags"] = []; p["has_size_guide"] = 0; p["updated_at"] = None
# Check if has matches in new table # Lookup from batch result
matches_count = await db.execute_query_async( base_code = (p.get("magento_ref_code") or p.get("internal_ref_code", "")).split("-")[0]
f"SELECT count(*) as count FROM {SCHEMA}.ai_outfit_product_matches WHERE anchor_product_code = %s", p["has_ai_matches"] = 1 if base_code in ai_match_codes else 0
params=(base_code,)
)
has_matches = 1 if matches_count and matches_count[0].get("count", 0) > 0 else 0
p["has_ai_matches"] = has_matches
# Post-filter by status # Post-filter by status
if status == "missing": if status == "missing":
...@@ -716,7 +618,7 @@ async def get_saved_desc(internal_ref_code: str): ...@@ -716,7 +618,7 @@ async def get_saved_desc(internal_ref_code: str):
desc_data = _json.loads(desc_data) desc_data = _json.loads(desc_data)
except Exception: except Exception:
desc_data = {} desc_data = {}
result["rendered_text"] = result.get("clean_description") or _render_description_text( result["rendered_text"] = result.get("clean_description") or render_description_text(
desc_data, desc_data,
result.get("product_name", ""), result.get("product_name", ""),
) )
...@@ -750,7 +652,7 @@ async def update_saved_desc(internal_ref_code: str, req: UpdateDescRequest): ...@@ -750,7 +652,7 @@ async def update_saved_desc(internal_ref_code: str, req: UpdateDescRequest):
logger.info("[UPDATE] CHANGED [%s]: '%s' → '%s'", k, str(old_v)[:60], str(v)[:60]) logger.info("[UPDATE] CHANGED [%s]: '%s' → '%s'", k, str(old_v)[:60], str(v)[:60])
# Render clean formatting # Render clean formatting
clean_desc = _render_description_text(req.description_data, row.get("product_name", "")) clean_desc = render_description_text(req.description_data, row.get("product_name", ""))
# Update DB # Update DB
updated = UltraDescriptionDB.update_data(internal_ref_code, req.description_data, clean_desc) updated = UltraDescriptionDB.update_data(internal_ref_code, req.description_data, clean_desc)
...@@ -832,7 +734,7 @@ Yêu cầu: ...@@ -832,7 +734,7 @@ Yêu cầu:
desc_data[req.field_key] = new_text desc_data[req.field_key] = new_text
# Update clean desc # Update clean desc
clean_desc = _render_description_text(desc_data, row.get("product_name", "")) clean_desc = render_description_text(desc_data, row.get("product_name", ""))
updated = UltraDescriptionDB.update_data(internal_ref_code, desc_data, clean_desc) updated = UltraDescriptionDB.update_data(internal_ref_code, desc_data, clean_desc)
if updated: if updated:
...@@ -882,7 +784,7 @@ async def approve_desc(req: StatusRequest): ...@@ -882,7 +784,7 @@ async def approve_desc(req: StatusRequest):
desc_data = json.loads(desc_data) desc_data = json.loads(desc_data)
except Exception: except Exception:
desc_data = {} desc_data = {}
clean_desc = _render_description_text(desc_data, row.get("product_name", "")) clean_desc = render_description_text(desc_data, row.get("product_name", ""))
# Try magento_ref_code first, then base internal_ref_code # Try magento_ref_code first, then base internal_ref_code
updated = UltraDescriptionDB.set_status(magento_code, req.status, clean_description=clean_desc) updated = UltraDescriptionDB.set_status(magento_code, req.status, clean_description=clean_desc)
...@@ -967,7 +869,7 @@ async def sync_material(req: SyncMaterialRequest): ...@@ -967,7 +869,7 @@ async def sync_material(req: SyncMaterialRequest):
old_mat = desc_data.get("chat_lieu", "") old_mat = desc_data.get("chat_lieu", "")
desc_data["chat_lieu"] = mat desc_data["chat_lieu"] = mat
clean_desc = _render_description_text(desc_data, pg_row.get("product_name", "")) clean_desc = render_description_text(desc_data, pg_row.get("product_name", ""))
# 3. Write back # 3. Write back
try: try:
...@@ -1010,7 +912,7 @@ async def sync_material(req: SyncMaterialRequest): ...@@ -1010,7 +912,7 @@ async def sync_material(req: SyncMaterialRequest):
async def approve_all_desc(): async def approve_all_desc():
"""Set all status=0 to status=1 in one shot.""" """Set all status=0 to status=1 in one shot."""
try: try:
count = UltraDescriptionDB.approve_all_pending(render_fn=_render_description_text) count = UltraDescriptionDB.approve_all_pending(render_fn=render_description_text)
return {"status": "success", "message": f"✅ Đã duyệt toàn bộ {count} mô tả!", "approved_count": count} return {"status": "success", "message": f"✅ Đã duyệt toàn bộ {count} mô tả!", "approved_count": count}
except Exception as e: except Exception as e:
logger.error(f"Approve all error: {e}") logger.error(f"Approve all error: {e}")
...@@ -1039,7 +941,7 @@ async def backfill_clean_description(): ...@@ -1039,7 +941,7 @@ async def backfill_clean_description():
"""Render clean_description in bulk from existing description_data.""" """Render clean_description in bulk from existing description_data."""
try: try:
updated_count = UltraDescriptionDB.backfill_missing_clean_descriptions( updated_count = UltraDescriptionDB.backfill_missing_clean_descriptions(
render_fn=_render_description_text render_fn=render_description_text
) )
remaining = UltraDescriptionDB.count_missing_clean_descriptions() remaining = UltraDescriptionDB.count_missing_clean_descriptions()
return { return {
...@@ -1460,3 +1362,56 @@ async def get_tags_batch_status(): ...@@ -1460,3 +1362,56 @@ async def get_tags_batch_status():
except Exception as e: except Exception as e:
logger.error(f"Tags Batch Status Error: {e}") logger.error(f"Tags Batch Status Error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)}) return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
@router.get("/compare/{magento_ref_code}")
async def get_compare_desc(magento_ref_code: str):
"""Fetch description from StarRocks and SQLite for side-by-side Diff."""
try:
# 1. Fetch original from StarRocks
starrocks_desc = ""
try:
db = get_db_connection()
starrocks_query = """
SELECT description_text_full
FROM test_db.magento_product_dimension_with_text_embedding
WHERE magento_ref_code = %s
LIMIT 1
"""
sr_rows = await db.execute_query_async(starrocks_query, params=(magento_ref_code,))
starrocks_desc = sr_rows[0].get("description_text_full") if sr_rows else ""
except Exception as e:
logger.error(f"StarRocks fetch error for compare: {e}")
starrocks_desc = f"Lỗi không thể kết nối hoặc thiếu bảng StarRocks: {str(e)}"
# 2. Fetch AI-generated from SQLite / Postgres
row = UltraDescriptionDB.get_by_magento_code(magento_ref_code)
sqlite_data = {}
if row:
tags_raw = row.get("tags") or []
if isinstance(tags_raw, str):
try: tags_raw = json.loads(tags_raw)
except: tags_raw = []
dd = row.get("description_data") or {}
if isinstance(dd, str):
try: dd = json.loads(dd)
except: dd = {}
sqlite_data = {
"tags": tags_raw,
"description_data": dd,
"description_data_cut": row.get("description_data_cut") or ""
}
return JSONResponse(status_code=200, content={
"status": "success",
"magento_ref_code": magento_ref_code,
"starrocks": {
"description_text_full": starrocks_desc
},
"sqlite": sqlite_data
})
except Exception as e:
logger.error(f"Compare Desc Error: {e}")
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
"""
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)
"""
Tags Direct API — Batch tagging thẳng vào PostgreSQL, không cần Redis/Worker.
- POST /api/tags-direct/run → chạy batch tagging (background asyncio task)
- GET /api/tags-direct/status → poll tiến trình
- POST /api/tags-direct/stop → dừng
- POST /api/tags-direct/single → sinh tag cho 1 sản phẩm cụ thể (sync, trả về ngay)
"""
import ast
import asyncio
import json
import logging
import re
import time
import httpx
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
from common.pool_wrapper import get_pooled_connection_compat
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tags-direct", tags=["Tags Direct"])
PG_TABLE = "dashboard_canifa.ultra_descriptions"
# ── Groq keys round-robin ──────────────────────────────────────────────────
GROQ_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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",
]
_key_idx = 0
def _next_key() -> str:
global _key_idx
k = GROQ_KEYS[_key_idx % len(GROQ_KEYS)]
_key_idx += 1
return k
GROQ_MODELS = [
"openai/gpt-oss-120b",
"openai/gpt-oss-20b",
"llama-4-scout-17b-16e-instruct",
"qwen/qwen3-32b",
]
VALID_PREFIXES = ("occ:", "wthr:", "func:", "style:", "fit:")
TAG_PROMPT = """Bạn là chuyên gia gán tag thời trang cho sản phẩm Canifa.
NHIỆM VỤ:
Đọc mô tả sản phẩm bên dưới (bao gồm Tên sản phẩm chứa mã màu) và trích xuất TỐI ĐA 6 tags phù hợp nhất từ DANH SÁCH TAGS HỢP LỆ.
DANH SÁCH TAGS HỢP LỆ:
--- TRỤC 1 — Màu sắc & Tông màu ---
(Tự nhận diện màu từ tên SP. VD: "Đen/ Black" -> "Màu đen", "Xám/ Gray" -> "Màu xám")
- Màu sắc: Màu đen, Màu trắng, Màu xám, Màu xanh navy, Màu xanh da trời, Màu be, Màu nâu, Màu hồng, Màu đỏ, Màu vàng...
- Tông màu: Tông trung tính (Neutral), Tông màu pastel, Tông màu rực rỡ, Tông màu tối, Tông màu sáng.
--- TRỤC 2 — Hoàn cảnh & Dịp ---
Đi làm / Công sở, Đi chơi / dạo phố, Mặc nhà, Tập thể thao, Đi tiệc, Đi biển, Du lịch, Đi học.
--- TRỤC 3 — Mùa vụ ---
Mùa hè, Mùa đông, Cả 2 mùa, Giao mùa.
--- TRỤC 4 — Phong cách & Tính năng ---
Cơ bản (Basic), Năng động, Thanh lịch, Dễ thương, Cá tính, Tối giản, Smart Casual, Thoáng mát, Giữ ấm, Thấm hút, Nhanh khô, Chống UV, Cản gió.
--- TRỤC 5 — Dáng & Kiểu cắt ---
Oversize, Slim, Regular, Wide leg, Cropped, Relaxed.
QUY TẮC BẮT BUỘC:
1. Bắt buộc phải có tag Hoàn cảnh & Dịp, Mùa vụ (nếu có thể suy luận).
2. Nếu Tên sản phẩm có màu, ưu tiên trả về tag Màu sắc và Tông màu.
3. KHÔNG gán "Đi tiệc" cho áo phông/đồ trẻ em/đồ mặc nhà.
4. Chọn tối đa 6 tags, tối thiểu 3 tags.
5. Chỉ trả về một Python list duy nhất chứa các chuỗi (string), KHÔNG giải thích, KHÔNG markdown.
Ví dụ: ['Màu đen', 'Tông trung tính (Neutral)', 'Đi chơi / dạo phố', 'Cả 2 mùa', 'Cơ bản (Basic)']
MÔ TẢ SẢN PHẨM:
{product_description}
OUTPUT (chỉ list, không gì khác):
"""
# ── In-memory state (singleton per process) ───────────────────────────────
_state = {
"is_running": False,
"total": 0,
"done": 0,
"errors": 0,
"current_code": None,
"stop_requested": False,
"started_at": None,
"finished_at": None,
"last_errors": [], # last 10 error codes
}
_task: asyncio.Task | None = None
# ── Helpers ────────────────────────────────────────────────────────────────
async def _call_ai(client: httpx.AsyncClient, text: str) -> list | None:
"""Call Groq with model fallback, return list of valid tags or None."""
global _state
payload = {
"messages": [{"role": "user", "content": text}],
"temperature": 0.1,
"max_tokens": 1000,
}
for model in GROQ_MODELS:
payload["model"] = model
for _ in range(len(GROQ_KEYS)):
if _state.get("stop_requested"):
return None
key = _next_key()
try:
resp = await client.post(
"https://api.groq.com/openai/v1/chat/completions",
json=payload,
headers={"Authorization": f"Bearer {key}"},
timeout=25.0,
)
if resp.status_code == 200:
content = resp.json()["choices"][0]["message"]["content"].strip()
# strip code fences
content = re.sub(r"^```[a-z]*\n?", "", content)
content = re.sub(r"```$", "", content).strip()
try:
result = ast.literal_eval(content)
except Exception:
try:
result = json.loads(content)
except Exception:
print(f"[tags-direct] Parse Error. Content: {content}")
result = []
if isinstance(result, list):
clean = [str(t) for t in result]
return clean
with open("direct_tag.log", "a", encoding="utf-8") as f: f.write(f"[tags-direct] Expected list, got {type(result)}. Content: {content}\n")
return None
if resp.status_code == 429:
match = re.search(r"try again in ([0-9.]+)s", resp.text)
wait = float(match.group(1)) if match else 10.0
with open("direct_tag.log", "a", encoding="utf-8") as f: f.write(f"[tags-direct] Rate limit (429) model={model}. Wait {wait}s.\n")
wait = min(wait, 15.0) # Cap wait at 15s
for _ in range(int(wait)):
if _state.get("stop_requested"): return None
await asyncio.sleep(1)
else:
with open("direct_tag.log", "a", encoding="utf-8") as f: f.write(f"[tags-direct] Groq Error {resp.status_code}: {resp.text}\n")
except Exception as e:
with open("direct_tag.log", "a", encoding="utf-8") as f: f.write(f"[tags-direct] Exception model={model}: {e}\n")
for _ in range(2):
if _state.get("stop_requested"): return None
await asyncio.sleep(1)
return None
def _fetch_untagged(limit: int = 2000, force_all: bool = False) -> list[tuple]:
"""Return list of (code, name, clean_description) for products."""
conn = get_pooled_connection_compat()
try:
cur = conn.cursor()
condition = "1=1" if force_all else "tags IS NULL"
cur.execute(
f"""SELECT internal_ref_code, product_name, clean_description
FROM {PG_TABLE}
WHERE {condition}
AND clean_description IS NOT NULL AND clean_description != ''
LIMIT %s""",
(limit,),
)
rows = cur.fetchall()
cur.close()
return rows
finally:
conn.close()
def _save_tags(code: str, tags: list) -> None:
conn = get_pooled_connection_compat()
try:
cur = conn.cursor()
cur.execute(
f"UPDATE {PG_TABLE} SET tags = %s::jsonb, updated_at = NOW() WHERE internal_ref_code = %s",
(json.dumps(tags, ensure_ascii=False), code),
)
conn.commit()
cur.close()
finally:
conn.close()
# ── Core batch runner ──────────────────────────────────────────────────────
async def _run_batch(force_all: bool = False):
global _state
logger.info(f"🏷️ [tags-direct] Batch started (force_all={force_all})")
try:
rows = await asyncio.get_event_loop().run_in_executor(None, _fetch_untagged, 2000, force_all)
except Exception as e:
logger.error(f"[tags-direct] DB fetch error: {e}")
_state["is_running"] = False
return
if not rows:
logger.info("[tags-direct] Không có sản phẩm nào cần gán tag.")
_state.update({"is_running": False, "total": 0, "done": 0, "finished_at": time.time()})
return
_state.update({
"total": len(rows),
"done": 0,
"errors": 0,
"last_errors": [],
"current_code": rows[0][0] if rows else None,
})
sem = asyncio.Semaphore(2) # Reduced to 2 to prevent Groq API rate limit exhaustion
async def _process(code: str, name: str, desc: str):
if _state["stop_requested"]:
return
async with sem:
try:
_state["current_code"] = code
product_text = f"Sản phẩm: {name or ''}\nMô tả:\n{desc or ''}"
prompt = TAG_PROMPT.format(product_description=product_text[:3000])
tags = await _call_ai(client, prompt)
if tags is not None:
await asyncio.get_event_loop().run_in_executor(None, _save_tags, code, tags)
_state["done"] += 1
else:
_state["errors"] += 1
_state["last_errors"].append(code)
_state["last_errors"] = _state["last_errors"][-10:]
except Exception as e:
with open("direct_tag.log", "a", encoding="utf-8") as f: f.write(f"[tags-direct] error for {code}: {e}\n")
_state["errors"] += 1
async with httpx.AsyncClient() as client:
tasks = [_process(r[0], r[1] or "", r[2] or "") for r in rows]
await asyncio.gather(*tasks)
_state.update({
"is_running": False,
"stop_requested": False,
"finished_at": time.time(),
})
logger.info(f"✅ [tags-direct] Done: {_state['done']}/{_state['total']}, errors: {_state['errors']}")
# ── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/run", summary="Chạy batch gán tags thẳng vào PostgreSQL (không cần Redis)")
async def run_tags_direct(background_tasks: BackgroundTasks, force_all: bool = False):
global _state, _task
if _state["is_running"]:
return JSONResponse(
status_code=409,
content={"status": "error", "message": "Đang chạy rồi! Đợi xong hoặc bấm Stop."},
)
_state.update({
"is_running": True,
"stop_requested": False,
"started_at": time.time(),
"finished_at": None,
"total": 0,
"done": 0,
"errors": 0,
"current_code": None,
"last_errors": [],
})
background_tasks.add_task(_run_batch, force_all)
return {"status": "success", "message": "🚀 Batch tagging đã bắt đầu chạy nền!"}
@router.get("/status", summary="Poll tiến trình batch tagging")
async def tags_direct_status():
s = _state.copy()
pct = round(s["done"] / s["total"] * 100, 1) if s["total"] > 0 else 0
elapsed = round(time.time() - s["started_at"], 0) if s["started_at"] else 0
return {
"is_running": s["is_running"],
"total": s["total"],
"done": s["done"],
"errors": s["errors"],
"current_code": s["current_code"],
"percent": pct,
"elapsed_sec": elapsed,
"last_errors": s["last_errors"],
}
@router.post("/stop", summary="Dừng batch tagging")
async def stop_tags_direct():
if not _state["is_running"]:
return {"status": "ok", "message": "Không có task nào đang chạy."}
_state["stop_requested"] = True
return {"status": "ok", "message": "✋ Đã yêu cầu dừng. Các job đang chạy sẽ kết thúc sau giây lát."}
@router.post("/single", summary="Sinh tag cho 1 sản phẩm cụ thể (sync)")
async def single_tag(body: dict):
code = (body.get("internal_ref_code") or "").strip()
if not code:
return JSONResponse(status_code=400, content={"status": "error", "message": "Thiếu internal_ref_code"})
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT product_name, clean_description FROM {PG_TABLE} WHERE internal_ref_code = %s",
(code,),
)
row = cur.fetchone()
cur.close()
conn.close()
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
if not row:
return JSONResponse(status_code=404, content={"status": "error", "message": f"Không tìm thấy {code}"})
name, desc = row
prompt = TAG_PROMPT.format(product_description=f"Sản phẩm: {name or ''}\nMô tả:\n{(desc or '')[:3000]}")
async with httpx.AsyncClient() as client:
tags = await _call_ai(client, prompt)
if not tags:
return JSONResponse(status_code=500, content={"status": "error", "message": "AI không trả về tags hợp lệ"})
try:
_save_tags(code, tags)
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": f"Lưu DB lỗi: {e}"})
return {"status": "success", "code": code, "tags": tags}
@router.get("/count-untagged", summary="Đếm số sản phẩm chưa có tag")
async def count_untagged():
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT COUNT(*) FROM {PG_TABLE} WHERE tags IS NULL"
)
count = cur.fetchone()[0]
cur.close()
conn.close()
return {"status": "success", "untagged": count}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
/**
* AI Assistant v3 — Agent-Style Search + Codex Bulk Edit
* Features: AI intent parsing → smart SQL filters, bulk field editing
*/
(function () {
'use strict';
// ═══ STATE ═══
let panelOpen = false;
let searchResults = [];
let selectedCodes = new Set();
let currentMode = 'idle'; // idle | searching | searched | ai_running
let pollTimer = null;
let fieldOptions = {};
let lastAiFilters = null;
let lastOccasion = '';
// ═══ DOM CREATION ═══
function createWidget() {
// ── Bubble ──
const bubble = document.createElement('button');
bubble.className = 'ai-bubble';
bubble.id = 'aiBubble';
bubble.title = 'AI Assistant — Tìm & Sinh Mô Tả Hàng Loạt';
bubble.innerHTML = '<span class="bubble-icon">✦</span><span class="ai-bubble-dot" id="aiBubbleDot"></span>';
bubble.onclick = togglePanel;
// ── Panel ──
const panel = document.createElement('div');
panel.className = 'ai-panel';
panel.id = 'aiPanel';
panel.innerHTML = `
<div class="ai-panel-header">
<div class="header-left">
<div class="header-avatar">✦</div>
<div class="header-info">
<h3>Canifa AI</h3>
<span>Agent Search + Sinh mô tả</span>
</div>
</div>
<div class="header-actions">
<button onclick="window._ai.clearChat()" title="Xóa chat">🗑</button>
<button onclick="window._ai.togglePanel()" title="Đóng">✕</button>
</div>
</div>
<div class="ai-quick-actions" id="aiQuickActions">
<button class="ai-quick-btn" onclick="window._ai.qSearch('áo màu đỏ cho bé trai')">🔴 Áo đỏ bé trai</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('váy cotton bé gái mùa hè')">👗 Váy bé gái</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('polo nam đi làm')">👔 Polo nam</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('áo khoác mùa đông')">🧥 Khoác đông</button>
<button class="ai-quick-btn" onclick="window._ai.showHelp()">❓ Help</button>
</div>
<div class="ai-messages" id="aiMessages">
<div class="ai-msg bot">
<div class="ai-msg-avatar">✦</div>
<div class="ai-msg-body">
<strong>Chào bro!</strong> 🤖<br><br>
Tao là AI Assistant tích hợp <strong>Codex Agent</strong>.<br>
Gõ <strong>câu tự nhiên</strong> — AI sẽ hiểu ý và tìm SP thông minh:<br><br>
VD: <em>"tìm áo đỏ cho bé trai phù hợp 2/9"</em><br>
→ AI hiểu: <strong>Áo + đỏ + bé trai + trẻ em</strong><br>
→ Tìm đúng SP, rồi cho AI sinh nội dung hàng loạt 🔥
</div>
</div>
</div>
<div class="ai-bulk-bar" id="aiBulkBar" style="display:none;">
<span>Đã chọn: <strong class="bulk-count" id="aiBulkCount">0</strong></span>
<button onclick="window._ai.selectAll()">✅ All</button>
<button onclick="window._ai.deselectAll()">◻️ None</button>
<button class="bulk-ai-btn" onclick="window._ai.showAiForm()">🤖 AI Sinh</button>
</div>
<div class="ai-progress-bar" id="aiProgressBar" style="display:none;">
<div class="progress-info">
<span>🤖 AI đang sinh:</span>
<strong id="aiProgressText">0/0</strong>
<span id="aiProgressName" class="progress-name"></span>
</div>
<div class="progress-track"><div class="progress-fill" id="aiProgressFill"></div></div>
</div>
<div class="ai-input-area">
<input type="text" id="aiInput" placeholder="Gõ tiếng Việt tự nhiên... VD: áo cotton bé gái" autocomplete="off">
<button class="ai-send-btn" id="aiSendBtn" onclick="window._ai.send()">➤</button>
</div>
`;
document.body.appendChild(bubble);
document.body.appendChild(panel);
document.getElementById('aiInput').addEventListener('keydown', e => {
if (e.key === 'Enter') window._ai.send();
});
// Load field options
fetch('/api/bulk/fields').then(r => r.json()).then(d => {
if (d.status === 'success') fieldOptions = d.fields;
}).catch(() => {});
}
// ═══ PANEL ═══
function togglePanel() {
panelOpen = !panelOpen;
document.getElementById('aiPanel').classList.toggle('open', panelOpen);
document.getElementById('aiBubble').classList.toggle('open', panelOpen);
if (panelOpen) setTimeout(() => document.getElementById('aiInput').focus(), 350);
}
// ═══ MESSAGES ═══
function msg(content, type = 'bot') {
const c = document.getElementById('aiMessages');
const avatar = type === 'bot' ? '✦' : '👤';
const el = document.createElement('div');
el.className = `ai-msg ${type}`;
el.innerHTML = `<div class="ai-msg-avatar">${avatar}</div><div class="ai-msg-body">${content}</div>`;
c.appendChild(el);
c.scrollTop = c.scrollHeight;
return el;
}
function typing(show) {
const existing = document.getElementById('aiTyping');
if (existing) existing.remove();
if (!show) return;
const c = document.getElementById('aiMessages');
const el = document.createElement('div');
el.className = 'ai-typing'; el.id = 'aiTyping';
el.innerHTML = '<div class="ai-typing-dot"></div><div class="ai-typing-dot"></div><div class="ai-typing-dot"></div>';
c.appendChild(el);
c.scrollTop = c.scrollHeight;
}
// ═══ SEND ═══
function send() {
const inp = document.getElementById('aiInput');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
msg(text, 'user');
doAiSearch(text);
}
function qSearch(kw) {
document.getElementById('aiInput').value = kw;
send();
}
// ═══ AI SEARCH (Agent-Style) ═══
async function doAiSearch(query) {
typing(true);
currentMode = 'searching';
// Show "AI đang phân tích..." message
const thinkMsg = msg('🧠 AI đang phân tích ý định tìm kiếm...');
try {
const resp = await fetch('/api/bulk/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit: 100 }),
});
const data = await resp.json();
typing(false);
// Remove thinking message
if (thinkMsg) thinkMsg.remove();
if (data.status !== 'success') {
msg(`❌ ${data.message || 'Lỗi tìm kiếm'}`);
return;
}
lastAiFilters = data.ai_filters || {};
lastOccasion = data.occasion || '';
// Show AI understanding
let filterHtml = '<div class="ai-filter-tags">';
filterHtml += '<strong style="font-size:11px;color:#6b7280;">🧠 AI hiểu:</strong> ';
if (data.explain) {
filterHtml += `<span class="ai-filter-explain">${esc(data.explain)}</span><br>`;
}
filterHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;">';
const f = data.ai_filters || {};
if (f.product_line) filterHtml += `<span class="ai-tag product">${esc(f.product_line)}</span>`;
if (f.color) filterHtml += `<span class="ai-tag color">🎨 ${esc(f.color)}</span>`;
if (f.gender) filterHtml += `<span class="ai-tag gender">👤 ${esc(f.gender)}</span>`;
if (f.age_group) filterHtml += `<span class="ai-tag age">${f.age_group === 'kid' ? '👶 Trẻ em' : '🧑 Người lớn'}</span>`;
if (f.season) filterHtml += `<span class="ai-tag season">🌤 ${esc(f.season)}</span>`;
if (f.occasion) filterHtml += `<span class="ai-tag occasion">📅 ${esc(f.occasion)}</span>`;
if (f.keywords && f.keywords.length > 0) {
f.keywords.forEach(kw => {
filterHtml += `<span class="ai-tag keyword">🔍 ${esc(kw)}</span>`;
});
}
filterHtml += '</div></div>';
if (!data.results || !data.results.length) {
msg(`${filterHtml}<br>Không tìm thấy SP nào phù hợp. Thử câu khác?`);
return;
}
searchResults = data.results;
selectedCodes = new Set(data.results.map(r => r.internal_ref_code));
currentMode = 'searched';
let html = `${filterHtml}<br>Tìm được <strong>${data.count}</strong> SP:`;
html += '<div class="ai-search-results">';
data.results.forEach((r, i) => {
const st = r.status === 1 ? 'done' : (r.status === 0 ? 'pending' : 'missing');
const stText = r.status === 1 ? '✅' : (r.status === 0 ? '⏳' : '✕');
const img = r.product_image_url
? `<img src="${esc(r.product_image_url)}" alt="" onerror="this.style.display='none'">`
: '<div style="width:36px;height:36px;border-radius:6px;background:#f0efec;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">📦</div>';
html += `
<div class="ai-result-item" onclick="window._ai.toggle('${esc(r.internal_ref_code)}',${i})">
<input type="checkbox" id="aiChk_${i}" ${selectedCodes.has(r.internal_ref_code) ? 'checked' : ''} onclick="event.stopPropagation();window._ai.toggle('${esc(r.internal_ref_code)}',${i})">
${img}
<div class="ai-result-info">
<div class="ai-result-name">${esc(r.product_name || '—')}</div>
<div class="ai-result-code">${esc(r.internal_ref_code)}</div>
</div>
<span class="ai-result-status ${st}">${stText}</span>
</div>`;
});
html += '</div>';
msg(html);
updateBar();
// Auto-suggest if occasion detected
if (lastOccasion) {
msg(`
💡 <strong>Gợi ý:</strong> AI phát hiện dịp <strong>"${esc(lastOccasion)}"</strong>.<br>
Bấm <strong>🤖 AI Sinh</strong> → chọn trường <strong>"Dịp mặc"</strong> → AI sẽ tự động thêm dịp này cho ${data.count} SP!
`);
} else {
msg(`
Chọn SP rồi bấm <strong>🤖 AI Sinh</strong> ở thanh dưới để:<br>
• Chọn <strong>trường</strong> cần sửa (dịp mặc, phối đồ...)<br>
• Gõ <strong>yêu cầu</strong> bằng tiếng Việt<br>
• AI Codex sinh nội dung <strong>riêng cho từng SP</strong> 🔥
`);
}
} catch (e) {
typing(false);
msg(`❌ Lỗi: ${e.message}`);
}
}
// ═══ SELECT ═══
function toggle(code, idx) {
selectedCodes.has(code) ? selectedCodes.delete(code) : selectedCodes.add(code);
const chk = document.getElementById(`aiChk_${idx}`);
if (chk) chk.checked = selectedCodes.has(code);
updateBar();
}
function selectAll() {
searchResults.forEach((r, i) => { selectedCodes.add(r.internal_ref_code); const c = document.getElementById(`aiChk_${i}`); if (c) c.checked = true; });
updateBar();
}
function deselectAll() {
selectedCodes.clear();
searchResults.forEach((_, i) => { const c = document.getElementById(`aiChk_${i}`); if (c) c.checked = false; });
updateBar();
}
function updateBar() {
const bar = document.getElementById('aiBulkBar');
document.getElementById('aiBulkCount').textContent = selectedCodes.size;
bar.style.display = (currentMode === 'searched' && searchResults.length > 0) ? 'flex' : 'none';
}
// ═══ AI EDIT FORM ═══
function showAiForm() {
if (selectedCodes.size === 0) { msg('⚠️ Chưa chọn SP nào!'); return; }
// Build field dropdown
let opts = '';
for (const [key, label] of Object.entries(fieldOptions)) {
const sel = key === 'dip_mac' ? ' selected' : '';
opts += `<option value="${key}"${sel}>${label}</option>`;
}
if (!opts) {
opts = `
<option value="dip_mac" selected>Dịp mặc</option>
<option value="phoi_do">Phối đồ</option>
<option value="mo_ta_chinh">Mô tả chính</option>
<option value="phong_cach">Phong cách</option>
<option value="hook_quang_cao">Hook quảng cáo</option>
<option value="cross_sell">Cross-sell</option>
<option value="ly_do_mua">Lý do mua</option>
<option value="tags">Tags</option>
<option value="mua">Mùa phù hợp</option>
<option value="layer">Gợi ý layering</option>
<option value="huong_dan_size">Hướng dẫn chọn size</option>
<option value="luu_y_size">Lưu ý size</option>
<option value="tagline">Tagline</option>
`;
}
// Pre-fill instruction if occasion detected
const defaultInstruction = lastOccasion
? `Thêm rằng sản phẩm này phù hợp mặc dịp ${lastOccasion}`
: '';
msg(`
<strong>🤖 AI Sinh Nội Dung — ${selectedCodes.size} sản phẩm</strong>
<div class="ai-edit-modal">
<label>Trường cần sửa</label>
<select id="aiFieldSelect">${opts}</select>
<label>Chế độ</label>
<select id="aiModeSelect">
<option value="append">➕ Bổ sung thêm (giữ cái cũ)</option>
<option value="overwrite">🔄 Viết lại hoàn toàn</option>
</select>
<label>Yêu cầu cho AI (tiếng Việt)</label>
<textarea id="aiInstruction" placeholder="VD: Thêm rằng áo này phù hợp mặc dịp Quốc Khánh 2/9 vì có gam đỏ cờ Tổ quốc...">${esc(defaultInstruction)}</textarea>
<div class="ai-edit-actions" style="margin-top:12px; display:flex; gap:8px;">
<button class="btn-secondary" onclick="window._ai.previewAiEdit()">👀 Thử nghiệm (1 SP)</button>
<button class="btn-apply" onclick="window._ai.startAiEdit()">🚀 Bắt đầu chạy HÀNG LOẠT</button>
</div>
<div id="aiPreviewBox" style="display:none; margin-top:12px; background:rgba(0,0,0,0.2); padding:10px; border-radius:8px; font-size:12px; border:1px solid rgba(255,255,255,0.1);">
</div>
</div>
`);
}
// ═══ PREVIEW AI EDIT ═══
async function previewAiEdit() {
const field = document.getElementById('aiFieldSelect').value;
const mode = document.getElementById('aiModeSelect').value;
const instruction = document.getElementById('aiInstruction').value.trim();
if (!instruction) { msg('⚠️ Nhập yêu cầu cho AI!'); return; }
// Mặc định lấy sản phẩm ĐẦU TIÊN trong list được chọn để preview
const codes = Array.from(selectedCodes);
if (codes.length === 0) return;
const previewCode = [codes[0]];
const box = document.getElementById('aiPreviewBox');
box.style.display = 'block';
box.innerHTML = `<em>Đang sinh thử nội dung cho SP <strong>${previewCode[0]}</strong>...</em> <span class="ai-spinner"></span>`;
try {
const resp = await fetch('/api/bulk/ai-edit-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes: previewCode, target_field: field, instruction, mode }),
});
const data = await resp.json();
if (data.status === 'success') {
box.innerHTML = `
<div style="color:#d6d3d1; margin-bottom:4px;"><strong>Kết quả thử nghiệm (${esc(data.code)}):</strong></div>
<div style="color:#dc2626; margin-bottom:4px; text-decoration:line-through;">${data.old_value ? esc(data.old_value) : '<em>(trống)</em>'}</div>
<div style="color:#22c55e;">${esc(data.new_value)}</div>
<div style="margin-top:6px; font-size:11px; color:#a8a29e;">Review ổn thì bấm Bắt đầu chạy hàng loạt nhé 👇</div>
`;
} else {
box.innerHTML = `<div style="color:#dc2626;">❌ ${esc(data.message)}</div>`;
}
} catch (e) {
box.innerHTML = `<div style="color:#dc2626;">❌ Lỗi: ${e.message}</div>`;
}
}
// ═══ START AI EDIT ═══
async function startAiEdit() {
const field = document.getElementById('aiFieldSelect').value;
const mode = document.getElementById('aiModeSelect').value;
const instruction = document.getElementById('aiInstruction').value.trim();
if (!instruction) { msg('⚠️ Nhập yêu cầu cho AI!'); return; }
const codes = Array.from(selectedCodes);
const fieldLabel = fieldOptions[field] || field;
msg(`🚀 Đang gửi yêu cầu cho AI Codex...<br>
Trường: <strong>${fieldLabel}</strong><br>
Chế độ: <strong>${mode === 'append' ? 'Bổ sung' : 'Viết lại'}</strong><br>
SP: <strong>${codes.length}</strong><br>
Yêu cầu: <em>"${esc(instruction)}"</em>`);
typing(true);
try {
const resp = await fetch('/api/bulk/ai-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes, target_field: field, instruction, mode }),
});
const data = await resp.json();
typing(false);
if (data.status === 'success') {
msg(`✅ ${data.message}`);
currentMode = 'ai_running';
showProgress();
startPolling();
} else {
msg(`❌ ${data.message}`);
}
} catch (e) {
typing(false);
msg(`❌ Lỗi: ${e.message}`);
}
}
// ═══ PROGRESS TRACKING ═══
function showProgress() {
document.getElementById('aiProgressBar').style.display = 'block';
}
function hideProgress() {
document.getElementById('aiProgressBar').style.display = 'none';
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollStatus, 2000);
}
async function pollStatus() {
try {
const resp = await fetch('/api/bulk/ai-edit-status');
const s = await resp.json();
const pct = s.total > 0 ? Math.round((s.done / s.total) * 100) : 0;
document.getElementById('aiProgressText').textContent = `${s.done}/${s.total}`;
document.getElementById('aiProgressFill').style.width = pct + '%';
document.getElementById('aiProgressName').textContent = s.current_name ? `→ ${s.current_name}` : '';
if (!s.is_running) {
clearInterval(pollTimer);
pollTimer = null;
hideProgress();
currentMode = 'searched';
// Show completion + results
let html = `🏁 <strong>AI hoàn tất!</strong> ${s.done}/${s.total} SP (${s.errors} lỗi)<br><br>`;
if (s.results && s.results.length > 0) {
html += '<div class="ai-search-results" style="max-height:220px;">';
s.results.forEach(r => {
if (r.status === 'success') {
html += `
<div class="ai-result-item" style="flex-direction:column;align-items:flex-start;gap:4px;cursor:default;">
<div style="display:flex;align-items:center;gap:6px;width:100%;">
<span class="ai-result-status done">✅</span>
<strong style="font-size:11px;">${esc(r.code)}</strong>
<span style="font-size:11px;color:#78716c;">${esc(r.name || '')}</span>
</div>
<div style="font-size:11px;width:100%;">
<div style="color:#dc2626;">- ${esc(r.old_value || '(trống)')}</div>
<div style="color:#16a34a;">+ ${esc(r.new_value || '')}</div>
</div>
</div>`;
} else {
html += `
<div class="ai-result-item" style="cursor:default;">
<span class="ai-result-status missing">❌</span>
<span style="font-size:11px;">${esc(r.code)} — ${esc(r.error || r.status)}</span>
</div>`;
}
});
html += '</div>';
}
msg(html);
}
} catch (e) {
console.warn('Poll error:', e);
}
}
// ═══ HELP ═══
function showHelp() {
msg(`
<strong>📖 Hướng dẫn sử dụng — AI Agent v3</strong><br><br>
<strong>1️⃣ Tìm kiếm thông minh:</strong><br>
Gõ <strong>câu tự nhiên</strong> — AI hiểu ý và tìm SP:<br>
• <em>"áo đỏ cho bé trai"</em> → lọc: Áo + đỏ + Nam + trẻ em<br>
• <em>"polo nam đi làm"</em> → lọc: Áo Polo + Nam<br>
• <em>"váy cotton mùa hè"</em> → lọc: Váy + cotton + Hè<br><br>
<strong>2️⃣ Chọn SP:</strong><br>
Tick checkbox chọn SP cần sửa (mặc định chọn hết)<br><br>
<strong>3️⃣ AI Sinh nội dung:</strong><br>
Bấm <strong>🤖 AI Sinh</strong> → chọn trường + gõ yêu cầu<br>
AI Codex sinh nội dung <strong>riêng cho từng SP</strong> 🔥<br><br>
<strong>💡 Tips:</strong> Nếu bạn nói dịp đặc biệt ("2/9", "Tết"), AI sẽ tự gợi ý instruction!
`);
}
// ═══ CLEAR ═══
function clearChat() {
document.getElementById('aiMessages').innerHTML = '';
searchResults = []; selectedCodes.clear(); currentMode = 'idle';
lastAiFilters = null; lastOccasion = '';
updateBar(); hideProgress();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
msg('🧹 Đã xóa. Gõ câu tự nhiên để AI tìm SP thông minh!');
}
// ═══ UTILS ═══
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═══ INIT ═══
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', createWidget);
else createWidget();
// ═══ PUBLIC API ═══
window._ai = {
togglePanel, send, qSearch, toggle, selectAll, deselectAll,
showAiForm, previewAiEdit, startAiEdit, showHelp, clearChat, updateBar
};
})();
<!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;
}
}
// compare-tab.js
// Handles the "Kiểm tra & Đối chiếu Data" tab and diff viewer
let compareCurrentPage = 1;
function switchTab(tab) {
document.getElementById('tabProducts').className = tab === 'products' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabProducts').style.borderBottomColor = tab === 'products' ? 'var(--primary)' : 'transparent';
document.getElementById('tabProducts').style.color = tab === 'products' ? 'var(--primary)' : 'var(--muted-fg)';
document.getElementById('tabCompare').className = tab === 'compare' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabCompare').style.borderBottomColor = tab === 'compare' ? 'var(--primary)' : 'transparent';
document.getElementById('tabCompare').style.color = tab === 'compare' ? 'var(--primary)' : 'var(--muted-fg)';
document.getElementById('sectionProducts').style.display = tab === 'products' ? 'block' : 'none';
document.getElementById('sectionCompare').style.display = tab === 'compare' ? 'block' : 'none';
if (tab === 'compare') {
loadCompareProducts(1);
} else {
loadProducts(currentPage);
}
}
async function loadCompareProducts(page = 1) {
compareCurrentPage = page;
const tbody = document.getElementById('compareTableBody');
const pagination = document.getElementById('comparePagination');
tbody.innerHTML = '<tr><td colspan="5" class="table-loading"><div class="spinner-sm"></div> Đang tải...</td></tr>';
const filterVal = document.getElementById('compareStatusFilter').value;
// Map filter to API params
let url = `${API}/list?limit=20&page=${page}`;
if (filterVal === 'missing') url += '&status=missing';
try {
const res = await fetch(url);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
let products = data.products;
// For 'missing_cut', we don't have a backend filter, so we might need to filter client side or just show them.
// Wait, the API doesn't return 'description_data_cut' in /list. So we can't filter 'missing_cut' here perfectly.
// We will just show what the backend gives and highlight if it has missing cut when we open it.
if (products.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="table-loading">Không có sản phẩm nào.</td></tr>';
pagination.innerHTML = '';
return;
}
tbody.innerHTML = products.map(p => {
const code = p.magento_ref_code || p.internal_ref_code;
const hasClean = p.has_desc === 1;
let statusHtml = '';
let missingHtml = '';
if (!hasClean) {
statusHtml = '<span class="badge" style="background:#fee2e2; color:#b91c1c;">✕ Trống trơn</span>';
missingHtml = 'Thiếu Clean Data, Tags, Data Cut';
} else {
statusHtml = '<span class="badge" style="background:#dcfce7; color:#15803d;">✓ Có Clean Data</span>';
missingHtml = 'Cần kiểm tra Data Cut'; // We don't know without fetching details
}
return `
<tr>
<td style="font-family:var(--font-mono); font-size:13px; font-weight:600; color:var(--primary);">${code}</td>
<td style="font-size:13px;">${esc(p.product_name)}</td>
<td>${statusHtml}</td>
<td style="font-size:12px; color:#64748b;">${missingHtml}</td>
<td>
<button class="btn btn-sm btn-outline-primary" onclick="openCompareModal('${code}')">⚖️ Đối chiếu</button>
</td>
</tr>
`;
}).join('');
// Pagination
let totalPages = Math.ceil(data.total / data.limit);
let pageHtml = '';
if (totalPages > 1) {
pageHtml += `<button ${page === 1 ? 'disabled' : ''} onclick="loadCompareProducts(${page - 1})">«</button>`;
let startP = Math.max(1, page - 2);
let endP = Math.min(totalPages, page + 2);
for (let i = startP; i <= endP; i++) {
pageHtml += `<button class="${i === page ? 'active' : ''}" onclick="loadCompareProducts(${i})">${i}</button>`;
}
pageHtml += `<button ${page === totalPages ? 'disabled' : ''} onclick="loadCompareProducts(${page + 1})">»</button>`;
}
pagination.innerHTML = pageHtml;
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-loading" style="color:var(--warn);">Lỗi: ${esc(e.message)}</td></tr>`;
}
}
async function openCompareModal(code) {
document.getElementById('compareModalCode').textContent = code;
document.getElementById('compareStarRocks').innerHTML = '<div class="spinner-sm"></div> Đang tải...';
document.getElementById('compareSQLite').innerHTML = '<div class="spinner-sm"></div> Đang tải...';
document.getElementById('compareModal').style.display = 'flex';
try {
const res = await fetch(`${API}/compare/${code}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
const sr = data.starrocks;
const sq = data.sqlite;
// StarRocks Panel
let srHtml = '';
if (!sr.description_text_full || sr.description_text_full.startsWith('Lỗi')) {
srHtml = `<div style="padding:20px; text-align:center; color:#b91c1c;">
<div style="font-size:40px; margin-bottom:10px;">❌</div>
<div style="font-weight:bold;">Chưa có mô tả gốc</div>
<div style="font-size:12px; color:#ef4444; margin-top:5px;">${esc(sr.description_text_full || 'Dữ liệu trống')}</div>
</div>`;
} else {
srHtml = `<div style="padding:20px; text-align:center; color:#15803d; border-bottom: 1px solid #e2e8f0; margin-bottom: 15px;">
<div style="font-size:40px; margin-bottom:10px;">✅</div>
<div style="font-weight:bold;">Đã có mô tả gốc</div>
</div>
<div style="padding:0 20px 20px 20px; font-size:13px; color:#475569; text-align:left;">
${esc(sr.description_text_full)}
</div>`;
}
document.getElementById('compareStarRocks').innerHTML = srHtml;
// SQLite/Postgres Panel
let sqHtml = '';
const hasDataCut = sq.description_data_cut && sq.description_data_cut.length > 0;
if (!hasDataCut) {
sqHtml = `<div style="padding:20px; text-align:center; color:#b91c1c;">
<div style="font-size:40px; margin-bottom:10px;">❌</div>
<div style="font-weight:bold;">Chưa có mô tả AI (Data Cut)</div>
<div style="font-size:12px; color:#ef4444; margin-top:5px;">Hãy chạy Auto-Pilot để sinh mô tả.</div>
</div>`;
} else {
sqHtml = `<div style="padding:20px; text-align:center; color:#15803d; border-bottom: 1px solid #e2e8f0; margin-bottom: 15px;">
<div style="font-size:40px; margin-bottom:10px;">✅</div>
<div style="font-weight:bold;">Đã có mô tả AI</div>
</div>
<div style="padding:0 20px 20px 20px; font-size:13px; color:#475569; text-align:left;">
${esc(sq.description_data_cut)}
</div>`;
}
document.getElementById('compareSQLite').innerHTML = sqHtml;
} catch (e) {
document.getElementById('compareStarRocks').innerHTML = `<span style="color:#dc2626">Lỗi: ${esc(e.message)}</span>`;
document.getElementById('compareSQLite').innerHTML = `<span style="color:#dc2626">Lỗi: ${esc(e.message)}</span>`;
}
}
// Helpers
function runAutoPilotCleanAndTags() {
if(confirm('Chạy Auto-Pilot: Sinh Clean Data & Tags cho các SP thiếu? Việc này sẽ chạy dưới nền.')) {
// Calling the API endpoint /regenerate-missing from batch_jobs if it exists, or just trigger missing
fetch('/api/product-desc/batch-regenerate-all', { method: 'POST' })
.then(r => r.json())
.then(d => {
alert(d.message || 'Đã gửi yêu cầu chạy ngầm!');
loadCompareProducts(compareCurrentPage);
})
.catch(e => alert('Lỗi: ' + e.message));
}
}
function runAutoPilotDataCut() {
if(confirm('Chạy Auto-Pilot: Sinh Data Cut cho các SP đã có Clean Data nhưng thiếu Cut? Việc này sẽ chạy dưới nền.')) {
// Currently relying on backend /batch-regenerate, or a specific endpoint for data cut
fetch('/api/product-desc/batch-regenerate-cut', { method: 'POST' })
.then(r => r.json())
.then(d => {
alert(d.message || 'Tính năng đang được phát triển hoặc đã gửi yêu cầu!');
loadCompareProducts(compareCurrentPage);
})
.catch(e => alert('Lỗi: ' + e.message));
}
}
...@@ -350,6 +350,7 @@ function switchDetailTab(tabId) { ...@@ -350,6 +350,7 @@ function switchDetailTab(tabId) {
if(document.getElementById('detailBodyClean')) document.getElementById('detailBodyClean').style.display = 'none'; if(document.getElementById('detailBodyClean')) document.getElementById('detailBodyClean').style.display = 'none';
if(document.getElementById('detailBodyFields')) document.getElementById('detailBodyFields').style.display = 'none'; if(document.getElementById('detailBodyFields')) document.getElementById('detailBodyFields').style.display = 'none';
if(document.getElementById('detailBodyEmbed')) document.getElementById('detailBodyEmbed').style.display = 'none'; if(document.getElementById('detailBodyEmbed')) document.getElementById('detailBodyEmbed').style.display = 'none';
if(document.getElementById('detailBodyOutfit')) document.getElementById('detailBodyOutfit').style.display = 'none';
if(document.getElementById('detailBodyRaw')) document.getElementById('detailBodyRaw').style.display = 'none'; if(document.getElementById('detailBodyRaw')) document.getElementById('detailBodyRaw').style.display = 'none';
document.querySelector('.detail-panel').style.background = 'var(--card)'; document.querySelector('.detail-panel').style.background = 'var(--card)';
...@@ -371,6 +372,10 @@ function switchDetailTab(tabId) { ...@@ -371,6 +372,10 @@ function switchDetailTab(tabId) {
} else if (tabId === 'embed') { } else if (tabId === 'embed') {
document.getElementById('detailBodyEmbed').style.display = 'block'; document.getElementById('detailBodyEmbed').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none'; document.getElementById('btnSavePopupText').style.display = 'none';
} else if (tabId === 'outfit') {
document.getElementById('detailBodyOutfit').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
loadOutfitMatches(currentDetailCode);
} else if (tabId === 'raw') { } else if (tabId === 'raw') {
document.getElementById('detailBodyRaw').style.display = 'block'; document.getElementById('detailBodyRaw').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none'; document.getElementById('btnSavePopupText').style.display = 'none';
...@@ -724,3 +729,87 @@ async function triggerInlineEdit(action) { ...@@ -724,3 +729,87 @@ async function triggerInlineEdit(action) {
// Initialize on load // Initialize on load
setupInlineEdit(); setupInlineEdit();
// ═══ Outfit Matches Tab ═══
let _outfitCache = {};
async function loadOutfitMatches(code) {
const container = document.getElementById('outfitMatchesContent');
if (!code) { container.innerHTML = '<p style="color:var(--muted-fg)">Không xác định mã SP.</p>'; return; }
// Use base code (strip color suffix)
const baseCode = code.split('-')[0];
if (_outfitCache[baseCode]) { renderOutfitMatches(_outfitCache[baseCode], container); return; }
container.innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> Đang tải dữ liệu phối đồ...</div>';
try {
const res = await fetch(`/api/outfit-matches/${encodeURIComponent(baseCode)}`);
const data = await res.json();
if (!data.ok) throw new Error(data.error || 'API error');
_outfitCache[baseCode] = data;
renderOutfitMatches(data, container);
} catch (e) {
container.innerHTML = `<div style="padding:20px; text-align:center; color:var(--muted-fg);">
<div style="font-size:40px; margin-bottom:12px;">👕</div>
<p style="font-weight:600;">Chưa có dữ liệu phối đồ cho sản phẩm này</p>
<p style="font-size:12px;">${esc(e.message)}</p>
</div>`;
}
}
function renderOutfitMatches(data, container) {
const matches = data.matches || [];
const byRole = data.by_role || {};
const usedAs = data.used_as_match_in || [];
if (!matches.length && !usedAs.length) {
container.innerHTML = `<div style="padding:30px; text-align:center; color:var(--muted-fg);">
<div style="font-size:48px; margin-bottom:12px;">👗</div>
<p style="font-weight:600; font-size:15px;">Chưa có dữ liệu phối đồ</p>
<p style="font-size:13px;">Hãy chạy Outfit Matcher Worker để sinh dữ liệu.</p>
</div>`;
return;
}
const roleLabels = {
'top': '👕 Áo (Top)', 'bottom': '👖 Quần/Váy (Bottom)',
'outerwear': '🧥 Áo khoác (Outerwear)', 'accessory': '👜 Phụ kiện',
'footwear': '👟 Giày dép', 'innerwear': '🩲 Đồ lót', 'other': '🎯 Khác'
};
let html = `<div style="margin-bottom:16px;"><strong style="font-size:16px;">👗 ${matches.length} SP phối cùng</strong></div>`;
for (const [role, items] of Object.entries(byRole)) {
const label = roleLabels[role] || role;
html += `<div style="margin-bottom:20px;">`;
html += `<div style="font-weight:700; font-size:13px; color:#1e3a5f; margin-bottom:8px; padding:6px 12px; background:#f1f5f9; border-radius:6px; text-transform:uppercase; letter-spacing:.05em;">${label}</div>`;
html += `<div style="display:grid; grid-template-columns:repeat(auto-fill, minmax(280px, 1fr)); gap:10px;">`;
for (const m of items) {
const score = m.score ? (m.score * 100).toFixed(0) : '?';
const scoreBg = m.score >= 0.8 ? '#dcfce7' : m.score >= 0.6 ? '#fef9c3' : '#fee2e2';
const scoreColor = m.score >= 0.8 ? '#166534' : m.score >= 0.6 ? '#854d0e' : '#991b1b';
html += `
<div style="border:1px solid #e5e7eb; border-radius:8px; padding:12px; background:#fff; display:flex; flex-direction:column; gap:6px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<strong style="font-size:13px; color:#0b1c44;">${esc(m.match_product_name || m.match_product_code)}</strong>
<span style="font-size:11px; font-weight:700; padding:2px 8px; border-radius:10px; background:${scoreBg}; color:${scoreColor};">${score}%</span>
</div>
<div style="font-size:12px; color:#6b7280;">Mã: ${esc(m.match_product_code)}</div>
${m.ai_reason ? `<div style="font-size:12px; color:#4b5563; font-style:italic; border-left:3px solid #3b82f6; padding-left:8px; margin-top:4px;">${esc(m.ai_reason)}</div>` : ''}
</div>`;
}
html += `</div></div>`;
}
if (usedAs.length) {
html += `<div style="margin-top:24px; padding-top:16px; border-top:2px solid #e5e7eb;">`;
html += `<div style="font-weight:700; font-size:14px; margin-bottom:10px; color:#7c3aed;">🔄 SP này được dùng làm match cho ${usedAs.length} SP khác:</div>`;
html += `<div style="display:flex; flex-wrap:wrap; gap:8px;">`;
for (const u of usedAs) {
html += `<span style="font-size:12px; padding:4px 10px; background:#f3e8ff; color:#7c3aed; border-radius:6px; font-weight:600; cursor:pointer;" onclick="openDetail('${esc(u.anchor_product_code)}','')">${esc(u.anchor_product_code)}</span>`;
}
html += `</div></div>`;
}
container.innerHTML = html;
}
/**
* Sync Check — Compare StarRocks catalog vs PostgreSQL descriptions
* Shows which products are missing AI descriptions and which are orphaned.
*/
async function openSyncCheck() {
const btn = document.getElementById('btnSyncCheck');
const origText = btn.textContent;
btn.textContent = '⏳ Đang kiểm tra...';
btn.disabled = true;
try {
const resp = await fetch('/api/product-desc/sync-check?include_details=true&limit=200');
const data = await resp.json();
if (data.status !== 'success') {
alert('Lỗi: ' + (data.message || 'Unknown'));
return;
}
showSyncModal(data);
} catch (err) {
alert('Sync Check Error: ' + err.message);
} finally {
btn.textContent = origText;
btn.disabled = false;
}
}
function showSyncModal(data) {
const s = data.sync_summary;
// Remove existing modal if any
const existing = document.getElementById('syncCheckModal');
if (existing) existing.remove();
const modal = document.createElement('div');
modal.id = 'syncCheckModal';
modal.style.cssText = `
position:fixed; top:0; left:0; width:100%; height:100%; z-index:10000;
background:rgba(0,0,0,0.5); display:flex; align-items:center; justify-content:center;
`;
const coveragePctColor = s.coverage_pct >= 90 ? '#059669' : s.coverage_pct >= 70 ? '#d97706' : '#dc2626';
let missingHtml = '';
if (data.missing_products && data.missing_products.length > 0) {
missingHtml = `
<div style="margin-top:16px;">
<h4 style="margin:0 0 8px; color:#dc2626;">❌ SP Thiếu Description (${s.missing_desc})</h4>
<div style="max-height:200px; overflow-y:auto; border:1px solid #fecaca; border-radius:6px; background:#fff5f5;">
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr style="background:#fef2f2; position:sticky; top:0;">
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fecaca;">Magento Code</th>
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fecaca;">Tên SP</th>
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fecaca;">Dòng SP</th>
</tr>
</thead>
<tbody>
${data.missing_products.map(p => `
<tr style="border-bottom:1px solid #fee2e2;">
<td style="padding:4px 8px; font-family:monospace; font-size:11px;">${p.magento_ref_code}</td>
<td style="padding:4px 8px;">${p.product_name || '—'}</td>
<td style="padding:4px 8px; color:#6b7280;">${p.product_line || '—'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
let orphanHtml = '';
if (data.orphan_products && data.orphan_products.length > 0) {
orphanHtml = `
<div style="margin-top:16px;">
<h4 style="margin:0 0 8px; color:#d97706;">⚠️ SP Mồ Côi — có trong PG nhưng không còn trên Magento (${s.orphan_in_pg})</h4>
<div style="max-height:150px; overflow-y:auto; border:1px solid #fde68a; border-radius:6px; background:#fffbeb;">
<table style="width:100%; border-collapse:collapse; font-size:12px;">
<thead>
<tr style="background:#fef3c7; position:sticky; top:0;">
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fde68a;">Magento Code</th>
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fde68a;">Tên SP</th>
<th style="padding:6px 8px; text-align:left; border-bottom:1px solid #fde68a;">Status</th>
</tr>
</thead>
<tbody>
${data.orphan_products.map(p => `
<tr style="border-bottom:1px solid #fde68a;">
<td style="padding:4px 8px; font-family:monospace; font-size:11px;">${p.magento_ref_code}</td>
<td style="padding:4px 8px;">${p.product_name || '—'}</td>
<td style="padding:4px 8px;">${p.status === 1 ? '✅ Approved' : '⏳ Pending'}</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
`;
}
modal.innerHTML = `
<div style="background:#fff; border-radius:12px; max-width:750px; width:95%; max-height:85vh; overflow-y:auto; padding:28px; box-shadow:0 20px 60px rgba(0,0,0,0.3); position:relative;">
<button onclick="document.getElementById('syncCheckModal').remove()"
style="position:absolute; top:12px; right:16px; background:none; border:none; font-size:20px; cursor:pointer; color:#6b7280;">✕</button>
<h2 style="margin:0 0 4px; font-size:20px;">🔄 Sync Check — StarRocks ↔ PostgreSQL</h2>
<p style="margin:0 0 20px; color:#6b7280; font-size:13px;">So sánh SP trên Magento catalog (StarRocks) vs AI descriptions đã sinh (PostgreSQL)</p>
<div style="display:grid; grid-template-columns:repeat(3,1fr); gap:12px; margin-bottom:20px;">
<div style="background:#f0f9ff; padding:16px; border-radius:8px; text-align:center; border:1px solid #bae6fd;">
<div style="font-size:28px; font-weight:700; color:#0284c7;">${s.starrocks_total}</div>
<div style="font-size:12px; color:#0369a1; margin-top:4px;">StarRocks Catalog</div>
</div>
<div style="background:#f0fdf4; padding:16px; border-radius:8px; text-align:center; border:1px solid #bbf7d0;">
<div style="font-size:28px; font-weight:700; color:#059669;">${s.synced}</div>
<div style="font-size:12px; color:#047857; margin-top:4px;">Đã Sync ✓</div>
</div>
<div style="background:#fef2f2; padding:16px; border-radius:8px; text-align:center; border:1px solid #fecaca;">
<div style="font-size:28px; font-weight:700; color:#dc2626;">${s.missing_desc}</div>
<div style="font-size:12px; color:#991b1b; margin-top:4px;">Thiếu Description</div>
</div>
</div>
<div style="display:flex; align-items:center; gap:16px; padding:12px 16px; background:#f8fafc; border-radius:8px; margin-bottom:16px; border:1px solid #e2e8f0;">
<div style="flex:1;">
<div style="display:flex; justify-content:space-between; margin-bottom:6px;">
<span style="font-weight:600; font-size:13px;">Coverage</span>
<span style="font-weight:700; font-size:13px; color:${coveragePctColor};">${s.coverage_pct}%</span>
</div>
<div style="background:#e2e8f0; height:10px; border-radius:5px; overflow:hidden;">
<div style="background:${coveragePctColor}; height:100%; width:${s.coverage_pct}%; transition:width 0.5s; border-radius:5px;"></div>
</div>
</div>
<div style="text-align:center; padding-left:16px; border-left:1px solid #e2e8f0;">
<div style="font-size:16px; font-weight:700; color:#d97706;">${s.orphan_in_pg}</div>
<div style="font-size:11px; color:#92400e;">Mồ côi</div>
</div>
<div style="text-align:center; padding-left:16px; border-left:1px solid #e2e8f0;">
<div style="font-size:16px; font-weight:700; color:#0284c7;">${s.pg_total}</div>
<div style="font-size:11px; color:#0369a1;">PG Total</div>
</div>
</div>
${missingHtml}
${orphanHtml}
<div style="margin-top:20px; display:flex; gap:8px; justify-content:flex-end;">
${s.missing_desc > 0 ? `<button class="btn btn-sm" style="background:#0284c7;color:#fff;border:none;font-weight:600;" onclick="document.getElementById('syncCheckModal').remove(); batchGenerateAll();">🚀 Sinh AI cho tất cả SP thiếu</button>` : ''}
<button class="btn btn-outline-dark btn-sm" onclick="document.getElementById('syncCheckModal').remove()">Đóng</button>
</div>
</div>
`;
// Close on backdrop click
modal.addEventListener('click', (e) => {
if (e.target === modal) modal.remove();
});
document.body.appendChild(modal);
}
...@@ -20,25 +20,22 @@ ...@@ -20,25 +20,22 @@
<p>Sinh mô tả sản phẩm AI — kết hợp Vision + Stock Data</p> <p>Sinh mô tả sản phẩm AI — kết hợp Vision + Stock Data</p>
</div> </div>
<div style="display:flex;gap:8px;"> <div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm"
onclick="let a=document.createElement('a'); a.setAttribute('data-page','product-desc/compare.html'); window.parent.navigateTo(a)"
title="Mở tab đối chiếu data độc lập">⚖️ Check Data</button>
<button class="btn btn-outline btn-sm" <button class="btn btn-outline btn-sm"
onclick="let a=document.createElement('a'); a.setAttribute('data-page','product-desc/docs.html'); window.parent.navigateTo(a)" onclick="let a=document.createElement('a'); a.setAttribute('data-page','product-desc/docs.html'); window.parent.navigateTo(a)"
title="Xem tài liệu hệ thống">📄 Tài liệu</button> title="Xem tài liệu hệ thống">📄 Tài liệu</button>
<button class="btn btn-outline btn-sm" <button class="btn btn-outline btn-sm"
onclick="loadOverview();if(currentTab==='products') loadProducts(); else loadFields();">↻ Refresh</button> onclick="loadOverview();if(typeof currentTab !== 'undefined' && currentTab==='products') loadProducts(); else loadFields();">↻ Refresh</button>
</div> </div>
</div> </div>
<!-- Tabs --> <!-- Tabs -->
<div style="display:flex; gap:20px; border-bottom:1px solid var(--border); margin-bottom:20px;"> <div style="display:flex; gap:20px; border-bottom:1px solid var(--border); margin-bottom:20px;">
<div class="tab-btn active" id="tabProducts" onclick="switchTab('products')" <div class="tab-btn active" id="tabProducts"
style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid var(--primary); color:var(--primary);"> style="padding:10px 0; font-weight:600; font-size:14px; border-bottom:2px solid var(--primary); color:var(--primary);">
Sản phẩm</div> Danh Sách Sản Phẩm</div>
<div class="tab-btn" id="tabFields" onclick="switchTab('fields')"
style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">
Quản lý Trường</div>
<div class="tab-btn" id="tabCleanData" onclick="switchTab('cleanData')"
style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">
Clean Data</div>
</div> </div>
<div id="sectionProducts"> <div id="sectionProducts">
...@@ -160,24 +157,19 @@ ...@@ -160,24 +157,19 @@
<div class="filter-actions"> <div class="filter-actions">
<button class="btn btn-outline-dark btn-sm" onclick="loadProducts()">Lọc</button> <button class="btn btn-outline-dark btn-sm" onclick="loadProducts()">Lọc</button>
<button class="btn btn-outline-dark btn-sm" onclick="resetFilters()">Reset</button> <button class="btn btn-outline-dark btn-sm" onclick="resetFilters()">Reset</button>
<button class="btn btn-sm" id="btnAutoPilot" style="background:#e11d48;color:#fff;border:none;font-weight:700;box-shadow:0 0 10px rgba(225,29,72,0.4);" onclick="runAutoPilot()">🚀 QUÉT & BƠM TỰ ĐỘNG (Auto-Pilot)</button>
<button class="btn btn-dark btn-sm" id="btnApprovePage" onclick="batchApprovePage()">Duyệt (Trang <button class="btn btn-dark btn-sm" id="btnApprovePage" onclick="batchApprovePage()">Duyệt (Trang
này)</button> này)</button>
<button class="btn btn-dark btn-sm" id="btnApproveAll" style="font-weight:600;" onclick="approveAll()">Duyệt <button class="btn btn-dark btn-sm" id="btnApproveAll" style="font-weight:600;" onclick="approveAll()">Duyệt
TOÀN BỘ</button> TOÀN BỘ</button>
<button class="btn btn-outline-dark btn-sm" id="btnBackfillClean" style="font-weight:600;"
onclick="backfillMissingCleanDescriptions()">Bù Clean Desc</button>
<button class="btn btn-outline-dark btn-sm" id="btnBatch" onclick="batchGeneratePage()">Sinh AI (Trang
này)</button>
<button class="btn btn-outline-dark btn-sm" id="btnBatchAll" onclick="batchGenerateAll()">Sinh AI (Toàn bộ
thiếu)</button>
<button class="btn btn-dark btn-sm" id="btnBatchTags" onclick="batchGenerateTags()">Bơm Tags (Queue)</button>
<button class="btn btn-sm" id="btnBatchTagsDirect" onclick="runTagsDirect()"
style="background:#059669;color:#fff;border:none;font-weight:700;">🏷️ Bơm Tags TOÀN BỘ (Direct)</button>
<button class="btn btn-sm" id="btnBatchSize" <button class="btn btn-sm" id="btnBatchSize"
style="background:#7c3aed;color:#fff;border:none;font-weight:600;" onclick="batchEnrichSize()">📏 Bơm Size style="background:#7c3aed;color:#fff;border:none;font-weight:600;" onclick="batchEnrichSize()">📏 Bơm Size
Guide</button> Guide</button>
<button class="btn btn-primary btn-sm" style="font-weight:bold; background:#0284c7; border-color:#0284c7;" <button class="btn btn-primary btn-sm" style="font-weight:bold; background:#0284c7; border-color:#0284c7;"
onclick="openNativeBulkAiModal()">🤖 Bulk AI Edit (Custom)</button> onclick="openNativeBulkAiModal()">🤖 Bulk AI Edit (Custom)</button>
<button class="btn btn-sm" id="btnSyncCheck"
style="background:#7c3aed;color:#fff;border:none;font-weight:600;" onclick="openSyncCheck()">🔄 Sync Check</button>
</div> </div>
</div><!-- Batch Tracking Bar --> </div><!-- Batch Tracking Bar -->
<div id="batchTrackingBox" <div id="batchTrackingBox"
...@@ -251,8 +243,6 @@ ...@@ -251,8 +243,6 @@
<th style="width:25%">Sản phẩm</th> <th style="width:25%">Sản phẩm</th>
<th>Dòng SP</th> <th>Dòng SP</th>
<th>Tags</th> <th>Tags</th>
<th>Giá</th>
<th>Giá</th> <th>Giá</th>
<th>Lượt bán</th> <th>Lượt bán</th>
<th>Trạng thái</th> <th>Trạng thái</th>
...@@ -275,37 +265,7 @@ ...@@ -275,37 +265,7 @@
<div class="pagination" id="pagination"></div> <div class="pagination" id="pagination"></div>
</div> <!-- end sectionProducts --> </div> <!-- end sectionProducts -->
<!-- Fields Section --> <!-- sectionCompare removed and extracted to compare.html -->
<div id="sectionFields" style="display:none;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<div>
<h2 style="margin:0; font-size:18px;">Cấu hình Prompt Fields</h2>
<p style="margin:4px 0 0; font-size:13px; color:var(--muted-fg);">Các trường này sẽ được sinh động trong quá
trình AI phân tích sản phẩm (Phase 1 & Phase 2).</p>
</div>
<button class="btn btn-primary" onclick="showFieldForm()"><span style="margin-right:6px">➕</span> Thêm
Trường</button>
</div>
<div style="border-radius:10px; overflow:hidden; border:1px solid var(--border); background:var(--card);">
<table class="product-table" id="fieldsTable">
<thead style="background:var(--muted);">
<tr>
<th style="width:15%">Key</th>
<th style="width:20%">Label</th>
<th style="width:40%">Instruction (Prompt)</th>
<th>Trạng thái</th>
<th style="width:120px">Thao tác</th>
</tr>
</thead>
<tbody id="fieldsTableBody">
<tr>
<td colspan="5" class="table-loading">Đang tải...</td>
</tr>
</tbody>
</table>
</div>
</div>
</div> </div>
<!-- INLINE EDIT POPUP --> <!-- INLINE EDIT POPUP -->
...@@ -354,6 +314,9 @@ ...@@ -354,6 +314,9 @@
<div class="tab-btn" id="dTabEmbed" onclick="switchDetailTab('embed')" <div class="tab-btn" id="dTabEmbed" onclick="switchDetailTab('embed')"
style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);"> style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">
Clean Descripts</div> Clean Descripts</div>
<div class="tab-btn" id="dTabOutfit" onclick="switchDetailTab('outfit')"
style="padding:10px 0; cursor:pointer; font-weight:600; font-size:14px; border-bottom:2px solid transparent; color:var(--muted-fg);">
👗 Phối đồ</div>
</div> </div>
</div> </div>
...@@ -377,6 +340,14 @@ ...@@ -377,6 +340,14 @@
</div> </div>
</div> </div>
<!-- Outfit Matches Tab -->
<div class="detail-body" id="detailBodyOutfit"
style="display:none; max-height: 60vh; overflow-y: auto; background: var(--card); padding: 24px;">
<div id="outfitMatchesContent" style="font-size:14px; color:#374151;">
<div class="table-loading"><div class="spinner-sm"></div> Đang tải phối đồ...</div>
</div>
</div>
<!-- Raw Tab --> <!-- Raw Tab -->
<div class="detail-body" id="detailBodyRaw" <div class="detail-body" id="detailBodyRaw"
style="display:none; background:#1e1e1e; color:#cfcbcc; max-height: 60vh; overflow-y: auto; padding: 20px;"> style="display:none; background:#1e1e1e; color:#cfcbcc; max-height: 60vh; overflow-y: auto; padding: 20px;">
...@@ -438,58 +409,19 @@ ...@@ -438,58 +409,19 @@
<button class="btn btn-outline" <button class="btn btn-outline"
onclick="document.getElementById('nativeBulkAiModal').style.display='none'">Hủy</button> onclick="document.getElementById('nativeBulkAiModal').style.display='none'">Hủy</button>
<button class="btn btn-primary" id="btnRunNativeBulkAi" onclick="execNativeBulkAi()">🚀 Chạy AI Ngay</button> <button class="btn btn-primary" id="btnRunNativeBulkAi" onclick="execNativeBulkAi()">🚀 Chạy AI Ngay</button>
</div> <!-- COMPARE MODAL extracted to compare.html -->
</div>
</div>
<!-- Clean Data Section -->
<div id="sectionCleanData"
style="display:none; padding: 20px; background:var(--card); border:1px solid var(--border); border-radius:10px; min-height: 400px;">
<div style="display:flex; justify-content:space-between; margin-bottom:12px;">
<div>
<h2 style="margin:0; font-size:18px;">Chỉnh sửa Dữ liệu Sản phẩm (Clean Data)</h2>
<p style="margin:4px 0 0; font-size:13px; color:var(--muted-fg);">Khu vực chỉnh sửa, bổ sung, thêm/xóa trường dữ
liệu chuyên sâu cho 1 sản phẩm.</p>
</div>
<div>
<div style="display:inline-flex; border:1px solid var(--border); border-radius:6px; overflow:hidden;">
<input type="text" id="cleanDataSearch" placeholder="Nhập mã sản phẩm..."
style="border:none; padding:8px 12px; font-size:13px; outline:none; width:200px;">
<button class="btn btn-primary" style="border-radius:0; border:none;" onclick="loadCleanDataForInput()">Tải dữ
liệu</button>
</div>
<button class="btn btn-sm" id="btnSaveCleanData"
style="background:#0d6efd;color:#fff;border:none;display:none;margin-left:10px;padding:8px 16px;"
onclick="saveDetailEdit()">💾 Lưu Dữ liệu JSON</button>
</div>
</div>
<div id="cleanDataTitle"
style="font-weight:bold; font-size:16px; margin:20px 0 10px; color:var(--primary); display:none;"></div>
<!-- Edit Tab that was previously in popup -->
<div id="cleanDataEditor"
style="background:#fafafa; padding:20px; border:1px solid var(--border); border-radius:8px; display:none;">
<!-- Rendered dynamically -->
</div>
<div id="cleanDataEmpty"
style="padding:40px; text-align:center; color:var(--muted-fg); background:#f9fafb; border-radius:8px; border:2px dashed var(--border);">
Bạn chưa chọn sản phẩm nào để chỉnh sửa. Vui lòng nhập mã phía trên hoặc qua từ danh sách Sản phẩm.
</div>
</div>
<script src="/static/product-desc/js/state.js?v=202604231530"></script> <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/core.js?v=202604231530"></script>
<script src="/static/product-desc/js/table.js?v=202604231530"></script> <script src="/static/product-desc/js/table.js?v=202604231530"></script>
<script src="/static/product-desc/js/detail.js?v=202604231530"></script> <script src="/static/product-desc/js/detail.js?v=202604231530"></script>
<script src="/static/product-desc/js/clean-data.js?v=202604231530"></script>
<script src="/static/product-desc/js/prompt-fields.js?v=202604231530"></script>
<script src="/static/product-desc/js/bulk-actions.js?v=202604231530"></script> <script src="/static/product-desc/js/bulk-actions.js?v=202604231530"></script>
<script src="/static/product-desc/js/batch-jobs.js?v=202604231530"></script> <script src="/static/product-desc/js/batch-jobs.js?v=202604231530"></script>
<script src="/static/product-desc/js/tags-manager.js?v=202604231530"></script> <script src="/static/product-desc/js/tags-manager.js?v=202604231530"></script>
<script src="/static/product-desc/js/size-guide.js?v=202604231530"></script> <script src="/static/product-desc/js/size-guide.js?v=202604231530"></script>
<script src="/static/product-desc/js/inline-editor.js?v=202604231530"></script> <script src="/static/product-desc/js/inline-editor.js?v=202604231530"></script>
<script src="/static/product-desc/js/sync-check.js?v=202605141"></script>
<script src="/static/product-desc/js/auto-pilot.js?v=1"></script>
<!-- Chatbot Widget --> <!-- Chatbot Widget -->
<script src="/static/product-desc/product-desc-chatbot.js?v=2"></script> <script src="/static/product-desc/product-desc-chatbot.js?v=2"></script>
</body> </body>
......
const API = '/api/product-desc';
let currentProducts = [];
let currentPage = 0;
let currentDetail = null;
let currentDetailCode = null;
const PAGE_SIZE = 30;
// ═══ Init ═══
window.addEventListener('DOMContentLoaded', () => {
try {
const parentParams = new URLSearchParams(window.parent.location.search);
if (parentParams.has('p')) {
currentPage = Math.max(0, parseInt(parentParams.get('p')) - 1) || 0;
}
} catch(e) {}
loadOverview();
loadFilters();
loadProducts();
// Enter to search
document.getElementById('searchInput').addEventListener('keydown', e => {
if (e.key === 'Enter') loadProducts();
});
});
// ═══ Overview Stats ═══
async function loadOverview() {
try {
const res = await fetch(`${API}/overview`);
const data = await res.json();
if (data.status !== 'success') return;
const totalSr = data.total_sr || 0; // StarRocks: distinct magento_ref_code in catalog
const hasDone = data.has_desc || 0; // Postgres rows (per máu)
const approved = data.approved || 0;
const pending = data.pending || 0;
const missing = Math.max(0, totalSr - hasDone); // never negative
// Stat cards
document.getElementById('statTotal').textContent = totalSr.toLocaleString();
const subEl = document.getElementById('statTotalSub');
if (subEl) subEl.textContent = `${hasDone.toLocaleString()} code đã có trong DB`;
document.getElementById('statDone').textContent = hasDone.toLocaleString();
document.getElementById('statApproved').textContent = approved.toLocaleString();
document.getElementById('statPending').textContent = pending.toLocaleString();
document.getElementById('statMissing').textContent = missing.toLocaleString();
document.getElementById('statMissingClean').textContent = (data.missing_clean_desc || 0).toLocaleString();
document.getElementById('statMissingTags').textContent = (data.missing_tags || 0).toLocaleString();
document.getElementById('statHasClean').textContent = (data.has_clean_desc || 0).toLocaleString();
document.getElementById('statMissingCleanSub').textContent =
`Đã có ${(hasDone - (data.missing_clean_desc || 0)).toLocaleString()} / ${hasDone.toLocaleString()} bản có format sạch`;
// Progress = approved / totalPg (not totalSr)
const approveProgress = hasDone > 0 ? (approved / hasDone * 100).toFixed(1) : 0;
document.getElementById('statProgress').textContent = approveProgress + '%';
document.getElementById('progressFill').style.width = approveProgress + '%';
document.getElementById('btnBatchTags').textContent = `Bơm Tags (Còn ${(data.missing_tags || 0).toLocaleString()} thiếu)`;
const sizeEl = document.getElementById('statSizeGuide');
if (sizeEl) sizeEl.textContent = (data.has_size_guide || 0).toLocaleString();
} catch (e) { console.error('Overview error:', e); }
}
// ═══ Filters ═══
async function loadFilters() {
try {
const res = await fetch(`${API}/filters`);
const data = await res.json();
if (data.status !== 'success') return;
const sel = document.getElementById('lineFilter');
(data.product_lines || []).forEach(l => {
const opt = document.createElement('option');
opt.value = l.product_line_vn;
opt.textContent = `${l.product_line_vn} (${l.cnt})`;
sel.appendChild(opt);
});
} catch (e) {}
}
function resetFilters() {
document.getElementById('searchInput').value = '';
document.getElementById('statusFilter').value = '';
document.getElementById('lineFilter').value = '';
document.getElementById('tagsFilter').value = '';
currentPage = 0;
loadProducts();
}
// ═══ Products List ═══
async function loadProducts() {
const body = document.getElementById('tableBody');
body.innerHTML = '<tr><td colspan="10" class="table-loading"><div class="spinner-sm"></div> Đang tải...</td></tr>';
const search = document.getElementById('searchInput').value.trim();
const status = document.getElementById('statusFilter').value;
const line = document.getElementById('lineFilter').value;
const tagName = document.getElementById('tagsFilter').value.trim();
const params = new URLSearchParams({ limit: PAGE_SIZE, page: currentPage + 1 });
if (search) params.set('search', search);
if (status) params.set('status', status);
if (line) params.set('product_line', line);
if (tagName) params.set('tag', tagName);
try {
const parentUrl = new URL(window.parent.location);
parentUrl.searchParams.set('p', currentPage + 1);
window.parent.history.replaceState(null, '', parentUrl.toString());
} catch(e) {}
try {
const res = await fetch(`${API}/list?${params}`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
currentProducts = data.products || [];
renderTable(currentProducts);
renderPagination(data.total);
} catch (e) {
body.innerHTML = `<tr><td colspan="10" class="table-loading" style="color:var(--error)">⚠️ ${e.message}</td></tr>`;
}
}
function renderTable(products) {
const body = document.getElementById('tableBody');
if (!products.length) {
body.innerHTML = '<tr><td colspan="10" class="table-loading">Không tìm thấy sản phẩm nào</td></tr>';
return;
}
body.innerHTML = products.map(p => {
const descStatus = p.desc_status;
let statusHtml;
if (descStatus === 1) {
statusHtml = '<span class="status-badge done">✅ Đã duyệt</span>';
} else if (descStatus === 0) {
statusHtml = '<span class="status-badge pending">⏳ Chờ duyệt</span>';
} else {
statusHtml = '<span class="status-badge missing">✕ Chưa có</span>';
}
const price = p.sale_price ? Number(p.sale_price).toLocaleString('vi-VN') + '₫' : '—';
const sold = p.quantity_sold ? Number(p.quantity_sold).toLocaleString() : '0';
const imgSrc = p.product_image_url_thumbnail || '';
const imgTag = imgSrc
? `<img src="${esc(imgSrc)}" alt="" onerror="this.style.display='none'">`
: `<div style="width:44px;height:44px;border-radius:6px;background:var(--muted);display:flex;align-items:center;justify-content:center;font-size:16px;">📦</div>`;
// Use magento_ref_code as the primary key for all actions
const actionCode = esc(p.magento_ref_code || p.internal_ref_code || '');
const colorBadge = p.master_color
? `<span style="background:#e0f2fe;border:1px solid #7dd3fc;padding:1px 6px;border-radius:8px;font-size:10px;color:#0369a1;">${esc(p.master_color)}</span>`
: '';
// Show if magento differs from internal (split variant)
const isVariant = p.magento_ref_code && p.magento_ref_code !== p.internal_ref_code;
const codeDisplay = isVariant
? `<div class="code" style="font-size:10px;">${esc(p.magento_ref_code || '')} ${colorBadge}</div><div style="font-size:9px;color:#94a3b8;">${esc(p.internal_ref_code || '')}</div>`
: `<div class="code">${esc(p.magento_ref_code || p.internal_ref_code || '')} ${colorBadge}</div>`;
return `<tr onclick="openDetail('${actionCode}', '${esc(p.product_name || '')}')">
<td>
<div class="product-cell">
${imgTag}
<div class="info">
<div class="name" title="${esc(p.product_name || '')}">${esc(p.product_name || '—')}</div>
${codeDisplay}
</div>
</div>
</td>
<td>${esc(p.product_line_vn || '—')}</td>
<td style="max-width:180px; font-size:11px;">
<div style="display: flex; flex-wrap: wrap; gap: 4px;">
${p.tags && p.tags.length > 0 ? p.tags.map(t => `<span style="background:#f1f5f9;border:1px solid #cbd5e1;padding:2px 6px;border-radius:12px;white-space:nowrap;">${esc(t)}</span>`).join('') : '<span style="color:#94a3b8">Chưa có</span>'}
</div>
</td>
<td>${price}</td>
<td>${sold}</td>
<td>${statusHtml}</td>
<td>${p.has_size_guide
? '<span style="color:#7c3aed;font-weight:700;font-size:14px" title="\u0110\u00e3 c\u00f3 size guide">\u2714</span>'
: '<span style="color:#cbd5e1;font-size:14px" title="Ch\u01b0a c\u00f3">\u2014</span>'
}</td>
<td style="font-size:11px;color:#64748b;white-space:nowrap">${p.updated_at ? new Date(p.updated_at).toLocaleDateString('vi-VN') + ' ' + new Date(p.updated_at).toLocaleTimeString('vi-VN',{hour:'2-digit',minute:'2-digit'}) : '\u2014'}</td>
<td>${descStatus >= 0
? (descStatus === 1
? `<button class="btn btn-sm" style="background:#dc3545;color:#fff;border:none;font-size:11px;padding:4px 10px" onclick="event.stopPropagation();approveRow('${actionCode}',0,this)">✕ Hủy</button>`
: `<button class="btn btn-sm" style="background:#28a745;color:#fff;border:none;font-size:11px;padding:4px 10px" onclick="event.stopPropagation();approveRow('${actionCode}',1,this)">✓ Duyệt</button>`)
: '<span style="color:var(--muted-fg);font-size:11px">—</span>'
}</td>
<td>
<div style="display:flex;gap:4px;flex-direction:column;">
<button class="btn btn-outline-dark btn-sm" onclick="event.stopPropagation();generateSingle('${actionCode}','${esc(p.product_name || '')}',this)">Desc</button>
<button class="btn btn-outline-dark btn-sm" onclick="event.stopPropagation();generateSingleTags('${actionCode}','${esc(p.product_name || '')}',this)">Tags</button>
<button class="btn btn-sm" style="background:#7c3aed;color:#fff;border:none;padding:2px 5px;font-size:11px;" onclick="event.stopPropagation();generateSingleSize('${actionCode}','${esc(p.product_name || '')}',this)">📏 Size</button>
<button class="btn btn-primary btn-sm" style="background:#0ea5e9; border:none; padding:2px 5px;" onclick="event.stopPropagation();openNativeBulkAiModal('${actionCode}')">🤖 Edit</button>
</div>
</td>
</tr>`;
}).join('');
}
function renderPagination(total) {
const pages = Math.ceil(total / PAGE_SIZE);
const el = document.getElementById('pagination');
if (pages <= 1) { el.innerHTML = ''; return; }
let html = `<button ${currentPage===0?'disabled':''} onclick="currentPage--;loadProducts()">← Trước</button>`;
const start = Math.max(0, currentPage - 3);
const end = Math.min(pages, start + 7);
for (let i = start; i < end; i++) {
html += `<button class="${i===currentPage?'active':''}" onclick="currentPage=${i};loadProducts()">${i+1}</button>`;
}
html += `<button ${currentPage>=pages-1?'disabled':''} onclick="currentPage++;loadProducts()">Sau →</button>`;
el.innerHTML = html;
}
// ═══ Generate Single ═══
async function generateSingle(code, name, btn) {
const ogText = btn.textContent;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch(`${API}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magento_ref_code: code }),
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
currentDetail = data;
currentDetailCode = code;
showDetailPanel(data, name);
// Update local state and re-render table row
const p = currentProducts.find(x => x.internal_ref_code === code);
if (p) {
p.desc_status = 0; // Chờ duyệt
renderTable(currentProducts);
}
loadOverview(); // Cập nhật lại board thống kê ở trên cùng
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
btn.textContent = ogText;
btn.disabled = false;
}
}
// ═══ Generate Single Tags ═══
async function generateSingleTags(code, name, btn) {
const ogText = btn.textContent;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch(`/api/bulk/generate-tags-single`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magento_ref_code: code }),
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
// Update local state and re-render table row
const p = currentProducts.find(x => x.internal_ref_code === code);
if (p) {
if (data.tags) {
p.tags = data.tags;
} else {
alert(data.message || "Đã gửi yêu cầu tạo Tags.");
}
renderTable(currentProducts);
}
} catch (e) {
alert('Lỗi: ' + e.message);
} finally {
btn.textContent = ogText;
btn.disabled = false;
}
}
// ═══ Regenerate from Detail Modal ═══
async function regenerateFromDetail() {
const code = currentDetailCode;
if (!code) { alert('Không xác định được mã SP!'); return; }
const btn = document.getElementById('btnRegenerate');
const ogText = btn.textContent;
btn.innerHTML = '<span class="spinner-sm"></span> Đang sinh...';
btn.disabled = true;
btn.style.opacity = '0.7';
// Show loading in Clean tab
const cleanBody = document.getElementById('detailBodyClean');
cleanBody.innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> AI đang sinh lại mô tả...<br><span style="font-size:11px;color:var(--muted-fg);">Thông thường mất 5-15 giây</span></div>';
try {
// Step 1: Call generate API (this saves to DB automatically)
const genRes = await fetch(`${API}/generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magento_ref_code: code }),
});
const genData = await genRes.json();
if (genData.status === 'error') throw new Error(genData.message);
// Step 2: Re-fetch from /saved/ to get proper format (rendered_text, clean_description)
const savedRes = await fetch(`${API}/saved/${encodeURIComponent(code)}`);
const savedData = await savedRes.json();
if (savedData.status === 'success' && savedData.description) {
const desc = savedData.description;
currentDetail = {
phase: desc.phase,
data: desc.description_data,
saved: true,
savedAt: desc.updated_at,
descStatus: desc.status,
description: desc
};
currentDetailCode = code;
// Re-render modal with fresh content — stays open!
showDetailPanel(currentDetail, desc.product_name || document.getElementById('detailTitle').textContent.split('(')[0].trim());
} else {
// Fallback: use generate response directly
currentDetail = genData;
currentDetailCode = code;
const name = genData.product?.product_name || document.getElementById('detailTitle').textContent.split('(')[0].trim();
showDetailPanel(genData, name);
}
// Update table row status
const p = currentProducts.find(x => x.internal_ref_code === code);
if (p) {
p.desc_status = 0; // Chờ duyệt (vừa sinh mới)
renderTable(currentProducts);
}
loadOverview();
} catch (e) {
cleanBody.innerHTML = `<div style="padding:24px;color:#dc3545;font-weight:600;">❌ Lỗi sinh lại: ${esc(e.message)}</div>`;
} finally {
btn.textContent = ogText;
btn.disabled = false;
btn.style.opacity = '1';
}
}
// ═══ Batch Approve Page ═══
async function batchApprovePage() {
const pendingCodes = currentProducts.filter(p => p.desc_status === 0).map(p => p.internal_ref_code);
if (pendingCodes.length === 0) {
alert("Trang hiện tại không có sản phẩm nào 'Chờ duyệt' để duyệt hàng loạt.");
return;
}
if (!confirm(`Xác nhận duyệt hàng loạt ${pendingCodes.length} sản phẩm đang chờ duyệt ở trang này?`)) return;
const btn = document.getElementById('btnApprovePage');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang tải...';
btn.disabled = true;
try {
const promises = pendingCodes.map(code =>
fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code, status: 1 }),
})
);
await Promise.all(promises);
alert(`✅ Đã duyệt thành công ${pendingCodes.length} sản phẩm.`);
loadProducts();
loadOverview();
} catch (e) {
alert('Lỗi duyệt hàng loạt: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Approve ALL (toàn bộ chờ duyệt) ═══
async function approveAll() {
if (!confirm('⚠️ Xác nhận DUYỆT TOÀN BỘ tất cả mô tả đang ở trạng thái "Chờ duyệt"?')) return;
const btn = document.getElementById('btnApproveAll');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang duyệt...';
btn.disabled = true;
try {
const resp = await fetch(`${API}/approve-all`, { method: 'POST' });
const data = await resp.json();
if (data.status === 'success') {
alert(`✅ ${data.message}`);
loadProducts();
loadOverview();
} else {
alert('❌ Lỗi: ' + (data.message || 'Unknown error'));
}
} catch (e) {
alert('❌ Lỗi: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
async function backfillMissingCleanDescriptions() {
const summaryRes = await fetch(`${API}/clean-missing-summary`);
const summary = await summaryRes.json();
if (summary.status !== 'success') {
alert('Không lấy được thống kê clean description.');
return;
}
const missing = Number(summary.missing_clean_desc || 0);
if (missing <= 0) {
alert('Không có sản phẩm nào thiếu clean description chuẩn.');
return;
}
if (!confirm(`Phát hiện ${missing} sản phẩm đang thiếu hoặc lỗi format clean description.\n\nBấm OK để render lại hàng loạt theo format chuẩn từ description_data hiện có.`)) {
return;
}
const btn = document.getElementById('btnBackfillClean');
const oldText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang render...';
btn.disabled = true;
try {
const res = await fetch(`${API}/backfill-clean-description`, { method: 'POST' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message || 'Unknown error');
alert(`✅ ${data.message}\nCòn thiếu: ${data.remaining_missing_clean_desc}`);
loadOverview();
loadProducts();
} catch (e) {
alert('Lỗi backfill clean description: ' + e.message);
} finally {
btn.innerHTML = oldText;
btn.disabled = false;
}
}
async function batchGeneratePage() {
const missingCodes = currentProducts.filter(p => p.desc_status === -1).map(p => p.internal_ref_code);
if (missingCodes.length === 0) {
alert("Trang hiện tại không có sản phẩm nào 'Chưa có mô tả'. Vui lòng chuyển trang hoặc đổi bộ lọc.");
return;
}
if (!confirm(`Phát hiện ${missingCodes.length} sản phẩm thiếu mô tả ở trang này.\\nĐưa vào hàng đợi tự động sinh AI (Chạy ngầm)?`)) return;
const btn = document.getElementById('btnBatch');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
try {
const res = await fetch(`${API}/batch-generate`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_codes: missingCodes })
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(data.message);
startBatchPolling();
// Refresh table immediately to not click again
loadProducts();
} catch (e) {
alert('Lỗi chạy hàng loạt: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Batch Generate All ═══
async function batchGenerateAll() {
if (!confirm(`Hệ thống sẽ tự động quét TẤT CẢ sản phẩm thiếu mô tả trong database (tối đa 500 SP/lần) và đưa vào hàng đợi sinh AI chạy ngầm.\\n\\nBạn có chắc chắn muốn chạy không?`)) return;
const btn = document.getElementById('btnBatchAll');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
try {
const res = await fetch(`${API}/batch-generate-all`, {
method: 'POST'
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(data.message);
startBatchPolling();
loadProducts();
loadOverview();
} catch (e) {
alert('Lỗi chạy toàn bộ: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// ═══ Batch Tracking Polling ═══
let batchPollInterval = null;
// ═══ NATIVE BULK AI EDIT ═══
let currentBulkAiTarget = [];
let currentBulkLineTarget = "";
function openNativeBulkAiModal(singleSku = null) {
if (singleSku) {
currentBulkAiTarget = [singleSku];
const p = currentProducts.find(x => x.internal_ref_code === singleSku);
currentBulkLineTarget = p ? p.product_line_vn : '';
document.getElementById('lblBulkAiCount').innerHTML = `<strong>1 sản phẩm</strong> (SKU: ${singleSku})`;
let lineOption = '';
if (currentBulkLineTarget) {
lineOption = `<label style="cursor:pointer; display:block; margin-top:6px;"><input type="radio" name="scopeTarget" value="line"> Áp dụng cho TẤT CẢ sản phẩm thuộc dòng: <strong>${currentBulkLineTarget}</strong></label>`;
}
document.getElementById('scopeContainer').innerHTML = `
<div style="background:#eff6ff; padding:12px; border-radius:6px; border:1px solid #bfdbfe; margin-bottom:15px;">
<div style="font-weight:bold; font-size:13px; margin-bottom:8px; color:#1e40af;">🎯 Phạm vi áp dụng:</div>
<label style="cursor:pointer; display:block;"><input type="radio" name="scopeTarget" value="sku" checked> Chỉ sửa DUY NHẤT 1 mã: <strong>${singleSku}</strong></label>
${lineOption}
</div>
`;
} else {
if (!currentProducts || currentProducts.length === 0) {
alert("Trang hiện tại không có sản phẩm nào để xử lý!");
return;
}
currentBulkAiTarget = currentProducts.map(p => p.internal_ref_code);
currentBulkLineTarget = "";
document.getElementById('lblBulkAiCount').innerHTML = `<strong>${currentBulkAiTarget.length} sản phẩm</strong> đang hiển thị trên trang này`;
document.getElementById('scopeContainer').innerHTML = ''; // Hide scope container for page-level bulk
}
// Try to load dynamic fields if possible, or fallback to default html opts
fetch('/api/bulk/fields').then(r => r.json()).then(d => {
if (d.status === 'success') {
const sel = document.getElementById('nativeBulkAiField');
sel.innerHTML = '';
for (const [key, label] of Object.entries(d.fields)) {
if (key === 'huong_dan_size') sel.innerHTML += `<option value="${key}" selected>${label}</option>`;
else sel.innerHTML += `<option value="${key}">${label}</option>`;
}
}
}).catch(() => {});
document.getElementById('nativeBulkAiModal').style.display = 'flex';
}
async function execNativeBulkAi() {
const field = document.getElementById('nativeBulkAiField').value;
const mode = document.getElementById('nativeBulkAiMode').value;
const instruction = document.getElementById('nativeBulkAiInstruction').value.trim();
if (!instruction) {
alert("⚠️ Vui lòng nhập hướng dẫn hoặc paste bảng size thô cho AI!");
return;
}
// Determine scope
let codes = [...(currentBulkAiTarget || [])];
let target_line = null;
const scopeRadio = document.querySelector('input[name="scopeTarget"]:checked');
if (scopeRadio && scopeRadio.value === 'line') {
target_line = currentBulkLineTarget;
codes = []; // Force backend to use target_line to resolve codes
}
if (codes.length === 0 && !target_line) {
alert("⚠️ Không có sản phẩm nào được chọn!");
return;
}
const btn = document.getElementById('btnRunNativeBulkAi');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi lệnh...';
btn.disabled = true;
try {
const resp = await fetch('/api/bulk/ai-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes, target_field: field, instruction, mode, target_line }),
});
const data = await resp.json();
if (data.status === 'success') {
document.getElementById('nativeBulkAiModal').style.display = 'none';
alert(`✅ ${data.message}\\nTiến trình sẽ chạy dưới nền (Background Worker)`);
// Poll ui in background
setTimeout(() => {
fetch(`/api/bulk/ai-edit-status`)
.then(r => r.json())
.then(s => {
if (s.is_running && s.total > 0) startAiEditBatchPolling();
});
}, 1500);
} else {
alert(`❌ Lỗi: ${data.message}`);
}
} catch (e) {
alert(`❌ Lỗi gọi API: ${e.message}`);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
// Custom poller for AI Edit Queue (mapped to Tag polling tracking UI just to show running status)
let aiEditPollInterval = null;
function startAiEditBatchPolling() {
if (aiEditPollInterval) return;
document.getElementById('tagsTrackingBox').style.display = 'flex'; // Reuse Tags box UI
aiEditPollInterval = setInterval(async () => {
try {
const res = await fetch(`/api/bulk/ai-edit-status`);
const data = await res.json();
if (!data.is_running) {
clearInterval(aiEditPollInterval);
aiEditPollInterval = null;
document.getElementById('tagsTrackTitle').innerText = '✅ Hoàn tất Bulk AI Edit!';
document.getElementById('tagsTrackFill').style.background = '#059669';
setTimeout(() => {
document.getElementById('tagsTrackingBox').style.display = 'none';
loadProducts();
}, 5000);
return;
}
const pct = data.total > 0 ? Math.round((data.done / data.total) * 100) : 0;
document.getElementById('tagsTrackTitle').innerText = 'Hệ thống đang Bulk AI Edit (Sửa trường)...';
document.getElementById('tagsTrackFill').style.background = '#2563eb';
document.getElementById('tagsTrackText').innerText = `${data.done} / ${data.total} (${pct}%)`;
document.getElementById('tagsTrackFill').style.width = pct + '%';
document.getElementById('tagsTrackCode').innerText = data.current_name ? `→ ${data.current_name}` : '—';
document.getElementById('tagsTrackErrors').innerText = data.errors || 0;
} catch(e) {}
}, 2000);
}
function startBatchPolling() {
if (batchPollInterval) return; // Already polling
document.getElementById('batchTrackingBox').style.display = 'flex';
batchPollInterval = setInterval(async () => {
try {
const res = await fetch(`${API}/batch-status`);
const data = await res.json();
if (!data.progress || !data.progress.is_running) {
clearInterval(batchPollInterval);
batchPollInterval = null;
document.getElementById('batchTrackTitle').innerText = '✅ Hoàn tất sinh AI hàng loạt!';
document.getElementById('batchTrackFill').style.background = '#10b981';
setTimeout(() => {
document.getElementById('batchTrackingBox').style.display = 'none';
loadOverview();
loadProducts();
}, 5000);
return;
}
const prog = data.progress;
const pct = Math.round((prog.done / prog.total) * 100) || 0;
document.getElementById('batchTrackTitle').innerText = 'Hệ thống đang chạy AI hàng loạt...';
document.getElementById('batchTrackFill').style.background = '#0ea5e9';
document.getElementById('batchTrackText').innerText = `${prog.done} / ${prog.total} (${pct}%)`;
document.getElementById('batchTrackFill').style.width = pct + '%';
document.getElementById('batchTrackCode').innerText = prog.current_code || '—';
document.getElementById('batchTrackErrors').innerText = prog.errors || 0;
} catch(e) {
console.error("Lỗi poll status:", e);
}
}, 2000); // poll every 2 seconds
}
// Check status on mount just in case a batch is already running
setTimeout(() => {
fetch(`${API}/batch-status`)
.then(r => r.json())
.then(s => {
if (s.is_running && s.progress) startBatchPolling();
else if (s.progress && s.progress.is_running) startBatchPolling();
})
.catch(()=>{});
fetch(`/api/bulk/tags-batch-status`)
.then(r => r.json())
.then(s => {
if (s.progress && s.progress.is_running) startTagsBatchPolling();
})
.catch(()=>{});
}, 1000);
// ═══ Batch Generate TAGS ═══
async function batchGenerateTags() {
if (!confirm(`Hệ thống sẽ chạy AI đọc toàn bộ clean_description (mô tả chuẩn) để tự động móc Tags (đi chơi, đi làm, thể thao...).\\nChỉ quét 500 SP đang trống tags.\\n\\nBạn có chắc chắn muốn bắt đầu?`)) return;
const btn = document.getElementById('btnBatchTags');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
try {
const res = await fetch(`/api/bulk/batch-generate-tags`, { method: 'POST' });
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(data.message);
startTagsBatchPolling();
} catch (e) {
alert('Lỗi bơm tags hàng loạt: ' + e.message);
} finally {
btn.innerHTML = ogText;
btn.disabled = false;
}
}
let tagsPollInterval = null;
function startTagsBatchPolling() {
if (tagsPollInterval) return;
document.getElementById('tagsTrackingBox').style.display = 'flex';
tagsPollInterval = setInterval(async () => {
try {
const res = await fetch(`/api/bulk/tags-batch-status`);
const data = await res.json();
if (!data.progress || !data.progress.is_running) {
clearInterval(tagsPollInterval);
tagsPollInterval = null;
document.getElementById('tagsTrackTitle').innerText = '✅ Hoàn tất Auto-Tagging!';
document.getElementById('tagsTrackFill').style.background = '#059669';
setTimeout(() => {
document.getElementById('tagsTrackingBox').style.display = 'none';
loadProducts(); // refresh list to show new tags
}, 5000);
return;
}
const prog = data.progress;
const pct = Math.round((prog.done / prog.total) * 100) || 0;
document.getElementById('tagsTrackTitle').innerText = 'Hệ thống đang Auto-Tagging hàng loạt...';
document.getElementById('tagsTrackFill').style.background = '#10b981';
document.getElementById('tagsTrackText').innerText = `${prog.done} / ${prog.total} (${pct}%)`;
document.getElementById('tagsTrackFill').style.width = pct + '%';
document.getElementById('tagsTrackCode').innerText = prog.current_code || '—';
document.getElementById('tagsTrackErrors').innerText = prog.errors || 0;
} catch(e) {
console.error("Lỗi poll status tags:", e);
}
}, 2000);
}
// ═══ Detail Panel ═══
async function openDetail(code, name) {
// Show loading state
document.getElementById('detailTitle').textContent = name || 'Đang tải...';
document.getElementById('detailBodyClean').innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> Đang tải...</div>';
document.getElementById('detailOverlay').classList.add('open');
// Try to load saved description first
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}`);
if (res.ok) {
const saved = await res.json();
if (saved.status === 'success' && saved.description) {
const desc = saved.description;
currentDetail = {
phase: desc.phase,
data: desc.description_data,
saved: true,
savedAt: desc.updated_at,
descStatus: desc.status,
description: desc
};
currentDetailCode = code;
showDetailPanel(currentDetail, desc.product_name || name);
return;
}
}
} catch (e) { /* not found, will generate */ }
// No saved description → generate new
closeDetail();
generateSingle(code, name, { textContent: '', innerHTML: '', disabled: false, closest: () => null });
}
function closeDetail() {
document.getElementById('detailOverlay').classList.remove('open');
// Clean up localStorage cache for this product (DB is source of truth)
if (currentDetailCode) {
try { localStorage.removeItem(`desc_cache_${currentDetailCode}`); } catch(e) {}
}
}
function showDetailPanel(data, name) {
const d = data.description?.description_data || data.data || {};
const c = data.description || {};
const title = name ? `${name} <span style="font-weight:400;color:var(--muted-fg);font-size:13px">(${c.internal_ref_code || currentDetailCode})</span>` : (c.internal_ref_code || currentDetailCode || 'Chi tiết');
document.getElementById('detailTitle').innerHTML = title;
// 1. Clean Tab - Render Format Đẹp Đẹp
const cleanBody = document.getElementById('detailBodyClean');
// Render "Dàn trang (Chuẩn)" — Premium structured view từ description_data
const descData = d || {};
const productName = name || descData['ten_san_pham'] || c.internal_ref_code || '';
function _fv(v) {
if (!v || v === 'null' || v === 'undefined') return '';
return String(v).trim();
}
function _row(label, key, value) {
const v = _fv(value);
if (!v) return '';
return `<div class="dd-row"><span class="dd-label">${label}</span><span class="dd-value" id="dantrang-val-${key}" contenteditable="true" title="Click để chỉnh sửa" style="outline:none;cursor:text;border-bottom:1px dashed transparent;border-radius:3px;padding:1px 4px;" onfocus="this.style.background='#fffbeb';this.style.borderColor='var(--primary)'" onblur="this.style.background='';this.style.borderColor='transparent'">${esc(v)}</span></div>`;
}
function _bullets(v) {
if (!_fv(v)) return '';
const items = _fv(v).split(/[\n•·]+/).map(x => x.trim()).filter(Boolean);
if (!items.length) return '';
return items.map(i => `<li>${esc(i)}</li>`).join('');
}
const sections = [];
// ── THÔNG TIN CƠ BẢN ──
const basicFields = [
['ten_san_pham','Tên sản phẩm'], ['tagline','Tagline'], ['mo_ta_chinh','Mô tả chính'],
['phong_cach','Phong cách'], ['tags','Tags'],
];
let basicHtml = basicFields.map(([k,l]) => _row(l, descData[k])).join('');
if (basicHtml) sections.push({ emoji:'🏷️', title:'THÔNG TIN CƠ BẢN', body: basicHtml });
// ── CHẤT LIỆU & BẢO QUẢN ──
let matHtml = [_row('Chất liệu', descData['chat_lieu']), _row('Tính năng vải', descData['tinh_nang_vai']), _row('Hướng dẫn bảo quản', descData['huong_dan_bao_quan'])].join('');
if (matHtml) sections.push({ emoji:'🌿', title:'CHẤT LIỆU & BẢO QUẢN', body: matHtml });
// ── ĐỐI TƯỢNG ──
let targetHtml = [_row('Độ tuổi', 'do_tuoi', descData['do_tuoi']), _row('Giới tính', 'gioi_tinh', descData['gioi_tinh']), _row('Lối sống', 'loi_song', descData['loi_song']), _row('Tính cách', 'tinh_cach', descData['tinh_cach'])].join('');
if (targetHtml) sections.push({ emoji:'👤', title:'ĐỐI TƯỢNG', body: targetHtml });
// ── DỊP MẶC & STYLING ──
const dipMac = _fv(descData['dip_mac']);
const phoi = _fv(descData['phoi_do']);
const nguyen = _fv(descData['nguyen_tac_phoi_do']);
const layer = _fv(descData['layer'] || descData['goi_y_layer']);
const mua = _fv(descData['mua_phu_hop']);
let styleHtml = '';
if (dipMac) styleHtml += `<div class="dd-row"><span class="dd-label">Dịp mặc</span><span class="dd-value"><ul class="dd-bullets">${_bullets(dipMac)}</ul></span></div>`;
if (phoi) styleHtml += `<div class="dd-row"><span class="dd-label">Gợi ý phối đồ</span><span class="dd-value"><ul class="dd-bullets">${_bullets(phoi)}</ul></span></div>`;
if (nguyen) styleHtml += `<div class="dd-row"><span class="dd-label">Nguyên tắc phối</span><span class="dd-value">${esc(nguyen)}</span></div>`;
if (layer) styleHtml += `<div class="dd-row"><span class="dd-label">Gợi ý layering</span><span class="dd-value">${esc(layer)}</span></div>`;
if (mua) styleHtml += `<div class="dd-row"><span class="dd-label">Mùa phù hợp</span><span class="dd-value">${esc(mua)}</span></div>`;
if (styleHtml) sections.push({ emoji:'👗', title:'DỊP MẶC & STYLING', body: styleHtml });
// ── FAQ ──
const faq = descData['faq'] || descData['FAQQ'] || '';
if (_fv(faq)) {
const pairs = _fv(faq).split(/Q:|💬/).map(x=>x.trim()).filter(Boolean);
let faqHtml = '';
pairs.forEach(pair => {
const [q, ...aParts] = pair.split(/A:|→/);
if (q) faqHtml += `<div class="dd-faq-q">💬 ${esc(q.trim())}</div>`;
if (aParts.length) faqHtml += `<div class="dd-faq-a">${esc(aParts.join('').trim())}</div>`;
});
if (faqHtml) sections.push({ emoji:'❓', title:'FAQ', body: faqHtml });
}
// ── HỖ TRỢ BÁN HÀNG ──
const hookQC = _fv(descData['hook_quang_cao']);
const crossSell = _fv(descData['cross_sell'] || descData['upsell']);
const sizeGuide = _fv(descData['huong_dan_size'] || descData['size_guide']);
let salesHtml = '';
if (hookQC) salesHtml += `<div class="dd-highlight">${esc(hookQC)}</div>`;
if (crossSell) salesHtml += `<div class="dd-row"><span class="dd-label">Cross-sell</span><span class="dd-value">${esc(crossSell)}</span></div>`;
if (sizeGuide) salesHtml += `<div class="dd-row"><span class="dd-label">Hướng dẫn size</span><span class="dd-value"><ul class="dd-bullets">${_bullets(sizeGuide)}</ul></span></div>`;
if (salesHtml) sections.push({ emoji:'🛒', title:'HỖ TRỢ BÁN HÀNG', body: salesHtml });
// Fallback nếu không có data struct → dùng rendered_text
const hasSections = sections.some(s => s.body);
let finalHtml = '';
if (hasSections) {
finalHtml = `
<div class="dd-product-name">${esc(productName).toUpperCase()}</div>
${sections.map(s => `
<div class="dd-section">
<div class="dd-section-header"><span class="dd-emoji">${s.emoji}</span> ${s.title}</div>
<div class="dd-section-body">${s.body}</div>
</div>
`).join('')}
`;
} else if (c.rendered_text) {
let html = c.rendered_text;
if (!html.includes('<h2') && !html.includes('<div') && !html.includes('<li')) {
let text = esc(html);
text = text.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/\n/g, '<br>');
html = text;
}
finalHtml = html;
} else {
finalHtml = '<p style="color:var(--muted-fg);padding:24px;">Chưa có nội dung từ AI.</p>';
}
cleanBody.innerHTML = `
<style>
.dd-product-name{font-size:20px;font-weight:800;text-align:center;letter-spacing:.06em;color:#0b1c44;margin:0 0 20px;padding-bottom:16px;border-bottom:2px solid #e5e7eb}
.dd-section{margin-bottom:20px;border:1px solid #e5e7eb;border-radius:10px;overflow:hidden;background:#fff}
.dd-section-header{display:flex;align-items:center;gap:8px;padding:10px 16px;background:linear-gradient(135deg,#f8fafc,#f1f5f9);font-weight:700;font-size:12px;letter-spacing:.08em;color:#1e3a5f;border-bottom:1px solid #e5e7eb;text-transform:uppercase}
.dd-emoji{font-size:16px}
.dd-section-body{padding:14px 16px}
.dd-row{display:flex;gap:12px;margin-bottom:10px;line-height:1.5;font-size:13.5px}
.dd-label{font-weight:700;color:#374151;min-width:140px;flex-shrink:0}
.dd-value{color:#4b5563;flex:1}
.dd-bullets{margin:0;padding-left:18px}
.dd-bullets li{margin-bottom:4px;font-size:13.5px;color:#4b5563}
.dd-highlight{background:linear-gradient(135deg,#eff6ff,#dbeafe);border-left:3px solid #3b82f6;padding:10px 14px;border-radius:6px;font-size:13.5px;color:#1e40af;font-style:italic;margin-bottom:10px}
.dd-faq-q{font-weight:700;font-size:13.5px;color:#374151;margin-bottom:4px;margin-top:12px}
.dd-faq-q:first-child{margin-top:0}
.dd-faq-a{font-size:13.5px;color:#4b5563;padding-left:16px;border-left:3px solid #10b981;margin-bottom:8px}
</style>
<div id="cleanTextEditor"
style="padding:24px;font-family:'Inter',system-ui,sans-serif;min-height:200px">
${finalHtml}
</div>
`;
// 2. We no longer render editBody here! Instead, we set up variables
// so that if the user clicks "Chỉnh sửa ở Tab Clean Data", it knows what to load.
window.currentDetailDataForCleanTab = data;
window.currentDetailNameForCleanTab = name;
// 3. Fields Tab
const fieldsBody = document.getElementById('detailBodyFields');
let fHtml = '<div style="padding: 24px;">';
for (const [k, v] of Object.entries(d)) {
fHtml += `
<div style="margin-bottom: 16px; padding: 12px; background: #f9fafb; border-radius: 8px; border: 1px solid #e5e7eb;">
<div style="display:flex; justify-content:space-between; margin-bottom:8px;">
<strong style="color:var(--primary); font-size:14px;">${esc(k)}</strong>
<button class="btn btn-sm" style="background:#e0e7ff; color:#4f46e5; border:none; border-radius:4px; font-size:12px; font-weight:600; cursor:pointer;" onclick="rewriteField('${c.internal_ref_code || currentDetailCode}', '${esc(k)}', this)">✨ AI Viết Lại</button>
</div>
<div id="field-val-${k}" style="font-size: 14px; color: #374151; white-space: pre-wrap;">${esc(v)}</div>
</div>
`;
}
fHtml += '</div>';
fieldsBody.innerHTML = fHtml;
// 3.5 Embed (Clean Descripts) Tab
const embedBody = document.getElementById('detailPreEmbed');
const rawCleanDesc = c.clean_description || c.rendered_text || 'Chưa có clean description cho sản phẩm này.';
embedBody.innerText = rawCleanDesc;
const isApproved = c.status === 1 || data.descStatus === 1;
if (!isApproved) {
embedBody.contentEditable = "true";
embedBody.style.border = "1px solid #10b981";
embedBody.style.cursor = "text";
embedBody.title = "Bạn có thể chỉnh sửa trực tiếp hoặc bôi đen văn bản để gọi AI Codex";
} else {
embedBody.contentEditable = "false";
embedBody.style.border = "1px dashed #d1d5db";
embedBody.style.cursor = "default";
embedBody.title = "Đã duyệt - Bạn không thể sửa (Hãy nhấn Hủy duyệt nếu muốn sửa)";
}
// 4. Raw Tab
const rawBody = document.getElementById('detailPreRaw');
rawBody.textContent = JSON.stringify(d, null, 2);
document.getElementById('detailOverlay').classList.add('open');
switchDetailTab('clean');
// Show/hide approve buttons
const btnApprove = document.getElementById('btnApprove');
const btnReject = document.getElementById('btnReject');
if (c.saved_at || data.saved) {
btnApprove.style.display = isApproved ? 'none' : '';
btnReject.style.display = isApproved ? '' : 'none';
} else {
// Just generated -> waiting for save
btnApprove.style.display = '';
btnReject.style.display = 'none';
}
}
let activeDetailTab = 'clean';
function switchDetailTab(tabId) {
// Reset all
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.classList.remove('active');
btn.style.color = 'var(--muted-fg)';
btn.style.borderColor = 'transparent';
});
if(document.getElementById('detailBodyClean')) document.getElementById('detailBodyClean').style.display = 'none';
if(document.getElementById('detailBodyFields')) document.getElementById('detailBodyFields').style.display = 'none';
if(document.getElementById('detailBodyEmbed')) document.getElementById('detailBodyEmbed').style.display = 'none';
if(document.getElementById('detailBodyRaw')) document.getElementById('detailBodyRaw').style.display = 'none';
document.querySelector('.detail-panel').style.background = 'var(--card)';
const activeTab = document.getElementById(tabId === 'clean' ? 'dTabClean' : 'dTabEmbed');
if (activeTab) {
activeTab.classList.add('active');
activeTab.style.color = 'var(--primary)';
activeTab.style.borderColor = 'var(--primary)';
}
if (tabId === 'clean') {
document.getElementById('detailBodyClean').style.display = 'block';
const _cleanEl = document.getElementById('detailBodyClean'); if(_cleanEl) _cleanEl.scrollTop = 0;
document.getElementById('btnSavePopupText').style.display = 'inline-block';
} else if (tabId === 'fields') {
document.getElementById('detailBodyFields').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
} else if (tabId === 'embed') {
document.getElementById('detailBodyEmbed').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
} else if (tabId === 'raw') {
document.getElementById('detailBodyRaw').style.display = 'block';
document.getElementById('btnSavePopupText').style.display = 'none';
}
}
async function rewriteField(code, key, btn) {
if(!code || !key) return;
const vDiv = document.getElementById(`field-val-${key}`);
btn.innerHTML = '<span class="spinner-sm" style="border-width:2px; width:12px; height:12px; border-color:#4f46e5 transparent transparent transparent;"></span> ...';
btn.disabled = true;
vDiv.style.opacity = '0.5';
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}/rewrite-field`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field_key: key })
});
const data = await res.json();
if (data.status === 'success') {
vDiv.innerText = data.new_value;
// Update local cache
if(window.currentDetailDataForCleanTab && window.currentDetailDataForCleanTab.data) {
window.currentDetailDataForCleanTab.data[key] = data.new_value;
}
if(window.currentDetailDataForCleanTab && window.currentDetailDataForCleanTab.description) {
window.currentDetailDataForCleanTab.description.rendered_text = data.clean_description;
}
btn.innerHTML = '✅ OK';
setTimeout(() => { btn.innerHTML = '✨ AI Viết Lại'; btn.disabled = false; }, 2000);
// Attempt to re-render clean html implicitly
const cleanEditor = document.getElementById('cleanTextEditor');
if (cleanEditor && cleanEditor.innerHTML && !cleanEditor.innerHTML.trim().includes('<h2')) {
// If pure text, let's just do a tiny logic to refresh the popup if they switch tabs
// No big deal if not, they can refresh page.
}
} else {
throw new Error(data.message || 'Lỗi');
}
} catch(e) {
alert("Lỗi: " + e.message);
btn.innerHTML = '✨ AI Viết Lại';
btn.disabled = false;
}
vDiv.style.opacity = '1';
}
// Helper for formatting fake strings temporarily
function StrReplaceFake(str) {
return str; // If need markdown replacement later
}
// --- Clean Data Tab Logic ---
function goToCleanData() {
closeDetail();
switchTab('cleanData');
document.getElementById('cleanDataSearch').value = currentDetailCode || '';
renderCleanDataForm();
}
async function loadCleanDataForInput() {
const code = document.getElementById('cleanDataSearch').value.trim();
if(!code) return alert("Nhập mã sản phẩm!");
document.getElementById('cleanDataEditor').innerHTML = '<div class="table-loading"><div class="spinner-sm"></div> Đang tải...</div>';
document.getElementById('cleanDataEditor').style.display = 'block';
document.getElementById('cleanDataEmpty').style.display = 'none';
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(code)}`);
if (res.ok) {
const saved = await res.json();
if (saved.status === 'success' && saved.description) {
window.currentDetailDataForCleanTab = {
data: saved.description.description_data,
description: saved.description
};
window.currentDetailNameForCleanTab = saved.description.product_name || `Sản phẩm ${code}`;
currentDetailCode = code;
renderCleanDataForm();
return;
}
}
alert("Không tìm thấy dữ liệu cho mã này. Hãy generate trước ở tab Sản phẩm.");
document.getElementById('cleanDataEditor').style.display = 'none';
document.getElementById('cleanDataEmpty').style.display = 'block';
} catch (e) { alert("Lỗi khi tải dữ liệu"); }
}
function renderCleanDataForm() {
const data = window.currentDetailDataForCleanTab;
const name = window.currentDetailNameForCleanTab;
if(!data) return;
document.getElementById('cleanDataEmpty').style.display = 'none';
document.getElementById('cleanDataEditor').style.display = 'block';
document.getElementById('cleanDataTitle').style.display = 'block';
document.getElementById('btnSaveCleanData').style.display = 'inline-block';
const c = data.description || {};
const d = data.description?.description_data || data.data || {};
document.getElementById('cleanDataTitle').innerHTML = `Đang chỉnh sửa: <span style="color:#000;">${name || c.internal_ref_code || currentDetailCode}</span> (${currentDetailCode})`;
const editBody = document.getElementById('cleanDataEditor');
let editHtml = `<div style="display:flex; flex-direction:column; gap:16px;">`;
const orderedKeys = ['ten_san_pham', 'tagline', 'mo_ta_chinh', 'phong_cach', 'chat_lieu', 'tinh_nang_vai', 'huong_dan_bao_quan'];
const allKeys = Object.keys(d);
const remainingKeys = allKeys.filter(k => !orderedKeys.includes(k)).sort();
const sortedKeys = [...orderedKeys.filter(k => allKeys.includes(k)), ...remainingKeys];
sortedKeys.forEach(k => {
editHtml += `
<div style="background:#fff; border:1px solid #e5e7eb; border-radius:8px; padding:16px;">
<label style="display:block; font-size:13px; font-weight:700; color:var(--primary); margin-bottom:8px; text-transform:uppercase;">${esc(k)} <button onclick="deleteField('${k}')" style="float:right; border:none; background:none; color:var(--warn); cursor:pointer; font-size:11px; font-weight:bold;">✕ Xóa trường này</button></label>
<textarea id="editField_${k}" data-key="${k}" style="width:100%; box-sizing:border-box; border:1px solid #d1d5db; border-radius:6px; padding:10px; font-size:14px; font-family:inherit; min-height:60px; line-height:1.5; resize:vertical;">${esc(d[k] || '')}</textarea>
</div>`;
});
editHtml += `
<div style="background:#f0f9ff; border:2px dashed #bae6fd; border-radius:8px; padding:16px; margin-top:12px;">
<label style="display:block; font-size:13px; font-weight:700; color:#0369a1; margin-bottom:10px;">➕ BỔ SUNG TRƯỜNG MỚI</label>
<div style="display:flex; gap:10px;">
<input type="text" id="newFieldKey" placeholder="Tên key (vd: dac_diem_noi_bat)" style="flex:1; border:1px solid #bae6fd; border-radius:6px; padding:10px; font-size:14px; outline:none;">
<input type="text" id="newFieldValue" placeholder="Nội dung" style="flex:2; border:1px solid #bae6fd; border-radius:6px; padding:10px; font-size:14px; outline:none;">
<button class="btn btn-sm" style="background:#0284c7;color:#fff;border:none;font-weight:bold;padding:0 20px;" onclick="addNewField()">Thêm</button>
</div>
</div>
</div>`;
editBody.innerHTML = editHtml;
}
function getSafeDetailData() {
if (window.currentDetailDataForCleanTab) {
if (window.currentDetailDataForCleanTab.description && window.currentDetailDataForCleanTab.description.description_data) return window.currentDetailDataForCleanTab.description.description_data;
if (window.currentDetailDataForCleanTab.data) return window.currentDetailDataForCleanTab.data;
}
return {};
}
function gatherInputsIntoData() {
const inputs = document.querySelectorAll('#cleanDataEditor textarea[data-key]');
const d = getSafeDetailData();
inputs.forEach(el => { d[el.getAttribute('data-key')] = el.value; });
return d;
}
function deleteField(key) {
if (!confirm(`Xóa trường [${key}]?`)) return;
const d = gatherInputsIntoData();
delete d[key];
renderCleanDataForm();
}
function addNewField() {
const k = document.getElementById('newFieldKey').value.trim();
const v = document.getElementById('newFieldValue').value.trim();
if (!k) return alert('Phải nhập Tên key!');
const safe_k = k.toLowerCase().replace(/[^a-z0-9_]/g, '_');
const d = gatherInputsIntoData();
d[safe_k] = v;
renderCleanDataForm();
}
async function saveDetailEdit() {
if (!currentDetailCode) return;
const btn = document.getElementById('btnSaveCleanData');
const orgText = btn.textContent;
btn.textContent = 'Đang lưu...';
btn.disabled = true;
try {
const newData = gatherInputsIntoData();
// Send update request
const res = await fetch(`${API}/saved/${currentDetailCode}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description_data: newData })
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
btn.textContent = '✓ Lưu thành công!';
// Re-fetch fresh data from server to refresh in-memory state correctly
// (avoids shared-reference mutation bugs that made the form appear blank)
try {
const freshRes = await fetch(`${API}/saved/${encodeURIComponent(currentDetailCode)}`);
const freshJson = await freshRes.json();
if (freshJson.status === 'success' && freshJson.description) {
window.currentDetailDataForCleanTab = {
data: freshJson.description.description_data,
description: freshJson.description,
};
renderCleanDataForm();
}
} catch (_) { /* non-critical — form already saved, just show stale data */ }
// Refresh main table
loadProducts();
loadOverview();
setTimeout(() => { btn.textContent = orgText; btn.disabled = false; }, 2000);
} catch (e) {
alert('Lỗi lưu thay đổi: ' + e.message);
btn.textContent = '💾 Lưu';
btn.disabled = false;
}
}
function copyDetailJson() {
if (!currentDetail) return;
const text = currentDetail.description?.rendered_text || "";
if (!text) {
alert("Chưa có Nội dung chuẩn để copy. Hãy lưu lại trước.");
return;
}
navigator.clipboard.writeText(text);
const btn = document.getElementById('btnCopyJson');
const og = btn.textContent;
btn.textContent = '✓ Đã copy!';
btn.style.borderColor = 'var(--success)';
btn.style.color = 'var(--success)';
setTimeout(() => { btn.textContent = og; btn.style.borderColor = ''; btn.style.color = ''; }, 2000);
}
// ═══ Approve / Reject ═══
async function approveDetail(newStatus) {
if (!currentDetailCode) return;
try {
const res = await fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ magento_ref_code: currentDetailCode, status: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
// Update buttons
document.getElementById('btnApprove').style.display = newStatus === 1 ? 'none' : '';
document.getElementById('btnReject').style.display = newStatus === 1 ? '' : 'none';
// Flash feedback
const label = newStatus === 1 ? '✅ Đã duyệt!' : '✕ Đã hủy duyệt';
const body = document.getElementById('detailBody');
if (body) {
const toast = document.createElement('div');
toast.style.cssText = 'padding:10px 14px;border-radius:8px;font-size:13px;font-weight:600;margin-bottom:12px;' +
(newStatus === 1 ? 'background:var(--success-light);color:var(--success)' : 'background:#fff3cd;color:#856404');
toast.textContent = label;
body.prepend(toast);
setTimeout(() => toast.remove(), 3000);
}
// Re-fetch latest description data from DB and refresh detail panel
try {
const savedRes = await fetch(`${API}/saved/${encodeURIComponent(currentDetailCode)}`);
if (savedRes.ok) {
const savedData = await savedRes.json();
if (savedData.status === 'success' && savedData.data) {
// Merge approval status into currentDetail and re-render Dàn trang
currentDetail = { ...currentDetail, ...savedData.data, descStatus: newStatus };
const tab = document.querySelector('.desc-tab-btn.active');
if (tab) tab.click(); // Re-trigger active tab render
}
}
} catch (_) { /* silently ignore refresh errors */ }
// Refresh table stats
loadOverview();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
// ═══ Approve from Table Row ═══
async function approveRow(code, newStatus, btn) {
const ogHtml = btn.innerHTML;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch(`${API}/approve`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code, status: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
// Update the button itself
if (newStatus === 1) {
btn.style.background = '#dc3545';
btn.innerHTML = '✕ Hủy';
btn.setAttribute('onclick', `event.stopPropagation();approveRow('${code}',0,this)`);
} else {
btn.style.background = '#28a745';
btn.innerHTML = '✓ Duyệt';
btn.setAttribute('onclick', `event.stopPropagation();approveRow('${code}',1,this)`);
}
// Update status badge in same row
const row = btn.closest('tr');
if (row) {
const badge = row.querySelector('.status-badge');
if (badge) {
if (newStatus === 1) {
badge.className = 'status-badge done';
badge.textContent = '✅ Đã duyệt';
} else {
badge.className = 'status-badge pending';
badge.textContent = '⏳ Chờ duyệt';
}
}
}
// Refresh stats
loadOverview();
} catch (e) {
alert('Lỗi: ' + e.message);
btn.innerHTML = ogHtml;
} finally {
btn.disabled = false;
}
}
// ═══ Tabs & Fields ═══
let currentTab = 'products';
function switchTab(tab) {
currentTab = tab;
document.getElementById('tabProducts').className = tab === 'products' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabProducts').style.borderBottomColor = tab === 'products' ? 'var(--primary)' : 'transparent';
document.getElementById('tabProducts').style.color = tab === 'products' ? 'var(--primary)' : 'var(--muted-fg)';
document.getElementById('tabFields').className = tab === 'fields' ? 'tab-btn active' : 'tab-btn';
document.getElementById('tabFields').style.borderBottomColor = tab === 'fields' ? 'var(--primary)' : 'transparent';
document.getElementById('tabFields').style.color = tab === 'fields' ? 'var(--primary)' : 'var(--muted-fg)';
const tabCleanData = document.getElementById('tabCleanData');
if (tabCleanData) {
tabCleanData.className = tab === 'cleanData' ? 'tab-btn active' : 'tab-btn';
tabCleanData.style.borderBottomColor = tab === 'cleanData' ? 'var(--primary)' : 'transparent';
tabCleanData.style.color = tab === 'cleanData' ? 'var(--primary)' : 'var(--muted-fg)';
}
document.getElementById('sectionProducts').style.display = tab === 'products' ? 'block' : 'none';
document.getElementById('sectionFields').style.display = tab === 'fields' ? 'block' : 'none';
document.getElementById('sectionCleanData').style.display = tab === 'cleanData' ? 'block' : 'none';
if (tab === 'fields') {
loadFields();
} else {
loadProducts();
}
}
async function loadFields() {
const tbody = document.getElementById('fieldsTableBody');
tbody.innerHTML = '<tr><td colspan="5" class="table-loading"><div class="spinner-sm"></div> Đang tải...</td></tr>';
try {
const res = await fetch(`${API}/fields`);
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
if (data.data.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="table-loading">Chưa có cấu hình trường nào.</td></tr>';
return;
}
tbody.innerHTML = data.data.map((f, i) => `
<tr style="opacity: ${f.is_active ? '1' : '0.5'}">
<td style="font-family:var(--font-mono); font-size:12px; font-weight:600;">${esc(f.field_key)}</td>
<td style="font-weight:600;">${esc(f.field_label)}</td>
<td style="font-size:12px; color:var(--secondary-fg);">${esc(f.field_instruction)}</td>
<td>
<button class="btn btn-sm" style="background:${f.is_active ? '#28a745' : '#6c757d'}; color:#fff; border:none;" onclick="toggleField('${f.field_key}', ${!f.is_active})">${f.is_active ? 'Bật' : 'Tắt'}</button>
</td>
<td>
<button class="btn btn-outline btn-sm" onclick="deleteField('${f.field_key}')" style="color:var(--warn); border-color:var(--warn);">Xóa</button>
</td>
</tr>
`).join('');
} catch (e) {
tbody.innerHTML = `<tr><td colspan="5" class="table-loading" style="color:var(--warn)">Lỗi: ${esc(e.message)}</td></tr>`;
}
}
async function toggleField(key, newStatus) {
try {
const res = await fetch(`${API}/fields/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ is_active: newStatus }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
async function deleteField(key) {
if (!confirm(`Bạn có chắc chắn muốn xóa trường ${key}?`)) return;
try {
const res = await fetch(`${API}/fields/${key}`, { method: 'DELETE' });
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
async function showFieldForm() {
const key = prompt('Nhập Key (viết liền không dấu, VD: chat_lieu_moi):');
if (!key) return;
const label = prompt('Nhập Label hiển thị:');
if (!label) return;
const instruction = prompt('Nhập Prompt (hướng dẫn AI sinh dữ liệu cho trường này):');
try {
const res = await fetch(`${API}/fields`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ field_key: key, field_label: label, field_instruction: instruction || '' }),
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
loadFields();
} catch (e) {
alert('Lỗi: ' + e.message);
}
}
// ═══ Utils ═══
function esc(str) {
if (!str) return '';
const d = document.createElement('div');
d.textContent = String(str);
return d.innerHTML;
}
// ═══ Edit Text in Popup ═══
async function savePopupText() {
if (!currentDetailCode) return;
// Detect active tab
const isCleanTab = document.getElementById('detailBodyClean')?.style.display !== 'none';
if (isCleanTab) {
// ── Tab "Dàn trang (Chuẩn)" → collect field-val-* and call /update ──
const btn = document.getElementById('btnSavePopupText');
const oldText = btn.innerText;
btn.innerText = 'Đang lưu...';
btn.disabled = true;
try {
// Collect all editable field values from Dàn trang tab only
const newData = {};
// Collect from dantrang-val-* (editable spans in Dàn trang view)
document.querySelectorAll('#detailBodyClean [id^="dantrang-val-"]').forEach(el => {
const key = el.id.replace('dantrang-val-', '');
newData[key] = el.innerText.trim();
});
// Fallback: also collect from hidden field-val-* in Fields tab (for fields not shown as editable)
document.querySelectorAll('#detailBodyFields [id^="field-val-"]').forEach(el => {
const key = el.id.replace('field-val-', '');
if (!(key in newData)) newData[key] = el.innerText.trim();
});
if (Object.keys(newData).length === 0) {
btn.innerText = '⚠️ Không có field';
setTimeout(() => { btn.innerText = oldText; btn.disabled = false; }, 2000);
return;
}
console.log('[SAVE] Sending description_data:', newData);
const res = await fetch(`${API}/saved/${encodeURIComponent(currentDetailCode)}/update`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ description_data: newData })
});
const data = await res.json();
if (data.status !== 'success') throw new Error(data.message);
// Patch currentDetail memory
if (window.currentDetailDataForCleanTab?.data) {
Object.assign(window.currentDetailDataForCleanTab.data, newData);
}
btn.innerText = '✅ Đã lưu';
setTimeout(() => { btn.innerText = oldText; btn.disabled = false; }, 2000);
} catch (e) {
alert('Lỗi lưu: ' + e.message);
btn.innerText = oldText;
btn.disabled = false;
}
return;
}
// ── Tab "Clean Descripts" → save text via /update-text ──
const embedEditor = document.getElementById('detailPreEmbed');
if (!embedEditor) return;
const updatedText = embedEditor.innerText;
const btn2 = document.getElementById('btnSavePopupText');
const oldText2 = btn2.innerText;
btn2.innerText = 'Đang lưu...';
btn2.disabled = true;
try {
const res = await fetch(`${API}/saved/${encodeURIComponent(currentDetailCode)}/update-text`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clean_description: updatedText })
});
const data = await res.json();
if (data.status === 'success') {
btn2.innerText = '✅ Đã lưu';
setTimeout(() => { btn2.innerText = oldText2; btn2.disabled = false; }, 2000);
if (window.currentDetailDataForCleanTab?.description) {
window.currentDetailDataForCleanTab.description.rendered_text = updatedText;
window.currentDetailDataForCleanTab.description.clean_description = updatedText;
}
if (window.currentDetailDataForCleanTab) {
window.currentDetailDataForCleanTab.clean_description = updatedText;
}
} else {
throw new Error(data.message || 'Lỗi không xác định');
}
} catch (e) {
alert('Lỗi lưu text: ' + e.message);
btn2.innerText = oldText2;
btn2.disabled = false;
}
}
function copyPopupText() {
const editor = document.getElementById('detailPreEmbed');
if (!editor) return;
navigator.clipboard.writeText(editor.innerText).then(() => {
const btn = document.getElementById('btnCopyJson');
const oldText = btn.innerText;
btn.innerText = '✅ Đã Copy';
setTimeout(() => { btn.innerText = oldText; }, 2000);
});
}
// ─── AI CODEX INLINE EDITOR ───
let selectedTextInfo = { text: '', context: '', range: null };
function setupInlineEdit() {
const embedBody = document.getElementById('detailPreEmbed');
const popup = document.getElementById('inlineEditPopup');
embedBody.addEventListener('mouseup', function(e) {
if (embedBody.contentEditable !== "true") return; // Only trigger if unlocked
const selection = window.getSelection();
const text = selection.toString().trim();
if (text.length > 5) {
// Find whole text context
const context = embedBody.innerText;
selectedTextInfo = {
text: text,
context: context,
range: selection.getRangeAt(0).cloneRange()
};
// Position popup next to cursor
popup.style.display = 'block';
popup.style.top = (e.pageY + 10) + 'px';
popup.style.left = e.pageX + 'px';
} else {
popup.style.display = 'none';
}
});
document.addEventListener('mousedown', function(e) {
if (!popup.contains(e.target) && !embedBody.contains(e.target)) {
popup.style.display = 'none';
}
});
}
async function triggerInlineEdit(action) {
const popup = document.getElementById('inlineEditPopup');
popup.style.display = 'none';
if (!selectedTextInfo.text || !selectedTextInfo.range) return;
// Visual feedback injection
const span = document.createElement('span');
span.className = 'agent-editing';
span.style.backgroundColor = '#fbbf24';
span.style.color = 'black';
span.textContent = `[⏳ AI Codex đang xử lý...]`;
selectedTextInfo.range.deleteContents();
selectedTextInfo.range.insertNode(span);
try {
let enrichedContext = "";
if (action === 'enrich') {
enrichedContext = "Đây là thông tin của một sản phẩm thời trang. Hãy bổ sung gợi ý cực hay về dịp mặc thích hợp, những ngày lễ hội có thể diện sản phẩm, hoặc cách ứng dụng thực tế vào đời sống. Giữ giọng văn xuất sắc của Stylist chuyên gia thời trang, nhưng KHÔNG viết dài dòng lê thê.\n\n" + selectedTextInfo.context;
} else {
enrichedContext = "Đây là thông tin của một sản phẩm thời trang. Hãy thực hiện yêu cầu chỉnh sửa trên đoạn text được đánh dấu.\n\n" + selectedTextInfo.context;
}
// Reuse the existing ai-report capability
const res = await fetch('/api/report-inline-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
selected_text: selectedTextInfo.text,
action: action,
context: enrichedContext
})
});
const data = await res.json();
span.style.backgroundColor = '#d1fae5'; // success green
span.innerHTML = `${data.new_text} <em style="font-size:10px;color:#059669;cursor:pointer;padding-left:4px" title="Chấp nhận gợi ý này">[Chấp nhận]</em>`;
const acceptBtn = span.querySelector('em');
acceptBtn.onclick = function() {
span.outerHTML = data.new_text;
};
} catch(e) {
span.style.backgroundColor = '#fecaca';
span.textContent = selectedTextInfo.text; // Revert safely
alert('Lỗi gọi AI Codex: ' + e.message);
}
}
// Initialize on load
setupInlineEdit();
// ═══ SIZE GUIDE ENRICHMENT ═══
async function generateSingleSize(code, name, btn) {
const ogText = btn.textContent;
btn.innerHTML = '<span class="spinner-sm"></span>';
btn.disabled = true;
try {
const res = await fetch('/api/bulk/enrich-size-single', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ internal_ref_code: code }),
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(`✅ Đã sinh Size Guide cho ${data.name || code}!\n\nSize: ${JSON.stringify(data.size_scale)}\n\nClick vào sản phẩm để xem chi tiết.`);
// Refresh the detail if it's open
if (currentDetailCode === code) {
openDetail(code, name);
}
} catch (e) {
alert('❌ Lỗi: ' + e.message);
} finally {
btn.textContent = ogText;
btn.disabled = false;
}
}
async function batchEnrichSize() {
// First get stats
try {
const statsRes = await fetch('/api/bulk/size-stats');
const stats = await statsRes.json();
if (stats.status !== 'success') throw new Error(stats.message);
if (stats.needs_enrichment <= 0) {
alert('🎉 Tất cả sản phẩm đã có hướng dẫn size!');
return;
}
if (!confirm(`📏 Phát hiện ${stats.needs_enrichment} sản phẩm cần bơm Size Guide AI.\n\n` +
`Tổng matched: ${stats.matched_products}\n` +
`Đã có size guide: ${stats.has_size_guide}\n` +
`Cần bơm: ${stats.needs_enrichment}\n\n` +
`Bấm OK để chạy batch AI (ngầm)?`)) return;
const btn = document.getElementById('btnBatchSize');
const ogText = btn.innerHTML;
btn.innerHTML = '<div class="spinner-sm"></div> Đang gửi...';
btn.disabled = true;
const res = await fetch('/api/bulk/enrich-size-batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes: [] }) // Empty = all needing enrichment
});
const data = await res.json();
if (data.status === 'error') throw new Error(data.message);
alert(`✅ ${data.message}`);
btn.innerHTML = ogText;
btn.disabled = false;
} catch (e) {
alert('❌ Lỗi: ' + e.message);
const btn = document.getElementById('btnBatchSize');
if (btn) { btn.innerHTML = '📏 Bơm Size Guide'; btn.disabled = false; }
}
}
...@@ -13,6 +13,11 @@ from api.product_desc.bulk_ops_route import ( ...@@ -13,6 +13,11 @@ from api.product_desc.bulk_ops_route import (
_render_description_text, _render_description_text,
get_pooled_connection_compat, get_pooled_connection_compat,
) )
from api.product_desc.product_desc_shared import (
GROQ_API_KEYS,
TAGS_ALLOWED,
TAGS_PROMPT,
)
from common.cache import redis_cache from common.cache import redis_cache
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", datefmt="%y-%m-%d %H:%M:%S")
...@@ -21,63 +26,20 @@ logger = logging.getLogger("ai_ops_worker") ...@@ -21,63 +26,20 @@ logger = logging.getLogger("ai_ops_worker")
BATCH_CONCURRENCY = 100 BATCH_CONCURRENCY = 100
PG_TABLE = "dashboard_canifa.ultra_descriptions" PG_TABLE = "dashboard_canifa.ultra_descriptions"
# ═══ DS2API Endpoint Config ═══ async def generate_tags_for_product(code: str, name: str, clean_desc: str) -> list[str] | None:
DS2API_BASE_URL = "http://127.0.0.1:8000/v1/chat/completions" product_description = f"Sản phẩm: {name or 'Trống'}\nMô tả:\n{clean_desc or 'Không có thông tin'}"
# CẮM API KEY CỦA ANH VÀO ĐÂY NHÉ (Tạo trên giao diện DS2API -> API Keys)
DS2API_KEY = "canifa-deepseek-key-123"
# ═══ Tags Allowed (Plain, No Prefix) ═══
# Đúng 10 tags siêu chuẩn anh Vũ yêu cầu
TAGS_ALLOWED = [
"Trung tính", "Sáng", "Đậm",
"Đi làm công sở", "Đi chơi / dạo phố", "Ở nhà / mặc ngủ", "Du lịch",
"Mùa hè", "Mùa đông", "Cả 2 mùa (Mùa xuân/ mùa thu)"
]
TAG_GENERATION_PROMPT = """Bạn là chuyên gia gán tag thời trang cho sản phẩm Canifa.
NHIỆM VỤ:
Đọc mô tả sản phẩm và chọn TỐI ĐA 6 tags phù hợp nhất từ DANH SÁCH TAGS HỢP LỆ.
DANH SÁCH TAGS HỢP LỆ:
""" + "\n".join(TAGS_ALLOWED) + """
QUY TẮC:
1. CHỈ dùng tags trong danh sách trên — KHÔNG bịa tag mới
2. Chọn tối đa 6 tags, tối thiểu 2 tags
3. KHÔNG cần quan tâm đến Size hay Chất liệu, chỉ phân loại Phong cách/Dịp/Màu/Mùa.
4. Chỉ trả về một list Python (ví dụ: ["Đi làm công sở", "Trung tính", "Mùa đông"])
5. KHÔNG giải thích, KHÔNG markdown
MÔ TẢ SẢN PHẨM: prompt = f"{TAGS_PROMPT}\n\nMÔ TẢ SẢN PHẨM:\n{product_description}"
{product_description}
OUTPUT (chỉ list, không gì khác): messages = [
""" {"role": "user", "content": prompt}
]
async def generate_tags_for_product(client: httpx.AsyncClient, code: str, name: str, clean_desc: str) -> list[str] | None:
product_description = f"Sản phẩm: {name or 'Trống'}\nMô tả:\n{clean_desc or 'Không có thông tin'}"
prompt_hydrated = TAG_GENERATION_PROMPT.format(product_description=product_description)
payload = {
"model": "deepseek-v4-flash",
"messages": [
{"role": "user", "content": prompt_hydrated}
],
"temperature": 0.1
}
for attempt in range(3): for attempt in range(2):
try: try:
auth_header = {"Authorization": f"Bearer {DS2API_KEY}"} content_str = await _call_codex(messages, max_tokens=150, temperature=0.1)
resp = await client.post(DS2API_BASE_URL, json=payload, headers=auth_header, timeout=60.0) content_str = content_str.strip()
if resp.status_code == 200: content_str = content_str.removeprefix("```json").removeprefix("```python").removeprefix("```").removesuffix("```")
data = resp.json()
content_str = data["choices"][0]["message"]["content"].strip()
content_str = content_str.removeprefix("```json")
content_str = content_str.removeprefix("```python")
content_str = content_str.removeprefix("```")
content_str = content_str.removesuffix("```")
import ast import ast
try: try:
res_list = ast.literal_eval(content_str.strip()) res_list = ast.literal_eval(content_str.strip())
...@@ -87,15 +49,9 @@ async def generate_tags_for_product(client: httpx.AsyncClient, code: str, name: ...@@ -87,15 +49,9 @@ async def generate_tags_for_product(client: httpx.AsyncClient, code: str, name:
res_json = json.loads(content_str.strip()) res_json = json.loads(content_str.strip())
if isinstance(res_json, list): return res_json if isinstance(res_json, list): return res_json
except: pass except: pass
elif resp.status_code == 429:
logger.warning("DS2API Rate limited (429). Sleeping 10s")
await asyncio.sleep(10.0)
else:
logger.error(f"HTTP {resp.status_code} from DS2API: {resp.text}")
await asyncio.sleep(5)
except Exception as e: except Exception as e:
logger.error(f"Error calling ds2api: {e}") logger.error(f"Error calling groq codex for tags: {e}")
await asyncio.sleep(5) await asyncio.sleep(2)
return None return None
async def handle_tags_job(redis, job_data, client, conn): async def handle_tags_job(redis, job_data, client, conn):
...@@ -138,7 +94,7 @@ async def handle_tags_job(redis, job_data, client, conn): ...@@ -138,7 +94,7 @@ async def handle_tags_job(redis, job_data, client, conn):
full_name = name or p_line or '' full_name = name or p_line or ''
tags = None tags = None
while not tags: while not tags:
tags = await generate_tags_for_product(client, code, full_name, clean_desc) tags = await generate_tags_for_product(code, full_name, clean_desc)
if not tags: await asyncio.sleep(3) if not tags: await asyncio.sleep(3)
if tags and isinstance(tags, list): if tags and isinstance(tags, list):
......
...@@ -7,16 +7,186 @@ services: ...@@ -7,16 +7,186 @@ services:
canifa-backend: canifa-backend:
build: build:
context: ./backend context: ./backend
dockerfile: Dockerfile dockerfile: Dockerfile.dev
container_name: canifa_stylist_api container_name: canifa_stylist_api
ports: ports:
- "5000:5000" - "5000:5000"
environment: environment:
- PORT=5000 - PORT=5000
- LANGFUSE_BASE_URL=http://langfuse-web:3000
- LANGFUSE_SELF_HOSTED_URL=http://langfuse-web:3000
volumes: volumes:
- ./backend:/app # Hot-reload mapping - ./backend:/app # Hot-reload mapping
restart: unless-stopped restart: unless-stopped
# ──────────────────────────────────────────────
# LANGFUSE SELF-HOSTED STACK
# ──────────────────────────────────────────────
langfuse-postgres:
image: docker.io/postgres:17
container_name: langfuse_postgres
environment:
- POSTGRES_USER=postgres
- POSTGRES_PASSWORD=postgres
- POSTGRES_DB=postgres
- TZ=UTC
- PGTZ=UTC
volumes:
- langfuse_postgres_data:/var/lib/postgresql/data
restart: unless-stopped
langfuse-redis:
image: docker.io/redis:7
container_name: langfuse_redis
command: ["redis-server", "--requirepass", "myredissecret", "--maxmemory-policy", "noeviction"]
volumes:
- langfuse_redis_data:/data
restart: unless-stopped
langfuse-clickhouse:
image: docker.io/clickhouse/clickhouse-server:latest
container_name: langfuse_clickhouse
environment:
- CLICKHOUSE_DB=default
- CLICKHOUSE_USER=clickhouse
- CLICKHOUSE_PASSWORD=clickhouse
volumes:
- langfuse_clickhouse_data:/var/lib/clickhouse
- langfuse_clickhouse_logs:/var/log/clickhouse-server
restart: unless-stopped
langfuse-minio:
image: cgr.dev/chainguard/minio
container_name: langfuse_minio
entrypoint: sh
command: -c 'mkdir -p /data/langfuse && minio server --address ":9000" --console-address ":9001" /data'
environment:
- MINIO_ROOT_USER=minio
- MINIO_ROOT_PASSWORD=miniosecret
ports:
- "9090:9000"
volumes:
- langfuse_minio_data:/data
restart: unless-stopped
langfuse-worker:
image: docker.io/langfuse/langfuse-worker:3
container_name: langfuse_worker
depends_on:
- langfuse-postgres
- langfuse-redis
- langfuse-clickhouse
- langfuse-minio
environment:
- NEXTAUTH_URL=http://localhost:3009
- DATABASE_URL=postgresql://postgres:postgres@langfuse-postgres:5432/postgres
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
- TELEMETRY_ENABLED=true
- CLICKHOUSE_MIGRATION_URL=clickhouse://langfuse-clickhouse:9000
- CLICKHOUSE_URL=http://langfuse-clickhouse:8123
- CLICKHOUSE_USER=clickhouse
- CLICKHOUSE_PASSWORD=clickhouse
- CLICKHOUSE_CLUSTER_ENABLED=false
- LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
- LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
- LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
- LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
- LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
- LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
- LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
- LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
- LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
- LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
- LANGFUSE_S3_BATCH_EXPORT_ENABLED=false
- LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
- LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
- LANGFUSE_S3_BATCH_EXPORT_REGION=auto
- LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://localhost:9090
- LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
- LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
- LANGFUSE_INGESTION_QUEUE_DELAY_MS=
- LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
- REDIS_HOST=langfuse-redis
- REDIS_PORT=6379
- REDIS_AUTH=myredissecret
- REDIS_TLS_ENABLED=false
- EMAIL_FROM_ADDRESS=
- SMTP_CONNECTION_URL=
restart: unless-stopped
langfuse-web:
image: docker.io/langfuse/langfuse:3
container_name: langfuse_web
depends_on:
- langfuse-postgres
- langfuse-redis
- langfuse-clickhouse
- langfuse-minio
ports:
- "3009:3000"
environment:
- NEXTAUTH_URL=http://localhost:3009
- NEXTAUTH_SECRET=mysecret
- LANGFUSE_INIT_ORG_ID=
- LANGFUSE_INIT_ORG_NAME=
- LANGFUSE_INIT_PROJECT_ID=
- LANGFUSE_INIT_PROJECT_NAME=
- LANGFUSE_INIT_PROJECT_PUBLIC_KEY=
- LANGFUSE_INIT_PROJECT_SECRET_KEY=
- LANGFUSE_INIT_USER_EMAIL=
- LANGFUSE_INIT_USER_NAME=
- LANGFUSE_INIT_USER_PASSWORD=
- DATABASE_URL=postgresql://postgres:postgres@langfuse-postgres:5432/postgres
- SALT=mysalt
- ENCRYPTION_KEY=0000000000000000000000000000000000000000000000000000000000000000
- TELEMETRY_ENABLED=true
- CLICKHOUSE_MIGRATION_URL=clickhouse://langfuse-clickhouse:9000
- CLICKHOUSE_URL=http://langfuse-clickhouse:8123
- CLICKHOUSE_USER=clickhouse
- CLICKHOUSE_PASSWORD=clickhouse
- CLICKHOUSE_CLUSTER_ENABLED=false
- LANGFUSE_S3_EVENT_UPLOAD_BUCKET=langfuse
- LANGFUSE_S3_EVENT_UPLOAD_REGION=auto
- LANGFUSE_S3_EVENT_UPLOAD_ACCESS_KEY_ID=minio
- LANGFUSE_S3_EVENT_UPLOAD_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_EVENT_UPLOAD_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_EVENT_UPLOAD_FORCE_PATH_STYLE=true
- LANGFUSE_S3_EVENT_UPLOAD_PREFIX=events/
- LANGFUSE_S3_MEDIA_UPLOAD_BUCKET=langfuse
- LANGFUSE_S3_MEDIA_UPLOAD_REGION=auto
- LANGFUSE_S3_MEDIA_UPLOAD_ACCESS_KEY_ID=minio
- LANGFUSE_S3_MEDIA_UPLOAD_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_MEDIA_UPLOAD_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_MEDIA_UPLOAD_FORCE_PATH_STYLE=true
- LANGFUSE_S3_MEDIA_UPLOAD_PREFIX=media/
- LANGFUSE_S3_BATCH_EXPORT_ENABLED=false
- LANGFUSE_S3_BATCH_EXPORT_BUCKET=langfuse
- LANGFUSE_S3_BATCH_EXPORT_PREFIX=exports/
- LANGFUSE_S3_BATCH_EXPORT_REGION=auto
- LANGFUSE_S3_BATCH_EXPORT_ENDPOINT=http://langfuse-minio:9000
- LANGFUSE_S3_BATCH_EXPORT_EXTERNAL_ENDPOINT=http://localhost:9090
- LANGFUSE_S3_BATCH_EXPORT_ACCESS_KEY_ID=minio
- LANGFUSE_S3_BATCH_EXPORT_SECRET_ACCESS_KEY=miniosecret
- LANGFUSE_S3_BATCH_EXPORT_FORCE_PATH_STYLE=true
- LANGFUSE_INGESTION_QUEUE_DELAY_MS=
- LANGFUSE_INGESTION_CLICKHOUSE_WRITE_INTERVAL_MS=
- REDIS_HOST=langfuse-redis
- REDIS_PORT=6379
- REDIS_AUTH=myredissecret
- REDIS_TLS_ENABLED=false
- EMAIL_FROM_ADDRESS=
- SMTP_CONNECTION_URL=
- LANGFUSE_PUBLIC_KEY=pk-lf-2bb249b1-77e3-4309-8954-312b8fb2fff9
- LANGFUSE_SECRET_KEY=sk-lf-bd0c7202-a7f4-4399-a36e-65ebe2e35104
restart: unless-stopped
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
# THẰNG EM 1: CUCCU NOTE BACKEND # THẰNG EM 1: CUCCU NOTE BACKEND
# ────────────────────────────────────────────── # ──────────────────────────────────────────────
...@@ -51,3 +221,10 @@ services: ...@@ -51,3 +221,10 @@ services:
restart: unless-stopped restart: unless-stopped
depends_on: depends_on:
- cuccu-backend - cuccu-backend
volumes:
langfuse_postgres_data:
langfuse_clickhouse_data:
langfuse_clickhouse_logs:
langfuse_minio_data:
langfuse_redis_data:
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