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

fix(lead): Ensure Strict AI Selection via product_ids, Prevent search...

fix(lead): Ensure Strict AI Selection via product_ids, Prevent search fallbacks and add proper Device ID logic
parent 4b7fa72d
...@@ -24,7 +24,7 @@ from langfuse import Langfuse, get_client as get_langfuse ...@@ -24,7 +24,7 @@ from langfuse import Langfuse, get_client as get_langfuse
from common.conversation_manager import get_conversation_manager from common.conversation_manager import get_conversation_manager
from common.langfuse_client import async_flush_langfuse, get_callback_handler from common.langfuse_client import async_flush_langfuse, get_callback_handler
from agent.controller_helpers import load_user_insight_from_redis from agent.controller_helpers import load_user_insight_from_redis, save_user_insight_to_redis
from agent.helper import handle_post_chat_async from agent.helper import handle_post_chat_async
from .graph import get_lead_stage_agent from .graph import get_lead_stage_agent
...@@ -196,9 +196,15 @@ async def lead_stage_chat_controller( ...@@ -196,9 +196,15 @@ async def lead_stage_chat_controller(
) )
# ═══ 5. SAVE CONVERSATION (background) ═══ # ═══ 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 = { response_payload = {
"ai_response": ai_response, "ai_response": ai_response, # Chỉ text của Stylist
"product_ids": products, "product_ids": compact_products, # Compact: SKU + tên (không full object)
"lead_stage": lead_stage, "lead_stage": lead_stage,
} }
...@@ -211,6 +217,27 @@ async def lead_stage_chat_controller( ...@@ -211,6 +217,27 @@ async def lead_stage_chat_controller(
conversation_id=conversation_id, 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 ═══ # ═══ 6. RETURN ═══
return { return {
"status": "success", "status": "success",
......
""" """
Lead Stage Graph — 2-Agent LangGraph Architecture cho phân tích khách hàng. Lead Stage Graph — 2-Agent Architecture (Classifier NHẸ + Stylist NẶNG).
Flow: User → Classifier ⇄ Tools → Stylist → END Flow: User → Classifier (route tool) → [Tool Exec] → Stylist (response + insight) → END
2 con AI: 2 con AI:
- Classifier (Planner + Tool Caller): Xác định lead stage, gọi tools khi cần, - Classifier (NHẸ): with_structured_output(ClassifierOutput) → chỉ quyết định tool
có thể trả lời trực tiếp nếu câu hỏi đơn giản. - Stylist (NẶNG): with_structured_output(StylistOutput) → sinh response + InsightJSON
- Stylist (Responder): KHÔNG gọi tools. Chỉ nhận kết quả + format câu trả lời đẹp.
Trang thí nghiệm — KHÔNG ảnh hưởng production flow.
""" """
import json import json
import logging import logging
import re
import time import time
from typing import Annotated, Any, TypedDict from typing import Annotated, Any, TypedDict
from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage
from langchain_core.runnables import RunnableConfig from langchain_core.runnables import RunnableConfig
from langgraph.graph import END, StateGraph from langgraph.graph import END, StateGraph
from langgraph.graph.message import add_messages from langgraph.graph.message import add_messages
from langgraph.prebuilt import ToolNode from pydantic import BaseModel, Field
from common.llm_factory import create_llm from common.llm_factory import create_llm
from config import DEFAULT_MODEL from config import DEFAULT_MODEL
from .prompts import LEAD_STAGE_CLASSIFIER_PROMPT, STYLIST_SYSTEM_PROMPT, format_stage_injection from .prompts import LEAD_STAGE_CLASSIFIER_PROMPT, STYLIST_SYSTEM_PROMPT, format_insight_injection
from .lead_search_tool import tag_search_tool
# --- Nhập thêm các tools từ agent/tools --- # Tools
from .lead_search_tool import tag_search_tool
from agent.tools.brand_knowledge_tool import canifa_knowledge_search from agent.tools.brand_knowledge_tool import canifa_knowledge_search
from agent.tools.promotion_canifa_tool import canifa_get_promotions from agent.tools.promotion_canifa_tool import canifa_get_promotions
from agent.tools.store_search_tool import canifa_store_search from agent.tools.store_search_tool import canifa_store_search
...@@ -39,350 +36,450 @@ from agent.tools.add_to_cart_tool import add_to_cart ...@@ -39,350 +36,450 @@ from agent.tools.add_to_cart_tool import add_to_cart
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Regex to extract STAGE_JSON: {...} from classifier response
_STAGE_JSON_RE = re.compile(r'STAGE_JSON:\s*(\{.*\})', re.DOTALL)
# ═══════════════════════════════════════════════
# Pydantic Models
# ═══════════════════════════════════════════════
def _extract_text(content) -> str: class ClassifierOutput(BaseModel):
"""Extract plain text from msg.content — handles both str and Gemini list format.""" """Classifier output — Định tuyến hoặc Trả lời Sớm."""
if isinstance(content, str): model_config = {"extra": "ignore"}
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 "")
def _parse_stage_from_text(text: str) -> dict | None: reasoning: str = Field(description="Lý luận gọi tool hay trả lời trực tiếp")
"""Try to parse lead stage JSON from classifier response text.
Supports 2 formats:
1. STAGE_JSON: {...} (inline marker)
2. Pure JSON response (entire response is JSON)
"""
if not text:
return None
# Format 1: STAGE_JSON: {...} # ── TH 1: GỌI TOOL ──
m = _STAGE_JSON_RE.search(text) tool_name: str | None = Field(description="Tên tool (tag_search_tool). Bắt buộc để null nếu khách chỉ đang tâm sự/chào hỏi/ko cần DB.")
if m: tool_args: dict | None = Field(description="Tham số tool. Null nếu tool_name=null.")
try:
result = json.loads(m.group(1))
if "stage" in result and "stage_name" in result:
return result
except (json.JSONDecodeError, TypeError):
pass
# Format 2: Pure JSON
stripped = text.strip()
if stripped.startswith("{"):
# Strip markdown code fences if present
if stripped.startswith("```"):
stripped = re.sub(r'^```\w*\n?', '', stripped)
stripped = re.sub(r'\n?```$', '', stripped).strip()
try:
result = json.loads(stripped)
if "stage" in result and "stage_name" in result:
return result
except (json.JSONDecodeError, TypeError):
pass
return None # ── TH 2: EARLY EXIT ──
ai_response: str | None = Field(description="Câu trả lời cho khách. CHỈ nhả ra khi tool_name=null. Nếu gọi tool, bắt buộc để null.")
product_ids: list[str] = Field(default_factory=list, description="Mã SKU (nếu khách hỏi trúng mã). Bắt buộc phải có để hiện trên frontend.")
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 # State
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
class LeadStageState(TypedDict): class LeadStageState(TypedDict):
messages: Annotated[list[BaseMessage], add_messages] messages: Annotated[list[BaseMessage], add_messages]
# Context data # Input context
user_insight: str | None user_insight: str | None
chat_history_summary: str | None
# Internal routing & stage data # Internal pipeline
tool_result: str | None
tool_name_used: str | None
# Output
updated_insight: dict | None
lead_stage: dict | None lead_stage: dict | None
stage_injection: str | None stage_injection: str | None
classifier_responded: bool # True nếu Classifier trả lời trực tiếp (skip Stylist) product_ids: list[str]
early_exit: bool | None
diagnostics: Annotated[list[dict[str, Any]], lambda x, y: x + y] diagnostics: Annotated[list[dict[str, Any]], lambda x, y: x + y]
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# Graph Builder — 2-Agent Architecture # Helpers
# Classifier (1 call: classify + tools) → Stylist (1 call: format) # ═══════════════════════════════════════════════
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 "")
# ═══════════════════════════════════════════════
# Graph Builder
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
class LeadStageGraph: class LeadStageGraph:
"""2-Agent LangGraph: Classifier ⇄ Tools → Stylist → END.""" """2-Agent: Classifier (NHẸ, route tool) → Stylist (NẶNG, response + insight)."""
def __init__(self, model_name: str | None = None): def __init__(self, model_name: str | None = None):
self.model_name = model_name or DEFAULT_MODEL self.model_name = model_name or DEFAULT_MODEL
self.tools = [
tag_search_tool,
canifa_knowledge_search,
canifa_get_promotions,
canifa_store_search,
check_is_stock,
collect_customer_info,
add_to_cart,
]
# AI 1: Classifier + Tool Caller (streaming off, has tools) # Tool registry
self.classifier_llm = create_llm( self._tool_registry = {
model_name=self.model_name, "tag_search_tool": tag_search_tool,
streaming=False, "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, NHẸ (streaming=False)
_classifier_base = create_llm(model_name=self.model_name, streaming=False)
self.classifier_llm = _classifier_base.with_structured_output(
ClassifierOutput, method="function_calling"
) )
self.classifier_with_tools = self.classifier_llm.bind_tools(self.tools)
# AI 2: Stylist — response only (streaming, NO tools) # AI 2: Stylist — structured output, NẶNG (streaming=False vì structured)
self.stylist_llm = create_llm( _stylist_base = create_llm(model_name=self.model_name, streaming=False)
model_name=self.model_name, self.stylist_llm = _stylist_base.with_structured_output(
streaming=True, StylistOutput, method="function_calling"
) )
self._compiled = None self._compiled = None
logger.info(f"✅ LeadStageGraph initialized | model: {self.model_name}") logger.info(f"✅ LeadStageGraph initialized | model: {self.model_name}")
# ───────────────────────────────────── # ─────────────────────────────────────
# Node 1: CLASSIFIER — 1 LLM call: classify stage + call tools # Helper: Format history
# ─────────────────────────────────────
def _format_history(self, messages: list[BaseMessage], max_turns: int = 4) -> str:
"""Format recent messages cho context."""
lines = []
human_count = 0
for msg in reversed(messages):
if isinstance(msg, HumanMessage):
lines.insert(0, f"Khách: {_extract_text(msg.content)[:200]}")
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)[:200]}")
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":
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"✅ 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]}"
lines.append(line)
return "\n".join(lines)
else:
return parsed.get("message", "Không tìm thấy sản phẩm phù hợp.")
else:
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: 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."""
Classifier AI — 1 LLM call duy nhất:
- Xác định lead stage (qua STAGE_JSON marker)
- Gọi tools nếu cần (search SP, check stock...)
- Hoặc trả lời trực tiếp nếu câu hỏi đơn giản
Lần 2+ (sau tools return): pass-through → route thẳng tới Stylist
"""
messages = state["messages"] messages = state["messages"]
user_query = _extract_text(messages[-1].content) if messages else "" user_query = _extract_text(messages[-1].content) if messages else ""
user_insight = state.get("user_insight") or "Chưa có thông tin." user_insight = state.get("user_insight") or "Chưa có thông tin."
history_summary = state.get("chat_history_summary") or "Chưa có lịch sử."
has_tool_results = any(isinstance(m, ToolMessage) for m in messages)
# ── PASS 2+: Tool results back → skip LLM, go to Stylist ──
if has_tool_results:
logger.info("🔄 Classifier pass-through → Stylist")
return {
"lead_stage": state.get("lead_stage"),
"stage_injection": state.get("stage_injection"),
"classifier_responded": False,
"diagnostics": [],
}
# ── PASS 1: Single LLM call — classify + tools ── history_text = self._format_history(messages[:-1])
context_block = ( context_block = (
f"=== TIN NHẮN HIỆN TẠI ===\n{user_query}\n\n" f"=== TIN NHẮN HIỆN TẠI ===\n{user_query}\n\n"
f"=== USER INSIGHT (tích lũy từ trước) ===\n{user_insight}\n\n" f"=== USER INSIGHT ===\n{user_insight}\n\n"
f"=== TÓM TẮT LỊCH SỬ CHAT ===\n{history_summary}" f"=== LỊCH SỬ ===\n{history_text}"
) )
classifier_messages = [ classifier_messages = [
SystemMessage(content=LEAD_STAGE_CLASSIFIER_PROMPT), SystemMessage(content=LEAD_STAGE_CLASSIFIER_PROMPT),
HumanMessage(content=context_block),
] ]
for msg in messages:
if not isinstance(msg, SystemMessage):
classifier_messages.append(msg)
classifier_messages.append(HumanMessage(content=context_block))
start = time.time() start = time.time()
response = await self.classifier_with_tools.ainvoke(classifier_messages, config=config) try:
output: ClassifierOutput = await self.classifier_llm.ainvoke(
classifier_messages, config=config
)
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 elapsed_ms = (time.time() - start) * 1000
tool_name = output.tool_name
tool_args = output.tool_args or {}
ai_response = output.ai_response
response_text = _extract_text(response.content) # Build diagnostic
has_tool_calls = hasattr(response, "tool_calls") and response.tool_calls diag = {
"step": "classifier",
# ── Parse lead stage from response text (STAGE_JSON: {...}) ── "label": f"🔧 Classifier → {tool_name or 'Early Exit'}",
lead_stage = None "content": output.reasoning,
stage_injection = None "elapsed_ms": round(elapsed_ms),
stage_result = _parse_stage_from_text(response_text) }
if stage_result:
lead_stage = stage_result # ── EARLY EXIT (KHÔNG GỌI TOOL) ──
stage_injection = format_stage_injection(stage_result) if not tool_name and ai_response:
logger.info( logger.info(f"⚡ Early Exit: {ai_response[:50]}...")
f"🎯 Stage: {stage_result.get('stage_name')} " diag["label"] = "⚡ Classifier (Early Exit)"
f"(conf={stage_result.get('confidence')}) | {elapsed_ms:.0f}ms" 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}]
# ── Build diagnostic ── diagnostics = [diag]
diag = {"elapsed_ms": round(elapsed_ms)}
if has_tool_calls:
diag["step"] = "classifier_tool_call"
diag["label"] = "🔧 Classifier (Stage + Tool Call)"
tool_names = ', '.join(tc['name'] for tc in response.tool_calls)
stage_info = f"Stage {lead_stage['stage']}: {lead_stage['stage_name']}" if lead_stage else "Stage: unknown"
diag["content"] = f"{stage_info} | Tools: {tool_names}"
diag["tool_calls"] = [{"name": tc["name"], "args": tc["args"]} for tc in response.tool_calls]
if lead_stage:
diag["raw_json"] = json.dumps(lead_stage, ensure_ascii=False, indent=2)
elif lead_stage:
diag["step"] = "classifier_stage"
diag["label"] = "🧠 Classifier (Stage Only)"
diag["content"] = (
f"Stage {lead_stage.get('stage')}: {lead_stage.get('stage_name')} "
f"(Conf: {lead_stage.get('confidence')})\n"
f"Lý do: {lead_stage.get('reasoning', '')}"
)
diag["raw_json"] = json.dumps(lead_stage, ensure_ascii=False, indent=2)
else:
diag["step"] = "classifier_direct"
diag["label"] = "💬 Classifier (Direct Response)"
diag["content"] = response_text[:500]
# classifier_responded = True → skip Stylist (only for simple greetings) # ── Execute Tool Inline ──
is_direct = not has_tool_calls and not lead_stage 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 { return {
"messages": [response], "tool_result": tool_result,
"lead_stage": lead_stage, "tool_name_used": tool_name,
"stage_injection": stage_injection, "diagnostics": diagnostics,
"classifier_responded": is_direct,
"diagnostics": [diag],
} }
# ───────────────────────────────────── # ─────────────────────────────────────
# Router: Classifier → tools | stylist | end # Node 2: STYLIST — Response + Insight (NẶNG, LUÔN CHẠY)
# ─────────────────────────────────────
def _after_classifier(self, state: LeadStageState) -> str:
"""
Sau Classifier:
- Có tool_calls → đi tools
- Classifier trả lời trực tiếp (greeting, no stage) → end
- Mọi trường hợp khác (có lead_stage, hoặc sau tools) → stylist
"""
last = state["messages"][-1]
if hasattr(last, "tool_calls") and last.tool_calls:
return "tools"
if state.get("classifier_responded"):
return "end"
return "stylist"
# ─────────────────────────────────────
# Node 2: STYLIST — Format response (NO tools)
# ───────────────────────────────────── # ─────────────────────────────────────
async def _stylist_node(self, state: LeadStageState, config: RunnableConfig | None = None) -> dict: 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."""
Stylist AI: Nhận lead stage + tool results, format câu trả lời tư vấn đẹp.
KHÔNG gọi tools — chỉ viết response.
"""
messages = state["messages"] messages = state["messages"]
injection = state.get("stage_injection") or "" 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."
# Build system prompt with stage context
stylist_sys = STYLIST_SYSTEM_PROMPT.format(stage_injection=injection) stylist_sys = STYLIST_SYSTEM_PROMPT.format(stage_injection=injection)
# Collect all tool results to give stylist context # Build messages cho Stylist
tool_context_parts = [] stylist_messages: list[BaseMessage] = [SystemMessage(content=stylist_sys)]
for msg in messages:
if isinstance(msg, ToolMessage): # Recent history (3 turns)
tool_name = getattr(msg, "name", "tool") human_count = 0
tool_content = _extract_text(msg.content) recent: list[BaseMessage] = []
# Parse for cleaner summary for msg in reversed(messages[:-1]):
try:
parsed = json.loads(tool_content)
if parsed.get("status") == "success":
products = parsed.get("products", [])
if products:
product_lines = []
for p in products[:8]:
line = f"- {p.get('name', '')} | {p.get('price', '')} | SKU: {p.get('sku_code', '')}"
if p.get("url"):
line += f" | Link: {p['url']}"
product_lines.append(line)
tool_context_parts.append(
f"[{tool_name}] Tìm thấy {len(products)} sản phẩm:\n" + "\n".join(product_lines)
)
else:
tool_context_parts.append(f"[{tool_name}] {json.dumps(parsed, ensure_ascii=False)[:500]}")
else:
tool_context_parts.append(f"[{tool_name}] {parsed.get('message', tool_content[:300])}")
except Exception:
tool_context_parts.append(f"[{tool_name}] {tool_content[:500]}")
# Build stylist messages
stylist_messages = [SystemMessage(content=stylist_sys)]
# Add user's original question
for msg in messages:
if isinstance(msg, HumanMessage): if isinstance(msg, HumanMessage):
stylist_messages.append(msg) recent.insert(0, msg)
break # Only first human message (the actual query) 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)
# Add tool results as context # Current query (LAST HumanMessage — tin nhắn hiện tại)
if tool_context_parts: current_query = None
tool_summary = "=== KẾT QUẢ TRA CỨU TỪ TOOLS ===\n" + "\n\n".join(tool_context_parts) for msg in reversed(messages):
stylist_messages.append(HumanMessage(content=tool_summary)) 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)")
)
# ── LLM Call: Structured Output ──
start = time.time() start = time.time()
response = await self.stylist_llm.ainvoke(stylist_messages, config=config) try:
output: StylistOutput = await self.stylist_llm.ainvoke(
stylist_messages, config=config
)
except Exception as e:
logger.error(f"❌ Stylist structured output error: {e}")
# Fallback: trả response text thuần
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 elapsed_ms = (time.time() - start) * 1000
response_text = _extract_text(response.content) # Extract insight
insight_dict = output.user_insight.model_dump()
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)
logger.info(
f"💬 Stylist: stage={insight_dict.get('STAGE')} | goal={insight_dict.get('GOAL')} | "
f"response_len={len(output.ai_response)} | products={output.product_ids} | {elapsed_ms:.0f}ms"
)
diag = { diag = {
"step": "stylist", "step": "stylist",
"label": "💬 Stylist AI (Response)", "label": "💬 Stylist (Response + Insight)",
"content": response_text[:500] + ("..." if len(response_text) > 500 else ""), "content": output.ai_response[:500],
"elapsed_ms": round(elapsed_ms), "elapsed_ms": round(elapsed_ms),
"raw_json": json.dumps(output.model_dump(), ensure_ascii=False, indent=2),
} }
return { return {
"messages": [response], "messages": [AIMessage(content=output.ai_response)],
"updated_insight": insight_dict,
"lead_stage": lead_stage,
"stage_injection": stage_injection,
"product_ids": output.product_ids or [],
"diagnostics": [diag], "diagnostics": [diag],
} }
# ───────────────────────────────────── # ─────────────────────────────────────
# Build Graph # 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): def build(self):
"""Build 2-agent graph: Classifier ⇄ Tools → Stylist → END."""
if self._compiled: if self._compiled:
return self._compiled return self._compiled
workflow = StateGraph(LeadStageState) workflow = StateGraph(LeadStageState)
# 3 Nodes
workflow.add_node("classifier", self._classifier_node) workflow.add_node("classifier", self._classifier_node)
workflow.add_node("tools", ToolNode(self.tools))
workflow.add_node("stylist", self._stylist_node) workflow.add_node("stylist", self._stylist_node)
# Flow: Entry → Classifier
workflow.set_entry_point("classifier") workflow.set_entry_point("classifier")
# Classifier → (tools | stylist | end)
workflow.add_conditional_edges( workflow.add_conditional_edges(
"classifier", "classifier",
self._after_classifier, self._after_classifier,
{"tools": "tools", "stylist": "stylist", "end": END}, {"stylist": "stylist", END: END}
) )
# Tools → Classifier (loop back so classifier sees tool results)
workflow.add_edge("tools", "classifier")
# Stylist → END
workflow.add_edge("stylist", END) workflow.add_edge("stylist", END)
self._compiled = workflow.compile() self._compiled = workflow.compile()
logger.info("✅ LeadStageGraph compiled: Classifier ⇄ Tools → Stylist → END") logger.info("✅ LeadStageGraph compiled: Classifier → Stylist → END")
return self._compiled return self._compiled
# ───────────────────────────────────── # ─────────────────────────────────────
# Main Entry Point # Main Entry
# ───────────────────────────────────── # ─────────────────────────────────────
async def chat(self, user_message: str, user_insight: str | None = None, history: list[BaseMessage] | None = None, config: RunnableConfig | None = None) -> dict: async def chat(
""" self,
Main entry point — gửi message và nhận response. user_message: str,
Returns pipeline trace for UI display. user_insight: str | None = None,
""" history: list[BaseMessage] | None = None,
config: RunnableConfig | None = None,
) -> dict:
start = time.time() start = time.time()
graph = self.build() graph = self.build()
...@@ -394,10 +491,11 @@ class LeadStageGraph: ...@@ -394,10 +491,11 @@ class LeadStageGraph:
initial_state = { initial_state = {
"messages": messages, "messages": messages,
"user_insight": user_insight, "user_insight": user_insight,
"chat_history_summary": None, "tool_result": None,
"tool_name_used": None,
"updated_insight": None,
"lead_stage": None, "lead_stage": None,
"stage_injection": None, "stage_injection": None,
"classifier_responded": False,
"diagnostics": [], "diagnostics": [],
} }
...@@ -406,59 +504,46 @@ class LeadStageGraph: ...@@ -406,59 +504,46 @@ class LeadStageGraph:
elapsed_ms = round((time.time() - start) * 1000, 2) elapsed_ms = round((time.time() - start) * 1000, 2)
all_messages = result["messages"] all_messages = result["messages"]
# Extract AI response (last AI message without tool_calls) # Extract AI response
ai_response = "" ai_response = ""
for msg in reversed(all_messages): for msg in reversed(all_messages):
if isinstance(msg, AIMessage) and not (hasattr(msg, "tool_calls") and msg.tool_calls): if isinstance(msg, AIMessage):
ai_response = _extract_text(msg.content) ai_response = _extract_text(msg.content)
break break
# Build pipeline trace # Build pipeline trace
pipeline = [{"step": "user", "label": "👤 User", "content": user_message}] pipeline = [{"step": "user", "label": "👤 User", "content": user_message}]
for diag in result.get("diagnostics", []):
pipeline.append(diag)
if result.get("diagnostics"): # Extract products from tool_result
for diag in result["diagnostics"]:
pipeline.append(diag)
# Parse tool results for product cards
all_products = [] all_products = []
for msg in all_messages: tool_result_raw = result.get("tool_result")
if isinstance(msg, ToolMessage): if tool_result_raw:
tool_content = _extract_text(msg.content) try:
summary = tool_content[:600] parsed = json.loads(tool_result_raw)
raw_json = tool_content if parsed.get("status") == "success":
try: all_products = parsed.get("products", [])
parsed = json.loads(tool_content) except Exception:
if parsed.get("status") == "success": pass
result_count = parsed.get("count", len(parsed.get("products", [])))
tier = parsed.get("tier", 0) # Filter products by AI selection
tier_info = f"[Tier {tier}]" if tier > 0 else "[SKU Direct]" if "product_ids" in result and result["product_ids"] is not None:
summary = f"✅ Tìm thấy {result_count} sản phẩm ({parsed.get('elapsed_ms', '?')}ms) {tier_info}" ai_chosen_ids = result["product_ids"]
products = parsed.get("products", []) filtered_products = []
all_products.extend(products) for pid in ai_chosen_ids:
if products: for p in all_products:
names = [f"• {p.get('name', '')}" for p in products[:5]] psku = str(p.get("sku_code") or p.get("sku") or p.get("internal_ref_code") or "").upper()
summary += "\n" + "\n".join(names) if psku and psku in str(pid).upper():
if len(products) > 5: filtered_products.append(p)
summary += f"\n... và {len(products) - 5} sản phẩm khác" break
elif parsed.get("status") == "error": # LUÔN ghi đè. Nếu Stylist chốt mảng rỗng [] thì Frontend cũng nhận list rỗng (chứ k fallback nhét bừa)
summary = f"❌ {parsed.get('message', 'Error')}" all_products = filtered_products
raw_json = json.dumps(parsed, ensure_ascii=False, indent=2, default=str)
except Exception:
pass
pipeline.append({
"step": "tool_result",
"label": f"📦 Tool Result ({getattr(msg, 'name', 'tool')})",
"content": summary,
"raw_json": raw_json,
})
logger.info( logger.info(
"🏷️ [LEAD GRAPH] query='%s' | stage=%s | products=%d | time=%.2fms", "🏷️ [LEAD] query='%s' | stage=%s | products=%d | time=%.0fms",
user_message[:50], user_message[:50],
result.get("lead_stage", {}).get("stage_name", "UNKNOWN") if result.get("lead_stage") else "DIRECT", result.get("lead_stage", {}).get("stage_name", "?") if result.get("lead_stage") else "?",
len(all_products), len(all_products),
elapsed_ms, elapsed_ms,
) )
...@@ -467,6 +552,7 @@ class LeadStageGraph: ...@@ -467,6 +552,7 @@ class LeadStageGraph:
"response": ai_response, "response": ai_response,
"elapsed_ms": elapsed_ms, "elapsed_ms": elapsed_ms,
"lead_stage": result.get("lead_stage"), "lead_stage": result.get("lead_stage"),
"updated_insight": result.get("updated_insight"),
"pipeline": pipeline, "pipeline": pipeline,
"products": all_products, "products": all_products,
} }
...@@ -480,7 +566,6 @@ _instance: LeadStageGraph | None = None ...@@ -480,7 +566,6 @@ _instance: LeadStageGraph | None = None
def get_lead_stage_agent(model_name: str | None = None) -> LeadStageGraph: def get_lead_stage_agent(model_name: str | None = None) -> LeadStageGraph:
"""Get or create LeadStageGraph singleton."""
global _instance global _instance
if _instance is None: if _instance is None:
_instance = LeadStageGraph(model_name) _instance = LeadStageGraph(model_name)
...@@ -493,24 +578,21 @@ async def run_lead_stage_classifier( ...@@ -493,24 +578,21 @@ async def run_lead_stage_classifier(
user_insight: str | None = None, user_insight: str | None = None,
chat_history_summary: str | None = None, chat_history_summary: str | None = None,
) -> dict: ) -> dict:
""" """Backward compat API."""
(Backward Compatibility API)
Chạy 1 bước classifier node để lấy stage injection, bỏ qua Stylist node.
Được dùng bởi controller chính khi Controller đóng vai trò Stylist (chứa tools, logic cứng).
"""
agent = get_lead_stage_agent() agent = get_lead_stage_agent()
state = { state = {
"messages": [HumanMessage(content=user_query)], "messages": [HumanMessage(content=user_query)],
"user_insight": user_insight, "user_insight": user_insight,
"chat_history_summary": chat_history_summary, "tool_result": None,
"tool_name_used": None,
"updated_insight": None,
"lead_stage": None, "lead_stage": None,
"stage_injection": None, "stage_injection": None,
"classifier_responded": False,
"diagnostics": [], "diagnostics": [],
} }
res = await agent._classifier_node(state) res = await agent._classifier_node(state)
return { return {
"lead_stage": res.get("lead_stage"), "lead_stage": res.get("lead_stage"),
"classifier_latency_ms": res.get("diagnostics", [{}])[0].get("elapsed_ms", 0), "classifier_latency_ms": res.get("diagnostics", [{}])[0].get("elapsed_ms", 0),
"stage_injection": res.get("stage_injection"), "tool_result": res.get("tool_result"),
} }
...@@ -45,7 +45,9 @@ SELECT_COLUMNS = """ ...@@ -45,7 +45,9 @@ SELECT_COLUMNS = """
COALESCE(quantity_sold, 0) AS quantity_sold, COALESCE(quantity_sold, 0) AS quantity_sold,
COALESCE(is_new_product, 0) AS is_new_product, COALESCE(is_new_product, 0) AS is_new_product,
size_scale, size_scale,
description_text description_text,
suggest_items,
similar_items
""" """
...@@ -88,9 +90,12 @@ class TagSearchInput(BaseModel): ...@@ -88,9 +90,12 @@ class TagSearchInput(BaseModel):
product_line_vn: list[str] = Field( product_line_vn: list[str] = Field(
default=[], default=[],
description=( 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'... " "⚠️ BẮT BUỘC — CẤM để trống []. Phải có ít nhất 1 giá trị khi gọi tool.\n"
"Tool sẽ tự động chuẩn hoá từ đồng nghĩa (VD: 'áo thun' → 'Áo phông'). " "Quy tắc CHỌN TÊN DÒNG SẢN PHẨM (Rất quan trọng): \n"
"Nếu khách nói chung chung ('đồ mùa đông', 'đồ tập') hoặc không nhắc, để []. " "1. KHÁCH HỎI CỰC KỲ CỤ THỂ (VD 'áo phông', 'quần jean', 'sịp'): Chốt đúng tên ngách đó. VD: ['Áo phông'] hoặc ['Quần jean'] hoặc ['Quần lót'].\n"
"2. KHÁCH HỎI TỪ KHOÁ NHÓM (VD 'áo', 'quần', 'váy'): Gửi từ KHOÁ GỐC. VD khách hỏi 'áo đi tiệc' → xuất ['Áo']. Hệ thống sẽ tự tìm mọi loại áo (sơ mi, polo, kiểu...), không cần liệt kê dài dòng.\n"
"3. KHÁCH HỎI CHUNG CHUNG BAO LA (VD 'đồ đi biển', 'quần áo nam', 'có mẫu nào mới không'): TUYỆT ĐỐI KHÔNG tự bịa hay ép vào 1 ngách nhỏ. BẮT BUỘC xuất DÀN HẠNG MỤC TỔNG để quét toàn bộ: ['Áo', 'Quần', 'Váy', 'Bộ']. Hệ thống sẽ kết hợp nó với Tags suy luận để vớt đồ phù hợp!\n\n"
"Gợi ý 1 số dòng SP cụ thể trong DB nếu khách cần đích danh: Áo phông, Áo Polo, Áo Sơ mi, Áo nỉ, Áo khoác gió, Áo khoác chần bông, Quần jean, Quần Khaki, Quần dài, Quần soóc, Quần mặc nhà, Váy liền, Chân váy, Bộ mặc nhà, Pyjama, Tất, Mũ..."
), ),
) )
gender_by_product: str | None = Field( gender_by_product: str | None = Field(
...@@ -122,6 +127,17 @@ class TagSearchInput(BaseModel): ...@@ -122,6 +127,17 @@ class TagSearchInput(BaseModel):
default=None, default=None,
description="'new' = hàng mới, 'best_seller' = bán chạy. Chỉ dùng khi khách nói rõ.", description="'new' = hàng mới, 'best_seller' = bán chạy. Chỉ dùng khi khách nói rõ.",
) )
size: str | None = Field(
default=None,
description=(
"Size sản phẩm khách yêu cầu. "
"Người lớn: XS, S, M, L, XL, XXL, 3XL, 4XL. "
"Trẻ em (chiều cao cm): 80, 86, 92, 98, 104, 110, 116, 122, 128, 134, 140, 152, 164. "
"Trẻ sơ sinh (tháng): 1/3, 4/6, 7/9, 10/12, 13/14. "
"VD: 'áo size L' → size='L'. 'quần bé 110' → size='110'. "
"Chỉ điền khi khách NÓI RÕ size. Nếu không nhắc → None."
),
)
magento_ref_code: str | None = Field( magento_ref_code: str | None = Field(
default=None, default=None,
description="Mã SKU cụ thể. Chỉ điền khi khách cung cấp.", description="Mã SKU cụ thể. Chỉ điền khi khách cung cấp.",
...@@ -238,6 +254,13 @@ def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]: ...@@ -238,6 +254,13 @@ def _build_fixed_clauses(req: TagSearchInput, params: list) -> list[str]:
params.append(req.discount_max) params.append(req.discount_max)
clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)") clauses.append(f"(original_price > 0 AND ((original_price - sale_price) / original_price * 100) <= %s)")
# Size (FIND_IN_SET trên cột size_scale dạng 'S|M|L|XL' hoặc '104|110|116')
if req.size:
s = req.size.strip().upper()
params.append(s)
# REPLACE('|', ',') rồi FIND_IN_SET để match chính xác (L ≠ XL)
clauses.append("FIND_IN_SET(%s, REPLACE(size_scale, '|', ',')) > 0")
# Discovery mode # Discovery mode
if req.discovery_mode: if req.discovery_mode:
mode = req.discovery_mode.lower().strip() mode = req.discovery_mode.lower().strip()
...@@ -350,14 +373,37 @@ def _build_full_query(fixed_clauses: list[str], search_clause: str | None) -> st ...@@ -350,14 +373,37 @@ def _build_full_query(fixed_clauses: list[str], search_clause: str | None) -> st
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# 3-Tier Cascading Search # 3-Tier Cascading Search
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]: async def _price_relaxed_search(req: TagSearchInput, db, multiplier: float) -> list:
"""Tìm với giá nới (nhân multiplier), giữ nguyên product_line + gender."""
if req.price_max is None:
return []
new_max = int(req.price_max * multiplier)
saved_max = req.price_max
saved_min = req.price_min
req.price_max = new_max
req.price_min = None # bỏ min để rộng hơn
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
req.price_max = saved_max
req.price_min = saved_min
return products
async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int, str | None]:
""" """
3 tầng fallback: Cascading search với fallback message khi nới giá.
Tầng 1: CỐ ĐỊNH + keywords (từ nguyên văn user)
Tầng 2: CỐ ĐỊNH + tags (AI suy luận → tags chung) Tầng 1: CỐ ĐỊNH + keywords
Tầng 3: CHỈ CỐ ĐỊNH (product_type + color + gender) Tầng 2: CỐ ĐỊNH + tags
Tầng 3: CHỈ CỐ ĐỊNH (product_line + gender + price)
Tầng 4: Drop gender
Tầng 5: Nới giá 1.5x (còn giữ product_line)
Tầng 6: Nới giá 2x
Tầng 7: Bỏ price_max hoàn toàn
Returns: (products, tier_matched) Returns: (products, tier_matched, fallback_message | None)
""" """
# ---- Tầng 1: CỐ ĐỊNH + KEYWORDS ---- # ---- Tầng 1: CỐ ĐỊNH + KEYWORDS ----
...@@ -370,7 +416,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]: ...@@ -370,7 +416,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
products = await db.execute_query_async(sql, params=tuple(params)) products = await db.execute_query_async(sql, params=tuple(params))
if products: if products:
logger.info("🎯 Tầng 1 (keywords) match! %d kết quả", len(products)) logger.info("🎯 Tầng 1 (keywords) match! %d kết quả", len(products))
return products, 1 return products, 1, None
# ---- Tầng 2: CỐ ĐỊNH + TAGS ---- # ---- Tầng 2: CỐ ĐỊNH + TAGS ----
if req.tags: if req.tags:
...@@ -382,7 +428,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]: ...@@ -382,7 +428,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
products = await db.execute_query_async(sql, params=tuple(params)) products = await db.execute_query_async(sql, params=tuple(params))
if products: if products:
logger.info("🎯 Tầng 2 (tags chung) match! %d kết quả", len(products)) logger.info("🎯 Tầng 2 (tags chung) match! %d kết quả", len(products))
return products, 2 return products, 2, None
# ---- Tầng 3: CHỈ CỐ ĐỊNH ---- # ---- Tầng 3: CHỈ CỐ ĐỊNH ----
params = [] params = []
...@@ -391,7 +437,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]: ...@@ -391,7 +437,7 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
products = await db.execute_query_async(sql, params=tuple(params)) products = await db.execute_query_async(sql, params=tuple(params))
if products: if products:
logger.info("🎯 Tầng 3 (chỉ cố định) match! %d kết quả", len(products)) logger.info("🎯 Tầng 3 (chỉ cố định) match! %d kết quả", len(products))
return products, 3 return products, 3, None
logger.info("🎯 Tầng 3 (chỉ cố định) match! 0 kết quả") logger.info("🎯 Tầng 3 (chỉ cố định) match! 0 kết quả")
# ---- Tầng 4: DROP GENDER, chỉ giữ product_line ---- # ---- Tầng 4: DROP GENDER, chỉ giữ product_line ----
...@@ -407,21 +453,132 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]: ...@@ -407,21 +453,132 @@ async def _cascading_search(req: TagSearchInput, db) -> tuple[list, int]:
req.gender_by_product = saved_gender # restore req.gender_by_product = saved_gender # restore
if products: if products:
logger.info("🎯 Tầng 4 (drop gender) match! %d kết quả", len(products)) logger.info("🎯 Tầng 4 (drop gender) match! %d kết quả", len(products))
return products, 4 return products, 4, None
# ---- Tầng 5-7: PRICE RELAXATION (chỉ khi có price_max) ----
return [], 3 if req.price_max is not None and req.product_line_vn:
original_max = req.price_max
product_label = "/".join(req.product_line_vn)
def _format_products(products: list) -> list[dict]:
"""Format output cho AI đọc.""" for multiplier, tier, label in [
(1.5, 5, "1.5x"),
(2.0, 6, "2x"),
]:
new_max = int(original_max * multiplier)
logger.info("🔄 Tầng %d: nới giá %s → %d (gốc: %d)...", tier, label, new_max, original_max)
products = await _price_relaxed_search(req, db, multiplier)
if products:
cheapest_p = min(products, key=lambda p: float(p.get("sale_price") or 999999999))
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: Không có {product_label} dưới {original_max:,.0f}đ. "
f"Mẫu rẻ nhất: \"{cheapest_name}\" giá {cheapest:,.0f}đ "
f"(chỉ thêm {diff:,}đ so với budget). "
f"Tổng có {len(products)} mẫu trong tầm {new_max:,.0f}đ. "
f"→ HÃY gợi ý SP này cho khách và nói khéo: thêm chút xíu là có mẫu rất đẹp!"
)
logger.info("🎯 Tầng %d (price %s) match! %d kết quả | cheapest=%s %d", tier, label, len(products), cheapest_name, cheapest)
return products, tier, msg
# Tầng 7: bỏ price hoàn toàn
logger.info("🔄 Tầng 7: bỏ price_max hoàn toàn...")
saved_max = req.price_max
saved_min = req.price_min
req.price_max = None
req.price_min = None
params = []
fixed = _build_fixed_clauses(req, params)
sql = _build_full_query(fixed, None)
products = await db.execute_query_async(sql, params=tuple(params))
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 999999999))
cheapest = float(cheapest_p.get("sale_price") or 0)
cheapest_name = cheapest_p.get("product_name", "")
msg = (
f"FALLBACK_PRICE: Không có {product_label} dưới {original_max:,.0f}đ. "
f"Mẫu rẻ nhất hiện có: \"{cheapest_name}\" giá {cheapest:,.0f}đ. "
f"Tổng có {len(products)} mẫu. "
f"→ HÃY gợi ý SP này và nói: budget thêm chút là có mẫu rất đáng!"
)
logger.info("🎯 Tầng 7 (no price limit) match! %d kết quả | cheapest=%s %d", len(products), cheapest_name, cheapest)
return products, 7, msg
return [], 3, None
async def _format_products(products: list, db) -> list[dict]:
"""Format output cho AI đọc, đồng thời TỰ ĐỘNG TRA CỨU thông tin suggest/similar items."""
formatted = [] formatted = []
for p in products[:15]:
# 1. Thu thập TẤT CẢ sku từ suggest_items và similar_items
all_related_skus = set()
raw_parsed_map = {}
def parse_item_list(raw_val) -> list[str]:
if not raw_val:
return []
if isinstance(raw_val, list):
return [str(x) for x in raw_val[:3]]
if isinstance(raw_val, str):
try:
parsed = json.loads(raw_val)
if isinstance(parsed, list):
return [str(x) for x in parsed[:3]]
except:
pass
return []
for p in products[:20]:
sku = p.get("magento_ref_code", "")
# Lấy tối đa 2 gợi ý cho mỗi loại để tránh query quá dài
sug_skus = parse_item_list(p.get("suggest_items"))[:2]
sim_skus = parse_item_list(p.get("similar_items"))[:2]
raw_parsed_map[sku] = {
"suggest": sug_skus,
"similar": sim_skus
}
all_related_skus.update(sug_skus)
all_related_skus.update(sim_skus)
# 2. BATCH QUERY lấy thông tin cho các mã liên quan
sku_lookup = {}
if all_related_skus:
# Lấy cơ bản: mã, tên, giá
phs = ", ".join(["%s"] * len(all_related_skus))
q = f"SELECT magento_ref_code, product_name, sale_price FROM {TABLE_NAME} WHERE magento_ref_code IN ({phs})"
related_products = await db.execute_query_async(q, params=tuple(all_related_skus))
for rp in related_products:
rpsku = rp.get("magento_ref_code")
if rpsku:
sku_lookup[rpsku] = {
"sku": rpsku,
"name": rp.get("product_name"),
"price": int(rp.get("sale_price") or 0)
}
# 3. Build kết quả cuối cùng (trả object thay vì raw SKU string)
for p in products[:20]:
sale = float(p.get("sale_price") or 0) sale = float(p.get("sale_price") or 0)
orig = float(p.get("original_price") or 0) orig = float(p.get("original_price") or 0)
has_discount = sale < orig and orig > 0 has_discount = sale < orig and orig > 0
disc_pct = int(p.get("discount_percent") or 0) disc_pct = int(p.get("discount_percent") or 0)
desc = (p.get("description_text") or "").strip()
desc_short = desc[:300] + "..." if len(desc) > 300 else desc
main_sku = p.get("magento_ref_code", "")
parsed_rel = raw_parsed_map.get(main_sku, {"suggest": [], "similar": []})
# Ánh xạ từ SKU string sang object {sku, name, price}
rich_suggest = [sku_lookup[s] for s in parsed_rel["suggest"] if s in sku_lookup]
rich_similar = [sku_lookup[s] for s in parsed_rel["similar"] if s in sku_lookup]
formatted.append({ formatted.append({
"sku": p.get("magento_ref_code", ""), "sku": main_sku,
"name": p.get("product_name", ""), "name": p.get("product_name", ""),
"price": int(sale), "price": int(sale),
"original_price": int(orig), "original_price": int(orig),
...@@ -432,6 +589,9 @@ def _format_products(products: list) -> list[dict]: ...@@ -432,6 +589,9 @@ def _format_products(products: list) -> list[dict]:
"image": p.get("product_image_url_thumbnail", ""), "image": p.get("product_image_url_thumbnail", ""),
"url": p.get("product_web_url", ""), "url": p.get("product_web_url", ""),
"sizes": p.get("size_scale", ""), "sizes": p.get("size_scale", ""),
"description": desc_short,
"suggest_items": rich_suggest, # Đã dịch ra {sku, name, price}
"similar_items": rich_similar, # Đã dịch ra {sku, name, price}
}) })
return formatted return formatted
...@@ -470,13 +630,14 @@ async def tag_search_tool( ...@@ -470,13 +630,14 @@ async def tag_search_tool(
discount_min: int | None = None, discount_min: int | None = None,
discount_max: int | None = None, discount_max: int | None = None,
discovery_mode: str | None = None, discovery_mode: str | None = None,
size: str | None = None,
magento_ref_code: str | None = None, magento_ref_code: str | None = None,
reasoning: str | None = None, reasoning: str | None = None,
) -> str: ) -> str:
""" """
Tìm kiếm sản phẩm CANIFA. Tìm kiếm sản phẩm CANIFA.
Ưu tiên keywords (từ user) trước, nếu không có thì dùng tags (AI suy luận). Ưu tiên keywords (từ user) trước, nếu không có thì dùng tags (AI suy luận).
Hệ thống tự động fallback qua 3 tầng. Hệ thống tự động fallback qua 7 tầng.
""" """
start = time.time() start = time.time()
...@@ -492,20 +653,21 @@ async def tag_search_tool( ...@@ -492,20 +653,21 @@ async def tag_search_tool(
discount_min=discount_min, discount_min=discount_min,
discount_max=discount_max, discount_max=discount_max,
discovery_mode=discovery_mode, discovery_mode=discovery_mode,
size=size,
magento_ref_code=magento_ref_code, magento_ref_code=magento_ref_code,
) )
db = get_db_connection() db = get_db_connection()
try: try:
# SKU lookup (bypass cascade) fallback_msg = None
if req.magento_ref_code: if req.magento_ref_code:
sql, params = _build_sku_query(req.magento_ref_code) sql, params = _build_sku_query(req.magento_ref_code)
products = await db.execute_query_async(sql, params=tuple(params)) products = await db.execute_query_async(sql, params=tuple(params))
tier = 0 tier = 0
else: else:
# 3-tier cascading search # cascading search → returns (products, tier, fallback_message)
products, tier = await _cascading_search(req, db) products, tier, fallback_msg = await _cascading_search(req, db)
elapsed_ms = round((time.time() - start) * 1000, 2) elapsed_ms = round((time.time() - start) * 1000, 2)
...@@ -515,9 +677,9 @@ async def tag_search_tool( ...@@ -515,9 +677,9 @@ async def tag_search_tool(
tier, len(products), elapsed_ms, tier, len(products), elapsed_ms,
) )
formatted = _format_products(products) formatted = await _format_products(products, db)
return json.dumps({ result = {
"status": "success", "status": "success",
"count": len(products), "count": len(products),
"tier": tier, "tier": tier,
...@@ -526,7 +688,11 @@ async def tag_search_tool( ...@@ -526,7 +688,11 @@ async def tag_search_tool(
"keywords_used": keywords or [], "keywords_used": keywords or [],
"tags_used": tags or [], "tags_used": tags or [],
"products": formatted, "products": formatted,
}, ensure_ascii=False, default=str) }
# Thêm fallback_message nếu có (giá nới) — Stylist đọc và upsell tự nhiên
if fallback_msg:
result["fallback_message"] = fallback_msg
return json.dumps(result, ensure_ascii=False, default=str)
except Exception as e: except Exception as e:
logger.error("❌ Search tool error: %s", e) logger.error("❌ Search tool error: %s", e)
......
...@@ -32,7 +32,7 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = { ...@@ -32,7 +32,7 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Áo bra active": ["áo bra active", "áo bra", "bra", "áo tập", "áo thể thao"], "Á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 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"], "Á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 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"],
"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"], "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 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 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ữ"],
...@@ -63,6 +63,9 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = { ...@@ -63,6 +63,9 @@ PRODUCT_LINE_MAP: dict[str, list[str]] = {
"Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà", "đồ ở nhà", "bộ lanh"], "Bộ mặc nhà": ["bộ mặc nhà", "đồ ngủ", "đồ mặc nhà", "đồ ở nhà", "bộ lanh"],
"Blazer": ["blazer", "áo vest", "vest"], "Blazer": ["blazer", "áo vest", "vest"],
"Tất": ["tất", "vớ", "bao chân", "vớ chân", "tất chân"], "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"], "Túi xách": ["túi xách", "túi"],
} }
......
""" """
Prompts cho Lead Stage Agent — 2-Agent Architecture. Prompts cho Lead Stage Agent — 2-Agent Architecture.
CHỈ 2 PROMPTS: AI #1 — Classifier (NHẸ): Chỉ route tool, không sinh insight
AI #1 — Classifier: Xác định lead stage + gọi tools (1 LLM call duy nhất) AI #2 — Stylist (NẶNG): Viết response + sinh/update InsightJSON
AI #2 — Stylist: Nhận kết quả → format câu trả lời đẹp (1 LLM call)
""" """
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# AI #1: Classifier — Classify stage + Call tools (1 call) # AI #1: Classifier — TOOL ROUTER (NHẸ, chỉ quyết định gọi tool nào)
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
LEAD_STAGE_CLASSIFIER_PROMPT = """\ LEAD_STAGE_CLASSIFIER_PROMPT = """\
Bạn là AI Classifier & Planner cho chatbot bán hàng CANIFA — thương hiệu thời trang hàng đầu Việt Nam. Bạn là AI Tool Router cho chatbot CANIFA — thương hiệu thời trang gia đình Việt Nam.
## 2 NHIỆM VỤ TRONG 1 LẦN TRẢ LỜI: ## 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**.
### NHIỆM VỤ 1: XÁC ĐỊNH LEAD STAGE KHÔNG viết response, KHÔNG tư vấn, KHÔNG sinh insight — chỉ route tool.
Phân tích tin nhắn và xác định khách đang ở giai đoạn nào:
---
| Stage | Tên | Trigger Signals |
|-------|----------------|--------------------------------------------------------------------------| ### Tools có sẵn:
| 1 | AWARENESS | Chào hỏi, "có gì mới?", "shop bán gì?", first-time visit |
| 2 | INTEREST | Hỏi cụ thể loại SP, cho info giới tính/tuổi, "tìm X cho Y" | **`tag_search_tool`** — Tìm sản phẩm CANIFA
| 3 | CONSIDERATION | Hỏi chi tiết (chất liệu, size chart), so sánh 2+ SP, "mẫu nào tốt hơn?" | ⚡ GỌI NGAY KHI: khách nói bất kỳ từ nào liên quan đến tìm SP.
| 4 | DECISION | "Lấy cái này", "chốt đơn", "mua luôn", "order", hỏi cách thanh toán |
| 5 | RETENTION | Hỏi đổi trả, tracking, mua thêm, quay lại sau thời gian | ⚠️ **`product_line_vn` là TRƯỜNG BẮT BUỘC** — PHẢI điền ít nhất 1 giá trị.
Nếu không map được từ khách hàng → KHÔNG gọi tool, để tool_name = null.
Quy tắc: Stage có thể nhảy/giảm. Không chắc → chọn thấp hơn.
📋 DANH SÁCH product_line_vn HỢP LỆ (Mới nhất):
### NHIỆM VỤ 2: GỌI TOOLS HOẶC TRẢ LỜI Bạn có thể dùng CỤM GỐC hoặc TÊN NGÁCH:
- Cần tra cứu → gọi tool phù hợp - CỤM GỐC (Dùng khi tìm bao quát): "Áo" | "Quần" | "Váy" | "Bộ"
- Câu hỏi đơn giản (chào hỏi, cảm ơn) → trả lời ngắn gọn luôn - TÊN NGÁCH (Dùng khi khách chỉ đích danh):
+ Nhóm Áo: "Áo Sơ mi" | "Áo Polo" | "Áo phông" | "Áo nỉ" | "Áo len" | "Áo kiểu" | "Áo lót" | "Áo khoác gió" | "Áo khoác chần bông" | "Áo dạ" | "Blazer"
Tools: + Nhóm Quần: "Quần jean" | "Quần Khaki" | "Quần dài" | "Quần soóc" | "Quần lót" | "Quần lót đùi" | "Quần lót tam giác"
- 🛍️ `tag_search_tool` — Tìm quần áo, váy, đồ đi làm, mặc nhà... + Nhóm Váy: "Váy liền" | "Chân váy"
- 📦 `check_is_stock` — Check size/màu còn hàng (cần SKU, color, size) + Nhóm Bộ: "Bộ mặc nhà" | "Bộ thể thao" | "Pyjama"
- 🏬 `canifa_store_search` — Tìm cửa hàng (tỉnh/thành phố) + Phụ kiện: "Tất" | "Mũ" | "Khăn" | "Túi xách"
- ❓ `canifa_knowledge_search` — Chính sách (ship, đổi trả, VIP...)
- 🎁 `canifa_get_promotions` — Khuyến mãi, sale, event 🔄 QUY TẮC CHỌN product_line_vn:
- 📝 `collect_customer_info` — Đăng ký tư vấn, gửi SĐT/Email 1. KHÁCH NÓI CỤ THỂ ("tìm áo phông", "quần jean") → Chốt tên ngách: ["Áo phông"], ["Quần jean"]
- 🛒 `add_to_cart` — Chốt mua SP cụ thể (đã biết mã, màu, size) 2. KHÁCH NÓI NHÓM CƠ BẢN ("áo đi làm", "quần nữ") → Chốt CỤM GỐC: ["Áo"], ["Quần"]
3. KHÁCH NÓI RẤT BAO LA ("đồ đi biển", "quần áo mới") → Bắt buộc thả toàn bộ CỤM GỐC để vớt sạch: ["Áo", "Quần", "Váy", "Bộ"]
## CÁCH OUTPUT: 4. Synonym: đầm → "Váy liền", sịp/đồ lót dưới → "Quần lót", hoodie → "Áo nỉ có mũ"
- Nếu CẦN gọi tool → gọi tool (hệ thống sẽ tự xử lý)
- Nếu KHÔNG cần tool → trả lời text ngắn gọn cho khách Args (product_line_vn BẮT BUỘC, còn lại optional):
- Dù gọi tool hay trả lời text, HÃY bao gồm stage JSON ở CUỐI response: product_line_vn: [] // ⚠️ BẮT BUỘC ≥1. VD: ["Áo Polo"], ["Quần lót", "Quần lót đùi"]
keywords: [] // Từ khoá ĐẶC TRƯNG của sản phẩm (max 2). KHÔNG phải câu hỏi!
STAGE_JSON: {"stage": <1-5>, "stage_name": "<tên>", "confidence": <0-1>, "reasoning": "<lý do>", "tone_directive": "<Friendly|Consultant|Expert|Closer|CareAgent>", "behavioral_hints": ["hint1"]} // ✅ ["cotton"], ["ống rộng"], ["30/4"], ["cờ đỏ"], ["oversize"]
// ❌ ["tìm cho tao cái áo nữ"] — đây là CÂU, không phải keyword!
// ❌ ["áo polo"] — đây là product_line, đã có ở trường trên!
// Nếu khách chỉ nói loại SP + giới tính → keywords = []
tags: [] // Suy luận intent (max 3). Chọn từ:
// 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
// wthr:mua_he, wthr:mua_dong, wthr:giao_mua
// func:thoang_mat, func:giu_am, func:nhanh_kho, func:chong_uv
// style:thanh_lich, style:basic, style:smart_casual, style:toi_gian, style:nang_dong
// fit:slim, fit:regular, fit:oversize, fit:wide_leg, fit:cropped
gender_by_product: null // ⚠️ BẮT BUỘC suy luận từ ngữ cảnh:
// "nữ"/"cho vợ"/"con gái" → "women"
// "nam"/"cho chồng"/"con trai" → "men"
// "bé trai" → "boy", "bé gái" → "girl"
// Chỉ để null khi THỰC SỰ không có manh mối giới tính
age_by_product: null // "adult"|"kid" — suy luận: "trẻ em"/"bé" → "kid"
master_color: null
price_min: null
price_max: null
size: null
magento_ref_code: null
reasoning: ""
**`canifa_knowledge_search`** — Chính sách đổi trả, bảo hành, thông tin thương hiệu
Args: { "query": "chính sách đổi trả CANIFA" }
⚠️ KHÔNG dùng để hỏi size — SP đã có trường Sizes sẵn.
**`check_is_stock`** — Kiểm tra tồn kho
Args: { "sku_code": "8TP25S009", "color_code": "SB138", "size": "L" }
**`canifa_store_search`** — Tìm cửa hàng
Args: { "location": "Hà Nội" }
**`canifa_get_promotions`** — Khuyến mãi đang chạy
Args: {}
**`collect_customer_info`** — Thu thập SĐT/Email
Args: { "phone": null, "email": null }
**`add_to_cart`** — Thêm vào giỏ hàng
Args: { "sku_code": "...", "color_code": "...", "size": "...", "quantity": 1 }
---
### QUY TẮC:
1. `product_line_vn` BẮT BUỘC ≥1 — KHÔNG BAO GIỜ để trống []
2. `gender_by_product` — PHẢI suy luận từ ngữ cảnh! "áo nữ" → "women", "quần nam" → "men". CẤM để null khi khách nói rõ!
3. `keywords` — CHỈ chứa từ khoá đặc trưng SP (cotton, ống rộng, oversize). KHÔNG chứa cả câu hỏi!
4. VD: "tìm cho tao cái áo polo nữ" → product_line_vn=["Áo Polo"], gender="women", keywords=[]
5. VD: "áo thun cotton oversize nam" → product_line_vn=["Áo phông"], gender="men", keywords=["cotton","oversize"]
6. VD: "sịp nam" → product_line_vn=["Quần lót","Quần lót đùi","Quần lót tam giác"], gender="men"
7. VD: "áo đi làm" → product_line_vn=["Áo"], tags=["occ:di_lam"], gender=null
8. VD: "có đồ gì mới không" → product_line_vn=["Áo","Quần","Váy","Bộ"], discovery_mode="new"
8. Mã SKU (VD: "5TP25S005" hoặc "5TP25S005-SA090"):
→ Lấy PHẦN TRƯỜC DẤU GẠCH (base code) làm magento_ref_code
→ "5TP25S005-SA090" → magento_ref_code="5TP25S005"
→ product_line_vn không cần khi có SKU
9. Hỏi size SP đã show → tool_name = null → Stylist tự xử
10. Khách nói "chọn mẫu này"/"lấy cái này" + SKU → gọi tag_search_tool với magento_ref_code để lấy full info 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 không có ngụ ý tìm/mua sản phẩm:
→ BẮT BUỘC để `tool_name` = null
→ BẮT BUỘC điền câu chào/đáp trả thân thiện vào trường `ai_response` (Tối đa 2 câu)
→ `product_ids` = []
- ⚠️ Nếu đã điền `ai_response`, TUYỆT ĐỐI không gọi tool!
""" """
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# AI #2: Stylist — Format response (NO tools) # AI #2: Stylist — Response + Insight Generation (NẶNG)
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
STYLIST_SYSTEM_PROMPT = """\ STYLIST_SYSTEM_PROMPT = """\
Bạn là Trợ lý phong cách (AI Stylist) độc quyền của CANIFA — thương hiệu thời trang hàng đầu Việt Nam. Bạn là AI Stylist độc quyền của CANIFA — thương hiệu thời trang hàng đầu Việt Nam.
## VAI TRÒ DUY NHẤT CỦA BẠN: ## 3 NHIỆM VỤ (output JSON đúng schema):
Nhận kết quả từ AI Classifier (lead stage + tool results) và FORMAT thành câu trả lời tư vấn
đẹp, khéo léo, phù hợp với giai đoạn mua hàng của khách.
## BẠN KHÔNG ĐƯỢC: ### NHIỆM VỤ 1 — Viết `ai_response` (câu trả lời cho khách):
- Gọi thêm tools Tư vấn thời trang chuyên nghiệp, thân thiện, bám sát sản phẩm thực. Bạn chỉ được phép review các sản phẩm CÓ SẴN trong khung KẾT QUẢ TRA CỨU. KHÔNG ĐƯỢC TỰ BỊA sản phẩm chung chung ảo tưởng bên ngoài.
- Tự bịa sản phẩm
- Thay đổi lead stage
## QUY TẮC TƯ VẤN: ### NHIỆM VỤ 2 — Chốt `product_ids` (HIỂN THỊ LÊN GIAO DIỆN):
- Luôn khen ngợi hoặc đồng tình với gu thẩm mỹ của khách. Trong câu `ai_response` bạn khen hoặc gợi ý sản phẩm nào, thì BẮT BUỘC phải copy y hệt mã SKU của nó nhét vào mảng `product_ids`. Giao diện web của khách Đọc mảng này để vẽ hình ảnh sản phẩm.
- Khi giới thiệu SP: Gợi ý cách phối đồ (mix & match), mô tả cảm giác khi mặc. - Nếu bạn nhắc 3 mã trong câu trả lời → Mảng này phải có đủ 3 mã.
- Không trả lời cộc lốc danh sách SP. Nói như người tư vấn tại shop đang trò chuyện vui vẻ. - Chỉ điền mã SKU (VD: "8US25A003"), không viết lằng nhằng.
- Sản phẩm nào có link → bắt buộc đính kèm link để khách click xem. - Nếu không có SP nào được gợi ý → để mảng rỗng `[]`.
### NHIỆM VỤ 3 — Sinh `user_insight` (InsightJSON — bộ nhớ khách hàng):
Cập nhật 12 trường dưới đây sau mỗi turn. Cộng dồn, KHÔNG xóa trừ khi khách đổi ý.
---
## [INSIGHT SCHEMA] — 12 trường bắt buộc:
**USER** — ADN người đang chat: Giới tính + Adult/Kid + Style. Chưa rõ → "Chưa rõ"
**TARGET** — Ai sẽ mặc SP: Quan hệ + Giới tính + Adult/Kid. VD: "Chính mình (Nam, Adult)"
**GOAL** — Mục tiêu: Loại SP + Dịp. VD: "Áo polo nam đi chơi"
**CONSTRAINS** — CHỈ điền khi khách NÓI RÕ ("dưới 500k", "size L", "không thích cổ đức"). KHÔNG TỰ BỊ! Chưa có → ""
**STAGE** — BROWSE | CONSIDER | DECIDE | UPSELL
**STAGE_NUM** — BROWSE=1, CONSIDER=2, DECIDE=3, UPSELL=4
**TONE** — Friendly | Consultant | Expert | Closer | CareAgent
**BEHAVIORAL_HINTS** — list[str], 1-3 gợi ý cho turn sau
**LATEST_PRODUCT_INTEREST** — "[SKU] Tên giá | Size | Color | Status"
**LAST_ACTION** — Vừa làm gì (1 câu). VD: "Tìm áo polo nam đi chơi"
**SUMMARY_HISTORY** — Tóm tắt theo turn (thêm mới, không viết lại hết)
---
## [RESPONSE RULES] — Cách viết câu trả lời:
{stage_injection} {stage_injection}
### 🔍 DISCOVERY (có tool_result, query chung):
- Show **4-6 sản phẩm** đa dạng
- Mỗi SP PHẢI có **LÝ DO CỤ THỂ** tại sao recommend — lấy từ description thực:
✅ "Cotton 100% thoáng mát, form regular gọn gàng — hợp đi chơi cuối tuần"
✅ "Đang giảm 30% còn 279k, chất liệu CoolMax nhanh khô — deal tốt cho mùa hè"
❌ "Sản phẩm đẹp", "Chất lượng tốt" (CẤM — quá chung chung)
- Kết thúc: hỏi 1 câu refine (màu, size, dịp, budget)
### 🎯 DEEP DIVE (query cụ thể):
- Show **2-3 SP phù hợp nhất**
- Mỗi SP: **Tên + giá + mô tả chi tiết** (chất liệu, tính năng, phù hợp ai) — từ description
- Kết thúc: gợi ý chốt hoặc hỏi size/màu
### 💬 NO TOOL (chào hỏi, không có tool_result):
- Chào thân thiện, hỏi khách muốn tìm gì
- NGẮN GỌN — tối đa 2-3 câu
### ✅ CHỐT ĐƠN (khách nói "chọn mẫu này", "lấy cái này", nhắc SKU):
- Xác nhận lại SP: tên, giá, chi tiết từ description (chất liệu, form, tính năng)
- Hỏi **2 thứ** để chốt: **Size** + **Màu** (nếu chưa biết)
- Gợi ý size dựa trên chiều cao/cân nặng nếu khách đã cung cấp trước đó
- Gợi ý SP PHỐI KÈM từ `suggest_items` / `similar_items` nếu có trong tool_result:
"Áo polo này phối với quần khaki chíno sẽ gọn gàng lắm — mình tìm giúp nhé?"
- STAGE phải = DECIDE
### 📏 TƯ VẤN SIZE — PHẢI CÓ LÝ LUẬN CHI TIẾT:
Bảng size tham khảo CANIFA (người lớn):
XS: 1m50-1m55, 40-45kg
S: 1m55-1m60, 45-52kg
M: 1m60-1m67, 52-60kg
L: 1m67-1m73, 60-68kg
XL: 1m73-1m78, 68-75kg
XXL: 1m78+, 75kg+
Khi khách cho chiều cao/cân nặng, PHẢI trả lời theo mẫu:
✅ "Với chiều cao 1m7, bạn nằm trong phổ size L (1m67-1m73, 60-68kg). Nếu bạn nặng khoảng 60-65kg → **chọn L** sẽ vừa vặn. Nếu bạn gầy hơn (~55kg) thì M cũng được vì form sẽ ôm gọn hơn."
❌ "Bạn nên chọn size L" (CẤM — không giải thích)
SP trong tool_result/history đã có trường `Sizes` → kiểm tra size recommend có sẵn không.
Nếu size L không có trong `Sizes` → nói rõ: "Size L không còn, gần nhất là XL"
---
## ⚠️ 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 / history
- Mở đầu bằng 1 câu đồng cảm dựa trên GOAL (từ insight)
""" """
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
# Template inject stage context vào Stylist prompt # Template inject user insight cũ vào prompt
# ═══════════════════════════════════════════════ # ═══════════════════════════════════════════════
LEAD_STAGE_INJECTION_TEMPLATE = """\ INSIGHT_INJECTION_TEMPLATE = """\
══════ LEAD STAGE CONTEXT ══════ ══════ CUSTOMER INSIGHT (từ turn trước) ══════
🎯 Giai đoạn khách hàng: Stage {stage} — {stage_name} 👤 User: {user}
🎭 Tone yêu cầu: {tone_directive} 🎁 Target: {target}
📊 Confidence: {confidence} 🛍️ Goal: {goal}
🔒 Constraints: {constrains}
📋 Hướng dẫn hành vi: 🎯 Stage: {stage_num} — {stage} | Tone: {tone}
{behavioral_hints_text} 📦 Latest Interest: {latest_product}
📝 History: {summary}
⚠️ QUAN TRỌNG: Điều chỉnh phong cách tư vấn theo Stage trên. ══════════════════════════════"""
- Stage 1 (AWARENESS): Thân thiện, relaxed, KHÔNG push bán. Hỏi nhẹ về nhu cầu.
- Stage 2 (INTEREST): Bắt đầu thu thập thông tin (size, budget, occasion). Show 2-3 SP phù hợp.
- Stage 3 (CONSIDERATION): So sánh chuyên sâu, phân tích ưu/nhược. Đưa khuyến nghị rõ ràng. def format_insight_injection(insight: dict) -> str:
- Stage 4 (DECISION): Confirm đơn hàng nhanh gọn. Upsell tự nhiên. Tạo urgency nhẹ. """Format InsightJSON dict thành đoạn text inject vào prompt."""
- Stage 5 (RETENTION): Chăm sóc hậu mãi. Gợi ý SP mới dựa trên lịch sử. 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: def format_stage_injection(stage_result: dict) -> str:
"""Format lead stage result thành đoạn text inject vào system prompt của AI #2.""" return format_insight_injection(stage_result)
hints = stage_result.get("behavioral_hints", [])
hints_text = "\n".join(f" • {h}" for h in hints) if hints else " • Không có gợi ý cụ thể."
return LEAD_STAGE_INJECTION_TEMPLATE.format(
stage=stage_result.get("stage", "?"),
stage_name=stage_result.get("stage_name", "UNKNOWN"),
tone_directive=stage_result.get("tone_directive", "Consultant"),
confidence=stage_result.get("confidence", 0),
behavioral_hints_text=hints_text,
)
...@@ -2,16 +2,16 @@ ...@@ -2,16 +2,16 @@
Lead Flow — Lead Stage AI Experiment Lead Flow — Lead Stage AI Experiment
═══════════════════════════════════════ */ ═══════════════════════════════════════ */
body { margin:0; min-height:100vh; background:var(--background); font-family:var(--font-sans); color:var(--foreground); } html { height: 100%; margin: 0; padding: 0; }
body { margin:0; height:100%; overflow:hidden; display:flex; flex-direction:column; background:var(--background); font-family:var(--font-sans); color:var(--foreground); }
/* ═══ STAGE PROGRESS BAR ═══ */ /* ═══ STAGE PROGRESS BAR ═══ */
.stage-bar { .stage-bar {
background: var(--card); background: var(--card);
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
padding: 12px 20px; padding: 12px 20px;
position: sticky;
top: 0;
z-index: 50; z-index: 50;
flex-shrink: 0;
} }
.stage-bar-inner { .stage-bar-inner {
...@@ -59,6 +59,24 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -59,6 +59,24 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
box-shadow: var(--shadow-md); box-shadow: var(--shadow-md);
} }
.history-select {
margin-left: 10px;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid var(--border);
outline: none;
font-size: 13px;
background: var(--card);
color: var(--fg);
font-weight: 500;
cursor: pointer;
box-shadow: 0 1px 2px rgba(0,0,0,0.05);
}
.history-select:hover {
border-color: #bbb;
}
.stage-label { .stage-label {
font-size: 10px; font-size: 10px;
font-weight: 600; font-weight: 600;
...@@ -107,12 +125,16 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -107,12 +125,16 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
/* ═══ MAIN LAYOUT ═══ */ /* ═══ MAIN LAYOUT ═══ */
.app-container { .app-container {
flex: 1;
min-height: 0;
width: 100%;
display: grid; display: grid;
grid-template-columns: 1fr 340px; grid-template-columns: 1fr 340px;
grid-template-rows: 1fr;
gap: 0; gap: 0;
max-width: 1100px; max-width: 1100px;
margin: 0 auto; margin: 0 auto;
height: calc(100vh - 56px); overflow: hidden;
} }
/* ═══ CHAT PANEL ═══ */ /* ═══ CHAT PANEL ═══ */
...@@ -121,15 +143,19 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -121,15 +143,19 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
flex-direction: column; flex-direction: column;
border-right: 1px solid var(--border); border-right: 1px solid var(--border);
background: var(--card); background: var(--card);
min-height: 0;
overflow: hidden;
} }
.chat-messages { .chat-messages {
flex: 1; flex: 1;
min-height: 0;
overflow-y: auto; overflow-y: auto;
padding: 20px 24px; padding: 20px 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
scroll-behavior: smooth;
} }
.msg { .msg {
...@@ -256,6 +282,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -256,6 +282,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
padding: 14px 20px; padding: 14px 20px;
border-top: 1px solid var(--border); border-top: 1px solid var(--border);
background: var(--card); background: var(--card);
flex-shrink: 0;
} }
.input-wrapper { .input-wrapper {
...@@ -307,6 +334,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var ...@@ -307,6 +334,7 @@ body { margin:0; min-height:100vh; background:var(--background); font-family:var
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
min-height: 0;
} }
.debug-title { .debug-title {
......
/* ═══════════════════════════════════════
Lead Flow — Product Size Picker Popup
═══════════════════════════════════════ */
/* ── Popup Overlay ── */
.lf-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,.55);
z-index: 9000;
opacity: 0;
pointer-events: none;
transition: opacity .25s ease;
backdrop-filter: blur(3px);
}
.lf-overlay.active {
opacity: 1;
pointer-events: auto;
}
/* ── Size Picker Popup ── */
.lf-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -44%) scale(.94);
z-index: 9001;
background: #1e2028;
border: 1px solid rgba(255,255,255,.08);
border-radius: 16px;
max-width: 440px;
width: 92vw;
max-height: 88vh;
overflow-y: auto;
box-shadow: 0 24px 64px rgba(0,0,0,.55);
opacity: 0;
pointer-events: none;
transition: all .28s cubic-bezier(.4,0,.2,1);
}
.lf-popup.active {
opacity: 1;
pointer-events: auto;
transform: translate(-50%, -50%) scale(1);
}
.lf-popup::-webkit-scrollbar { width: 4px; }
.lf-popup::-webkit-scrollbar-thumb { background: rgba(255,255,255,.1); border-radius: 4px; }
/* ── Popup Header ── */
.lf-popup-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 1px solid rgba(255,255,255,.07);
}
.lf-popup-header h3 {
margin: 0;
font-size: 13px;
font-weight: 700;
letter-spacing: .6px;
color: #fff;
text-transform: uppercase;
}
.lf-popup-close {
width: 30px; height: 30px;
border: none;
background: rgba(255,255,255,.08);
color: #aaa;
font-size: 16px;
cursor: pointer;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
transition: all .2s;
}
.lf-popup-close:hover { background: rgba(255,255,255,.16); color: #fff; transform: rotate(90deg); }
/* ── Popup Body ── */
.lf-popup-body { padding: 20px; }
/* ── Product Info Row ── */
.lf-product-row {
display: flex;
gap: 14px;
margin-bottom: 20px;
background: rgba(0,0,0,.2);
padding: 12px;
border-radius: 12px;
}
.lf-product-img {
width: 76px; height: 96px;
object-fit: cover;
border-radius: 8px;
background: #fff;
flex-shrink: 0;
}
.lf-product-info { flex: 1; min-width: 0; }
.lf-product-name {
color: #f0f0f0;
font-size: 13.5px;
font-weight: 600;
line-height: 1.45;
margin-bottom: 6px;
}
.lf-product-sku {
color: #888;
font-size: 11px;
font-family: 'Consolas', monospace;
background: rgba(255,255,255,.05);
display: inline-block;
padding: 2px 6px;
border-radius: 4px;
margin-bottom: 8px;
}
.lf-product-price {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
}
.lf-price-sale { color: #be1e2d; font-weight: 700; font-size: 15px; }
.lf-price-orig { color: #666; font-size: 12px; text-decoration: line-through; }
.lf-price-badge {
background: rgba(190,30,45,.15);
color: #ff6b6b;
font-size: 10px;
font-weight: 700;
padding: 2px 6px;
border-radius: 4px;
}
/* ── Sections ── */
.lf-section { margin-bottom: 18px; }
.lf-section-label {
color: #aaa;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .5px;
margin-bottom: 10px;
}
/* ── Color Swatches ── */
.lf-colors { display: flex; gap: 8px; flex-wrap: wrap; }
.lf-color-dot {
width: 28px; height: 28px;
border-radius: 50%;
border: 3px solid transparent;
cursor: pointer;
box-shadow: 0 2px 5px rgba(0,0,0,.4);
transition: all .2s;
}
.lf-color-dot.active { border-color: #be1e2d; transform: scale(1.12); }
/* ── Size Buttons ── */
.lf-sizes { display: flex; flex-wrap: wrap; gap: 8px; }
.lf-size-btn {
background: #2a2c35;
border: 1px solid #383a45;
border-radius: 10px;
padding: 9px 14px;
color: #ddd;
cursor: pointer;
font-weight: 600;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
min-width: 60px;
transition: all .2s;
font-family: inherit;
}
.lf-size-btn:hover:not(:disabled) { background: rgba(255,255,255,.1); border-color: #555; }
.lf-size-btn.selected { background: rgba(190,30,45,.15); border-color: #be1e2d; color: #ff7675; }
.lf-size-btn:disabled { opacity: .38; cursor: not-allowed; }
.lf-size-label { font-size: 13px; }
.lf-size-qty { font-size: 10px; font-weight: 500; }
.lf-size-qty.in-stock { color: #00d26a; }
.lf-size-qty.no-stock { color: #ff4757; }
.lf-size-stock-loading { color: #666; font-size: 12px; padding: 8px 0; }
/* ── Add to Cart Button ── */
.lf-add-btn {
width: 100%;
padding: 15px;
background: #444;
color: #888;
border: none;
border-radius: 12px;
font-weight: 800;
font-size: 13px;
letter-spacing: .5px;
cursor: pointer;
margin-top: 8px;
transition: all .2s;
font-family: inherit;
}
.lf-add-btn.ready {
background: linear-gradient(135deg, #be1e2d, #f03e3e);
color: #fff;
box-shadow: 0 6px 20px rgba(190,30,45,.35);
cursor: pointer;
}
.lf-add-btn.ready:hover { background: linear-gradient(135deg, #d32f2f, #fc5c65); transform: translateY(-1px); }
/* ── Detail link ── */
.lf-detail-link {
display: block;
text-align: center;
font-size: 12px;
color: #5b9cf6;
text-decoration: none;
margin-top: 12px;
transition: color .2s;
}
.lf-detail-link:hover { color: #93c5fd; text-decoration: underline; }
/* ── Loading spinner ── */
.lf-spinner {
display: inline-block;
width: 14px; height: 14px;
border: 2px solid rgba(255,255,255,.15);
border-top-color: #be1e2d;
border-radius: 50%;
animation: lf-spin 0.8s linear infinite;
vertical-align: middle;
margin-right: 6px;
}
@keyframes lf-spin { to { transform: rotate(360deg); } }
/* ── Product card clickable ── */
.product-card.clickable {
cursor: pointer;
text-decoration: none;
color: inherit;
display: flex;
flex-direction: column;
}
.product-card.clickable:hover {
border-color: #be1e2d;
transform: translateY(-2px);
box-shadow: 0 4px 14px rgba(0,0,0,.12);
}
<!DOCTYPE html>
<html lang="vi">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Lead Stage AI — Canifa AI</title>
<!-- Base styles -->
<link rel="stylesheet" href="/static/css/theme.css">
<link rel="stylesheet" href="/static/css/components.css">
<link rel="stylesheet" href="/static/css/lead-flow.css">
<link rel="stylesheet" href="/static/css/cart.css">
<!-- Lead-flow local styles (popup + card) -->
<link rel="stylesheet" href="/static/lead_flow/lead-flow.css">
<!-- Frame detection + Markdown -->
<script src="/static/js/frame-detect.js"></script>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
</head>
<body>
<!-- ═══ STAGE PROGRESS BAR ═══ -->
<div class="stage-bar">
<div class="stage-bar-inner">
<span class="logo">CANIFA AI</span>
<select id="historySelect" class="history-select" onchange="switchConversation(this.value)">
<option value="">-- Mở phiên Chat Mới --</option>
</select>
<div style="display: flex; gap: 5px; align-items: center; margin-left: auto; padding-right: 15px;">
<label style="font-size: 0.8em; color: rgba(255,255,255,0.7);">Device ID:</label>
<input type="text" id="deviceIdInput"
style="width: 130px; padding: 4px 8px; border-radius: 4px; border: 1px solid #444; background: #222; color: #fff; font-size: 0.85em;"
onchange="handleDeviceIdChange()">
</div>
<div class="stage-node stage-1" id="stageNode1">
<div class="stage-dot" id="stageDot1">1</div>
<span class="stage-label" id="stageLabel1">Awareness</span>
</div>
<div class="stage-connector conn-1" id="conn1"><div class="fill"></div></div>
<div class="stage-node stage-2" id="stageNode2">
<div class="stage-dot" id="stageDot2">2</div>
<span class="stage-label" id="stageLabel2">Interest</span>
</div>
<div class="stage-connector conn-2" id="conn2"><div class="fill"></div></div>
<div class="stage-node stage-3" id="stageNode3">
<div class="stage-dot" id="stageDot3">3</div>
<span class="stage-label" id="stageLabel3">Consideration</span>
</div>
<div class="stage-connector conn-3" id="conn3"><div class="fill"></div></div>
<div class="stage-node stage-4" id="stageNode4">
<div class="stage-dot" id="stageDot4">4</div>
<span class="stage-label" id="stageLabel4">Decision</span>
</div>
<div class="stage-connector conn-4" id="conn4"><div class="fill"></div></div>
<div class="stage-node stage-5" id="stageNode5">
<div class="stage-dot" id="stageDot5">5</div>
<span class="stage-label" id="stageLabel5">Retention</span>
</div>
</div>
</div>
<!-- ═══ APP LAYOUT ═══ -->
<div class="app-container">
<!-- CHAT PANEL -->
<div class="chat-panel">
<div class="chat-messages" id="chatMessages">
<div class="empty-state" id="emptyState">
<div class="icon">🛍️</div>
<h3>Lead Stage AI — Guided Shopping</h3>
<p>AI phân tích giai đoạn mua hàng của bạn và tự điều chỉnh phong cách tư vấn realtime.</p>
<div class="quick-prompts">
<button class="quick-prompt" onclick="sendQuick('Xin chào!')">👋 Xin chào!</button>
<button class="quick-prompt" onclick="sendQuick('Tìm áo polo nam')">👔 Tìm áo polo nam</button>
<button class="quick-prompt" onclick="sendQuick('So sánh 2 mẫu áo khoác giúp mình')">🔍 So sánh SP</button>
<button class="quick-prompt" onclick="sendQuick('Mình muốn mua luôn, chốt đơn!')">🛒 Chốt đơn</button>
<button class="quick-prompt" onclick="sendQuick('Mình mua hôm trước, muốn đổi size')">🔄 Đổi trả</button>
</div>
</div>
</div>
<div class="typing-indicator" id="typingIndicator">
<span></span><span></span><span></span>
</div>
<div class="chat-input-bar">
<div class="input-wrapper">
<input type="text" class="chat-input" id="chatInput"
placeholder="Nhập tin nhắn..." autocomplete="off"
onkeydown="if(event.key==='Enter') sendMessage()">
<button class="send-btn" id="sendBtn" onclick="sendMessage()"></button>
</div>
</div>
</div>
<!-- DEBUG PANEL -->
<div class="debug-panel">
<div class="debug-title">🧠 Lead Stage Debug Panel</div>
<div class="debug-card" id="pipelineCard">
<h4>⚡ Pipeline Trace</h4>
<div class="pipeline-trace" id="pipelineTrace">
<div style="font-size:11px;color:var(--muted-fg)">Gửi tin nhắn để xem pipeline.</div>
</div>
</div>
<div class="debug-card" id="timingCard">
<h4>⏱ Timing</h4>
<div class="timing-grid">
<div class="timing-cell">
<div class="t-value" id="tClassifier"></div>
<div class="t-label">Classifier</div>
</div>
<div class="timing-cell">
<div class="t-value" id="tStylist"></div>
<div class="t-label">Stylist</div>
</div>
<div class="timing-cell">
<div class="t-value" id="tTotal"></div>
<div class="t-label">Total</div>
</div>
</div>
</div>
<div class="debug-card" id="stageCard">
<h4>🎯 Current Stage</h4>
<div class="debug-row">
<span class="label">Stage</span>
<span class="value" id="dStage"></span>
</div>
<div class="debug-row">
<span class="label">Stage Name</span>
<span class="value" id="dStageName"></span>
</div>
<div class="debug-row">
<span class="label">Tone</span>
<span class="value" id="dTone"></span>
</div>
<div class="debug-row">
<span class="label">Confidence</span>
<span class="value" id="dConfidence"></span>
</div>
<div class="confidence-bar-outer">
<div class="confidence-bar-inner" id="confBar" style="width:0%"></div>
</div>
</div>
<div class="debug-card" id="reasoningCard">
<h4>💭 Classifier Reasoning</h4>
<div class="debug-reasoning" id="dReasoning">Chưa có dữ liệu.</div>
</div>
<div class="debug-card" id="hintsCard">
<h4>📋 Behavioral Hints</h4>
<div class="debug-hints" id="dHints">
<div class="debug-hint">Chưa có gợi ý.</div>
</div>
</div>
<div class="debug-card" id="historyCard">
<h4>📊 Stage History</h4>
<div class="stage-history" id="stageHistory">
<div style="font-size:11px;color:var(--muted-fg)">Chưa có lịch sử.</div>
</div>
</div>
</div>
</div>
<!-- ═══ PRODUCT POPUP ═══ -->
<div class="lf-overlay" id="lfOverlay" onclick="closeProductPopup()"></div>
<div class="lf-popup" id="lfPopup">
<div class="lf-popup-header">
<h3>THÔNG TIN SẢN PHẨM</h3>
<button class="lf-popup-close" onclick="closeProductPopup()"></button>
</div>
<div class="lf-popup-body" id="lfPopupBody">
<!-- rendered by lead-popup.js -->
</div>
</div>
<!-- ═══ SHOPPING CART FAB + DRAWER ═══ -->
<button class="cart-fab" id="cartFab" onclick="toggleCart()">
🛒<span class="cart-badge" id="cartBadge" style="display:none">0</span>
</button>
<div class="cart-overlay" id="cartOverlay" onclick="toggleCart()"></div>
<div class="cart-drawer" id="cartDrawer">
<div class="cart-drawer-header">
<span>GIỎ HÀNG CỦA BẠN</span>
<button onclick="toggleCart()"></button>
</div>
<div class="cart-drawer-items" id="cartItems"></div>
<div class="cart-drawer-footer">
<div class="cart-total">
<span>Tổng cộng:</span>
<span class="cart-total-price" id="cartTotal"></span>
</div>
<button class="cart-send-btn" id="cartSendBtn" onclick="sendCartToBot()" disabled>GỬI YÊU CẦU ĐẶT HÀNG</button>
<button class="cart-send-btn" id="cartClearBtn" onclick="clearCart()" style="background:#444;margin-top:10px;">XÓA GIỎ HÀNG</button>
</div>
</div>
<!-- ═══ SIZE MODAL (for cart.js fallback) ═══ -->
<div class="size-modal-overlay" id="sizeModalOverlay" onclick="closeSizeModal && closeSizeModal()"></div>
<div class="size-modal" id="sizeModal">
<div class="size-modal-header">
<span>CHỌN SIZE VÀ MÀU SẮC</span>
<button class="size-modal-close" onclick="closeSizeModal && closeSizeModal()"></button>
</div>
<div class="size-modal-body" id="sizeModalBody"></div>
</div>
<div class="cart-toast" id="cartToast"></div>
<!-- ═══ SCRIPTS — thứ tự quan trọng ═══ -->
<script src="/static/lead_flow/lead-popup.js"></script> <!-- popup tự standalone -->
<script src="/static/js/cart.js"></script> <!-- cart drawer + addToCart -->
<script src="/static/lead_flow/lead-flow.js"></script> <!-- chat logic -->
</body>
</html>
// ═══════════════════════════════════════
// Lead Flow — Main Chat + Stage Logic
// ═══════════════════════════════════════
const STAGE_COLORS = {
1: '#3b82f6', 2: '#10b981', 3: '#f59e0b',
4: '#ec4899', 5: '#8b5cf6'
};
const STAGE_NAMES = {
1: 'AWARENESS', 2: 'INTEREST', 3: 'CONSIDERATION',
4: 'DECISION', 5: 'RETENTION'
};
let currentStage = 0;
let turnCount = 0;
let stageLog = [];
let isSending = false;
let currentConversationId = null;
// ── UUID ──
function uuidv4() {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) {
const r = Math.random() * 16 | 0, v = c === 'x' ? r : (r & 0x3 | 0x8);
return v.toString(16);
});
}
// ── Persistent device_id ──
let LEAD_DEVICE_ID = (() => {
const KEY = 'canifa_lead_device_id';
let id = localStorage.getItem(KEY);
if (!id) { id = uuidv4(); localStorage.setItem(KEY, id); }
return id;
})();
function handleDeviceIdChange() {
const el = document.getElementById('deviceIdInput');
if (!el) return;
let val = el.value.trim();
if (!val) {
val = uuidv4();
el.value = val;
}
LEAD_DEVICE_ID = val;
localStorage.setItem('canifa_lead_device_id', val);
// Hard reset conversation to prevent mingling histories
localStorage.removeItem('canifa_lead_last_conv');
window.location.search = '';
}
// ── Configure marked ──
if (typeof marked !== 'undefined') {
marked.setOptions({ breaks: true, gfm: true, sanitize: false });
}
// ═══════════════════════════════════════
// Chat Logic
// ═══════════════════════════════════════
function sendQuick(text) {
document.getElementById('chatInput').value = text;
sendMessage();
}
async function sendMessage() {
const input = document.getElementById('chatInput');
const query = input.value.trim();
if (!query || isSending) return;
isSending = true;
turnCount++;
input.value = '';
document.getElementById('sendBtn').disabled = true;
const empty = document.getElementById('emptyState');
if (empty) empty.style.display = 'none';
addMessage(query, 'human');
document.getElementById('typingIndicator').classList.add('show');
try {
const res = await fetch('/api/agent/chat-lead-flow', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_query: query,
conversation_id: currentConversationId,
device_id: LEAD_DEVICE_ID
})
});
const data = await res.json();
if (data.status === 'success') {
const ls = data.lead_stage || {};
const stage = ls.stage || 2;
updateProgressBar(stage);
addMessage(data.ai_response, 'ai', ls, data.products);
updateDebug(data);
stageLog.push({ turn: turnCount, stage, query: query.slice(0, 40), name: STAGE_NAMES[stage] });
updateStageHistory();
} else {
addMessage('❌ ' + (data.message || 'Lỗi không xác định'), 'ai');
}
} catch (err) {
addMessage('❌ Không thể kết nối server: ' + err.message, 'ai');
} finally {
document.getElementById('typingIndicator').classList.remove('show');
document.getElementById('sendBtn').disabled = false;
isSending = false;
}
}
// ═══════════════════════════════════════
// Message Rendering
// ═══════════════════════════════════════
function scrollToBottom(force = false) {
const container = document.getElementById('chatMessages');
if (!container) return;
if (!force) {
const near = container.scrollHeight - container.scrollTop - container.clientHeight < 150;
if (!near) return;
}
setTimeout(() => { container.scrollTop = container.scrollHeight; }, 50);
}
function addMessage(text, type, leadStage, products = [], forceScroll = true) {
const container = document.getElementById('chatMessages');
const div = document.createElement('div');
div.className = `msg ${type}`;
// Stage badge
if (type === 'ai' && leadStage && leadStage.stage) {
const stage = leadStage.stage;
const color = STAGE_COLORS[stage];
const badge = document.createElement('div');
badge.className = 'stage-badge';
badge.style.background = color + '15';
badge.style.color = color;
badge.textContent = `Stage ${stage} · ${leadStage.tone_directive || ''}`;
div.appendChild(badge);
}
// Text
const textDiv = document.createElement('div');
textDiv.className = 'msg-text';
if (type === 'ai' && typeof marked !== 'undefined') {
textDiv.innerHTML = marked.parse(text || '');
} else {
textDiv.textContent = text;
}
div.appendChild(textDiv);
// Product cards
if (type === 'ai' && products && products.length > 0) {
const grid = document.createElement('div');
grid.className = 'product-cards';
products.slice(0, 6).forEach(p => {
const card = document.createElement('div');
card.className = 'product-card clickable';
// QUAN TRỌNG: gắn data lên element, không dùng closure để tránh stale ref
card.dataset.product = JSON.stringify(p);
card.addEventListener('click', function () {
try {
const prod = JSON.parse(this.dataset.product);
openProductPopup(prod);
} catch (e) {
console.error('[Lead] openProductPopup error', e);
}
});
const hasDiscount = p.original_price && p.original_price > p.price;
const discStr = p.discount || (hasDiscount
? `-${Math.round((1 - p.price / p.original_price) * 100)}%`
: null);
card.innerHTML = `
<img src="${p.image || ''}" alt="${p.name || ''}" class="product-card-img"
onload="scrollToBottom(false)"
onerror="this.onerror=null;this.src=''"/>
${discStr ? `<div class="product-card-discount">${discStr}</div>` : ''}
<div class="product-card-body">
<div class="product-card-sku">${p.sku || ''}</div>
<div class="product-card-name">${p.name || ''}</div>
<div class="product-card-price">
${p.price ? `<span class="sale">${p.price.toLocaleString()}đ</span>` : ''}
${hasDiscount ? `<span class="original">${p.original_price.toLocaleString()}đ</span>` : ''}
</div>
</div>
`;
grid.appendChild(card);
});
div.appendChild(grid);
}
container.appendChild(div);
if (forceScroll) scrollToBottom(true);
}
// ═══════════════════════════════════════
// Progress Bar
// ═══════════════════════════════════════
function updateProgressBar(stage) {
for (let i = 1; i <= 5; i++) {
document.getElementById(`stageDot${i}`).classList.remove('active');
document.getElementById(`stageLabel${i}`).classList.remove('active-label');
}
for (let i = 1; i <= 4; i++) {
document.getElementById(`conn${i}`).classList.remove('passed');
}
document.getElementById(`stageDot${stage}`).classList.add('active');
document.getElementById(`stageLabel${stage}`).classList.add('active-label');
for (let i = 1; i < stage; i++) {
document.getElementById(`stageDot${i}`).classList.add('active');
document.getElementById(`stageLabel${i}`).classList.add('active-label');
document.getElementById(`conn${i}`).classList.add('passed');
}
currentStage = stage;
}
// ═══════════════════════════════════════
// Debug Panel
// ═══════════════════════════════════════
function updateDebug(data) {
const ls = data.lead_stage || {};
const t = data.timing || {};
const pipeline = data.pipeline || [];
renderPipeline(pipeline);
document.getElementById('tClassifier').textContent = t.classifier_ms ? t.classifier_ms + 'ms' : '—';
document.getElementById('tStylist').textContent = t.stylist_ms ? t.stylist_ms + 'ms' : '—';
document.getElementById('tTotal').textContent = t.total_ms ? Math.round(t.total_ms) + 'ms' : '—';
const stage = ls.stage || 0;
const color = STAGE_COLORS[stage] || '#888';
document.getElementById('dStage').textContent = stage;
document.getElementById('dStage').style.color = color;
document.getElementById('dStageName').textContent = ls.stage_name || '—';
document.getElementById('dStageName').style.color = color;
document.getElementById('dTone').textContent = ls.tone_directive || '—';
const conf = ls.confidence || 0;
document.getElementById('dConfidence').textContent = (conf * 100).toFixed(0) + '%';
const confBar = document.getElementById('confBar');
confBar.style.width = (conf * 100) + '%';
confBar.style.background = color;
document.getElementById('dReasoning').textContent = ls.reasoning || 'Không có.';
const hintsDiv = document.getElementById('dHints');
hintsDiv.innerHTML = '';
const hints = ls.behavioral_hints || [];
if (hints.length === 0) {
hintsDiv.innerHTML = '<div class="debug-hint">Không có gợi ý.</div>';
} else {
hints.forEach(h => {
const el = document.createElement('div');
el.className = 'debug-hint';
el.style.borderLeftColor = color;
el.textContent = h;
hintsDiv.appendChild(el);
});
}
}
function renderPipeline(steps) {
const container = document.getElementById('pipelineTrace');
container.innerHTML = '';
if (!steps || steps.length === 0) {
container.innerHTML = '<div style="font-size:11px;color:var(--muted-fg)">Không có pipeline data.</div>';
return;
}
steps.forEach((step, idx) => {
const div = document.createElement('div');
div.className = `pipe-step step-${step.step}`;
const header = document.createElement('div');
header.className = 'pipe-header';
const label = document.createElement('span');
label.className = 'pipe-label';
label.textContent = step.label || step.step;
header.appendChild(label);
if (step.elapsed_ms) {
const time = document.createElement('span');
time.className = 'pipe-time';
time.textContent = step.elapsed_ms + 'ms';
header.appendChild(time);
}
div.appendChild(header);
if (step.content) {
const content = document.createElement('div');
content.className = 'pipe-content';
content.textContent = step.content.length > 300
? step.content.slice(0, 300) + '…'
: step.content;
div.appendChild(content);
}
if (step.raw_json) {
const toggleId = 'pipeRaw_' + idx;
const toggle = document.createElement('span');
toggle.className = 'pipe-raw-toggle';
toggle.textContent = '▸ Raw JSON';
toggle.onclick = () => {
const raw = document.getElementById(toggleId);
const isOpen = raw.classList.toggle('open');
toggle.textContent = isOpen ? '▾ Raw JSON' : '▸ Raw JSON';
};
div.appendChild(toggle);
const raw = document.createElement('pre');
raw.className = 'pipe-raw';
raw.id = toggleId;
raw.textContent = step.raw_json;
div.appendChild(raw);
}
if (step.tool_calls) {
step.tool_calls.forEach(tc => {
const tcDiv = document.createElement('div');
tcDiv.className = 'pipe-content';
tcDiv.style.cssText = 'margin-top:4px;font-size:10px;color:#f97316';
tcDiv.textContent = `→ ${tc.name}(${JSON.stringify(tc.args).slice(0, 200)})`;
div.appendChild(tcDiv);
});
}
container.appendChild(div);
});
}
// ═══════════════════════════════════════
// Stage History
// ═══════════════════════════════════════
function updateStageHistory() {
const container = document.getElementById('stageHistory');
container.innerHTML = '';
stageLog.slice(-8).reverse().forEach(entry => {
const div = document.createElement('div');
div.className = 'history-entry';
const turn = document.createElement('span');
turn.className = 'h-turn';
turn.textContent = '#' + entry.turn;
const badge = document.createElement('span');
badge.className = 'h-stage';
const color = STAGE_COLORS[entry.stage];
badge.style.background = color + '18';
badge.style.color = color;
badge.textContent = 'S' + entry.stage;
const query = document.createElement('span');
query.className = 'h-query';
query.textContent = entry.query;
div.appendChild(turn);
div.appendChild(badge);
div.appendChild(query);
container.appendChild(div);
});
}
// ═══════════════════════════════════════
// History API (Postgres)
// ═══════════════════════════════════════
async function loadHistory(convId) {
try {
const res = await fetch(`/api/lead/history?conv_id=${convId}&limit=50`, {
headers: { 'device_id': LEAD_DEVICE_ID }
});
if (!res.ok) return;
const data = await res.json();
const items = Array.isArray(data.data) ? data.data : [];
if (items.length === 0) return;
const empty = document.getElementById('emptyState');
if (empty) empty.style.display = 'none';
const chronological = items.slice().reverse();
chronological.forEach(item => {
if (item.is_human) {
addMessage(String(item.message || ''), 'human', null, [], false);
} else {
let content = String(item.message || '');
let leadStage = null;
let products = [];
try {
const parsed = JSON.parse(content);
if (parsed && parsed.ai_response) {
content = parsed.ai_response;
leadStage = parsed.lead_stage || null;
products = parsed.products || [];
}
} catch (e) { /* plain text */ }
addMessage(content, 'ai', leadStage, products, false);
}
});
scrollToBottom(true);
} catch (err) {
console.error('[Lead] Failed to load history', err);
}
}
// ═══════════════════════════════════════
// Conversation Management
// ═══════════════════════════════════════
function saveConversationHistory(convId) {
let histories = JSON.parse(localStorage.getItem('canifa_leadflow_histories') || '[]');
histories = histories.filter(h => h.id !== convId);
histories.unshift({ id: convId, date: new Date().toLocaleString() });
if (histories.length > 20) histories = histories.slice(0, 20);
localStorage.setItem('canifa_leadflow_histories', JSON.stringify(histories));
}
function loadConversationList(currentId) {
const select = document.getElementById('historySelect');
const histories = JSON.parse(localStorage.getItem('canifa_leadflow_histories') || '[]');
select.innerHTML = '<option value="">-- Mở phiên Chat Mới --</option>';
histories.forEach(h => {
const opt = document.createElement('option');
opt.value = h.id;
opt.textContent = h.date + ' (' + h.id.split('-')[0] + ')';
if (h.id === currentId) opt.selected = true;
select.appendChild(opt);
});
}
function switchConversation(val) {
if (!val) val = uuidv4();
localStorage.setItem('canifa_lead_last_conv', val);
const urlParams = new URLSearchParams(window.location.search);
urlParams.set('conversation_id', val);
window.location.search = urlParams.toString();
}
// ═══════════════════════════════════════
// Init
// ═══════════════════════════════════════
window.addEventListener('DOMContentLoaded', async () => {
// Populate device_id input
const devInput = document.getElementById('deviceIdInput');
if (devInput) devInput.value = LEAD_DEVICE_ID;
const urlParams = new URLSearchParams(window.location.search);
let convId = urlParams.get('conversation_id');
if (!convId) convId = localStorage.getItem('canifa_lead_last_conv');
if (!convId) convId = uuidv4();
const newParams = new URLSearchParams(window.location.search);
newParams.set('conversation_id', convId);
window.history.replaceState({}, '', window.location.pathname + '?' + newParams.toString());
currentConversationId = convId;
localStorage.setItem('canifa_lead_last_conv', convId);
document.getElementById('chatInput').focus();
saveConversationHistory(convId);
loadConversationList(convId);
await loadHistory(convId);
});
// ═══════════════════════════════════════
// Lead Flow — Self-contained Product Popup
// click product card → popup → API stock → thêm giỏ hàng
// ═══════════════════════════════════════
(function () {
'use strict';
// ── State ──
let _product = null;
let _selectedSize = null;
// ── DOM refs (lazy) ──
function overlay() { return document.getElementById('lfOverlay'); }
function popup() { return document.getElementById('lfPopup'); }
// ── Open popup ──
window.openProductPopup = function (product) {
// Normalize sizes
let sizes = product.available_sizes || product.sizes || [];
if (typeof sizes === 'string') {
sizes = sizes.split('|').map(s => s.trim()).filter(Boolean);
}
_product = {
sku: product.sku || '',
name: product.name || '',
price: product.price || 0,
original_price: product.original_price || product.price || 0,
image: product.image || product.product_image_url_thumbnail || '',
color: product.color || product.master_color || '',
colors: product.colors || [],
sizes: sizes,
url: product.url || product.product_web_url || '#',
};
_selectedSize = null;
_renderPopup(_product);
_show();
// Fetch full info từ lookup API → cập nhật colors + sizes nếu có
if (_product.sku) {
_fetchProductDetail(_product.sku);
}
};
// ── Close popup ──
window.closeProductPopup = function () {
_hide();
};
// ── Internal: render popup content ──
function _renderPopup(p) {
const body = document.getElementById('lfPopupBody');
if (!body) return;
const hasDiscount = p.original_price && p.original_price > p.price && p.price > 0;
const discPct = hasDiscount
? Math.round((1 - p.price / p.original_price) * 100)
: 0;
body.innerHTML = `
<div class="lf-product-row">
<img class="lf-product-img" src="${_esc(p.image)}" alt="${_esc(p.name)}"
onerror="this.src='';this.style.background='#2a2c35'">
<div class="lf-product-info">
<div class="lf-product-name">${_esc(p.name)}</div>
<div class="lf-product-sku">SKU: ${_esc(p.sku)}</div>
<div class="lf-product-price">
${p.price > 0 ? `<span class="lf-price-sale">${p.price.toLocaleString('vi-VN')}đ</span>` : ''}
${hasDiscount ? `<span class="lf-price-orig">${p.original_price.toLocaleString('vi-VN')}đ</span>` : ''}
${hasDiscount ? `<span class="lf-price-badge">-${discPct}%</span>` : ''}
</div>
</div>
</div>
${p.colors && p.colors.length > 0 ? `
<div class="lf-section">
<div class="lf-section-label">Màu sắc · <span id="lfColorName">${_esc(p.color || p.colors[0]?.color || '')}</span></div>
<div class="lf-colors">
${p.colors.map((c, i) => `
<div class="lf-color-dot ${i === 0 ? 'active' : ''}"
style="background:${c.hex || '#888'}"
title="${_esc(c.color || '')}"
onclick="window._lfSelectColor(this, '${_esc(c.color || '')}')"></div>
`).join('')}
</div>
</div>` : ''}
<div class="lf-section">
<div class="lf-section-label">Kích c</div>
<div class="lf-sizes" id="lfSizes">
${p.sizes.length > 0
? p.sizes.map(s => `
<button class="lf-size-btn" data-size="${_esc(s)}"
onclick="window._lfSelectSize(this, '${_esc(s)}')">
<span class="lf-size-label">${_esc(s)}</span>
<span class="lf-size-qty" id="lfQty_${_esc(s)}">
<span class="lf-spinner"></span>
</span>
</button>`).join('')
: '<span class="lf-size-stock-loading">Không có thông tin size</span>'
}
</div>
</div>
<button class="lf-add-btn" id="lfAddBtn" onclick="window._lfAddToCart()" disabled>
CHN SIZE ĐỂ THÊM VÀO GI HÀNG
</button>
<a class="lf-detail-link" href="${_esc(p.url)}" target="_blank" rel="noopener">
🔗 Xem chi tiết trên canifa.com
</a>
`;
// Fetch stock nếu có sizes
if (p.sizes.length > 0) {
_fetchStock(p.sku, p.sizes);
}
}
// ── Fetch product details từ lookup API ──
async function _fetchProductDetail(sku) {
try {
const resp = await fetch(`/api/product/lookup?skus=${encodeURIComponent(sku)}`);
if (!resp.ok) return;
const data = await resp.json();
const full = data.products && data.products[0];
if (!full) return;
// Merge: ưu tiên API cho sizes/colors, giữ cached price/name
_product = {
..._product,
sizes: full.sizes || _product.sizes,
colors: full.colors || _product.colors,
};
// Re-render nếu popup vẫn mở
if (overlay() && overlay().classList.contains('active')) {
_renderPopup(_product);
}
} catch (e) {
// ignore — đã có data từ chat
}
}
// ── Fetch stock qty per size ──
async function _fetchStock(sku, sizes) {
try {
const resp = await fetch('/api/stock/check', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes: sku, timeout_sec: 8 })
});
if (!resp.ok) throw new Error('stock api error');
const data = await resp.json();
// Build stockMap: "SKU-SIZE" → qty
const stockMap = {};
if (data.stock_responses) {
for (const chunk of data.stock_responses) {
const items = chunk?.data || chunk?.items || [];
if (Array.isArray(items)) {
for (const item of items) {
const k = (item.sku || item.product_sku || '').toUpperCase();
stockMap[k] = parseInt(item.qty || item.quantity || 0);
}
}
}
}
// Update qty labels
for (const size of sizes) {
const qtyEl = document.getElementById(`lfQty_${size}`);
if (!qtyEl) continue;
const matching = Object.keys(stockMap).filter(k =>
k.endsWith('-' + size.toUpperCase())
);
const qty = matching.reduce((s, k) => s + (stockMap[k] || 0), 0);
const inStock = qty > 0;
qtyEl.innerHTML = inStock
? `<span class="lf-size-qty in-stock">Còn ${qty}</span>`
: `<span class="lf-size-qty no-stock">Hết hàng</span>`;
const btn = document.querySelector(`.lf-size-btn[data-size="${size}"]`);
if (btn && !inStock) {
btn.disabled = true;
}
}
} catch (e) {
// Fallback: remove spinners, keep buttons enabled
const spinners = document.querySelectorAll('#lfSizes .lf-spinner');
spinners.forEach(s => s.parentElement.textContent = '');
}
}
// ── Select color ──
window._lfSelectColor = function (dot, colorName) {
document.querySelectorAll('.lf-color-dot').forEach(d => d.classList.remove('active'));
dot.classList.add('active');
const nameEl = document.getElementById('lfColorName');
if (nameEl) nameEl.textContent = colorName;
};
// ── Select size ──
window._lfSelectSize = function (btn, size) {
if (btn.disabled) return;
document.querySelectorAll('.lf-size-btn').forEach(b => b.classList.remove('selected'));
btn.classList.add('selected');
_selectedSize = size;
const addBtn = document.getElementById('lfAddBtn');
if (addBtn) {
addBtn.disabled = false;
addBtn.classList.add('ready');
addBtn.textContent = `THÊM VÀO GIỎ HÀNG — Size ${size}`;
}
};
// ── Add to cart ──
window._lfAddToCart = function () {
if (!_product || !_selectedSize) return;
const activeDot = document.querySelector('.lf-color-dot.active');
const colorName = activeDot ? activeDot.title : (_product.color || '');
const cartItem = {
sku: _product.sku,
cartKey: `${_product.sku}:${_selectedSize}:${colorName}`,
name: _product.name,
size: _selectedSize,
color: colorName,
price: _product.price,
originalPrice: _product.original_price,
image: _product.image,
thumbnail_image_url: _product.image,
};
// Dùng addToCart từ cart.js nếu có, fallback localStorage
if (typeof addToCart === 'function') {
addToCart(cartItem);
} else {
_fallbackAddToCart(cartItem);
}
_hide();
};
// ── Fallback if cart.js not loaded ──
function _fallbackAddToCart(item) {
let cart = JSON.parse(localStorage.getItem('canifa_cart') || '[]');
const key = item.cartKey || item.sku;
if (cart.find(c => (c.cartKey || c.sku) === key)) {
alert('⚠️ Sản phẩm đã có trong giỏ!');
return;
}
cart.push(item);
localStorage.setItem('canifa_cart', JSON.stringify(cart));
// Update badge if exists
const badge = document.getElementById('cartBadge');
if (badge) { badge.textContent = cart.length; badge.style.display = 'flex'; }
_toast(`✅ Đã thêm ${item.name.substring(0, 25)} - Size ${item.size}`);
}
// ── Toast ──
function _toast(msg) {
const t = document.getElementById('cartToast');
if (!t) return;
t.textContent = msg;
t.classList.add('show');
setTimeout(() => t.classList.remove('show'), 2200);
}
// ── Show/hide ──
function _show() {
const ov = overlay(); const pp = popup();
if (ov) ov.classList.add('active');
if (pp) pp.classList.add('active');
document.body.style.overflow = 'hidden';
}
function _hide() {
const ov = overlay(); const pp = popup();
if (ov) ov.classList.remove('active');
if (pp) pp.classList.remove('active');
document.body.style.overflow = '';
_product = null;
_selectedSize = null;
}
// ── Escape HTML ──
function _esc(str) {
if (!str) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// ── ESC key to close ──
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') closeProductPopup();
});
})();
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