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

feat(bulk-ops): refactor AI search to use Gemini structured outputs and add Preview UI

parent 00dffd6a
"""
Bulk Operations API v2 — Search in clean_description + AI-powered bulk field editing.
Integrates with Codex (Groq LLM) for context-aware content generation per product.
"""
import asyncio
import json
import logging
import re
from typing import Optional
import httpx
from fastapi import APIRouter, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from langchain_core.messages import SystemMessage, HumanMessage
from common.llm_factory import create_llm
class AiSearchFilter(BaseModel):
keywords: list[str] = Field(default=[], description="Từ khóa tìm kiếm trong mô tả (VD: 'cotton', 'co giãn')")
product_line: str | None = Field(default=None, description="Dòng sản phẩm xuất hiện trong yêu cầu (VD: 'Áo phông', 'Quần jean')")
gender: str | None = Field(default=None, description="Giới tính: Nam, Nữ, Unisex, Bé trai, Bé gái")
age_group: str | None = Field(default=None, description="Độ tuổi: adult, kid")
color: str | None = Field(default=None, description="Màu sắc (VD: 'đỏ', 'xanh navy')")
season: str | None = Field(default=None, description="Mùa: Xuân, Hè, Thu, Đông")
occasion: str | None = Field(default=None, description="Dịp đặc biệt (VD: '2/9', 'đi biển')")
search_explain: str = Field(default="", description="Giải thích ngắn gọn cách AI hiểu query")
from common.pool_wrapper import get_pooled_connection_compat
from common.ultra_desc_db import UltraDescriptionDB
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/bulk", tags=["Bulk Operations"])
PG_TABLE = "dashboard_canifa.ultra_descriptions"
# ═══ Groq Config — Reuse from product_desc_route ═══
GROQ_API_KEYS = [
"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",
"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",
"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",
"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",
"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",
"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",
]
_groq_key_index = 0
AI_MODEL = "openai/gpt-oss-120b"
# ═══ Field Labels for dropdown ═══
FIELD_OPTIONS = {
"dip_mac": "Dịp mặc",
"phoi_do": "Phối đồ",
"mo_ta_chinh": "Mô tả chính",
"phong_cach": "Phong cách",
"layer": "Gợi ý layering",
"cross_sell": "Cross-sell",
"ly_do_mua": "Lý do mua",
"luu_y_size": "Lưu ý size",
"hook_quang_cao": "Hook quảng cáo",
"tinh_nang_vai": "Tính năng vải",
"tags": "Tags",
"mua": "Mùa phù hợp",
"nguyen_tac_phoi_do": "Nguyên tắc phối đồ",
"tranh_phoi_cung": "Tránh phối cùng",
"loi_song": "Lối sống",
"tinh_cach": "Tính cách",
"tagline": "Tagline",
"faq_1_q": "FAQ 1 - Câu hỏi",
"faq_1_a": "FAQ 1 - Trả lời",
"faq_2_q": "FAQ 2 - Câu hỏi",
"faq_2_a": "FAQ 2 - Trả lời",
"faq_3_q": "FAQ 3 - Câu hỏi",
"faq_3_a": "FAQ 3 - Trả lời",
}
# ═══ Section grouping for re-rendering clean_description ═══
_FIELD_LABELS = {
"ten_san_pham": "Tên sản phẩm", "tagline": "Tagline", "mo_ta_chinh": "Mô tả chính",
"chat_lieu": "Chất liệu", "tinh_nang_vai": "Tính năng vải",
"huong_dan_bao_quan": "Hướng dẫn bảo quản", "phong_cach": "Phong cách", "tags": "Tags",
"do_tuoi": "Độ tuổi", "gioi_tinh": "Giới tính", "loi_song": "Lối sống",
"tinh_cach": "Tính cách", "dip_mac": "Dịp mặc", "phoi_do": "Gợi ý phối đồ",
"nguyen_tac_phoi_do": "Nguyên tắc phối đồ", "tranh_phoi_cung": "Tránh phối cùng",
"layer": "Gợi ý layering", "mua": "Mùa phù hợp",
"faq_1_q": "FAQ 1 - Câu hỏi", "faq_1_a": "FAQ 1 - Trả lời",
"faq_2_q": "FAQ 2 - Câu hỏi", "faq_2_a": "FAQ 2 - Trả lời",
"faq_3_q": "FAQ 3 - Câu hỏi", "faq_3_a": "FAQ 3 - Trả lời",
"hook_quang_cao": "Hook quảng cáo", "cross_sell": "Cross-sell",
"ly_do_mua": "Lý do nên mua", "luu_y_size": "Lưu ý chọn size",
}
_SECTIONS = [
("📦 THÔNG TIN CƠ BẢN", ["ten_san_pham", "tagline", "mo_ta_chinh", "phong_cach", "tags"]),
("🧵 CHẤT LIỆU & BẢO QUẢN", ["chat_lieu", "tinh_nang_vai", "huong_dan_bao_quan"]),
("🎯 ĐỐI TƯỢNG", ["do_tuoi", "gioi_tinh", "loi_song", "tinh_cach"]),
("📅 DỊP MẶC & STYLING", ["dip_mac", "phoi_do", "nguyen_tac_phoi_do", "tranh_phoi_cung", "layer", "mua"]),
("❓ FAQ", ["faq_1_q", "faq_1_a", "faq_2_q", "faq_2_a", "faq_3_q", "faq_3_a"]),
("🛒 HỖ TRỢ BÁN HÀNG", ["hook_quang_cao", "cross_sell", "ly_do_mua", "luu_y_size"]),
]
# ═══ Batch AI Edit State ═══
BULK_AI_STATE = {
"is_running": False,
"total": 0,
"done": 0,
"errors": 0,
"current_code": None,
"current_name": None,
"results": [], # last results for display
}
# ═══════════════════════════════════════════════════════
# HELPERS
# ═══════════════════════════════════════════════════════
def _next_groq_key() -> str:
global _groq_key_index
key = GROQ_API_KEYS[_groq_key_index % len(GROQ_API_KEYS)]
_groq_key_index += 1
return key
async def _call_codex(messages: list, max_tokens: int = 1000, temperature: float = 0.7) -> str:
"""Call Groq Codex with multi-key round-robin and model fallback."""
model_chain = [AI_MODEL, "openai/gpt-oss-20b", "qwen/qwen3-32b"]
for attempt_round in range(3): # max 3 full rounds
for model in model_chain:
for _ in range(len(GROQ_API_KEYS)):
api_key = _next_groq_key()
try:
async with httpx.AsyncClient(timeout=25) as client:
resp = await client.post(
"https://api.groq.com/openai/v1/chat/completions",
headers={
"Authorization": f"Bearer {api_key}",
"Content-Type": "application/json",
},
json={
"model": model,
"max_tokens": max_tokens,
"temperature": temperature,
"messages": messages,
},
)
if resp.status_code == 200:
result = resp.json()
if "error" not in result:
return result["choices"][0]["message"]["content"]
elif resp.status_code == 429:
continue # next key
else:
break # next model
except Exception as e:
logger.warning(f"Codex error: {e}")
break
await asyncio.sleep(3)
raise Exception("Codex: tất cả model và key đều thất bại")
def _render_description_text(data: dict, product_name: str = "") -> str:
"""Re-render clean_description from description_data JSON."""
if not data:
data = {}
lines = []
title = data.get("ten_san_pham", product_name or "Sản phẩm")
lines.append(f"{'=' * 50}")
lines.append(f" {title.upper()}")
lines.append(f"{'=' * 50}")
lines.append("")
for section_title, fields in _SECTIONS:
section_lines = []
for key in fields:
val = data.get(key, "")
if not val:
continue
label = _FIELD_LABELS.get(key, key)
if key.endswith("_q"):
section_lines.append(f" 💬 {val}")
continue
elif key.endswith("_a"):
section_lines.append(f" → {val}")
continue
if key == "phoi_do" and "|" in str(val):
section_lines.append(f" {label}:")
for combo in str(val).split("|"):
section_lines.append(f" • {combo.strip()}")
continue
if key == "ly_do_mua" and "|" in str(val):
section_lines.append(f" {label}:")
for reason in str(val).split("|"):
section_lines.append(f" • {reason.strip()}")
continue
section_lines.append(f" {label}: {val}")
if section_lines:
lines.append(f"── {section_title} ──")
lines.extend(section_lines)
lines.append("")
else:
lines.append(f"── {section_title} ──")
lines.append(" Chưa có dữ liệu.")
lines.append("")
return "\n".join(lines)
# ═══════════════════════════════════════════════════════
# 1. SEARCH — In clean_description (PostgreSQL)
# ═══════════════════════════════════════════════════════
class BulkSearchRequest(BaseModel):
keyword: str
limit: int = 100
@router.post("/search", summary="Search products by keyword in clean_description")
async def bulk_search(req: BulkSearchRequest):
"""Search in clean_description using LIKE. Primary search for AI assistant."""
keyword = req.keyword.strip()
if not keyword or len(keyword) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "Keyword phải có ít nhất 2 ký tự"}
)
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"""SELECT internal_ref_code, product_name, product_image_url, product_line,
phase, status,
LEFT(clean_description, 300) AS description_preview
FROM {PG_TABLE}
WHERE LOWER(clean_description) LIKE %s
OR LOWER(product_name) LIKE %s
ORDER BY updated_at DESC
LIMIT %s""",
(f"%{keyword.lower()}%", f"%{keyword.lower()}%", req.limit),
)
cols = [desc[0] for desc in cur.description]
results = [dict(zip(cols, row)) for row in cur.fetchall()]
cur.close()
return {
"status": "success",
"keyword": keyword,
"count": len(results),
"results": results,
}
except Exception as e:
logger.error(f"Bulk search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
finally:
if conn:
conn.close()
# ═══════════════════════════════════════════════════════
# 1B. AI SEARCH — Agent-style intent parsing + smart SQL
# ═══════════════════════════════════════════════════════
# Canifa product lines for AI context (fallback static list)
CANIFA_LINES = [
"Áo phông", "Quần soóc", "Áo nỉ", "Váy liền", "Quần nỉ", "Tất", "Áo len",
"Bộ mặc nhà", "Bộ quần áo", "Áo Polo", "Chân váy", "Áo nỉ có mũ", "Áo Sơ mi",
"Quần jean", "Quần dài", "Áo khoác chần bông", "Quần mặc nhà", "Áo Body",
"Quần Khaki", "Áo khoác gió", "Áo khoác dáng ngắn", "Áo kiểu", "Áo len gilet",
"Áo khoác gilet chần bông", "Áo giữ nhiệt", "Áo mặc nhà", "Quần lót", "Pyjama",
"Áo ba lỗ", "Cardigan", "Bộ thể thao", "Áo khoác chống nắng", "Quần váy",
"Blazer", "Áo khoác", "Áo khoác lông vũ", "Quần leggings", "Áo hai dây",
]
# Attempt to dynamically fetch product lines from DB to override the static list
try:
_conn = get_pooled_connection_compat()
_cur = _conn.cursor()
_cur.execute(f"SELECT DISTINCT product_line FROM {PG_TABLE} WHERE product_line IS NOT NULL AND product_line != ''")
_db_lines = [r[0] for r in _cur.fetchall()]
_cur.close()
_conn.close()
if _db_lines:
CANIFA_LINES = _db_lines
logger.info(f"✅ Loaded {len(CANIFA_LINES)} product lines dynamically from DB")
except Exception as e:
logger.warning(f"⚠️ Could not load product lines from DB, using fallback. Error: {e}")
AI_SEARCH_SYSTEM = """Bạn là AI phân tích ý định tìm kiếm sản phẩm thời trang Canifa.
Nhiệm vụ: Phân tích câu nhập của người dùng và trích xuất thành JSON filter.
DANH SÁCH DÒNG SẢN PHẨM HỢP LỆ:
""" + ", ".join(CANIFA_LINES) + """
TRƯỜNG CÓ THỂ TRÍCH XUẤT:
{
"keywords": ["từ khóa tìm trong mô tả"], // VD: ["cotton", "co giãn"]
"product_line": "Dòng SP từ danh sách trên", // VD: "Áo Polo" (chỉ chọn từ list)
"gender": "Nam|Nữ|Unisex|Bé trai|Bé gái", // null nếu không rõ
"age_group": "adult|kid", // null nếu không rõ
"color": "màu sắc", // VD: "đỏ", "xanh navy"
"season": "Xuân|Hè|Thu|Đông", // null nếu không rõ
"occasion": "dịp mặc đặc biệt", // VD: "2/9", "Tết", "đi làm" — dùng cho step bulk edit sau
"search_explain": "giải thích ngắn gọn cách hiểu câu query"
}
QUY TẮC:
- Chỉ điền trường nào có thể suy ra từ câu nhập. Không bịa.
- "bé trai" → gender: "Nam", age_group: "kid"
- "bé gái" → gender: "Nữ", age_group: "kid"
- "trẻ em" → age_group: "kid"
- "quần bò" → product_line: "Quần jean"
- "sịp" → product_line: "Quần lót"
- "áo đỏ" → color: "đỏ", keywords chứa product_line nếu nói rõ loại áo
- Nếu người dùng nói "cotton", "kháng khuẩn" → đó là keywords tìm trong mô tả
- Trả về ĐÚNG JSON, không text rác.
"""
class AiSearchRequest(BaseModel):
query: str
limit: int = 100
def _build_ai_search_sql(filters: dict, limit: int, use_jsonb: bool = True) -> tuple[str, list, str]:
"""
Build SQL from AI-parsed filters.
Supports 2 modes:
- JSONB mode (strict): queries description_data JSONB fields directly
- TEXT mode (relaxed): falls back to LIKE on clean_description
Returns: (sql_string, params_list, search_mode_label)
"""
clauses = []
params = []
# ── Product line → always use dedicated columns ──
product_line = filters.get("product_line")
if product_line:
clauses.append("(LOWER(product_name) LIKE %s OR LOWER(product_line) LIKE %s)")
params.append(f"%{product_line.lower()}%")
params.append(f"%{product_line.lower()}%")
if use_jsonb:
# ═══ JSONB STRUCTURED FILTERS ═══
# Gender → description_data->>'gioi_tinh'
gender = filters.get("gender")
if gender:
gender_map = {
"nam": "Nam", "bé trai": "Nam", "men": "Nam", "boy": "Nam",
"nữ": "Nữ", "bé gái": "Nữ", "women": "Nữ", "girl": "Nữ",
"unisex": "Unisex",
}
mapped = gender_map.get(gender.lower(), gender)
if mapped in ("Nam", "Nữ"):
clauses.append(
"(description_data->>'gioi_tinh' ILIKE %s OR description_data->>'gioi_tinh' ILIKE %s)"
)
params.extend([f"%{mapped}%", "%Unisex%"])
else:
clauses.append("description_data->>'gioi_tinh' ILIKE %s")
params.append(f"%{mapped}%")
# Age group → description_data->>'do_tuoi'
age_group = filters.get("age_group")
if age_group and age_group == "kid":
clauses.append(
"(description_data->>'do_tuoi' ILIKE %s OR description_data->>'do_tuoi' ILIKE %s "
"OR LOWER(product_line) LIKE %s)"
)
params.extend(["%trẻ%", "%%", "%%"])
# Color → LIKE in clean_description (color is spread across text, not a single JSONB field)
color = filters.get("color")
if color:
clauses.append("LOWER(clean_description) LIKE %s")
params.append(f"%{color.lower()}%")
# Season → description_data->>'mua'
season = filters.get("season")
if season:
clauses.append("description_data->>'mua' ILIKE %s")
params.append(f"%{season}%")
# Keywords → LIKE in clean_description (for material keywords like "cotton", "co giãn")
keywords = filters.get("keywords", [])
if keywords:
kw_parts = []
for kw in keywords[:5]:
kw_parts.append("LOWER(clean_description) LIKE %s")
params.append(f"%{kw.lower()}%")
clauses.append(f"({' AND '.join(kw_parts)})")
mode_label = "JSONB structured"
else:
# ═══ TEXT FALLBACK (relaxed) ═══
gender = filters.get("gender")
if gender:
gender_variants = set()
gl = gender.lower()
if gl in ("nam", "bé trai"):
gender_variants.update(["nam", "unisex"])
elif gl in ("nữ", "bé gái"):
gender_variants.update(["nữ", "unisex"])
else:
gender_variants.add(gl)
gparts = []
for g in gender_variants:
gparts.append("LOWER(clean_description) LIKE %s")
params.append(f"%{g}%")
if gparts:
clauses.append(f"({' OR '.join(gparts)})")
age_group = filters.get("age_group")
if age_group and age_group == "kid":
clauses.append(
"(LOWER(clean_description) LIKE %s OR LOWER(clean_description) LIKE %s)"
)
params.extend(["%trẻ em%", "%%"])
color = filters.get("color")
if color:
clauses.append("LOWER(clean_description) LIKE %s")
params.append(f"%{color.lower()}%")
season = filters.get("season")
if season:
clauses.append("LOWER(clean_description) LIKE %s")
params.append(f"%{season.lower()}%")
keywords = filters.get("keywords", [])
if keywords:
kw_parts = []
for kw in keywords[:5]:
kw_parts.append("LOWER(clean_description) LIKE %s")
params.append(f"%{kw.lower()}%")
clauses.append(f"({' AND '.join(kw_parts)})")
mode_label = "TEXT fallback"
where_str = " AND ".join(clauses) if clauses else "1=1"
sql = f"""SELECT internal_ref_code, product_name, product_image_url, product_line,
phase, status,
LEFT(clean_description, 300) AS description_preview
FROM {PG_TABLE}
WHERE {where_str}
ORDER BY updated_at DESC
LIMIT %s"""
params.append(limit)
return sql, params, mode_label
@router.post("/ai-search", summary="AI-powered search: parse intent → smart SQL filters")
async def ai_search(req: AiSearchRequest):
"""
Agent-style search (v2 — JSONB structured filters):
1. Send user query to structured Gemini endpoint → extract filters
2. Build multi-condition SQL using JSONB operators on description_data
3. Progressive relaxation: if JSONB returns 0 → retry with TEXT fallback
4. Return matched products + explanation of how AI understood the query
"""
query = req.query.strip()
if not query or len(query) < 2:
return JSONResponse(
status_code=400,
content={"status": "error", "message": "Query phải có ít nhất 2 ký tự"}
)
try:
# ── Step 1: AI Parse Intent (Structured) ──
logger.info(f"🧠 AI Search (Structured): parsing '{query}'...")
# Use robust Gemini model with structured output
llm = create_llm("gemini-3.1-flash-lite-preview", streaming=False)
structured_llm = llm.with_structured_output(AiSearchFilter)
messages = [
SystemMessage(content=AI_SEARCH_SYSTEM),
HumanMessage(content=f"Phân tích câu tìm kiếm: \"{query}\""),
]
result: AiSearchFilter = await structured_llm.ainvoke(messages)
filters = result.model_dump(exclude_none=True)
if "keywords" not in filters or filters["keywords"] is None:
filters["keywords"] = []
logger.info(f"🧠 AI Filters: {json.dumps(filters, ensure_ascii=False)}")
# ── Step 2+3: Build SQL → Execute with progressive relaxation ──
results = []
search_mode = "JSONB structured"
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
# Tier 1: JSONB structured query (strict, precise)
sql, params, search_mode = _build_ai_search_sql(filters, req.limit, use_jsonb=True)
logger.info(f"🔍 Tier 1 ({search_mode}): {sql[:120]}...")
cur.execute(sql, tuple(params))
cols = [desc[0] for desc in cur.description]
results = [dict(zip(cols, row)) for row in cur.fetchall()]
# Tier 2: TEXT fallback if JSONB returned too few results
if len(results) < 3:
logger.info(f"⚠️ Tier 1 returned {len(results)} results, trying TEXT fallback...")
sql2, params2, search_mode = _build_ai_search_sql(filters, req.limit, use_jsonb=False)
cur.execute(sql2, tuple(params2))
cols2 = [desc[0] for desc in cur.description]
text_results = [dict(zip(cols2, row)) for row in cur.fetchall()]
# Merge: keep existing + add new unique results
existing_codes = {r["internal_ref_code"] for r in results}
for r in text_results:
if r["internal_ref_code"] not in existing_codes:
results.append(r)
existing_codes.add(r["internal_ref_code"])
search_mode = "JSONB + TEXT merged"
cur.close()
finally:
if conn:
conn.close()
explain = filters.get("search_explain", "")
occasion = filters.get("occasion", "")
logger.info(
f"✅ AI Search done: query='{query}', mode={search_mode}, "
f"results={len(results)}, filters={json.dumps(filters, ensure_ascii=False)}"
)
return {
"status": "success",
"query": query,
"ai_filters": filters,
"explain": explain,
"occasion": occasion,
"search_mode": search_mode,
"count": len(results),
"results": results,
}
except Exception as e:
logger.error(f"AI Search error: {e}", exc_info=True)
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
# ═══════════════════════════════════════════════════════
# 2. FIELD OPTIONS — For the widget dropdown
# ═══════════════════════════════════════════════════════
@router.get("/fields", summary="Get available fields for bulk AI editing")
async def get_fields():
return {"status": "success", "fields": FIELD_OPTIONS}
# ═══════════════════════════════════════════════════════
# 3. AI EDIT — Codex-powered bulk field generation
# ═══════════════════════════════════════════════════════
class BulkAiEditRequest(BaseModel):
codes: list[str]
target_field: str # which field in description_data to edit
instruction: str # Vietnamese instruction for AI
mode: str = "append" # append | overwrite
@router.post("/ai-edit", summary="Bulk AI-powered field editing with Codex")
async def bulk_ai_edit(req: BulkAiEditRequest, background_tasks: BackgroundTasks):
"""
Use Codex to generate context-aware content for a specific field
across multiple products. Runs in background.
"""
if not req.codes:
return JSONResponse(status_code=400, content={"status": "error", "message": "Trống codes"})
if req.target_field not in FIELD_OPTIONS and req.target_field not in _FIELD_LABELS:
return JSONResponse(status_code=400, content={
"status": "error",
"message": f"Trường '{req.target_field}' không hợp lệ. Chọn: {', '.join(FIELD_OPTIONS.keys())}"
})
if not req.instruction.strip():
return JSONResponse(status_code=400, content={"status": "error", "message": "Thiếu instruction"})
if BULK_AI_STATE["is_running"]:
return JSONResponse(status_code=409, content={
"status": "error", "message": "Đang có task AI chạy. Vui lòng đợi hoàn tất."
})
# Start background task
BULK_AI_STATE.update({
"is_running": True,
"total": len(req.codes),
"done": 0,
"errors": 0,
"current_code": None,
"current_name": None,
"results": [],
})
background_tasks.add_task(
_run_bulk_ai_edit, req.codes, req.target_field, req.instruction, req.mode
)
return {
"status": "success",
"message": f"Đã bắt đầu AI sinh nội dung cho {len(req.codes)} SP, trường '{FIELD_OPTIONS.get(req.target_field, req.target_field)}'. Chạy nền...",
"total": len(req.codes),
}
@router.post("/ai-edit-preview", summary="Preview AI generated content for a single product")
async def bulk_ai_edit_preview(req: BulkAiEditRequest):
"""
Run Codex on ONE product and return the old and new text without saving to the DB.
Provides a quick UI preview before running the background bulk task.
"""
if not req.codes:
return JSONResponse(status_code=400, content={"status": "error", "message": "Trống codes"})
code = req.codes[0] # Just take the first one
target_field = req.target_field
instruction = req.instruction
mode = req.mode
field_label = FIELD_OPTIONS.get(target_field, _FIELD_LABELS.get(target_field, target_field))
conn = None
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT internal_ref_code, product_name, product_line, description_data FROM {PG_TABLE} WHERE internal_ref_code = %s",
(code,)
)
cols = [desc[0] for desc in cur.description]
row = cur.fetchone()
cur.close()
if not row:
return JSONResponse(status_code=404, content={"status": "error", "message": "Không tìm thấy sản phẩm"})
product = dict(zip(cols, row))
desc_data = product.get("description_data", {})
if isinstance(desc_data, str):
try:
desc_data = json.loads(desc_data)
except Exception:
desc_data = {}
current_value = desc_data.get(target_field, "")
# Build context for AI
context_fields = {
"ten_san_pham": desc_data.get("ten_san_pham", product.get("product_name", "")),
"phong_cach": desc_data.get("phong_cach", ""),
"chat_lieu": desc_data.get("chat_lieu", ""),
"gioi_tinh": desc_data.get("gioi_tinh", ""),
"mua": desc_data.get("mua", ""),
"do_tuoi": desc_data.get("do_tuoi", ""),
"product_line": product.get("product_line", ""),
}
context_str = "\n".join(f"- {k}: {v}" for k, v in context_fields.items() if v)
if mode == "append" and current_value:
prompt = f"""Bạn là Stylist thời trang Canifa. Dựa trên thông tin sản phẩm dưới đây, hãy BỔ SUNG thêm nội dung cho trường "{field_label}".
THÔNG TIN SẢN KHẨM:
{context_str}
GIÁ TRỊ HIỆN TẠI của trường "{field_label}":
{current_value}
YÊU CẦU BỔ SUNG:
{instruction}
QUY TẮC:
- GIỮ NGUYÊN nội dung cũ, CHỈ THÊM phần mới vào
- Viết tự nhiên, phù hợp với sản phẩm cụ thể này
- Dùng cùng format/style với nội dung cũ (nếu dùng dấu · thì thêm cũng dùng dấu ·)
- Trả về TOÀN BỘ nội dung trường (cũ + mới), KHÔNG giải thích
Trả về chỉ nội dung trường, không có gì khác."""
else:
prompt = f"""Bạn là Stylist thời trang Canifa. Dựa trên thông tin sản phẩm dưới đây, hãy VIẾT nội dung cho trường "{field_label}".
THÔNG TIN SẢN PHẨM:
{context_str}
YÊU CẦU:
{instruction}
QUY TẮC:
- Viết tự nhiên, phù hợp với sản phẩm cụ thể này
- Phong cách giống Stylist thời trang cao cấp
- Trả về chỉ nội dung trường, KHÔNG giải thích
Trả về chỉ nội dung trường, không có gì khác."""
messages = [
{"role": "system", "content": "Bạn là AI phụ trách nội dung sản phẩm thời trang Canifa. Trả lời ngắn gọn, chỉ trả về nội dung được yêu cầu."},
{"role": "user", "content": prompt},
]
logger.info(f"👀 Preview for code: {code}")
ai_output = await _call_codex(messages, max_tokens=500, temperature=0.7)
new_value = ai_output.strip().strip('"').strip("'")
return {
"status": "success",
"code": code,
"name": product.get("product_name", ""),
"old_value": current_value,
"new_value": new_value
}
except Exception as e:
logger.error(f"Preview error: {e}", exc_info=True)
return {"status": "error", "message": str(e)}
finally:
if conn:
conn.close()
async def _run_bulk_ai_edit(codes: list[str], target_field: str, instruction: str, mode: str):
"""Background worker: process each product with Codex."""
field_label = FIELD_OPTIONS.get(target_field, _FIELD_LABELS.get(target_field, target_field))
for code in codes:
BULK_AI_STATE["current_code"] = code
conn = None
try:
# 1. Fetch product data from PostgreSQL
conn = get_pooled_connection_compat()
cur = conn.cursor()
cur.execute(
f"SELECT internal_ref_code, product_name, product_line, description_data, clean_description FROM {PG_TABLE} WHERE internal_ref_code = %s",
(code,)
)
cols = [desc[0] for desc in cur.description]
row = cur.fetchone()
cur.close()
if not row:
BULK_AI_STATE["errors"] += 1
BULK_AI_STATE["results"].append({"code": code, "status": "not_found"})
continue
product = dict(zip(cols, row))
BULK_AI_STATE["current_name"] = product.get("product_name", "")
# 2. Parse description_data
desc_data = product.get("description_data", {})
if isinstance(desc_data, str):
try:
desc_data = json.loads(desc_data)
except Exception:
desc_data = {}
current_value = desc_data.get(target_field, "")
# 3. Build context for AI
context_fields = {
"ten_san_pham": desc_data.get("ten_san_pham", product.get("product_name", "")),
"phong_cach": desc_data.get("phong_cach", ""),
"chat_lieu": desc_data.get("chat_lieu", ""),
"gioi_tinh": desc_data.get("gioi_tinh", ""),
"mua": desc_data.get("mua", ""),
"do_tuoi": desc_data.get("do_tuoi", ""),
"product_line": product.get("product_line", ""),
}
context_str = "\n".join(f"- {k}: {v}" for k, v in context_fields.items() if v)
# 4. Call Codex
if mode == "append" and current_value:
prompt = f"""Bạn là Stylist thời trang Canifa. Dựa trên thông tin sản phẩm dưới đây, hãy BỔ SUNG thêm nội dung cho trường "{field_label}".
THÔNG TIN SẢN PHẨM:
{context_str}
GIÁ TRỊ HIỆN TẠI của trường "{field_label}":
{current_value}
YÊU CẦU BỔ SUNG:
{instruction}
QUY TẮC:
- GIỮ NGUYÊN nội dung cũ, CHỈ THÊM phần mới vào
- Viết tự nhiên, phù hợp với sản phẩm cụ thể này
- Dùng cùng format/style với nội dung cũ (nếu dùng dấu · thì thêm cũng dùng dấu ·)
- Trả về TOÀN BỘ nội dung trường (cũ + mới), KHÔNG giải thích
Trả về chỉ nội dung trường, không có gì khác."""
else:
prompt = f"""Bạn là Stylist thời trang Canifa. Dựa trên thông tin sản phẩm dưới đây, hãy VIẾT nội dung cho trường "{field_label}".
THÔNG TIN SẢN PHẨM:
{context_str}
YÊU CẦU:
{instruction}
QUY TẮC:
- Viết tự nhiên, phù hợp với sản phẩm cụ thể này
- Phong cách giống Stylist thời trang cao cấp
- Trả về chỉ nội dung trường, KHÔNG giải thích
Trả về chỉ nội dung trường, không có gì khác."""
messages = [
{"role": "system", "content": "Bạn là AI phụ trách nội dung sản phẩm thời trang Canifa. Trả lời ngắn gọn, chỉ trả về nội dung được yêu cầu."},
{"role": "user", "content": prompt},
]
ai_output = await _call_codex(messages, max_tokens=500, temperature=0.7)
new_value = ai_output.strip().strip('"').strip("'")
# 5. Update description_data
desc_data[target_field] = new_value
# 6. Re-render clean_description
new_clean = _render_description_text(desc_data, product.get("product_name", ""))
# 7. Save back to DB
conn2 = get_pooled_connection_compat()
cur2 = conn2.cursor()
cur2.execute(
f"UPDATE {PG_TABLE} SET description_data = %s::jsonb, clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(json.dumps(desc_data, ensure_ascii=False), new_clean, code)
)
cur2.close()
conn2.close()
BULK_AI_STATE["done"] += 1
BULK_AI_STATE["results"].append({
"code": code,
"name": product.get("product_name", ""),
"status": "success",
"field": target_field,
"old_value": current_value[:100] if current_value else "",
"new_value": new_value[:150],
})
logger.info(f"✅ AI edit [{BULK_AI_STATE['done']}/{BULK_AI_STATE['total']}] {code} → {target_field}")
except Exception as e:
BULK_AI_STATE["errors"] += 1
BULK_AI_STATE["results"].append({"code": code, "status": "error", "error": str(e)})
logger.error(f"❌ AI edit error for {code}: {e}")
finally:
if conn:
try:
conn.close()
except Exception:
pass
# Done
BULK_AI_STATE["is_running"] = False
BULK_AI_STATE["current_code"] = None
BULK_AI_STATE["current_name"] = None
logger.info(f"🏁 Bulk AI edit completed: {BULK_AI_STATE['done']}/{BULK_AI_STATE['total']} (errors: {BULK_AI_STATE['errors']})")
# ═══════════════════════════════════════════════════════
# 4. AI EDIT STATUS — Poll progress
# ═══════════════════════════════════════════════════════
@router.get("/ai-edit-status", summary="Get progress of bulk AI edit")
async def ai_edit_status():
return BULK_AI_STATE
# ═══════════════════════════════════════════════════════
# 5. SIMPLE BULK UPDATE — Static text (keep from v1)
# ═══════════════════════════════════════════════════════
class BulkUpdateRequest(BaseModel):
codes: list[str]
action: str # append | prepend | replace | remove
text: str = ""
find: str = ""
replace_with: str = ""
@router.post("/update", summary="Bulk update clean_description with static text")
async def bulk_update(req: BulkUpdateRequest):
"""Simple bulk text operations (non-AI). For quick static edits."""
if not req.codes:
return JSONResponse(status_code=400, content={"status": "error", "message": "Trống codes"})
conn = None
updated = 0
errors = []
try:
conn = get_pooled_connection_compat()
cur = conn.cursor()
for code in req.codes:
try:
cur.execute(
f"SELECT clean_description FROM {PG_TABLE} WHERE internal_ref_code = %s",
(code,)
)
row = cur.fetchone()
if not row:
errors.append({"code": code, "error": "Không tìm thấy"})
continue
current_text = row[0] or ""
if req.action == "append":
new_text = current_text + "\n" + req.text
elif req.action == "prepend":
new_text = req.text + "\n" + current_text
elif req.action == "replace":
new_text = current_text.replace(req.find, req.replace_with) if req.find else current_text
elif req.action == "remove":
new_text = current_text.replace(req.text, "")
else:
continue
cur.execute(
f"UPDATE {PG_TABLE} SET clean_description = %s, updated_at = NOW() WHERE internal_ref_code = %s",
(new_text.strip(), code)
)
updated += 1
except Exception as e:
errors.append({"code": code, "error": str(e)})
cur.close()
return {
"status": "success",
"message": f"Đã cập nhật {updated}/{len(req.codes)} sản phẩm",
"updated": updated,
"total": len(req.codes),
"errors": errors,
}
except Exception as e:
return JSONResponse(status_code=500, content={"status": "error", "message": str(e)})
finally:
if conn:
conn.close()
/* ═══════════════════════════════════════════════════════════
AI Assistant — Floating Chat Widget
Premium glassmorphism design for Canifa Admin Console
═══════════════════════════════════════════════════════════ */
/* ── Bubble Button ── */
.ai-bubble {
position: fixed;
bottom: 24px;
right: 24px;
width: 56px;
height: 56px;
border-radius: 50%;
background: linear-gradient(135deg, #3b5998 0%, #2d4a7a 100%);
color: #fff;
border: none;
cursor: pointer;
z-index: 9998;
display: flex;
align-items: center;
justify-content: center;
font-size: 24px;
box-shadow:
0 4px 16px rgba(59, 89, 152, 0.35),
0 2px 6px rgba(0, 0, 0, 0.12);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: ai-bubble-pulse 3s ease-in-out infinite;
}
.ai-bubble:hover {
transform: scale(1.1);
box-shadow:
0 6px 24px rgba(59, 89, 152, 0.5),
0 3px 8px rgba(0, 0, 0, 0.15);
}
.ai-bubble:active {
transform: scale(0.95);
}
.ai-bubble.open {
animation: none;
transform: rotate(0deg);
}
.ai-bubble .bubble-icon { transition: transform 0.3s ease; }
.ai-bubble.open .bubble-icon { transform: rotate(45deg); }
@keyframes ai-bubble-pulse {
0%, 100% { box-shadow: 0 4px 16px rgba(59, 89, 152, 0.35), 0 2px 6px rgba(0, 0, 0, 0.12); }
50% { box-shadow: 0 4px 24px rgba(59, 89, 152, 0.55), 0 2px 8px rgba(0, 0, 0, 0.18), 0 0 0 8px rgba(59, 89, 152, 0.08); }
}
/* ── Notification dot ── */
.ai-bubble-dot {
position: absolute;
top: 2px;
right: 2px;
width: 12px;
height: 12px;
background: #ef4444;
border-radius: 50%;
border: 2px solid #fff;
display: none;
}
/* ── Chat Panel ── */
.ai-panel {
position: fixed;
bottom: 92px;
right: 24px;
width: 420px;
max-width: calc(100vw - 48px);
height: 580px;
max-height: calc(100vh - 120px);
background: #fff;
border: 1px solid var(--border, #e7e5e2);
border-radius: 16px;
box-shadow:
0 12px 48px rgba(0, 0, 0, 0.12),
0 4px 16px rgba(0, 0, 0, 0.06);
z-index: 9999;
display: flex;
flex-direction: column;
overflow: hidden;
opacity: 0;
transform: translateY(20px) scale(0.95);
pointer-events: none;
transition: all 0.35s cubic-bezier(0.4, 0, 0.2, 1);
}
.ai-panel.open {
opacity: 1;
transform: translateY(0) scale(1);
pointer-events: auto;
}
/* ── Panel Header ── */
.ai-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background: linear-gradient(135deg, #3b5998 0%, #2d4a7a 100%);
color: #fff;
flex-shrink: 0;
}
.ai-panel-header .header-left {
display: flex;
align-items: center;
gap: 10px;
}
.ai-panel-header .header-avatar {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
}
.ai-panel-header .header-info h3 {
margin: 0;
font-size: 14px;
font-weight: 700;
}
.ai-panel-header .header-info span {
font-size: 11px;
opacity: 0.8;
}
.ai-panel-header .header-actions {
display: flex;
gap: 6px;
}
.ai-panel-header .header-actions button {
background: rgba(255, 255, 255, 0.15);
border: none;
color: #fff;
width: 30px;
height: 30px;
border-radius: 8px;
cursor: pointer;
font-size: 13px;
transition: background 0.2s;
display: flex;
align-items: center;
justify-content: center;
}
.ai-panel-header .header-actions button:hover {
background: rgba(255, 255, 255, 0.3);
}
/* ── Quick Actions Bar ── */
.ai-quick-actions {
display: flex;
gap: 6px;
padding: 10px 14px;
background: #f8f7f4;
border-bottom: 1px solid var(--border, #e7e5e2);
flex-shrink: 0;
overflow-x: auto;
}
.ai-quick-actions::-webkit-scrollbar { height: 0; }
.ai-quick-btn {
padding: 5px 12px;
border: 1px solid var(--border, #e7e5e2);
border-radius: 20px;
background: #fff;
font-size: 11px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
color: var(--secondary-fg, #57534e);
transition: all 0.2s;
}
.ai-quick-btn:hover {
background: var(--primary, #3b5998);
color: #fff;
border-color: var(--primary, #3b5998);
}
/* ── Messages Area ── */
.ai-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 12px;
background: #faf9f7;
min-height: 0;
}
.ai-messages::-webkit-scrollbar { width: 4px; }
.ai-messages::-webkit-scrollbar-thumb { background: #d6d3ce; border-radius: 4px; }
/* ── Message Bubble ── */
.ai-msg {
display: flex;
gap: 8px;
max-width: 92%;
animation: msg-in 0.3s ease;
}
@keyframes msg-in {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
.ai-msg.bot { align-self: flex-start; }
.ai-msg.user { align-self: flex-end; flex-direction: row-reverse; }
.ai-msg-avatar {
width: 28px;
height: 28px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
flex-shrink: 0;
margin-top: 2px;
}
.ai-msg.bot .ai-msg-avatar {
background: linear-gradient(135deg, #3b5998, #2d4a7a);
color: #fff;
}
.ai-msg.user .ai-msg-avatar {
background: var(--muted, #f0efec);
color: var(--muted-fg, #78716c);
}
.ai-msg-body {
padding: 10px 14px;
border-radius: 14px;
font-size: 13px;
line-height: 1.55;
word-break: break-word;
}
.ai-msg.bot .ai-msg-body {
background: #fff;
color: var(--foreground, #1c1917);
border: 1px solid var(--border, #e7e5e2);
border-top-left-radius: 4px;
}
.ai-msg.user .ai-msg-body {
background: var(--primary, #3b5998);
color: #fff;
border-top-right-radius: 4px;
}
/* ── Search Results inside chat ── */
.ai-search-results {
margin-top: 8px;
max-height: 280px;
overflow-y: auto;
border: 1px solid var(--border, #e7e5e2);
border-radius: 10px;
background: #fff;
}
.ai-search-results::-webkit-scrollbar { width: 3px; }
.ai-search-results::-webkit-scrollbar-thumb { background: #d6d3ce; border-radius: 3px; }
.ai-result-item {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 12px;
border-bottom: 1px solid #f0efec;
cursor: pointer;
transition: background 0.15s;
}
.ai-result-item:last-child { border-bottom: none; }
.ai-result-item:hover { background: #f8f7f4; }
.ai-result-item input[type="checkbox"] {
width: 16px;
height: 16px;
accent-color: var(--primary, #3b5998);
flex-shrink: 0;
cursor: pointer;
}
.ai-result-item img {
width: 36px;
height: 36px;
border-radius: 6px;
object-fit: cover;
flex-shrink: 0;
background: var(--muted, #f0efec);
border: 1px solid var(--border, #e7e5e2);
}
.ai-result-info {
flex: 1;
min-width: 0;
}
.ai-result-name {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ai-result-code {
font-size: 10px;
color: var(--muted-fg, #78716c);
font-family: var(--font-mono, monospace);
}
.ai-result-status {
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 12px;
flex-shrink: 0;
}
.ai-result-status.done { background: #ecfdf5; color: #16a34a; }
.ai-result-status.pending { background: #fff3cd; color: #856404; }
.ai-result-status.missing { background: #fef2f2; color: #dc2626; }
/* ── Bulk Action Bar (appears after search) ── */
.ai-bulk-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
background: #eef1f8;
border-top: 1px solid var(--border, #e7e5e2);
font-size: 12px;
flex-shrink: 0;
}
.ai-bulk-bar .bulk-count {
font-weight: 700;
color: var(--primary, #3b5998);
}
.ai-bulk-bar select {
flex: 1;
padding: 6px 8px;
border: 1px solid var(--border, #e7e5e2);
border-radius: 6px;
font-size: 12px;
background: #fff;
outline: none;
}
.ai-bulk-bar button {
padding: 6px 14px;
border: none;
border-radius: 6px;
background: var(--primary, #3b5998);
color: #fff;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
white-space: nowrap;
}
.ai-bulk-bar button:hover { background: var(--primary-hover, #2d4a7a); }
/* ── Input Area ── */
.ai-input-area {
display: flex;
align-items: center;
gap: 8px;
padding: 12px 14px;
border-top: 1px solid var(--border, #e7e5e2);
background: #fff;
flex-shrink: 0;
}
.ai-input-area input {
flex: 1;
padding: 10px 14px;
border: 1px solid var(--border, #e7e5e2);
border-radius: 10px;
font: inherit;
font-size: 13px;
outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
background: #faf9f7;
}
.ai-input-area input:focus {
border-color: var(--primary, #3b5998);
box-shadow: 0 0 0 3px rgba(59, 89, 152, 0.08);
background: #fff;
}
.ai-input-area input::placeholder { color: #a8a29e; }
.ai-send-btn {
width: 40px;
height: 40px;
border-radius: 10px;
border: none;
background: var(--primary, #3b5998);
color: #fff;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 16px;
transition: all 0.2s;
flex-shrink: 0;
}
.ai-send-btn:hover { background: var(--primary-hover, #2d4a7a); transform: scale(1.05); }
.ai-send-btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
/* ── Typing indicator ── */
.ai-typing {
display: flex;
gap: 4px;
padding: 12px 14px;
align-self: flex-start;
}
.ai-typing-dot {
width: 7px;
height: 7px;
background: #a8a29e;
border-radius: 50%;
animation: typing-bounce 1.4s infinite ease-in-out;
}
.ai-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.ai-typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing-bounce {
0%, 80%, 100% { transform: translateY(0); }
40% { transform: translateY(-6px); }
}
/* ── Bulk Edit Modal (inside chat) ── */
.ai-edit-modal {
margin-top: 8px;
padding: 12px;
background: #fff;
border: 1px solid var(--border, #e7e5e2);
border-radius: 10px;
}
.ai-edit-modal label {
display: block;
font-size: 11px;
font-weight: 700;
color: var(--muted-fg, #78716c);
margin-bottom: 4px;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.ai-edit-modal select,
.ai-edit-modal input,
.ai-edit-modal textarea {
width: 100%;
padding: 8px 10px;
border: 1px solid var(--border, #e7e5e2);
border-radius: 8px;
font: inherit;
font-size: 12px;
outline: none;
margin-bottom: 8px;
resize: vertical;
}
.ai-edit-modal textarea { min-height: 60px; }
.ai-edit-modal select:focus,
.ai-edit-modal input:focus,
.ai-edit-modal textarea:focus {
border-color: var(--primary, #3b5998);
}
.ai-edit-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
}
.ai-edit-actions button {
padding: 6px 16px;
border: none;
border-radius: 6px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
.ai-edit-actions .btn-preview {
background: #f0efec;
color: #57534e;
}
.ai-edit-actions .btn-preview:hover { background: #e7e5e2; }
.ai-edit-actions .btn-apply {
background: #16a34a;
color: #fff;
}
.ai-edit-actions .btn-apply:hover { background: #15803d; }
/* ── Responsive ── */
@media (max-width: 480px) {
.ai-panel {
width: calc(100vw - 16px);
right: 8px;
bottom: 80px;
height: calc(100vh - 100px);
border-radius: 12px;
}
.ai-bubble {
right: 16px;
bottom: 16px;
width: 50px;
height: 50px;
}
}
/* ── Progress Bar ── */
.ai-progress-bar {
padding: 10px 14px;
background: #eef1f8;
border-top: 1px solid var(--border, #e7e5e2);
flex-shrink: 0;
}
.ai-progress-bar .progress-info {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
margin-bottom: 6px;
color: var(--foreground, #1c1917);
}
.ai-progress-bar .progress-name {
font-size: 10px;
color: var(--muted-fg, #78716c);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 180px;
}
.ai-progress-bar .progress-track {
width: 100%;
height: 6px;
background: #e7e5e2;
border-radius: 3px;
overflow: hidden;
}
.ai-progress-bar .progress-fill {
height: 100%;
background: linear-gradient(90deg, #3b5998, #16a34a);
border-radius: 3px;
width: 0%;
transition: width 0.5s ease;
}
/* ── AI Sinh Button (accent green) ── */
.bulk-ai-btn {
background: #16a34a !important;
font-weight: 700 !important;
}
.bulk-ai-btn:hover {
background: #15803d !important;
}
/* ── AI Filter Tags (Agent search results) ── */
.ai-filter-tags {
padding: 8px 10px;
background: #f8fafc;
border-radius: 8px;
border: 1px solid #e2e8f0;
margin-bottom: 8px;
}
.ai-filter-explain {
font-size: 11px;
color: #64748b;
font-style: italic;
}
.ai-tag {
display: inline-flex;
align-items: center;
gap: 2px;
padding: 2px 8px;
border-radius: 12px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
}
.ai-tag.product { background: #dbeafe; color: #1d4ed8; }
.ai-tag.color { background: #fce7f3; color: #be185d; }
.ai-tag.gender { background: #f3e8ff; color: #7c3aed; }
.ai-tag.age { background: #fef3c7; color: #b45309; }
.ai-tag.season { background: #d1fae5; color: #047857; }
.ai-tag.occasion{ background: #fee2e2; color: #dc2626; }
.ai-tag.keyword { background: #e0e7ff; color: #4338ca; }
/**
* AI Assistant v3 — Agent-Style Search + Codex Bulk Edit
* Features: AI intent parsing → smart SQL filters, bulk field editing
*/
(function () {
'use strict';
// ═══ STATE ═══
let panelOpen = false;
let searchResults = [];
let selectedCodes = new Set();
let currentMode = 'idle'; // idle | searching | searched | ai_running
let pollTimer = null;
let fieldOptions = {};
let lastAiFilters = null;
let lastOccasion = '';
// ═══ DOM CREATION ═══
function createWidget() {
// ── Bubble ──
const bubble = document.createElement('button');
bubble.className = 'ai-bubble';
bubble.id = 'aiBubble';
bubble.title = 'AI Assistant — Tìm & Sinh Mô Tả Hàng Loạt';
bubble.innerHTML = '<span class="bubble-icon">✦</span><span class="ai-bubble-dot" id="aiBubbleDot"></span>';
bubble.onclick = togglePanel;
// ── Panel ──
const panel = document.createElement('div');
panel.className = 'ai-panel';
panel.id = 'aiPanel';
panel.innerHTML = `
<div class="ai-panel-header">
<div class="header-left">
<div class="header-avatar">✦</div>
<div class="header-info">
<h3>Canifa AI</h3>
<span>Agent Search + Sinh mô tả</span>
</div>
</div>
<div class="header-actions">
<button onclick="window._ai.clearChat()" title="Xóa chat">🗑</button>
<button onclick="window._ai.togglePanel()" title="Đóng">✕</button>
</div>
</div>
<div class="ai-quick-actions" id="aiQuickActions">
<button class="ai-quick-btn" onclick="window._ai.qSearch('áo màu đỏ cho bé trai')">🔴 Áo đỏ bé trai</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('váy cotton bé gái mùa hè')">👗 Váy bé gái</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('polo nam đi làm')">👔 Polo nam</button>
<button class="ai-quick-btn" onclick="window._ai.qSearch('áo khoác mùa đông')">🧥 Khoác đông</button>
<button class="ai-quick-btn" onclick="window._ai.showHelp()">❓ Help</button>
</div>
<div class="ai-messages" id="aiMessages">
<div class="ai-msg bot">
<div class="ai-msg-avatar">✦</div>
<div class="ai-msg-body">
<strong>Chào bro!</strong> 🤖<br><br>
Tao là AI Assistant tích hợp <strong>Codex Agent</strong>.<br>
Gõ <strong>câu tự nhiên</strong> — AI sẽ hiểu ý và tìm SP thông minh:<br><br>
VD: <em>"tìm áo đỏ cho bé trai phù hợp 2/9"</em><br>
→ AI hiểu: <strong>Áo + đỏ + bé trai + trẻ em</strong><br>
→ Tìm đúng SP, rồi cho AI sinh nội dung hàng loạt 🔥
</div>
</div>
</div>
<div class="ai-bulk-bar" id="aiBulkBar" style="display:none;">
<span>Đã chọn: <strong class="bulk-count" id="aiBulkCount">0</strong></span>
<button onclick="window._ai.selectAll()">✅ All</button>
<button onclick="window._ai.deselectAll()">◻️ None</button>
<button class="bulk-ai-btn" onclick="window._ai.showAiForm()">🤖 AI Sinh</button>
</div>
<div class="ai-progress-bar" id="aiProgressBar" style="display:none;">
<div class="progress-info">
<span>🤖 AI đang sinh:</span>
<strong id="aiProgressText">0/0</strong>
<span id="aiProgressName" class="progress-name"></span>
</div>
<div class="progress-track"><div class="progress-fill" id="aiProgressFill"></div></div>
</div>
<div class="ai-input-area">
<input type="text" id="aiInput" placeholder="Gõ tiếng Việt tự nhiên... VD: áo cotton bé gái" autocomplete="off">
<button class="ai-send-btn" id="aiSendBtn" onclick="window._ai.send()">➤</button>
</div>
`;
document.body.appendChild(bubble);
document.body.appendChild(panel);
document.getElementById('aiInput').addEventListener('keydown', e => {
if (e.key === 'Enter') window._ai.send();
});
// Load field options
fetch('/api/bulk/fields').then(r => r.json()).then(d => {
if (d.status === 'success') fieldOptions = d.fields;
}).catch(() => {});
}
// ═══ PANEL ═══
function togglePanel() {
panelOpen = !panelOpen;
document.getElementById('aiPanel').classList.toggle('open', panelOpen);
document.getElementById('aiBubble').classList.toggle('open', panelOpen);
if (panelOpen) setTimeout(() => document.getElementById('aiInput').focus(), 350);
}
// ═══ MESSAGES ═══
function msg(content, type = 'bot') {
const c = document.getElementById('aiMessages');
const avatar = type === 'bot' ? '✦' : '👤';
const el = document.createElement('div');
el.className = `ai-msg ${type}`;
el.innerHTML = `<div class="ai-msg-avatar">${avatar}</div><div class="ai-msg-body">${content}</div>`;
c.appendChild(el);
c.scrollTop = c.scrollHeight;
return el;
}
function typing(show) {
const existing = document.getElementById('aiTyping');
if (existing) existing.remove();
if (!show) return;
const c = document.getElementById('aiMessages');
const el = document.createElement('div');
el.className = 'ai-typing'; el.id = 'aiTyping';
el.innerHTML = '<div class="ai-typing-dot"></div><div class="ai-typing-dot"></div><div class="ai-typing-dot"></div>';
c.appendChild(el);
c.scrollTop = c.scrollHeight;
}
// ═══ SEND ═══
function send() {
const inp = document.getElementById('aiInput');
const text = inp.value.trim();
if (!text) return;
inp.value = '';
msg(text, 'user');
doAiSearch(text);
}
function qSearch(kw) {
document.getElementById('aiInput').value = kw;
send();
}
// ═══ AI SEARCH (Agent-Style) ═══
async function doAiSearch(query) {
typing(true);
currentMode = 'searching';
// Show "AI đang phân tích..." message
const thinkMsg = msg('🧠 AI đang phân tích ý định tìm kiếm...');
try {
const resp = await fetch('/api/bulk/ai-search', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, limit: 100 }),
});
const data = await resp.json();
typing(false);
// Remove thinking message
if (thinkMsg) thinkMsg.remove();
if (data.status !== 'success') {
msg(`❌ ${data.message || 'Lỗi tìm kiếm'}`);
return;
}
lastAiFilters = data.ai_filters || {};
lastOccasion = data.occasion || '';
// Show AI understanding
let filterHtml = '<div class="ai-filter-tags">';
filterHtml += '<strong style="font-size:11px;color:#6b7280;">🧠 AI hiểu:</strong> ';
if (data.explain) {
filterHtml += `<span class="ai-filter-explain">${esc(data.explain)}</span><br>`;
}
filterHtml += '<div style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px;">';
const f = data.ai_filters || {};
if (f.product_line) filterHtml += `<span class="ai-tag product">${esc(f.product_line)}</span>`;
if (f.color) filterHtml += `<span class="ai-tag color">🎨 ${esc(f.color)}</span>`;
if (f.gender) filterHtml += `<span class="ai-tag gender">👤 ${esc(f.gender)}</span>`;
if (f.age_group) filterHtml += `<span class="ai-tag age">${f.age_group === 'kid' ? '👶 Trẻ em' : '🧑 Người lớn'}</span>`;
if (f.season) filterHtml += `<span class="ai-tag season">🌤 ${esc(f.season)}</span>`;
if (f.occasion) filterHtml += `<span class="ai-tag occasion">📅 ${esc(f.occasion)}</span>`;
if (f.keywords && f.keywords.length > 0) {
f.keywords.forEach(kw => {
filterHtml += `<span class="ai-tag keyword">🔍 ${esc(kw)}</span>`;
});
}
filterHtml += '</div></div>';
if (!data.results || !data.results.length) {
msg(`${filterHtml}<br>Không tìm thấy SP nào phù hợp. Thử câu khác?`);
return;
}
searchResults = data.results;
selectedCodes = new Set(data.results.map(r => r.internal_ref_code));
currentMode = 'searched';
let html = `${filterHtml}<br>Tìm được <strong>${data.count}</strong> SP:`;
html += '<div class="ai-search-results">';
data.results.forEach((r, i) => {
const st = r.status === 1 ? 'done' : (r.status === 0 ? 'pending' : 'missing');
const stText = r.status === 1 ? '✅' : (r.status === 0 ? '⏳' : '✕');
const img = r.product_image_url
? `<img src="${esc(r.product_image_url)}" alt="" onerror="this.style.display='none'">`
: '<div style="width:36px;height:36px;border-radius:6px;background:#f0efec;display:flex;align-items:center;justify-content:center;font-size:14px;flex-shrink:0;">📦</div>';
html += `
<div class="ai-result-item" onclick="window._ai.toggle('${esc(r.internal_ref_code)}',${i})">
<input type="checkbox" id="aiChk_${i}" ${selectedCodes.has(r.internal_ref_code) ? 'checked' : ''} onclick="event.stopPropagation();window._ai.toggle('${esc(r.internal_ref_code)}',${i})">
${img}
<div class="ai-result-info">
<div class="ai-result-name">${esc(r.product_name || '—')}</div>
<div class="ai-result-code">${esc(r.internal_ref_code)}</div>
</div>
<span class="ai-result-status ${st}">${stText}</span>
</div>`;
});
html += '</div>';
msg(html);
updateBar();
// Auto-suggest if occasion detected
if (lastOccasion) {
msg(`
💡 <strong>Gợi ý:</strong> AI phát hiện dịp <strong>"${esc(lastOccasion)}"</strong>.<br>
Bấm <strong>🤖 AI Sinh</strong> → chọn trường <strong>"Dịp mặc"</strong> → AI sẽ tự động thêm dịp này cho ${data.count} SP!
`);
} else {
msg(`
Chọn SP rồi bấm <strong>🤖 AI Sinh</strong> ở thanh dưới để:<br>
• Chọn <strong>trường</strong> cần sửa (dịp mặc, phối đồ...)<br>
• Gõ <strong>yêu cầu</strong> bằng tiếng Việt<br>
• AI Codex sinh nội dung <strong>riêng cho từng SP</strong> 🔥
`);
}
} catch (e) {
typing(false);
msg(`❌ Lỗi: ${e.message}`);
}
}
// ═══ SELECT ═══
function toggle(code, idx) {
selectedCodes.has(code) ? selectedCodes.delete(code) : selectedCodes.add(code);
const chk = document.getElementById(`aiChk_${idx}`);
if (chk) chk.checked = selectedCodes.has(code);
updateBar();
}
function selectAll() {
searchResults.forEach((r, i) => { selectedCodes.add(r.internal_ref_code); const c = document.getElementById(`aiChk_${i}`); if (c) c.checked = true; });
updateBar();
}
function deselectAll() {
selectedCodes.clear();
searchResults.forEach((_, i) => { const c = document.getElementById(`aiChk_${i}`); if (c) c.checked = false; });
updateBar();
}
function updateBar() {
const bar = document.getElementById('aiBulkBar');
document.getElementById('aiBulkCount').textContent = selectedCodes.size;
bar.style.display = (currentMode === 'searched' && searchResults.length > 0) ? 'flex' : 'none';
}
// ═══ AI EDIT FORM ═══
function showAiForm() {
if (selectedCodes.size === 0) { msg('⚠️ Chưa chọn SP nào!'); return; }
// Build field dropdown
let opts = '';
for (const [key, label] of Object.entries(fieldOptions)) {
const sel = key === 'dip_mac' ? ' selected' : '';
opts += `<option value="${key}"${sel}>${label}</option>`;
}
if (!opts) {
opts = `
<option value="dip_mac" selected>Dịp mặc</option>
<option value="phoi_do">Phối đồ</option>
<option value="mo_ta_chinh">Mô tả chính</option>
<option value="phong_cach">Phong cách</option>
<option value="hook_quang_cao">Hook quảng cáo</option>
<option value="cross_sell">Cross-sell</option>
<option value="ly_do_mua">Lý do mua</option>
<option value="tags">Tags</option>
<option value="mua">Mùa phù hợp</option>
<option value="layer">Gợi ý layering</option>
<option value="luu_y_size">Lưu ý size</option>
<option value="tagline">Tagline</option>
`;
}
// Pre-fill instruction if occasion detected
const defaultInstruction = lastOccasion
? `Thêm rằng sản phẩm này phù hợp mặc dịp ${lastOccasion}`
: '';
msg(`
<strong>🤖 AI Sinh Nội Dung — ${selectedCodes.size} sản phẩm</strong>
<div class="ai-edit-modal">
<label>Trường cần sửa</label>
<select id="aiFieldSelect">${opts}</select>
<label>Chế độ</label>
<select id="aiModeSelect">
<option value="append">➕ Bổ sung thêm (giữ cái cũ)</option>
<option value="overwrite">🔄 Viết lại hoàn toàn</option>
</select>
<label>Yêu cầu cho AI (tiếng Việt)</label>
<textarea id="aiInstruction" placeholder="VD: Thêm rằng áo này phù hợp mặc dịp Quốc Khánh 2/9 vì có gam đỏ cờ Tổ quốc...">${esc(defaultInstruction)}</textarea>
<div class="ai-edit-actions" style="margin-top:12px; display:flex; gap:8px;">
<button class="btn-secondary" onclick="window._ai.previewAiEdit()">👀 Thử nghiệm (1 SP)</button>
<button class="btn-apply" onclick="window._ai.startAiEdit()">🚀 Bắt đầu chạy HÀNG LOẠT</button>
</div>
<div id="aiPreviewBox" style="display:none; margin-top:12px; background:rgba(0,0,0,0.2); padding:10px; border-radius:8px; font-size:12px; border:1px solid rgba(255,255,255,0.1);">
</div>
</div>
`);
}
// ═══ PREVIEW AI EDIT ═══
async function previewAiEdit() {
const field = document.getElementById('aiFieldSelect').value;
const mode = document.getElementById('aiModeSelect').value;
const instruction = document.getElementById('aiInstruction').value.trim();
if (!instruction) { msg('⚠️ Nhập yêu cầu cho AI!'); return; }
// Mặc định lấy sản phẩm ĐẦU TIÊN trong list được chọn để preview
const codes = Array.from(selectedCodes);
if (codes.length === 0) return;
const previewCode = [codes[0]];
const box = document.getElementById('aiPreviewBox');
box.style.display = 'block';
box.innerHTML = `<em>Đang sinh thử nội dung cho SP <strong>${previewCode[0]}</strong>...</em> <span class="ai-spinner"></span>`;
try {
const resp = await fetch('/api/bulk/ai-edit-preview', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes: previewCode, target_field: field, instruction, mode }),
});
const data = await resp.json();
if (data.status === 'success') {
box.innerHTML = `
<div style="color:#d6d3d1; margin-bottom:4px;"><strong>Kết quả thử nghiệm (${esc(data.code)}):</strong></div>
<div style="color:#dc2626; margin-bottom:4px; text-decoration:line-through;">${data.old_value ? esc(data.old_value) : '<em>(trống)</em>'}</div>
<div style="color:#22c55e;">${esc(data.new_value)}</div>
<div style="margin-top:6px; font-size:11px; color:#a8a29e;">Review ổn thì bấm Bắt đầu chạy hàng loạt nhé 👇</div>
`;
} else {
box.innerHTML = `<div style="color:#dc2626;">❌ ${esc(data.message)}</div>`;
}
} catch (e) {
box.innerHTML = `<div style="color:#dc2626;">❌ Lỗi: ${e.message}</div>`;
}
}
// ═══ START AI EDIT ═══
async function startAiEdit() {
const field = document.getElementById('aiFieldSelect').value;
const mode = document.getElementById('aiModeSelect').value;
const instruction = document.getElementById('aiInstruction').value.trim();
if (!instruction) { msg('⚠️ Nhập yêu cầu cho AI!'); return; }
const codes = Array.from(selectedCodes);
const fieldLabel = fieldOptions[field] || field;
msg(`🚀 Đang gửi yêu cầu cho AI Codex...<br>
Trường: <strong>${fieldLabel}</strong><br>
Chế độ: <strong>${mode === 'append' ? 'Bổ sung' : 'Viết lại'}</strong><br>
SP: <strong>${codes.length}</strong><br>
Yêu cầu: <em>"${esc(instruction)}"</em>`);
typing(true);
try {
const resp = await fetch('/api/bulk/ai-edit', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ codes, target_field: field, instruction, mode }),
});
const data = await resp.json();
typing(false);
if (data.status === 'success') {
msg(`✅ ${data.message}`);
currentMode = 'ai_running';
showProgress();
startPolling();
} else {
msg(`❌ ${data.message}`);
}
} catch (e) {
typing(false);
msg(`❌ Lỗi: ${e.message}`);
}
}
// ═══ PROGRESS TRACKING ═══
function showProgress() {
document.getElementById('aiProgressBar').style.display = 'block';
}
function hideProgress() {
document.getElementById('aiProgressBar').style.display = 'none';
}
function startPolling() {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(pollStatus, 2000);
}
async function pollStatus() {
try {
const resp = await fetch('/api/bulk/ai-edit-status');
const s = await resp.json();
const pct = s.total > 0 ? Math.round((s.done / s.total) * 100) : 0;
document.getElementById('aiProgressText').textContent = `${s.done}/${s.total}`;
document.getElementById('aiProgressFill').style.width = pct + '%';
document.getElementById('aiProgressName').textContent = s.current_name ? `→ ${s.current_name}` : '';
if (!s.is_running) {
clearInterval(pollTimer);
pollTimer = null;
hideProgress();
currentMode = 'searched';
// Show completion + results
let html = `🏁 <strong>AI hoàn tất!</strong> ${s.done}/${s.total} SP (${s.errors} lỗi)<br><br>`;
if (s.results && s.results.length > 0) {
html += '<div class="ai-search-results" style="max-height:220px;">';
s.results.forEach(r => {
if (r.status === 'success') {
html += `
<div class="ai-result-item" style="flex-direction:column;align-items:flex-start;gap:4px;cursor:default;">
<div style="display:flex;align-items:center;gap:6px;width:100%;">
<span class="ai-result-status done">✅</span>
<strong style="font-size:11px;">${esc(r.code)}</strong>
<span style="font-size:11px;color:#78716c;">${esc(r.name || '')}</span>
</div>
<div style="font-size:11px;width:100%;">
<div style="color:#dc2626;">- ${esc(r.old_value || '(trống)')}</div>
<div style="color:#16a34a;">+ ${esc(r.new_value || '')}</div>
</div>
</div>`;
} else {
html += `
<div class="ai-result-item" style="cursor:default;">
<span class="ai-result-status missing">❌</span>
<span style="font-size:11px;">${esc(r.code)}${esc(r.error || r.status)}</span>
</div>`;
}
});
html += '</div>';
}
msg(html);
}
} catch (e) {
console.warn('Poll error:', e);
}
}
// ═══ HELP ═══
function showHelp() {
msg(`
<strong>📖 Hướng dẫn sử dụng — AI Agent v3</strong><br><br>
<strong>1️⃣ Tìm kiếm thông minh:</strong><br>
Gõ <strong>câu tự nhiên</strong> — AI hiểu ý và tìm SP:<br>
• <em>"áo đỏ cho bé trai"</em> → lọc: Áo + đỏ + Nam + trẻ em<br>
• <em>"polo nam đi làm"</em> → lọc: Áo Polo + Nam<br>
• <em>"váy cotton mùa hè"</em> → lọc: Váy + cotton + Hè<br><br>
<strong>2️⃣ Chọn SP:</strong><br>
Tick checkbox chọn SP cần sửa (mặc định chọn hết)<br><br>
<strong>3️⃣ AI Sinh nội dung:</strong><br>
Bấm <strong>🤖 AI Sinh</strong> → chọn trường + gõ yêu cầu<br>
AI Codex sinh nội dung <strong>riêng cho từng SP</strong> 🔥<br><br>
<strong>💡 Tips:</strong> Nếu bạn nói dịp đặc biệt ("2/9", "Tết"), AI sẽ tự gợi ý instruction!
`);
}
// ═══ CLEAR ═══
function clearChat() {
document.getElementById('aiMessages').innerHTML = '';
searchResults = []; selectedCodes.clear(); currentMode = 'idle';
lastAiFilters = null; lastOccasion = '';
updateBar(); hideProgress();
if (pollTimer) { clearInterval(pollTimer); pollTimer = null; }
msg('🧹 Đã xóa. Gõ câu tự nhiên để AI tìm SP thông minh!');
}
// ═══ UTILS ═══
function esc(s) {
if (!s) return '';
const d = document.createElement('div');
d.textContent = s;
return d.innerHTML;
}
// ═══ INIT ═══
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', createWidget);
else createWidget();
// ═══ PUBLIC API ═══
window._ai = {
togglePanel, send, qSearch, toggle, selectAll, deselectAll,
showAiForm, previewAiEdit, startAiEdit, showHelp, clearChat, updateBar
};
})();
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